pindeck 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -12,16 +12,18 @@
12
12
  - stores figures and audio attached to chat messages in <deck>/figures/ for Claude to use, and attached
13
13
  PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
14
14
  - imports slides from PowerPoint files in the deck's own layouts
15
- Listens on 127.0.0.1 only; every API call needs the per-run token.
15
+ Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
16
+ cookie in the browser, a header for the CLI).
16
17
  Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
17
18
 
18
19
  import http from "node:http";
20
+ import { EventEmitter } from "node:events";
19
21
  import fs from "node:fs";
20
22
  import os from "node:os";
21
23
  import path from "node:path";
22
24
  import crypto from "node:crypto";
23
25
  import { spawn, execFile } from "node:child_process";
24
- import { fileURLToPath } from "node:url";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
27
  import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
26
28
  import { unifiedDiff } from "./lib/diff.mjs";
27
29
  import { ClaudeSession } from "./lib/claude.mjs";
@@ -30,17 +32,31 @@ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs
30
32
  import { readPptx } from "./lib/pptx.mjs";
31
33
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
32
34
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
35
+ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
36
+ import { writeAtomic, sameContent, revision } from "./lib/deck-files.mjs";
33
37
 
34
38
  /* ---- Config ------------------------------------------------------------------ */
35
39
  const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
40
+ pindeck login [deck-folder]
36
41
 
37
42
  Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
38
- The folder must contain index.html.`;
43
+ The folder must contain index.html.
44
+ login prints a new one-time login link for the PinDeck running for that deck
45
+ (without a folder: for the PinDeck started last).`;
39
46
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
40
47
  const argv = process.argv.slice(2);
41
48
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
42
49
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
43
- const PORT = +opt("--port", process.env.PORT || 5180);
50
+ const portArg = opt("--port", process.env.PORT || 5180);
51
+ let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
52
+ if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
53
+ if (!isSupportedPlatform(process.platform)) {
54
+ const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
55
+ await showPlatformNotice(PORT);
56
+ process.exit(0);
57
+ }
58
+ const LOGIN = argv[0] === "login";
59
+ if (LOGIN) argv.shift();
44
60
  const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
45
61
  const DECK_DIR = path.resolve(argv[0] || process.cwd());
46
62
  const DECK_FILE = "index.html";
@@ -52,35 +68,100 @@ const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
52
68
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
53
69
  const SHOT_DIR = path.join(STATE_DIR, "shots");
54
70
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
71
+ /* pindeck login: a new one-time login link from the running server, asked with the token in its server
72
+ file (readable only by this user) */
73
+ if (LOGIN) {
74
+ const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
75
+ let info = null;
76
+ for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
77
+ if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
78
+ try {
79
+ const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
80
+ if (!res.ok) throw new Error(res.status);
81
+ console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
82
+ process.exit(0);
83
+ } catch {
84
+ console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
85
+ process.exit(1);
86
+ }
87
+ }
55
88
  const TOKEN = crypto.randomBytes(16).toString("hex");
56
- const ORIGIN = `http://127.0.0.1:${PORT}`;
89
+ let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
57
90
  const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
58
- const CLI_CMD = /\s/.test(CLI_REL) ? `node "${CLI_REL}"` : `node ${CLI_REL}`;
91
+ const CLI_CMD = cliCommand(CLI_REL);
92
+ const CAPS = detectCapabilities();
59
93
 
60
- if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
61
- console.error(`No ${DECK_FILE} in ${DECK_DIR}\n\n${USAGE}`);
94
+ /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
95
+ function checkDeck() {
96
+ const file = path.join(DECK_DIR, DECK_FILE);
97
+ if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
98
+ try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
99
+ catch { return `${file} cannot be read and written (check its permissions).`; }
100
+ // saving replaces the file through a temporary file next to it, so the folder must be writable too
101
+ try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
102
+ catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
103
+ return null;
104
+ }
105
+ const deckProblem = checkDeck();
106
+ if (deckProblem) {
107
+ console.error(`${deckProblem}\n\n${USAGE}`);
62
108
  process.exit(1);
63
109
  }
64
- fs.mkdirSync(SHOT_DIR, { recursive: true });
65
- const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
66
- fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
67
- fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
110
+ /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
111
+ port is bound (see Start), so a failed start leaves no state behind. */
112
+ const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
113
+ function writeServerInfo() {
114
+ for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
115
+ const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
116
+ for (const f of SERVER_FILES) {
117
+ fs.writeFileSync(f, info, { mode: 0o600 });
118
+ fs.chmodSync(f, 0o600);
119
+ }
120
+ }
121
+ /* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
122
+ function removeServerInfo() {
123
+ for (const f of SERVER_FILES) {
124
+ try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
125
+ }
126
+ }
68
127
 
69
128
  /* ---- Small helpers --------------------------------------------------------------- */
70
129
  const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
71
130
  mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
72
131
  jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
73
132
  woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
74
- mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
133
+ ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
134
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
75
135
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
76
136
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
77
137
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
78
138
  function writeDeck(f, content, manual = false) {
79
139
  ourWrites.set(f, { hash: hash(content), manual });
80
- fs.writeFileSync(path.join(DECK_DIR, f), content);
140
+ writeAtomic(path.join(DECK_DIR, f), content);
141
+ }
142
+ /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
143
+ function writeDeckFile(rel, data, manual = true) {
144
+ const abs = path.join(DECK_DIR, rel);
145
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
146
+ ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
147
+ writeAtomic(abs, data);
81
148
  }
82
149
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
83
- const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
150
+ /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
151
+ const deckRev = () => revision(DECK_DIR, EDITABLE);
152
+ const json = (res, code, obj) => {
153
+ res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
154
+ res.end(JSON.stringify(obj));
155
+ };
156
+ /* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
157
+ written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
158
+ notes sent while the tab closes) are not checked. */
159
+ function staleEdit(res, b) {
160
+ if (b.rev == null || b.rev === deckRev()) return false;
161
+ json(res, 409, { code: "stale", rev: deckRev(),
162
+ error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
163
+ return true;
164
+ }
84
165
  const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
85
166
  async function body(req) {
86
167
  let s = "";
@@ -104,12 +185,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
104
185
  let backedUp = false;
105
186
  function runEidPass() {
106
187
  const html = readDeck(DECK_FILE);
188
+ if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
107
189
  const r = assignEids(html);
108
190
  if (r.changed) {
109
191
  if (!backedUp) {
110
192
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
111
- fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
112
- fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
193
+ fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
194
+ fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
113
195
  backedUp = true;
114
196
  }
115
197
  writeDeck(DECK_FILE, r.html);
@@ -126,11 +208,12 @@ function runEidPass() {
126
208
  let gitFilter = null; // null: not checked yet
127
209
  const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
128
210
  function refreshGit() {
129
- const run = () => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
211
+ // another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
212
+ const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
130
213
  if (e1 || !cleaned) return;
131
214
  git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
132
215
  const staged = entry.split(/\s+/)[1];
133
- if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
216
+ if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
134
217
  });
135
218
  });
136
219
  if (gitFilter === null) {
@@ -152,23 +235,19 @@ function resolveSlide(spec) {
152
235
  }
153
236
 
154
237
  /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
155
- const CHROME = [process.env.CHROME_BIN,
156
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
157
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
158
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
159
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
160
- "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
238
+ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
161
239
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
162
240
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
163
241
  let chromeSeq = 0;
164
242
  function chrome(args, done) {
165
- if (!CHROME) return Promise.reject(new Error("No Chrome/Chromium found (set CHROME_BIN)."));
243
+ if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
166
244
  return new Promise((resolve, reject) => {
167
245
  // a profile per run: concurrent runs must not share one
168
246
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
169
247
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
170
248
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
171
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000", ...args]);
249
+ "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
250
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
172
251
  let out = "", err = "", finished = false;
173
252
  const finish = (fn, v) => {
174
253
  if (finished) return;
@@ -182,7 +261,8 @@ function chrome(args, done) {
182
261
  proc.stdout.on("data", (d) => { out += d; check(); });
183
262
  proc.stderr.on("data", (d) => { err += d; check(); });
184
263
  proc.on("error", (e) => finish(reject, e));
185
- proc.on("exit", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
264
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
265
+ proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
186
266
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
187
267
  });
188
268
  }
@@ -221,7 +301,11 @@ function measureInEditor(slide) {
221
301
  pending.set(id, { resolve, reject, t });
222
302
  });
223
303
  }
224
- const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(slide));
304
+ const measure = (slide) => measureInEditor(slide).catch((e) => {
305
+ if (CAPS.headlessLayout.available) return measureHeadless(slide);
306
+ throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
307
+ `headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
308
+ });
225
309
 
226
310
  /* PDF export. handout: one page per slide, build steps in their final state.
227
311
  presentation: one page per build step (a slide with two clicks gives three pages).
@@ -229,18 +313,19 @@ const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(sl
229
313
  let exportQueue = Promise.resolve(), snapSeq = 0;
230
314
  const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
231
315
  function exportPdf(kind, includeHidden = false) {
232
- if (!CHROME) return Promise.reject(new Error("PDF export needs Chrome or Chromium (set CHROME_BIN)."));
316
+ if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
233
317
  const split = kind === "presentation";
234
318
  const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
235
319
  const out = path.join(DECK_DIR, "export", name);
236
320
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
237
321
  const run = async () => {
238
322
  fs.mkdirSync(path.dirname(out), { recursive: true });
239
- const html = await printLayout(CHROME,
240
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`, profile("layout"));
323
+ const layoutProfile = profile("layout"), printProfile = profile("print");
324
+ const html = await printLayout(CHROME, chromeStart(layoutProfile,
325
+ `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
241
326
  const id = `${process.pid}-${++snapSeq}`;
242
327
  snapshots.set(id, html);
243
- try { await printPdf(CHROME, `${ORIGIN}/deck/__pindeck-print-${id}.html`, out, profile("print")); }
328
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}/deck/__pindeck-print-${id}.html`), out, printProfile); }
244
329
  finally { snapshots.delete(id); }
245
330
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
246
331
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -262,8 +347,15 @@ try {
262
347
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
263
348
  Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
264
349
  } catch {}
265
- const saveChat = () => fs.writeFile(CHAT_FILE,
266
- JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId }), () => {});
350
+ /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
351
+ let chatTimer = null;
352
+ function writeChat() {
353
+ clearTimeout(chatTimer);
354
+ chatTimer = null;
355
+ try { writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId })); }
356
+ catch (e) { console.error(e.message); }
357
+ }
358
+ const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
267
359
  function addEntry(entry) {
268
360
  entry.id = ++state.seq;
269
361
  entry.time = Date.now();
@@ -290,28 +382,36 @@ else if (fileConfig.model !== undefined) console.warn(`config.json: unknown mode
290
382
  if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
291
383
  else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
292
384
  const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
293
- if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings(); // create or complete the file
385
+ const completeSettings = () => { // create or complete the file (at start, once the port is bound)
386
+ if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
387
+ };
294
388
  if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
295
389
 
296
390
  const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
297
- running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
391
+ running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
392
+ capabilities: publicCapabilities(CAPS), rev: deckRev(),
298
393
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
299
394
  const pushStatus = () => broadcast("status", status());
300
395
 
301
396
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
302
- .replaceAll("{{CLI}}", CLI_CMD);
397
+ .replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD);
303
398
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
304
399
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
305
400
  MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
306
- the system prompt instead, read afresh at each session start. */
401
+ the system prompt instead, read afresh at each session start, together with the deck's AGENTS.md (the
402
+ name other coding agents read). Decks copied from the example call the CLI as `node ../cli.mjs`, which
403
+ is right only next to PinDeck: that becomes this server's command. */
307
404
  function deckInstructions() {
308
- try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
309
- catch { return ""; }
405
+ return ["CLAUDE.md", "AGENTS.md"].map((name) => {
406
+ let s;
407
+ try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
408
+ return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
409
+ }).join("");
310
410
  }
311
411
  let stderrTail = "";
312
412
  const session = new ClaudeSession({
313
413
  cwd: DECK_DIR,
314
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
414
+ env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
315
415
  args: () => [
316
416
  "--append-system-prompt", systemPrompt + deckInstructions(),
317
417
  "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
@@ -333,7 +433,10 @@ const session = new ClaudeSession({
333
433
  },
334
434
  });
335
435
  function ensureSession() {
336
- if (!session.running) { stderrTail = ""; session.start(state.sessionId); pushStatus(); }
436
+ if (session.running || quitting || !detectCapabilities().claude.available) return;
437
+ stderrTail = "";
438
+ session.start(state.sessionId);
439
+ pushStatus();
337
440
  }
338
441
 
339
442
  function toolSummary(name, input = {}) {
@@ -393,6 +496,7 @@ function startTurn(promptText, userEntry) {
393
496
  state.busy = true;
394
497
  pushStatus();
395
498
  ensureSession();
499
+ if (!session.running) return finishTurn({ error: detectCapabilities().claude.reason || "Claude Code could not be started." });
396
500
  session.send([{ type: "text", text: promptText }]);
397
501
  }
398
502
 
@@ -498,7 +602,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
498
602
  screenshots folder, for Claude to look at before choosing; with one, into the deck. */
499
603
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
500
604
  const bad = (t, status = 400) => ({ status, text: t });
501
- if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
605
+ if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
502
606
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
503
607
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
504
608
  [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
@@ -521,18 +625,25 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
521
625
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
522
626
  const buf = fs.readFileSync(tmp);
523
627
  fs.rmSync(tmp, { force: true });
524
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
525
- ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
526
- fs.writeFileSync(path.join(DECK_DIR, out), buf);
628
+ writeDeckFile(out, buf);
527
629
  state.pdfFigures.add(out);
528
630
  return { text: `${out} (${size})` };
529
631
  }
530
632
 
531
- /* ---- File watching -------------------------------------------------------------------------- */
532
- let changed = new Set(), flush = null, eidTimer = null;
533
- fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
534
- if (!file || /(^|\/)\.|\.bak$|~$|^export[\/]/.test(file)) return;
535
- changed.add(file.split(path.sep).join("/"));
633
+ /* ---- File watching --------------------------------------------------------------------------
634
+ Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
635
+ events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
636
+ editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
637
+ files), it is started again and the editor told to reload everything. */
638
+ let changed = new Set(), flush = null, eidTimer = null, watcher = null;
639
+ function onFileEvent(_ev, file) {
640
+ if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
641
+ file = String(file).split(path.sep).join("/");
642
+ if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
643
+ changed.add(file);
644
+ scheduleFlush();
645
+ }
646
+ function scheduleFlush() {
536
647
  clearTimeout(flush);
537
648
  flush = setTimeout(() => {
538
649
  const files = [...changed];
@@ -548,14 +659,71 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
548
659
  if (external) {
549
660
  state.notes = [...new Set([...state.notes,
550
661
  "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
551
- // new elements from a hand edit get ids once the file is quiet
662
+ // new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
552
663
  clearTimeout(eidTimer);
553
- eidTimer = setTimeout(() => { if (!state.busy) runEidPass(); }, 800);
664
+ const seen = hash(readDeck(DECK_FILE) ?? "");
665
+ const pass = () => {
666
+ if (state.busy) return;
667
+ const now = readDeck(DECK_FILE);
668
+ if (now == null || hash(now) !== seen) return; // still being written; its own event comes
669
+ runEidPass();
670
+ };
671
+ eidTimer = setTimeout(pass, 800);
554
672
  }
555
673
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
556
- broadcast("files", { files, manual });
674
+ broadcast("files", { files, manual, rev: deckRev() });
557
675
  }, 120);
558
- });
676
+ }
677
+ /* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
678
+ rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
679
+ every folder (inotify on directories) keeps working; folders created later are added as they appear.
680
+ Hidden folders (.git) and node_modules are skipped. */
681
+ function watchFolders(root, onEvent) {
682
+ const watchers = new Map(), tree = new EventEmitter();
683
+ const add = (dir) => {
684
+ if (watchers.has(dir)) return;
685
+ let w;
686
+ try {
687
+ w = fs.watch(dir, (ev, name) => {
688
+ if (name == null) return onEvent(ev, null);
689
+ const abs = path.join(dir, String(name));
690
+ if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
691
+ onEvent(ev, path.relative(root, abs));
692
+ });
693
+ } catch (e) {
694
+ if (dir === root) throw e;
695
+ return;
696
+ }
697
+ w.on("error", (e) => {
698
+ try { w.close(); } catch {}
699
+ watchers.delete(dir);
700
+ if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
701
+ });
702
+ watchers.set(dir, w);
703
+ };
704
+ const scan = (dir) => {
705
+ add(dir);
706
+ let entries = [];
707
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
708
+ for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
709
+ };
710
+ scan(root);
711
+ tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
712
+ return tree;
713
+ }
714
+ function startWatcher() {
715
+ watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
716
+ : fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
717
+ watcher.on("error", (e) => {
718
+ console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
719
+ try { watcher.close(); } catch {}
720
+ setTimeout(() => {
721
+ if (quitting) return;
722
+ try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
723
+ broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
724
+ }, 1000);
725
+ });
726
+ }
559
727
 
560
728
  /* ---- Hand edits ----------------------------------------------------------------------------------------- */
561
729
  function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
@@ -592,21 +760,46 @@ function deletePlan(eid) {
592
760
  return { ...target, html: rest, files };
593
761
  }
594
762
 
595
- /* ---- Undo (Claude's turns and hand edits share one history) ---------------------------------------- */
763
+ /* ---- Undo (Claude's turns and hand edits share one history) ----------------------------------------
764
+ Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
765
+ every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
766
+ files, and the pictures or audio files they would delete or bring back. Anything else means the files
767
+ were changed since, by hand or by another program, and going back would silently throw that work away.
768
+ Then nothing is written and the answer is 409 with the files that differ. */
769
+ function undoConflicts(t, direction) {
770
+ const expect = direction === "undo" ? t.after : t.before;
771
+ const files = EDITABLE.filter((f) => (readDeck(f) ?? null) !== (expect[f] ?? null));
772
+ // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
773
+ // ones (which must be unchanged); redo the other way round
774
+ const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
775
+ const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
776
+ for (const { rel, data } of comeBack) {
777
+ const abs = path.join(DECK_DIR, rel);
778
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
779
+ }
780
+ for (const { rel, data } of go) {
781
+ const abs = path.join(DECK_DIR, rel);
782
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
783
+ }
784
+ return files;
785
+ }
786
+ function conflict(res, direction, files) {
787
+ json(res, 409, { code: "diverged", files,
788
+ error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
789
+ `it would overwrite those changes. Reload to see the files as they are.` });
790
+ }
596
791
  function undo(res) {
597
792
  if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
598
793
  const t = liveTurns().at(-1);
599
794
  if (!t) return json(res, 409, { error: "Nothing to undo." });
600
- const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
795
+ const diverged = undoConflicts(t, "undo");
796
+ if (diverged.length) return conflict(res, "undo", diverged);
601
797
  for (const f of EDITABLE) {
602
798
  if (readDeck(f) === t.before[f]) continue;
603
799
  if (t.before[f] != null) writeDeck(f, t.before[f]);
604
800
  else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
605
801
  }
606
- for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
607
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
608
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
609
- }
802
+ for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
610
803
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
611
804
  t.undone = true;
612
805
  state.redo.push(t);
@@ -614,7 +807,6 @@ function undo(res) {
614
807
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
615
808
  : `The user undid your change from turn ${t.id}; ${t.files.map((f) => f.file).join(" and ")} are back to the state before it. Re-read before editing.`);
616
809
  broadcast("undone", { turnId: t.id, undone: true });
617
- if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
618
810
  pushStatus();
619
811
  return json(res, 200, {});
620
812
  }
@@ -626,7 +818,6 @@ function undo(res) {
626
818
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
627
819
  const IMPORT_MAX = 500 * 1024 * 1024;
628
820
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
629
- fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
630
821
 
631
822
  function startImport(name, buf) {
632
823
  const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
@@ -673,12 +864,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
673
864
  const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
674
865
  const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
675
866
  const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
676
- for (const f of r.files) {
677
- const abs = path.join(DECK_DIR, f.rel);
678
- fs.mkdirSync(path.dirname(abs), { recursive: true });
679
- ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
680
- fs.writeFileSync(abs, f.data);
681
- }
867
+ for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
682
868
  const all = findSlides(parse(html));
683
869
  const first = afterIdx + 1, last = afterIdx + r.fragments.length;
684
870
  const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
@@ -736,31 +922,122 @@ async function refineImport(imp, pdf, plans, eids, first) {
736
922
 
737
923
  /* ---- HTTP ------------------------------------------------------------------------------------- */
738
924
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
739
- const HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
925
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
926
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
927
+ const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
928
+
929
+ /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
930
+ where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
931
+ code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
932
+ and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
933
+ carries this server's port. */
934
+ const loginCodes = new Map(); // code -> expiry (ms)
935
+ function loginCode(ttl) {
936
+ const now = Date.now();
937
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
938
+ const code = crypto.randomBytes(16).toString("hex");
939
+ loginCodes.set(code, now + ttl);
940
+ return code;
941
+ }
942
+ const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
943
+ const cookieName = () => `pindeck-${PORT}`;
944
+ const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
945
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
946
+ function cookie(req, name) {
947
+ for (const part of String(req.headers.cookie || "").split(";")) {
948
+ const i = part.indexOf("=");
949
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
950
+ }
951
+ return null;
952
+ }
953
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
954
+ const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
955
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
956
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
957
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
958
+ PinDeck checkout).</p>`;
959
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
960
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
961
+ function login(res, url) {
962
+ const code = url.searchParams.get("code") || "";
963
+ const exp = loginCodes.get(code);
964
+ loginCodes.delete(code);
965
+ if (!exp || exp < Date.now()) {
966
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
967
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
968
+ }
969
+ let next = url.searchParams.get("next") || "/";
970
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
971
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
972
+ "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
973
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
974
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
975
+ }
976
+ /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
977
+ its command line: the page sends it to a one-time login code, which leads on to the target. */
978
+ function chromeStart(profile, target) {
979
+ fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
980
+ const start = path.join(profile, "pindeck-start.html");
981
+ const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
982
+ fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
983
+ return pathToFileURL(start).href;
984
+ }
740
985
 
741
- function serveFile(res, file, transform) {
742
- fs.readFile(file, (err, data) => {
986
+ /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
987
+ Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
988
+ function serveFile(res, file, transform, req) {
989
+ const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
990
+ if (transform) return fs.readFile(file, (err, data) => {
743
991
  if (err) return text(res, 404, "Not found");
744
- const ext = path.extname(file).slice(1).toLowerCase();
745
- res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream", "cache-control": "no-store" });
746
- res.end(transform ? transform(String(data)) : data);
992
+ const out = Buffer.from(transform(String(data)));
993
+ res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
994
+ res.end(out);
995
+ });
996
+ fs.stat(file, (err, st) => {
997
+ if (err || !st.isFile()) return text(res, 404, "Not found");
998
+ const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
999
+ let start = 0, end = st.size - 1, code = 200;
1000
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1001
+ if (range && (range[1] || range[2])) {
1002
+ if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
1003
+ else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
1004
+ if (start > end || start >= st.size) {
1005
+ res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
1006
+ return res.end();
1007
+ }
1008
+ code = 206;
1009
+ headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
1010
+ }
1011
+ headers["content-length"] = st.size ? end - start + 1 : 0;
1012
+ res.writeHead(code, headers);
1013
+ if (!st.size) return res.end();
1014
+ const stream = fs.createReadStream(file, { start, end });
1015
+ stream.on("error", () => res.destroy());
1016
+ res.on("close", () => stream.destroy());
1017
+ stream.pipe(res);
747
1018
  });
748
1019
  }
749
1020
 
1021
+ let ready = false;
750
1022
  const server = http.createServer(async (req, res) => {
751
- // only pages opened as 127.0.0.1 or localhost: another site's name pointed at 127.0.0.1 (DNS rebinding)
752
- // must not read the editor page, its token or the deck
753
- if (!HOSTS.has(String(req.headers.host || "").toLowerCase())) return text(res, 403, "Bad host");
1023
+ if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1024
+ const host = String(req.headers.host || "").toLowerCase();
1025
+ if (!goodHost(host)) return text(res, 403, "Bad host");
754
1026
  const url = new URL(req.url, ORIGIN);
755
1027
  const p = url.pathname;
756
1028
  try {
757
- // UI and deck files (read-only)
758
- if (req.method === "GET" && (p === "/" || p === "/index.html"))
759
- return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("%%TOKEN%%", TOKEN));
1029
+ if (req.method === "GET" && p === "/login") return login(res, url);
1030
+ // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1031
+ const ok = authed(req);
1032
+ if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1033
+ if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1034
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1035
+ }
760
1036
  if (req.method === "GET" && p.startsWith("/editor/")) {
761
1037
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
762
- return f ? serveFile(res, f) : text(res, 403, "Forbidden");
1038
+ return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
763
1039
  }
1040
+ if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
764
1041
  let snap;
765
1042
  if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
766
1043
  const html = snapshots.get(snap[1]);
@@ -772,7 +1049,7 @@ const server = http.createServer(async (req, res) => {
772
1049
  const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
773
1050
  if (!f) return text(res, 403, "Forbidden");
774
1051
  const mode = url.searchParams.get("editor");
775
- if (!mode || !f.endsWith(".html")) return serveFile(res, f);
1052
+ if (!mode || !f.endsWith(".html")) return serveFile(res, f, null, req);
776
1053
  return serveFile(res, f, (s) => {
777
1054
  // headless runs must not stream media, or Chrome never finishes the page
778
1055
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -781,23 +1058,24 @@ const server = http.createServer(async (req, res) => {
781
1058
  });
782
1059
  }
783
1060
 
784
- // API: token required; POSTs must come from the editor page itself
1061
+ // API: token required (header or login cookie); POSTs must come from the editor page itself
785
1062
  if (!p.startsWith("/api/")) return text(res, 404, "Not found");
786
- const tok = req.headers["x-editor-token"] || url.searchParams.get("t");
787
- if (tok !== TOKEN) return text(res, 403, "Bad token");
1063
+ if (!ok) return text(res, 403, "Bad token");
788
1064
  const origin = req.headers.origin;
789
- if (req.method === "POST" && origin && origin !== ORIGIN && origin !== `http://localhost:${PORT}`)
1065
+ if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
790
1066
  return text(res, 403, "Bad origin");
791
1067
 
792
1068
  if (p === "/api/events") {
793
1069
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
794
- res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
1070
+ res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
795
1071
  turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
796
1072
  clients.add(res);
797
1073
  req.on("close", () => clients.delete(res));
798
1074
  return;
799
1075
  }
800
1076
  if (p === "/api/ping") return json(res, 200, { ok: true });
1077
+ if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1078
+ if (p === "/api/status") return json(res, 200, status());
801
1079
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
802
1080
  if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
803
1081
  if (p === "/api/measure-result" && req.method === "POST") {
@@ -809,6 +1087,9 @@ const server = http.createServer(async (req, res) => {
809
1087
 
810
1088
  if (p === "/api/message" && req.method === "POST") {
811
1089
  if (state.busy) return json(res, 409, { error: "Claude is still working." });
1090
+ // checked again here: the claude command may have been installed (or removed) since the start
1091
+ const claude = detectCapabilities().claude;
1092
+ if (!claude.available) return json(res, 503, { code: "claude-unavailable", error: claude.reason });
812
1093
  const b = await body(req);
813
1094
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
814
1095
  // what the prompt reads from pins and the selection must be there
@@ -848,18 +1129,16 @@ const server = http.createServer(async (req, res) => {
848
1129
  const buf = Buffer.concat(chunks);
849
1130
  if (kind === "pdf") {
850
1131
  if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
851
- fs.mkdirSync(PDF_DIR, { recursive: true });
1132
+ fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
852
1133
  const abs = attachPath(original, PDF_DIR);
853
- fs.writeFileSync(abs, buf);
1134
+ fs.writeFileSync(abs, buf, { mode: 0o600 });
854
1135
  state.uploads.add(abs);
855
1136
  let info = null;
856
- if (process.platform === "darwin") { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
1137
+ if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
857
1138
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
858
1139
  }
859
1140
  const rel = attachPath(original);
860
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
861
- ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
862
- fs.writeFileSync(path.join(DECK_DIR, rel), buf);
1141
+ writeDeckFile(rel, buf);
863
1142
  state.uploads.add(rel);
864
1143
  return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
865
1144
  }
@@ -881,7 +1160,7 @@ const server = http.createServer(async (req, res) => {
881
1160
  Object.assign(settings, { model, effort });
882
1161
  saveSettings();
883
1162
  // restart the session with the new flags; --resume keeps the conversation
884
- if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
1163
+ if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
885
1164
  addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
886
1165
  pushStatus();
887
1166
  return json(res, 200, { settings });
@@ -889,6 +1168,7 @@ const server = http.createServer(async (req, res) => {
889
1168
  if (p === "/api/slide-hidden" && req.method === "POST") {
890
1169
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
891
1170
  const b = await body(req);
1171
+ if (staleEdit(res, b)) return;
892
1172
  const before = snapshot(EDITABLE);
893
1173
  const slides = findSlides(parse(before[DECK_FILE]));
894
1174
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
@@ -904,6 +1184,7 @@ const server = http.createServer(async (req, res) => {
904
1184
  if (p === "/api/move-slide" && req.method === "POST") {
905
1185
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
906
1186
  const b = await body(req);
1187
+ if (staleEdit(res, b)) return;
907
1188
  const before = snapshot(EDITABLE);
908
1189
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
909
1190
  if (r.error) return json(res, 409, { error: r.error });
@@ -916,6 +1197,7 @@ const server = http.createServer(async (req, res) => {
916
1197
  if (p === "/api/add-slide" && req.method === "POST") {
917
1198
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
918
1199
  const b = await body(req);
1200
+ if (staleEdit(res, b)) return;
919
1201
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
920
1202
  const before = snapshot(EDITABLE);
921
1203
  const r = insertSlide(before[DECK_FILE], b.kind, b.after);
@@ -930,6 +1212,7 @@ const server = http.createServer(async (req, res) => {
930
1212
  return json(res, 200, { turnId, index: r.index, eid });
931
1213
  }
932
1214
  if (p === "/api/import" && req.method === "POST") {
1215
+ if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
933
1216
  const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
934
1217
  if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
935
1218
  const chunks = [];
@@ -955,6 +1238,7 @@ const server = http.createServer(async (req, res) => {
955
1238
  if (p === "/api/import-apply" && req.method === "POST") {
956
1239
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
957
1240
  const b = await body(req);
1241
+ if (staleEdit(res, b)) return;
958
1242
  const imp = lastImport;
959
1243
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
960
1244
  const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
@@ -997,18 +1281,16 @@ const server = http.createServer(async (req, res) => {
997
1281
  if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
998
1282
  const t = state.redo.at(-1);
999
1283
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1000
- if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
1001
- state.redo = [];
1002
- pushStatus();
1003
- return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
1004
- }
1284
+ const diverged = undoConflicts(t, "redo");
1285
+ if (diverged.length) return conflict(res, "redo", diverged);
1005
1286
  state.redo.pop();
1006
- for (const f of EDITABLE) if (t.after[f] != null) writeDeck(f, t.after[f]);
1007
- for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1008
- for (const { rel, data } of t.addedFiles || []) {
1009
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
1010
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
1287
+ for (const f of EDITABLE) {
1288
+ if (readDeck(f) === t.after[f]) continue;
1289
+ if (t.after[f] != null) writeDeck(f, t.after[f]);
1290
+ else fs.rmSync(path.join(DECK_DIR, f), { force: true });
1011
1291
  }
1292
+ for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1293
+ for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1012
1294
  t.undone = false;
1013
1295
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1014
1296
  : `The user redid your change from turn ${t.id} after undoing it.`);
@@ -1026,6 +1308,7 @@ const server = http.createServer(async (req, res) => {
1026
1308
  if (p === "/api/delete" && req.method === "POST") {
1027
1309
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1028
1310
  const b = await body(req);
1311
+ if (staleEdit(res, b)) return;
1029
1312
  const plan = deletePlan(b.eid);
1030
1313
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1031
1314
  const before = snapshot(EDITABLE);
@@ -1045,6 +1328,7 @@ const server = http.createServer(async (req, res) => {
1045
1328
  if (p === "/api/edit" && req.method === "POST") {
1046
1329
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1047
1330
  const b = await body(req);
1331
+ if (staleEdit(res, b)) return;
1048
1332
  const before = snapshot(EDITABLE);
1049
1333
  const r = setStyles(before[DECK_FILE], b.ops || []);
1050
1334
  if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
@@ -1066,6 +1350,7 @@ const server = http.createServer(async (req, res) => {
1066
1350
  if (p === "/api/notes" && req.method === "POST") {
1067
1351
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1068
1352
  const b = await body(req);
1353
+ if (staleEdit(res, b)) return;
1069
1354
  const before = snapshot(EDITABLE);
1070
1355
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
1071
1356
  if (!r) return json(res, 404, { error: "Slide not found in the file." });
@@ -1080,6 +1365,7 @@ const server = http.createServer(async (req, res) => {
1080
1365
  if (p === "/api/text" && req.method === "POST") {
1081
1366
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1082
1367
  const b = await body(req);
1368
+ if (staleEdit(res, b)) return;
1083
1369
  const before = snapshot(EDITABLE);
1084
1370
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
1085
1371
  if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1095,6 +1381,7 @@ const server = http.createServer(async (req, res) => {
1095
1381
  if (p === "/api/split" && req.method === "POST") {
1096
1382
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1097
1383
  const b = await body(req);
1384
+ if (staleEdit(res, b)) return;
1098
1385
  const before = snapshot(EDITABLE);
1099
1386
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1100
1387
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1107,6 +1394,7 @@ const server = http.createServer(async (req, res) => {
1107
1394
  if (p === "/api/remove" && req.method === "POST") {
1108
1395
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1109
1396
  const b = await body(req);
1397
+ if (staleEdit(res, b)) return;
1110
1398
  const before = snapshot(EDITABLE);
1111
1399
  const html = removeElement(before[DECK_FILE], b.eid);
1112
1400
  if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1118,13 +1406,14 @@ const server = http.createServer(async (req, res) => {
1118
1406
  if (p === "/api/export" && req.method === "POST") {
1119
1407
  const b = await body(req);
1120
1408
  try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
1121
- catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1409
+ catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
1122
1410
  }
1123
1411
 
1124
1412
  // CLI endpoints (used by Claude Code via cli.mjs)
1125
1413
  if (p === "/api/pdf") {
1126
1414
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1127
1415
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
1416
+ if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
1128
1417
  const r = await exportPdf(kind, url.searchParams.get("all") === "1");
1129
1418
  return text(res, 200, r.path);
1130
1419
  }
@@ -1140,8 +1429,12 @@ const server = http.createServer(async (req, res) => {
1140
1429
  if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1141
1430
  if (!slide.eid) runEidPass();
1142
1431
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1143
- if (p === "/api/shot") return text(res, 200, await takeShot(s2));
1144
- return text(res, 200, formatLayout(await measure(s2)));
1432
+ if (p === "/api/shot") {
1433
+ if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
1434
+ return text(res, 200, await takeShot(s2));
1435
+ }
1436
+ try { return text(res, 200, formatLayout(await measure(s2))); }
1437
+ catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
1145
1438
  }
1146
1439
  return text(res, 404, "Not found");
1147
1440
  } catch (e) {
@@ -1152,15 +1445,56 @@ const server = http.createServer(async (req, res) => {
1152
1445
  }
1153
1446
  });
1154
1447
 
1155
- /* ---- Start ------------------------------------------------------------------------------------- */
1156
- const first = runEidPass();
1157
- if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1448
+ /* ---- Start ----------------------------------------------------------------------------------------
1449
+ The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
1450
+ the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
1451
+ let quitting = false;
1452
+ server.on("error", (e) => {
1453
+ if (ready) return console.error(e);
1454
+ console.error(e.code === "EADDRINUSE"
1455
+ ? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
1456
+ : `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
1457
+ process.exit(1);
1458
+ });
1158
1459
  server.listen(PORT, "127.0.0.1", () => {
1159
- console.log(`PinDeck: ${ORIGIN}/`);
1460
+ PORT = server.address().port;
1461
+ ORIGIN = `http://127.0.0.1:${PORT}`;
1462
+ try {
1463
+ writeServerInfo();
1464
+ completeSettings();
1465
+ fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
1466
+ const first = runEidPass();
1467
+ if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1468
+ startWatcher();
1469
+ } catch (e) {
1470
+ console.error(`PinDeck could not open the deck: ${e.message}`);
1471
+ removeServerInfo();
1472
+ process.exit(1);
1473
+ }
1474
+ ready = true;
1475
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1476
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1160
1477
  console.log(`Deck: ${DECK_DIR}`);
1161
- if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1478
+ console.log(`Server: ${CAPS.label}`);
1479
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1480
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1162
1481
  ensureSession();
1163
1482
  });
1164
- const quit = () => { session.stop(); setTimeout(() => process.exit(0), 300); };
1483
+ /* Ctrl+C or SIGTERM: stop taking requests, let Claude Code end (it gets end of input, then SIGTERM, then
1484
+ SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
1485
+ async function quit() {
1486
+ if (quitting) process.exit(1);
1487
+ quitting = true;
1488
+ try { watcher?.close(); } catch {}
1489
+ clearTimeout(flush); clearTimeout(eidTimer);
1490
+ for (const res of clients) res.end();
1491
+ for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
1492
+ server.close();
1493
+ server.closeAllConnections?.();
1494
+ removeServerInfo();
1495
+ await Promise.race([session.stop(), new Promise((r) => setTimeout(r, 6000))]);
1496
+ if (chatTimer) writeChat();
1497
+ process.exit(0);
1498
+ }
1165
1499
  process.on("SIGINT", quit);
1166
1500
  process.on("SIGTERM", quit);