pindeck 2.8.2 → 2.10.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,17 +12,19 @@
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";
25
- import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
27
+ import { assignEids, parse, findSlides, slideSelector, deckSize, 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";
28
30
  import { formatLayout, formatPin } from "./lib/layout.mjs";
@@ -30,62 +32,194 @@ 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 { unsupportedMessage } from "./lib/frameworks.mjs";
37
+ import { readInstructions } from "./lib/instructions.mjs";
38
+ import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
33
39
 
34
40
  /* ---- Config ------------------------------------------------------------------ */
35
- const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
41
+ const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--model <name>] [--asset-root <folder>]...
42
+ pindeck login [deck-folder | deck-file.html]
36
43
 
37
- Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
38
- The folder must contain index.html.`;
44
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
45
+ folder), which then opens its index.html. A deck made otherwise names its slides in its <head> with
46
+ <meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
47
+ login prints a new one-time login link for the PinDeck running for that deck
48
+ (without a deck: for the PinDeck started last).
49
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
50
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
51
+ "assetRoots" in ~/.pindeck/config.json.`;
39
52
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
40
53
  const argv = process.argv.slice(2);
41
54
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
42
55
  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);
44
- if (process.platform !== "darwin") {
56
+ const portArg = opt("--port", process.env.PORT || 5180);
57
+ const assetArgs = [];
58
+ for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
59
+ let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
60
+ if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
61
+ if (!isSupportedPlatform(process.platform)) {
45
62
  const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
46
63
  await showPlatformNotice(PORT);
47
64
  process.exit(0);
48
65
  }
66
+ const LOGIN = argv[0] === "login";
67
+ if (LOGIN) argv.shift();
49
68
  const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
50
- const DECK_DIR = path.resolve(argv[0] || process.cwd());
51
- const DECK_FILE = "index.html";
52
- const EDITABLE = ["index.html", "theme.css"]; // Claude may edit these
69
+ /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
70
+ that is not a folder is a file, so a missing file gets a clear message. */
71
+ const TARGET = path.resolve(argv[0] || process.cwd());
72
+ const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
73
+ const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
74
+ const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
75
+ const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
76
+ const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
77
+ const EDITABLE = [DECK_FILE, "theme.css"]; // Claude may edit these
53
78
  const LOCKED = ["brand.css"]; // never, enforced twice
54
79
  const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
55
- const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
56
- crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
80
+ /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
81
+ histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
82
+ const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
83
+ crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
57
84
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
58
85
  const SHOT_DIR = path.join(STATE_DIR, "shots");
59
86
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
87
+ /* pindeck login: a new one-time login link from the running server, asked with the token in its server
88
+ file (readable only by this user) */
89
+ if (LOGIN) {
90
+ const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
91
+ let info = null;
92
+ for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
93
+ if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
94
+ try {
95
+ const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
96
+ if (!res.ok) throw new Error(res.status);
97
+ console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
98
+ process.exit(0);
99
+ } catch {
100
+ console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
101
+ process.exit(1);
102
+ }
103
+ }
60
104
  const TOKEN = crypto.randomBytes(16).toString("hex");
61
- const ORIGIN = `http://127.0.0.1:${PORT}`;
105
+ let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
62
106
  const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
63
- const CLI_CMD = /\s/.test(CLI_REL) ? `node "${CLI_REL}"` : `node ${CLI_REL}`;
107
+ const CLI_CMD = cliCommand(CLI_REL);
108
+ const CAPS = detectCapabilities();
64
109
 
65
- if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
66
- console.error(`No ${DECK_FILE} in ${DECK_DIR}\n\n${USAGE}`);
110
+ /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
111
+ function checkDeck() {
112
+ const file = path.join(DECK_DIR, DECK_FILE);
113
+ if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
114
+ try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
115
+ catch { return `${file} cannot be read and written (check its permissions).`; }
116
+ // saving replaces the file through a temporary file next to it, so the folder must be writable too
117
+ try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
118
+ catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
119
+ return null;
120
+ }
121
+ const deckProblem = checkDeck();
122
+ if (deckProblem) {
123
+ console.error(`${deckProblem}\n\n${USAGE}`);
124
+ process.exit(1);
125
+ }
126
+ // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
127
+ const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
128
+ const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
129
+ if (frameworkProblem) {
130
+ console.error(frameworkProblem);
67
131
  process.exit(1);
68
132
  }
69
- fs.mkdirSync(SHOT_DIR, { recursive: true });
70
- const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
71
- fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
72
- fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
133
+ /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
134
+ port is bound (see Start), so a failed start leaves no state behind. */
135
+ const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
136
+ function writeServerInfo() {
137
+ for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
138
+ const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
139
+ for (const f of SERVER_FILES) {
140
+ fs.writeFileSync(f, info, { mode: 0o600 });
141
+ fs.chmodSync(f, 0o600);
142
+ }
143
+ }
144
+ /* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
145
+ function removeServerInfo() {
146
+ for (const f of SERVER_FILES) {
147
+ try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
148
+ }
149
+ }
73
150
 
74
151
  /* ---- Small helpers --------------------------------------------------------------- */
75
152
  const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
76
153
  mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
77
154
  jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
78
155
  woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
79
- mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
156
+ ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
157
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
80
158
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
81
159
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
160
+ /* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
161
+ their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
162
+ the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
163
+ screenshot is the slide itself. */
164
+ function deckSlides() {
165
+ const tree = parse(readDeck(DECK_FILE) || "");
166
+ const selector = slideSelector(tree);
167
+ return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
168
+ }
169
+ /* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
170
+ start-up check's message, which the editor shows and Claude is told */
171
+ function deckProblemNow() {
172
+ const html = readDeck(DECK_FILE);
173
+ return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
174
+ }
175
+ let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
82
176
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
83
177
  function writeDeck(f, content, manual = false) {
84
178
  ourWrites.set(f, { hash: hash(content), manual });
85
- fs.writeFileSync(path.join(DECK_DIR, f), content);
179
+ writeAtomic(path.join(DECK_DIR, f), content);
180
+ }
181
+ /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
182
+ function writeDeckFile(rel, data, manual = true) {
183
+ const abs = path.join(DECK_DIR, rel);
184
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
185
+ ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
186
+ writeAtomic(abs, data);
86
187
  }
87
188
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
88
- const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
189
+ /* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
190
+ a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
191
+ const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
192
+ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
193
+ const asText = (v) => (v == null ? "" : String(v));
194
+ const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
195
+ function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
196
+ if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
197
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
198
+ writeDeck(rel, v);
199
+ }
200
+ function fileChange(rel, before, after) {
201
+ if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
202
+ const d = unifiedDiff(asText(before), asText(after), rel);
203
+ return { file: rel, added: d.added, removed: d.removed };
204
+ }
205
+ const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
206
+ ? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
207
+ : unifiedDiff(asText(before), asText(after), rel).text);
208
+ /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
209
+ const deckRev = () => revision(DECK_DIR, EDITABLE);
210
+ const json = (res, code, obj) => {
211
+ res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
212
+ res.end(JSON.stringify(obj));
213
+ };
214
+ /* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
215
+ written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
216
+ notes sent while the tab closes) are not checked. */
217
+ function staleEdit(res, b) {
218
+ if (b.rev == null || b.rev === deckRev()) return false;
219
+ json(res, 409, { code: "stale", rev: deckRev(),
220
+ error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
221
+ return true;
222
+ }
89
223
  const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
90
224
  async function body(req) {
91
225
  let s = "";
@@ -109,12 +243,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
109
243
  let backedUp = false;
110
244
  function runEidPass() {
111
245
  const html = readDeck(DECK_FILE);
246
+ if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
112
247
  const r = assignEids(html);
113
248
  if (r.changed) {
114
249
  if (!backedUp) {
115
250
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
116
- fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
117
- fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
251
+ fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
252
+ fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
118
253
  backedUp = true;
119
254
  }
120
255
  writeDeck(DECK_FILE, r.html);
@@ -131,11 +266,12 @@ function runEidPass() {
131
266
  let gitFilter = null; // null: not checked yet
132
267
  const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
133
268
  function refreshGit() {
134
- const run = () => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
269
+ // another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
270
+ const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
135
271
  if (e1 || !cleaned) return;
136
272
  git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
137
273
  const staged = entry.split(/\s+/)[1];
138
- if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
274
+ if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
139
275
  });
140
276
  });
141
277
  if (gitFilter === null) {
@@ -157,23 +293,29 @@ function resolveSlide(spec) {
157
293
  }
158
294
 
159
295
  /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
160
- const CHROME = [process.env.CHROME_BIN,
161
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
162
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
163
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
164
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
165
- "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
296
+ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
166
297
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
167
298
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
168
299
  let chromeSeq = 0;
300
+ /* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
301
+ a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
302
+ to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
303
+ function sized(url, size) {
304
+ const u = new URL(url);
305
+ u.searchParams.set("width", size.width);
306
+ u.searchParams.set("height", size.height);
307
+ return u.href;
308
+ }
169
309
  function chrome(args, done) {
170
- if (!CHROME) return Promise.reject(new Error("No Chrome/Chromium found (set CHROME_BIN)."));
310
+ if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
171
311
  return new Promise((resolve, reject) => {
172
312
  // a profile per run: concurrent runs must not share one
173
313
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
314
+ const { size } = deckSlides();
174
315
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
175
316
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
176
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000", ...args]);
317
+ `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
318
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
177
319
  let out = "", err = "", finished = false;
178
320
  const finish = (fn, v) => {
179
321
  if (finished) return;
@@ -187,12 +329,13 @@ function chrome(args, done) {
187
329
  proc.stdout.on("data", (d) => { out += d; check(); });
188
330
  proc.stderr.on("data", (d) => { err += d; check(); });
189
331
  proc.on("error", (e) => finish(reject, e));
190
- proc.on("exit", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
332
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
333
+ proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
191
334
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
192
335
  });
193
336
  }
194
337
  const deckUrl = (mode, params = {}) =>
195
- `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
338
+ `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
196
339
  async function takeShot(slide) {
197
340
  const pins = state.pins.filter((p) => p.slide.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
198
341
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
@@ -226,26 +369,31 @@ function measureInEditor(slide) {
226
369
  pending.set(id, { resolve, reject, t });
227
370
  });
228
371
  }
229
- const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(slide));
372
+ const measure = (slide) => measureInEditor(slide).catch((e) => {
373
+ if (CAPS.headlessLayout.available) return measureHeadless(slide);
374
+ throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
375
+ `headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
376
+ });
230
377
 
231
378
  /* PDF export. handout: one page per slide, build steps in their final state.
232
379
  presentation: one page per build step (a slide with two clicks gives three pages).
233
380
  Files go to <deck>/export/. One export at a time. */
234
381
  let exportQueue = Promise.resolve(), snapSeq = 0;
235
- const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
382
+ const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
236
383
  function exportPdf(kind, includeHidden = false) {
237
- if (!CHROME) return Promise.reject(new Error("PDF export needs Chrome or Chromium (set CHROME_BIN)."));
384
+ if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
238
385
  const split = kind === "presentation";
239
- const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
386
+ const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
240
387
  const out = path.join(DECK_DIR, "export", name);
241
388
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
242
389
  const run = async () => {
243
390
  fs.mkdirSync(path.dirname(out), { recursive: true });
244
- const html = await printLayout(CHROME,
245
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`, profile("layout"));
391
+ const layoutProfile = profile("layout"), printProfile = profile("print");
392
+ const html = await printLayout(CHROME, chromeStart(layoutProfile,
393
+ `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
246
394
  const id = `${process.pid}-${++snapSeq}`;
247
395
  snapshots.set(id, html);
248
- try { await printPdf(CHROME, `${ORIGIN}/deck/__pindeck-print-${id}.html`, out, profile("print")); }
396
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
249
397
  finally { snapshots.delete(id); }
250
398
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
251
399
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -267,8 +415,15 @@ try {
267
415
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
268
416
  Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
269
417
  } catch {}
270
- const saveChat = () => fs.writeFile(CHAT_FILE,
271
- JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId }), () => {});
418
+ /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
419
+ let chatTimer = null;
420
+ function writeChat() {
421
+ clearTimeout(chatTimer);
422
+ chatTimer = null;
423
+ try { writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId })); }
424
+ catch (e) { console.error(e.message); }
425
+ }
426
+ const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
272
427
  function addEntry(entry) {
273
428
  entry.id = ++state.seq;
274
429
  entry.time = Date.now();
@@ -294,35 +449,99 @@ if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
294
449
  else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
295
450
  if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
296
451
  else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
452
+ /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
453
+ deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
454
+ it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
455
+ or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
456
+ a symlink that leads anywhere else is refused. */
457
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
458
+ const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
459
+ const abs = path.resolve(expandHome(String(r)));
460
+ let st = null;
461
+ try { st = fs.statSync(abs); } catch {}
462
+ if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
463
+ return abs;
464
+ });
465
+ const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
466
+ const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
467
+ const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
468
+ const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
469
+ const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
470
+ /* The deck page's URL, and the folder URL its relative paths resolve against */
471
+ const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
472
+ : `/deck/${encodeURIComponent(DECK_FILE)}`;
473
+ const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
474
+ /* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
475
+ followed. null: refuse. A missing file passes, and is answered 404. */
476
+ function servable(base, rel) {
477
+ const f = safeJoin(base, rel);
478
+ if (!f) return null;
479
+ let real;
480
+ try { real = fs.realpathSync(f); } catch { return f; }
481
+ return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
482
+ }
297
483
  const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
298
- if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings(); // create or complete the file
484
+ const completeSettings = () => { // create or complete the file (at start, once the port is bound)
485
+ if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
486
+ };
299
487
  if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
300
488
 
301
489
  const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
302
- running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
490
+ running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
491
+ slideSelector: deckNow.selector, deckProblem: deckNow.problem,
492
+ capabilities: publicCapabilities(CAPS), rev: deckRev(),
303
493
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
304
494
  const pushStatus = () => broadcast("status", status());
305
495
 
306
496
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
307
- .replaceAll("{{CLI}}", CLI_CMD);
497
+ .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
498
+ // a template's own notes usually say index.html; other decks may share this folder
499
+ (NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
500
+ `${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
501
+ (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
502
+ `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
503
+ `other decks may use them too.\n` : "");
504
+ /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
505
+ function slidesInstructions() {
506
+ const { selector } = deckSlides();
507
+ return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
508
+ `CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
509
+ `instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
510
+ `a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
511
+ `left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
512
+ }
513
+ /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
514
+ git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
515
+ win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
516
+ function lockedPaths() {
517
+ let others = [];
518
+ try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
519
+ return [...LOCKED, "vendor/**", ".git/**", ...others];
520
+ }
308
521
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
309
522
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
310
- MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
311
- the system prompt instead, read afresh at each session start. */
523
+ MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
524
+ into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
525
+ other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
526
+ not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
527
+ `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
312
528
  function deckInstructions() {
313
- try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
314
- catch { return ""; }
529
+ return ["CLAUDE.md", "AGENTS.md"].map((name) => {
530
+ const s = readInstructions(path.join(DECK_DIR, name));
531
+ return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
532
+ }).join("");
315
533
  }
316
534
  let stderrTail = "";
317
535
  const session = new ClaudeSession({
318
536
  cwd: DECK_DIR,
319
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
537
+ env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
320
538
  args: () => [
321
- "--append-system-prompt", systemPrompt + deckInstructions(),
539
+ "--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
322
540
  "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
323
541
  "--permission-mode", "default",
324
- "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map((f) => `Edit(./${f})`), `Bash(${CLI_CMD}:*)`,
325
- "--disallowedTools", ...LOCKED.flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
542
+ // any file in the deck folder (Edit rules cover Write too; Write is named as well, to be explicit)
543
+ "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)", `Bash(${CLI_CMD}:*)`,
544
+ "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
326
545
  ...(settings.model ? ["--model", settings.model] : []),
327
546
  ...(settings.effort ? ["--effort", settings.effort] : []),
328
547
  ],
@@ -338,7 +557,10 @@ const session = new ClaudeSession({
338
557
  },
339
558
  });
340
559
  function ensureSession() {
341
- if (!session.running) { stderrTail = ""; session.start(state.sessionId); pushStatus(); }
560
+ if (session.running || quitting || !detectCapabilities().claude.available) return;
561
+ stderrTail = "";
562
+ session.start(state.sessionId);
563
+ pushStatus();
342
564
  }
343
565
 
344
566
  function toolSummary(name, input = {}) {
@@ -394,10 +616,13 @@ function handleEvent(ev) {
394
616
  }
395
617
 
396
618
  function startTurn(promptText, userEntry) {
397
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
619
+ // every deck file as it is now: Claude may change any of them (not brand.css or vendor/), and undo
620
+ // takes back all it changed
621
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
398
622
  state.busy = true;
399
623
  pushStatus();
400
624
  ensureSession();
625
+ if (!session.running) return finishTurn({ error: detectCapabilities().claude.reason || "Claude Code could not be started." });
401
626
  session.send([{ type: "text", text: promptText }]);
402
627
  }
403
628
 
@@ -417,11 +642,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
417
642
  }
418
643
  toolEntries.clear();
419
644
  const eid = runEidPass();
420
- const after = snapshot(EDITABLE);
421
- const files = EDITABLE.map((f) => {
422
- const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
423
- return { file: f, added: d.added, removed: d.removed };
424
- }).filter((f) => f.added || f.removed);
645
+ const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
646
+ const before = {}, after = {};
647
+ for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
648
+ // the deck file and theme.css first, then the rest by name
649
+ const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
650
+ const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
651
+ .map((rel) => fileChange(rel, before[rel], after[rel]));
425
652
  // after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
426
653
  // deleted; undo brings them back with the turn
427
654
  const deletedFiles = [];
@@ -431,11 +658,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
431
658
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
432
659
  fs.rmSync(abs, { force: true });
433
660
  }
434
- const t = { id: turn.id, kind: "claude", before: turn.before, after, files, deletedFiles, undone: false };
661
+ const t = { id: turn.id, kind: "claude", before, after, files, deletedFiles, undone: false };
435
662
  if (files.length) { state.turns.push(t); state.redo = []; }
436
663
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
437
664
  denials, restored, newIds: eid.assigned.length });
438
665
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
666
+ if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
667
+ `too large to keep a copy of (over 2 MB): Undo leaves ${tooLarge.length > 1 ? "them" : "it"} as ${tooLarge.length > 1 ? "they are" : "it is"}.` });
439
668
  pushStatus();
440
669
  }
441
670
 
@@ -494,7 +723,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
494
723
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
495
724
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
496
725
  if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
497
- "use these paths relative to index.html):\n" + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
726
+ `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
498
727
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
499
728
  }
500
729
 
@@ -503,7 +732,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
503
732
  screenshots folder, for Claude to look at before choosing; with one, into the deck. */
504
733
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
505
734
  const bad = (t, status = 400) => ({ status, text: t });
506
- if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
735
+ if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
507
736
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
508
737
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
509
738
  [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
@@ -521,23 +750,30 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
521
750
  if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
522
751
  const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
523
752
  await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
524
- if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
753
+ if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
525
754
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
526
755
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
527
756
  const buf = fs.readFileSync(tmp);
528
757
  fs.rmSync(tmp, { force: true });
529
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
530
- ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
531
- fs.writeFileSync(path.join(DECK_DIR, out), buf);
758
+ writeDeckFile(out, buf);
532
759
  state.pdfFigures.add(out);
533
760
  return { text: `${out} (${size})` };
534
761
  }
535
762
 
536
- /* ---- File watching -------------------------------------------------------------------------- */
537
- let changed = new Set(), flush = null, eidTimer = null;
538
- fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
539
- if (!file || /(^|\/)\.|\.bak$|~$|^export[\/]/.test(file)) return;
540
- changed.add(file.split(path.sep).join("/"));
763
+ /* ---- File watching --------------------------------------------------------------------------
764
+ Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
765
+ events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
766
+ editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
767
+ files), it is started again and the editor told to reload everything. */
768
+ let changed = new Set(), flush = null, eidTimer = null, watcher = null;
769
+ function onFileEvent(_ev, file) {
770
+ if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
771
+ file = String(file).split(path.sep).join("/");
772
+ if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
773
+ changed.add(file);
774
+ scheduleFlush();
775
+ }
776
+ function scheduleFlush() {
541
777
  clearTimeout(flush);
542
778
  flush = setTimeout(() => {
543
779
  const files = [...changed];
@@ -553,14 +789,79 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
553
789
  if (external) {
554
790
  state.notes = [...new Set([...state.notes,
555
791
  "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
556
- // new elements from a hand edit get ids once the file is quiet
792
+ // new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
557
793
  clearTimeout(eidTimer);
558
- eidTimer = setTimeout(() => { if (!state.busy) runEidPass(); }, 800);
794
+ const seen = hash(readDeck(DECK_FILE) ?? "");
795
+ const pass = () => {
796
+ if (state.busy) return;
797
+ const now = readDeck(DECK_FILE);
798
+ if (now == null || hash(now) !== seen) return; // still being written; its own event comes
799
+ runEidPass();
800
+ };
801
+ eidTimer = setTimeout(pass, 800);
802
+ }
803
+ if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
804
+ const now = { selector: deckSlides().selector, problem: deckProblemNow() };
805
+ if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
806
+ if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
807
+ deckNow = now;
808
+ pushStatus();
809
+ }
559
810
  }
560
811
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
561
- broadcast("files", { files, manual });
812
+ broadcast("files", { files, manual, rev: deckRev() });
562
813
  }, 120);
563
- });
814
+ }
815
+ /* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
816
+ rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
817
+ every folder (inotify on directories) keeps working; folders created later are added as they appear.
818
+ Hidden folders (.git) and node_modules are skipped. */
819
+ function watchFolders(root, onEvent) {
820
+ const watchers = new Map(), tree = new EventEmitter();
821
+ const add = (dir) => {
822
+ if (watchers.has(dir)) return;
823
+ let w;
824
+ try {
825
+ w = fs.watch(dir, (ev, name) => {
826
+ if (name == null) return onEvent(ev, null);
827
+ const abs = path.join(dir, String(name));
828
+ if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
829
+ onEvent(ev, path.relative(root, abs));
830
+ });
831
+ } catch (e) {
832
+ if (dir === root) throw e;
833
+ return;
834
+ }
835
+ w.on("error", (e) => {
836
+ try { w.close(); } catch {}
837
+ watchers.delete(dir);
838
+ if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
839
+ });
840
+ watchers.set(dir, w);
841
+ };
842
+ const scan = (dir) => {
843
+ add(dir);
844
+ let entries = [];
845
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
846
+ for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
847
+ };
848
+ scan(root);
849
+ tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
850
+ return tree;
851
+ }
852
+ function startWatcher() {
853
+ watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
854
+ : fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
855
+ watcher.on("error", (e) => {
856
+ console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
857
+ try { watcher.close(); } catch {}
858
+ setTimeout(() => {
859
+ if (quitting) return;
860
+ try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
861
+ broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
862
+ }, 1000);
863
+ });
864
+ }
564
865
 
565
866
  /* ---- Hand edits ----------------------------------------------------------------------------------------- */
566
867
  function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
@@ -597,21 +898,42 @@ function deletePlan(eid) {
597
898
  return { ...target, html: rest, files };
598
899
  }
599
900
 
600
- /* ---- Undo (Claude's turns and hand edits share one history) ---------------------------------------- */
901
+ /* ---- Undo (Claude's turns and hand edits share one history) ----------------------------------------
902
+ Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
903
+ every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
904
+ files, and the pictures or audio files they would delete or bring back. Anything else means the files
905
+ were changed since, by hand or by another program, and going back would silently throw that work away.
906
+ Then nothing is written and the answer is 409 with the files that differ. */
907
+ function undoConflicts(t, direction) {
908
+ const expect = direction === "undo" ? t.after : t.before;
909
+ const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
910
+ // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
911
+ // ones (which must be unchanged); redo the other way round
912
+ const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
913
+ const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
914
+ for (const { rel, data } of comeBack) {
915
+ const abs = path.join(DECK_DIR, rel);
916
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
917
+ }
918
+ for (const { rel, data } of go) {
919
+ const abs = path.join(DECK_DIR, rel);
920
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
921
+ }
922
+ return files;
923
+ }
924
+ function conflict(res, direction, files) {
925
+ json(res, 409, { code: "diverged", files,
926
+ error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
927
+ `it would overwrite those changes. Reload to see the files as they are.` });
928
+ }
601
929
  function undo(res) {
602
930
  if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
603
931
  const t = liveTurns().at(-1);
604
932
  if (!t) return json(res, 409, { error: "Nothing to undo." });
605
- const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
606
- for (const f of EDITABLE) {
607
- if (readDeck(f) === t.before[f]) continue;
608
- if (t.before[f] != null) writeDeck(f, t.before[f]);
609
- else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
610
- }
611
- for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
612
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
613
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
614
- }
933
+ const diverged = undoConflicts(t, "undo");
934
+ if (diverged.length) return conflict(res, "undo", diverged);
935
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
936
+ for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
615
937
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
616
938
  t.undone = true;
617
939
  state.redo.push(t);
@@ -619,7 +941,6 @@ function undo(res) {
619
941
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
620
942
  : `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.`);
621
943
  broadcast("undone", { turnId: t.id, undone: true });
622
- if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
623
944
  pushStatus();
624
945
  return json(res, 200, {});
625
946
  }
@@ -631,7 +952,6 @@ function undo(res) {
631
952
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
632
953
  const IMPORT_MAX = 500 * 1024 * 1024;
633
954
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
634
- fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
635
955
 
636
956
  function startImport(name, buf) {
637
957
  const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
@@ -678,12 +998,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
678
998
  const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
679
999
  const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
680
1000
  const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
681
- for (const f of r.files) {
682
- const abs = path.join(DECK_DIR, f.rel);
683
- fs.mkdirSync(path.dirname(abs), { recursive: true });
684
- ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
685
- fs.writeFileSync(abs, f.data);
686
- }
1001
+ for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
687
1002
  const all = findSlides(parse(html));
688
1003
  const first = afterIdx + 1, last = afterIdx + r.fragments.length;
689
1004
  const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
@@ -741,68 +1056,172 @@ async function refineImport(imp, pdf, plans, eids, first) {
741
1056
 
742
1057
  /* ---- HTTP ------------------------------------------------------------------------------------- */
743
1058
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
744
- const HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
1059
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
1060
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
1061
+ const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
1062
+
1063
+ /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
1064
+ where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
1065
+ code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
1066
+ and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
1067
+ carries this server's port. */
1068
+ const loginCodes = new Map(); // code -> expiry (ms)
1069
+ function loginCode(ttl) {
1070
+ const now = Date.now();
1071
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
1072
+ const code = crypto.randomBytes(16).toString("hex");
1073
+ loginCodes.set(code, now + ttl);
1074
+ return code;
1075
+ }
1076
+ const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
1077
+ const cookieName = () => `pindeck-${PORT}`;
1078
+ const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
1079
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
1080
+ function cookie(req, name) {
1081
+ for (const part of String(req.headers.cookie || "").split(";")) {
1082
+ const i = part.indexOf("=");
1083
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
1084
+ }
1085
+ return null;
1086
+ }
1087
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
1088
+ const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
1089
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
1090
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
1091
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
1092
+ PinDeck checkout).</p>`;
1093
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
1094
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
1095
+ function login(res, url) {
1096
+ const code = url.searchParams.get("code") || "";
1097
+ const exp = loginCodes.get(code);
1098
+ loginCodes.delete(code);
1099
+ if (!exp || exp < Date.now()) {
1100
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1101
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
1102
+ }
1103
+ let next = url.searchParams.get("next") || "/";
1104
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
1105
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
1106
+ "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
1107
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
1108
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
1109
+ }
1110
+ /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
1111
+ its command line: the page sends it to a one-time login code, which leads on to the target. */
1112
+ function chromeStart(profile, target) {
1113
+ fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
1114
+ const start = path.join(profile, "pindeck-start.html");
1115
+ const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
1116
+ fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
1117
+ return pathToFileURL(start).href;
1118
+ }
745
1119
 
746
- function serveFile(res, file, transform) {
747
- fs.readFile(file, (err, data) => {
1120
+ /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
1121
+ Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
1122
+ function serveFile(res, file, transform, req) {
1123
+ const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
1124
+ if (transform) return fs.readFile(file, (err, data) => {
748
1125
  if (err) return text(res, 404, "Not found");
749
- const ext = path.extname(file).slice(1).toLowerCase();
750
- res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream", "cache-control": "no-store" });
751
- res.end(transform ? transform(String(data)) : data);
1126
+ const out = Buffer.from(transform(String(data)));
1127
+ res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
1128
+ res.end(out);
1129
+ });
1130
+ fs.stat(file, (err, st) => {
1131
+ if (err || !st.isFile()) return text(res, 404, "Not found");
1132
+ const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
1133
+ let start = 0, end = st.size - 1, code = 200;
1134
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1135
+ if (range && (range[1] || range[2])) {
1136
+ if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
1137
+ else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
1138
+ if (start > end || start >= st.size) {
1139
+ res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
1140
+ return res.end();
1141
+ }
1142
+ code = 206;
1143
+ headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
1144
+ }
1145
+ headers["content-length"] = st.size ? end - start + 1 : 0;
1146
+ res.writeHead(code, headers);
1147
+ if (!st.size) return res.end();
1148
+ const stream = fs.createReadStream(file, { start, end });
1149
+ stream.on("error", () => res.destroy());
1150
+ res.on("close", () => stream.destroy());
1151
+ stream.pipe(res);
752
1152
  });
753
1153
  }
754
1154
 
1155
+ let ready = false;
755
1156
  const server = http.createServer(async (req, res) => {
756
- // only pages opened as 127.0.0.1 or localhost: another site's name pointed at 127.0.0.1 (DNS rebinding)
757
- // must not read the editor page, its token or the deck
758
- if (!HOSTS.has(String(req.headers.host || "").toLowerCase())) return text(res, 403, "Bad host");
1157
+ if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1158
+ const host = String(req.headers.host || "").toLowerCase();
1159
+ if (!goodHost(host)) return text(res, 403, "Bad host");
759
1160
  const url = new URL(req.url, ORIGIN);
760
1161
  const p = url.pathname;
761
1162
  try {
762
- // UI and deck files (read-only)
763
- if (req.method === "GET" && (p === "/" || p === "/index.html"))
764
- return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("%%TOKEN%%", TOKEN));
1163
+ if (req.method === "GET" && p === "/login") return login(res, url);
1164
+ // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1165
+ const ok = authed(req);
1166
+ if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1167
+ if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1168
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1169
+ }
765
1170
  if (req.method === "GET" && p.startsWith("/editor/")) {
766
1171
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
767
- return f ? serveFile(res, f) : text(res, 403, "Forbidden");
1172
+ return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
768
1173
  }
1174
+ if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
769
1175
  let snap;
770
- if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
1176
+ if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
771
1177
  const html = snapshots.get(snap[1]);
772
1178
  if (!html) return text(res, 404, "Not found");
773
1179
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
774
1180
  return res.end(html);
775
1181
  }
776
- if (req.method === "GET" && p.startsWith("/deck/")) {
777
- const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
778
- if (!f) return text(res, 403, "Forbidden");
1182
+ // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1183
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1184
+ if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1185
+ res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1186
+ return res.end();
1187
+ }
1188
+ const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
1189
+ : p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
1190
+ if (deckPath === null) return text(res, 403, "Forbidden");
1191
+ if (deckPath) {
1192
+ const f = deckPath;
779
1193
  const mode = url.searchParams.get("editor");
780
- if (!mode || !f.endsWith(".html")) return serveFile(res, f);
1194
+ if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
781
1195
  return serveFile(res, f, (s) => {
782
1196
  // headless runs must not stream media, or Chrome never finishes the page
783
1197
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
784
- if (mode === "pdf") return s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1198
+ if (mode === "pdf") {
1199
+ s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1200
+ // a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
1201
+ return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1202
+ }
785
1203
  return s.replace(/<\/body>/i, BRIDGE_TAG);
786
1204
  });
787
1205
  }
788
1206
 
789
- // API: token required; POSTs must come from the editor page itself
1207
+ // API: token required (header or login cookie); POSTs must come from the editor page itself
790
1208
  if (!p.startsWith("/api/")) return text(res, 404, "Not found");
791
- const tok = req.headers["x-editor-token"] || url.searchParams.get("t");
792
- if (tok !== TOKEN) return text(res, 403, "Bad token");
1209
+ if (!ok) return text(res, 403, "Bad token");
793
1210
  const origin = req.headers.origin;
794
- if (req.method === "POST" && origin && origin !== ORIGIN && origin !== `http://localhost:${PORT}`)
1211
+ if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
795
1212
  return text(res, 403, "Bad origin");
796
1213
 
797
1214
  if (p === "/api/events") {
798
1215
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
799
- res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
1216
+ res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
800
1217
  turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
801
1218
  clients.add(res);
802
1219
  req.on("close", () => clients.delete(res));
803
1220
  return;
804
1221
  }
805
1222
  if (p === "/api/ping") return json(res, 200, { ok: true });
1223
+ if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1224
+ if (p === "/api/status") return json(res, 200, status());
806
1225
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
807
1226
  if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
808
1227
  if (p === "/api/measure-result" && req.method === "POST") {
@@ -814,6 +1233,9 @@ const server = http.createServer(async (req, res) => {
814
1233
 
815
1234
  if (p === "/api/message" && req.method === "POST") {
816
1235
  if (state.busy) return json(res, 409, { error: "Claude is still working." });
1236
+ // checked again here: the claude command may have been installed (or removed) since the start
1237
+ const claude = detectCapabilities().claude;
1238
+ if (!claude.available) return json(res, 503, { code: "claude-unavailable", error: claude.reason });
817
1239
  const b = await body(req);
818
1240
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
819
1241
  // what the prompt reads from pins and the selection must be there
@@ -853,18 +1275,16 @@ const server = http.createServer(async (req, res) => {
853
1275
  const buf = Buffer.concat(chunks);
854
1276
  if (kind === "pdf") {
855
1277
  if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
856
- fs.mkdirSync(PDF_DIR, { recursive: true });
1278
+ fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
857
1279
  const abs = attachPath(original, PDF_DIR);
858
- fs.writeFileSync(abs, buf);
1280
+ fs.writeFileSync(abs, buf, { mode: 0o600 });
859
1281
  state.uploads.add(abs);
860
1282
  let info = null;
861
- if (process.platform === "darwin") { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
1283
+ if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
862
1284
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
863
1285
  }
864
1286
  const rel = attachPath(original);
865
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
866
- ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
867
- fs.writeFileSync(path.join(DECK_DIR, rel), buf);
1287
+ writeDeckFile(rel, buf);
868
1288
  state.uploads.add(rel);
869
1289
  return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
870
1290
  }
@@ -886,7 +1306,7 @@ const server = http.createServer(async (req, res) => {
886
1306
  Object.assign(settings, { model, effort });
887
1307
  saveSettings();
888
1308
  // restart the session with the new flags; --resume keeps the conversation
889
- if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
1309
+ if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
890
1310
  addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
891
1311
  pushStatus();
892
1312
  return json(res, 200, { settings });
@@ -894,21 +1314,25 @@ const server = http.createServer(async (req, res) => {
894
1314
  if (p === "/api/slide-hidden" && req.method === "POST") {
895
1315
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
896
1316
  const b = await body(req);
1317
+ if (staleEdit(res, b)) return;
897
1318
  const before = snapshot(EDITABLE);
898
- const slides = findSlides(parse(before[DECK_FILE]));
1319
+ const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
899
1320
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
900
1321
  if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
901
1322
  const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
902
1323
  if (!r.changed) return json(res, 200, { unchanged: true });
1324
+ const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
1325
+ : "skipped when presenting and in PDF exports";
903
1326
  const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
904
1327
  `The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
905
- `data-visibility="hidden": they stay in the file but are skipped when presenting and in PDF exports.`,
1328
+ `data-visibility="hidden": they stay in the file but are ${skipped}.`,
906
1329
  { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
907
1330
  return json(res, 200, { turnId, index });
908
1331
  }
909
1332
  if (p === "/api/move-slide" && req.method === "POST") {
910
1333
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
911
1334
  const b = await body(req);
1335
+ if (staleEdit(res, b)) return;
912
1336
  const before = snapshot(EDITABLE);
913
1337
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
914
1338
  if (r.error) return json(res, 409, { error: r.error });
@@ -921,6 +1345,7 @@ const server = http.createServer(async (req, res) => {
921
1345
  if (p === "/api/add-slide" && req.method === "POST") {
922
1346
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
923
1347
  const b = await body(req);
1348
+ if (staleEdit(res, b)) return;
924
1349
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
925
1350
  const before = snapshot(EDITABLE);
926
1351
  const r = insertSlide(before[DECK_FILE], b.kind, b.after);
@@ -935,6 +1360,7 @@ const server = http.createServer(async (req, res) => {
935
1360
  return json(res, 200, { turnId, index: r.index, eid });
936
1361
  }
937
1362
  if (p === "/api/import" && req.method === "POST") {
1363
+ if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
938
1364
  const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
939
1365
  if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
940
1366
  const chunks = [];
@@ -960,6 +1386,7 @@ const server = http.createServer(async (req, res) => {
960
1386
  if (p === "/api/import-apply" && req.method === "POST") {
961
1387
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
962
1388
  const b = await body(req);
1389
+ if (staleEdit(res, b)) return;
963
1390
  const imp = lastImport;
964
1391
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
965
1392
  const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
@@ -990,7 +1417,7 @@ const server = http.createServer(async (req, res) => {
990
1417
  if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
991
1418
  const t = state.turns.find((t) => t.id === +m[1]);
992
1419
  if (!t) return text(res, 404, "No such change");
993
- return text(res, 200, EDITABLE.map((f) => unifiedDiff(t.before[f] ?? "", t.after[f] ?? "", f).text).filter(Boolean).join("\n\n"));
1420
+ return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
994
1421
  }
995
1422
  if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
996
1423
  const t = liveTurns().at(-1);
@@ -1002,18 +1429,12 @@ const server = http.createServer(async (req, res) => {
1002
1429
  if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
1003
1430
  const t = state.redo.at(-1);
1004
1431
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1005
- if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
1006
- state.redo = [];
1007
- pushStatus();
1008
- return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
1009
- }
1432
+ const diverged = undoConflicts(t, "redo");
1433
+ if (diverged.length) return conflict(res, "redo", diverged);
1010
1434
  state.redo.pop();
1011
- for (const f of EDITABLE) if (t.after[f] != null) writeDeck(f, t.after[f]);
1435
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1012
1436
  for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1013
- for (const { rel, data } of t.addedFiles || []) {
1014
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
1015
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
1016
- }
1437
+ for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1017
1438
  t.undone = false;
1018
1439
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1019
1440
  : `The user redid your change from turn ${t.id} after undoing it.`);
@@ -1031,6 +1452,7 @@ const server = http.createServer(async (req, res) => {
1031
1452
  if (p === "/api/delete" && req.method === "POST") {
1032
1453
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1033
1454
  const b = await body(req);
1455
+ if (staleEdit(res, b)) return;
1034
1456
  const plan = deletePlan(b.eid);
1035
1457
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1036
1458
  const before = snapshot(EDITABLE);
@@ -1050,6 +1472,7 @@ const server = http.createServer(async (req, res) => {
1050
1472
  if (p === "/api/edit" && req.method === "POST") {
1051
1473
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1052
1474
  const b = await body(req);
1475
+ if (staleEdit(res, b)) return;
1053
1476
  const before = snapshot(EDITABLE);
1054
1477
  const r = setStyles(before[DECK_FILE], b.ops || []);
1055
1478
  if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
@@ -1071,6 +1494,7 @@ const server = http.createServer(async (req, res) => {
1071
1494
  if (p === "/api/notes" && req.method === "POST") {
1072
1495
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1073
1496
  const b = await body(req);
1497
+ if (staleEdit(res, b)) return;
1074
1498
  const before = snapshot(EDITABLE);
1075
1499
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
1076
1500
  if (!r) return json(res, 404, { error: "Slide not found in the file." });
@@ -1085,6 +1509,7 @@ const server = http.createServer(async (req, res) => {
1085
1509
  if (p === "/api/text" && req.method === "POST") {
1086
1510
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1087
1511
  const b = await body(req);
1512
+ if (staleEdit(res, b)) return;
1088
1513
  const before = snapshot(EDITABLE);
1089
1514
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
1090
1515
  if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1100,6 +1525,7 @@ const server = http.createServer(async (req, res) => {
1100
1525
  if (p === "/api/split" && req.method === "POST") {
1101
1526
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1102
1527
  const b = await body(req);
1528
+ if (staleEdit(res, b)) return;
1103
1529
  const before = snapshot(EDITABLE);
1104
1530
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1105
1531
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1112,6 +1538,7 @@ const server = http.createServer(async (req, res) => {
1112
1538
  if (p === "/api/remove" && req.method === "POST") {
1113
1539
  if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1114
1540
  const b = await body(req);
1541
+ if (staleEdit(res, b)) return;
1115
1542
  const before = snapshot(EDITABLE);
1116
1543
  const html = removeElement(before[DECK_FILE], b.eid);
1117
1544
  if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1123,13 +1550,14 @@ const server = http.createServer(async (req, res) => {
1123
1550
  if (p === "/api/export" && req.method === "POST") {
1124
1551
  const b = await body(req);
1125
1552
  try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
1126
- catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1553
+ catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
1127
1554
  }
1128
1555
 
1129
1556
  // CLI endpoints (used by Claude Code via cli.mjs)
1130
1557
  if (p === "/api/pdf") {
1131
1558
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1132
1559
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
1560
+ if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
1133
1561
  const r = await exportPdf(kind, url.searchParams.get("all") === "1");
1134
1562
  return text(res, 200, r.path);
1135
1563
  }
@@ -1138,15 +1566,21 @@ const server = http.createServer(async (req, res) => {
1138
1566
  return text(res, r.status || 200, r.text);
1139
1567
  }
1140
1568
  if (p === "/api/slides") {
1141
- return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1569
+ const list = slideList(), why = !list.length && deckProblemNow();
1570
+ if (why) return text(res, 409, why);
1571
+ return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1142
1572
  }
1143
1573
  if (p === "/api/layout" || p === "/api/shot") {
1144
1574
  const slide = resolveSlide(url.searchParams.get("slide"));
1145
- if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1575
+ if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1146
1576
  if (!slide.eid) runEidPass();
1147
1577
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1148
- if (p === "/api/shot") return text(res, 200, await takeShot(s2));
1149
- return text(res, 200, formatLayout(await measure(s2)));
1578
+ if (p === "/api/shot") {
1579
+ if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
1580
+ return text(res, 200, await takeShot(s2));
1581
+ }
1582
+ try { return text(res, 200, formatLayout(await measure(s2))); }
1583
+ catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
1150
1584
  }
1151
1585
  return text(res, 404, "Not found");
1152
1586
  } catch (e) {
@@ -1157,15 +1591,56 @@ const server = http.createServer(async (req, res) => {
1157
1591
  }
1158
1592
  });
1159
1593
 
1160
- /* ---- Start ------------------------------------------------------------------------------------- */
1161
- const first = runEidPass();
1162
- if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1594
+ /* ---- Start ----------------------------------------------------------------------------------------
1595
+ The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
1596
+ the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
1597
+ let quitting = false;
1598
+ server.on("error", (e) => {
1599
+ if (ready) return console.error(e);
1600
+ console.error(e.code === "EADDRINUSE"
1601
+ ? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
1602
+ : `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
1603
+ process.exit(1);
1604
+ });
1163
1605
  server.listen(PORT, "127.0.0.1", () => {
1164
- console.log(`PinDeck: ${ORIGIN}/`);
1606
+ PORT = server.address().port;
1607
+ ORIGIN = `http://127.0.0.1:${PORT}`;
1608
+ try {
1609
+ writeServerInfo();
1610
+ completeSettings();
1611
+ fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
1612
+ const first = runEidPass();
1613
+ if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1614
+ startWatcher();
1615
+ } catch (e) {
1616
+ console.error(`PinDeck could not open the deck: ${e.message}`);
1617
+ removeServerInfo();
1618
+ process.exit(1);
1619
+ }
1620
+ ready = true;
1621
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1622
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1165
1623
  console.log(`Deck: ${DECK_DIR}`);
1166
- if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1624
+ console.log(`Server: ${CAPS.label}`);
1625
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1626
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1167
1627
  ensureSession();
1168
1628
  });
1169
- const quit = () => { session.stop(); setTimeout(() => process.exit(0), 300); };
1629
+ /* Ctrl+C or SIGTERM: stop taking requests, let Claude Code end (it gets end of input, then SIGTERM, then
1630
+ SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
1631
+ async function quit() {
1632
+ if (quitting) process.exit(1);
1633
+ quitting = true;
1634
+ try { watcher?.close(); } catch {}
1635
+ clearTimeout(flush); clearTimeout(eidTimer);
1636
+ for (const res of clients) res.end();
1637
+ for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
1638
+ server.close();
1639
+ server.closeAllConnections?.();
1640
+ removeServerInfo();
1641
+ await Promise.race([session.stop(), new Promise((r) => setTimeout(r, 6000))]);
1642
+ if (chatTimer) writeChat();
1643
+ process.exit(0);
1644
+ }
1170
1645
  process.on("SIGINT", quit);
1171
1646
  process.on("SIGTERM", quit);