pindeck 3.0.0-beta.1 → 3.0.0-beta.2

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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /* PinDeck: local server.
3
3
 
4
- pindeck [deck-dir] [--port 5180] [--provider claude|codex] [--model <name>] (or: node server.mjs ...)
4
+ pindeck [deck-dir | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>] (or: node server.mjs ...)
5
5
 
6
6
  - serves the editor UI and the deck (with a bridge script injected in edit mode)
7
7
  - pushes file changes to the browser (server-sent events) for live reload
@@ -12,16 +12,18 @@
12
12
  - stores figures and audio attached to chat messages in <deck>/figures/ for the assistant 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";
@@ -33,65 +35,177 @@ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs
33
35
  import { readPptx } from "./lib/pptx.mjs";
34
36
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
35
37
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
38
+ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
39
+ import { unsupportedMessage } from "./lib/frameworks.mjs";
40
+ import { readInstructions } from "./lib/instructions.mjs";
41
+ import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
36
42
 
37
43
  /* ---- Config ------------------------------------------------------------------ */
38
- const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--provider claude|codex] [--model <name>]
44
+ const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
45
+ pindeck login [deck-folder | deck-file.html]
39
46
 
40
- Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
41
- The folder must contain index.html.`;
47
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
48
+ folder), which then opens its index.html.
49
+ login prints a new one-time login link for the PinDeck running for that deck
50
+ (without a deck: for the PinDeck started last).
51
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
52
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
53
+ "assetRoots" in ~/.pindeck-v3/config.json.`;
42
54
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
43
55
  const argv = process.argv.slice(2);
44
56
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
45
57
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
46
- const PORT = +opt("--port", process.env.PORT || 5180);
47
- if (process.platform !== "darwin") {
58
+ const portArg = opt("--port", process.env.PORT || 5180);
59
+ const assetArgs = [];
60
+ for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
61
+ let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
62
+ if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
63
+ if (!isSupportedPlatform(process.platform)) {
48
64
  const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
49
65
  await showPlatformNotice(PORT);
50
66
  process.exit(0);
51
67
  }
68
+ const LOGIN = argv[0] === "login";
69
+ if (LOGIN) argv.shift();
52
70
  const PROVIDER = opt("--provider", "");
53
71
  const MODEL = opt("--model", "");
54
- const deckPath = path.resolve(argv[0] || process.cwd());
55
- const DECK_DIR = fs.existsSync(deckPath) ? fs.realpathSync(deckPath) : deckPath;
56
- const DECK_FILE = "index.html";
57
- const EDITABLE = ["index.html", "theme.css"]; // Both backends may edit these
72
+ /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
73
+ that is not a folder is a file, so a missing file gets a clear message. */
74
+ const TARGET = path.resolve(argv[0] || process.cwd());
75
+ const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
76
+ const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
77
+ const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
78
+ const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
79
+ const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
80
+ const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
58
81
  const LOCKED = ["brand.css"]; // never, enforced twice
59
82
  const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
60
- const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
61
- crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
83
+ /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
84
+ histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
85
+ const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
86
+ crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
62
87
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
63
88
  const SHOT_DIR = path.join(STATE_DIR, "shots");
64
89
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
90
+ /* pindeck login: a new one-time login link from the running server, asked with the token in its server
91
+ file (readable only by this user) */
92
+ if (LOGIN) {
93
+ const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
94
+ let info = null;
95
+ for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
96
+ if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
97
+ try {
98
+ const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
99
+ if (!res.ok) throw new Error(res.status);
100
+ console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
101
+ process.exit(0);
102
+ } catch {
103
+ console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
104
+ process.exit(1);
105
+ }
106
+ }
65
107
  const TOKEN = crypto.randomBytes(16).toString("hex");
66
- const ORIGIN = `http://127.0.0.1:${PORT}`;
108
+ let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
67
109
  const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
68
- const CLI_CMD = /\s/.test(CLI_REL) ? `node "${CLI_REL}"` : `node ${CLI_REL}`;
110
+ const CLI_CMD = cliCommand(CLI_REL);
111
+ const CAPS = detectCapabilities();
69
112
 
70
- if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
71
- console.error(`No ${DECK_FILE} in ${DECK_DIR}\n\n${USAGE}`);
113
+ /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
114
+ function checkDeck() {
115
+ const file = path.join(DECK_DIR, DECK_FILE);
116
+ if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
117
+ try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
118
+ catch { return `${file} cannot be read and written (check its permissions).`; }
119
+ // saving replaces the file through a temporary file next to it, so the folder must be writable too
120
+ try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
121
+ catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
122
+ return null;
123
+ }
124
+ const deckProblem = checkDeck();
125
+ if (deckProblem) {
126
+ console.error(`${deckProblem}\n\n${USAGE}`);
72
127
  process.exit(1);
73
128
  }
74
- fs.mkdirSync(SHOT_DIR, { recursive: true });
75
- fs.mkdirSync(PDF_DIR, { recursive: true });
76
- const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
77
- fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
78
- fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
129
+ // only reveal.js for now: another framework's deck is left exactly as it is (no ids, no state)
130
+ const frameworkProblem = unsupportedMessage(fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8"), DECK_FILE);
131
+ if (frameworkProblem) {
132
+ console.error(frameworkProblem);
133
+ process.exit(1);
134
+ }
135
+ /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
136
+ port is bound (see Start), so a failed start leaves no state behind. */
137
+ const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
138
+ function writeServerInfo() {
139
+ for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
140
+ const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
141
+ for (const f of SERVER_FILES) {
142
+ fs.writeFileSync(f, info, { mode: 0o600 });
143
+ fs.chmodSync(f, 0o600);
144
+ }
145
+ }
146
+ /* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
147
+ function removeServerInfo() {
148
+ for (const f of SERVER_FILES) {
149
+ try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
150
+ }
151
+ }
79
152
 
80
153
  /* ---- Small helpers --------------------------------------------------------------- */
81
154
  const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
82
155
  mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
83
156
  jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
84
157
  woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
85
- mp4: "video/mp4", webm: "video/webm", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
158
+ ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
159
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
86
160
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
87
161
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
88
162
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
89
163
  function writeDeck(f, content, manual = false) {
90
164
  ourWrites.set(f, { hash: hash(content), manual });
91
- fs.writeFileSync(path.join(DECK_DIR, f), content);
165
+ writeAtomic(path.join(DECK_DIR, f), content);
166
+ }
167
+ /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
168
+ function writeDeckFile(rel, data, manual = true) {
169
+ const abs = path.join(DECK_DIR, rel);
170
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
171
+ ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
172
+ writeAtomic(abs, data);
92
173
  }
93
174
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
94
- const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
175
+ /* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
176
+ a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
177
+ const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
178
+ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
179
+ const asText = (v) => (v == null ? "" : String(v));
180
+ const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
181
+ function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
182
+ if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
183
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
184
+ writeDeck(rel, v);
185
+ }
186
+ function fileChange(rel, before, after) {
187
+ if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
188
+ const d = unifiedDiff(asText(before), asText(after), rel);
189
+ return { file: rel, added: d.added, removed: d.removed };
190
+ }
191
+ const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
192
+ ? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
193
+ : unifiedDiff(asText(before), asText(after), rel).text);
194
+ /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
195
+ const deckRev = () => revision(DECK_DIR, EDITABLE);
196
+ const json = (res, code, obj) => {
197
+ res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
198
+ res.end(JSON.stringify(obj));
199
+ };
200
+ /* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
201
+ written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
202
+ notes sent while the tab closes) are not checked. */
203
+ function staleEdit(res, b) {
204
+ if (b.rev == null || b.rev === deckRev()) return false;
205
+ json(res, 409, { code: "stale", rev: deckRev(),
206
+ error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
207
+ return true;
208
+ }
95
209
  const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
96
210
  async function body(req) {
97
211
  let s = "";
@@ -115,12 +229,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
115
229
  let backedUp = false;
116
230
  function runEidPass() {
117
231
  const html = readDeck(DECK_FILE);
232
+ if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
118
233
  const r = assignEids(html);
119
234
  if (r.changed) {
120
235
  if (!backedUp) {
121
236
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
122
- fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
123
- fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
237
+ fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
238
+ fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
124
239
  backedUp = true;
125
240
  }
126
241
  writeDeck(DECK_FILE, r.html);
@@ -137,11 +252,12 @@ function runEidPass() {
137
252
  let gitFilter = null; // null: not checked yet
138
253
  const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
139
254
  function refreshGit() {
140
- const run = () => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
255
+ // another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
256
+ const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
141
257
  if (e1 || !cleaned) return;
142
258
  git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
143
259
  const staged = entry.split(/\s+/)[1];
144
- if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
260
+ if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
145
261
  });
146
262
  });
147
263
  if (gitFilter === null) {
@@ -163,23 +279,19 @@ function resolveSlide(spec) {
163
279
  }
164
280
 
165
281
  /* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
166
- const CHROME = [process.env.CHROME_BIN,
167
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
168
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
169
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
170
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
171
- "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
282
+ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
172
283
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
173
284
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
174
285
  let chromeSeq = 0;
175
286
  function chrome(args, done) {
176
- if (!CHROME) return Promise.reject(new Error("No Chrome/Chromium found (set CHROME_BIN)."));
287
+ if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
177
288
  return new Promise((resolve, reject) => {
178
289
  // a profile per run: concurrent runs must not share one
179
290
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
180
291
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
181
292
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
182
- "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000", ...args]);
293
+ "--window-size=1280,720", "--force-device-scale-factor=1", "--virtual-time-budget=5000",
294
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
183
295
  let out = "", err = "", finished = false;
184
296
  const finish = (fn, v) => {
185
297
  if (finished) return;
@@ -193,12 +305,13 @@ function chrome(args, done) {
193
305
  proc.stdout.on("data", (d) => { out += d; check(); });
194
306
  proc.stderr.on("data", (d) => { err += d; check(); });
195
307
  proc.on("error", (e) => finish(reject, e));
196
- proc.on("exit", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
308
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
309
+ proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
197
310
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
198
311
  });
199
312
  }
200
313
  const deckUrl = (mode, params = {}) =>
201
- `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
314
+ `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
202
315
  async function takeShot(slide) {
203
316
  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 }));
204
317
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
@@ -232,26 +345,31 @@ function measureInEditor(slide) {
232
345
  pending.set(id, { resolve, reject, t });
233
346
  });
234
347
  }
235
- const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(slide));
348
+ const measure = (slide) => measureInEditor(slide).catch((e) => {
349
+ if (CAPS.headlessLayout.available) return measureHeadless(slide);
350
+ throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
351
+ `headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
352
+ });
236
353
 
237
354
  /* PDF export. handout: one page per slide, build steps in their final state.
238
355
  presentation: one page per build step (a slide with two clicks gives three pages).
239
356
  Files go to <deck>/export/. One export at a time. */
240
357
  let exportQueue = Promise.resolve(), snapSeq = 0;
241
- const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
358
+ const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
242
359
  function exportPdf(kind, includeHidden = false) {
243
- if (!CHROME) return Promise.reject(new Error("PDF export needs Chrome or Chromium (set CHROME_BIN)."));
360
+ if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
244
361
  const split = kind === "presentation";
245
- const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
362
+ const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
246
363
  const out = path.join(DECK_DIR, "export", name);
247
364
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
248
365
  const run = async () => {
249
366
  fs.mkdirSync(path.dirname(out), { recursive: true });
250
- const html = await printLayout(CHROME,
251
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`, profile("layout"));
367
+ const layoutProfile = profile("layout"), printProfile = profile("print");
368
+ const html = await printLayout(CHROME, chromeStart(layoutProfile,
369
+ `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
252
370
  const id = `${process.pid}-${++snapSeq}`;
253
371
  snapshots.set(id, html);
254
- try { await printPdf(CHROME, `${ORIGIN}/deck/__pindeck-print-${id}.html`, out, profile("print")); }
372
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
255
373
  finally { snapshots.delete(id); }
256
374
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
257
375
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -273,9 +391,17 @@ try {
273
391
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
274
392
  Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
275
393
  } catch {}
276
- const saveChat = () => fs.writeFileSync(CHAT_FILE,
277
- JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
278
- provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
394
+ /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
395
+ let chatTimer = null;
396
+ function writeChat() {
397
+ clearTimeout(chatTimer);
398
+ chatTimer = null;
399
+ try {
400
+ writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
401
+ provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
402
+ } catch (e) { console.error(e.message); }
403
+ }
404
+ const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
279
405
  function addEntry(entry) {
280
406
  entry.id = ++state.seq;
281
407
  entry.time = Date.now();
@@ -291,44 +417,98 @@ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") :
291
417
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
292
418
  let fileConfig = {};
293
419
  try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
294
- catch (e) { if (e.code !== "ENOENT") console.warn("Invalid PinDeck configuration; using defaults."); }
420
+ catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
295
421
  const settings = migrateSettings(fileConfig);
296
422
  let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
297
423
  // Preserve the legacy selection as the first model-specific preference.
298
424
  if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
299
425
  if (PROVIDER) {
300
- if (!["claude", "codex"].includes(PROVIDER)) throw new Error("Use --provider claude or codex.");
426
+ if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
301
427
  settings.provider = PROVIDER;
302
428
  if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
303
429
  }
304
- if (MODEL) settings.model = MODEL;
430
+ if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
305
431
  // A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
306
432
  if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
307
433
  state.provider = settings.provider;
434
+ /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
435
+ deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
436
+ it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
437
+ or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
438
+ a symlink that leads anywhere else is refused. */
439
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
440
+ const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
441
+ const abs = path.resolve(expandHome(String(r)));
442
+ let st = null;
443
+ try { st = fs.statSync(abs); } catch {}
444
+ if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
445
+ return abs;
446
+ });
447
+ const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
448
+ const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
449
+ const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
450
+ const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
451
+ const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
452
+ /* The deck page's URL, and the folder URL its relative paths resolve against */
453
+ const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
454
+ : `/deck/${encodeURIComponent(DECK_FILE)}`;
455
+ const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
456
+ /* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
457
+ followed. null: refuse. A missing file passes, and is answered 404. */
458
+ function servable(base, rel) {
459
+ const f = safeJoin(base, rel);
460
+ if (!f) return null;
461
+ let real;
462
+ try { real = fs.realpathSync(f); } catch { return f; }
463
+ return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
464
+ }
465
+
308
466
  let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
309
467
  let session = null, starting = null;
310
468
  const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
311
- running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
469
+ running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
470
+ chrome: CAPS.screenshot.available, capabilities: publicCapabilities(CAPS), rev: deckRev(),
312
471
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
313
472
  const pushStatus = () => broadcast("status", status());
314
473
 
315
474
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
316
- .replaceAll("{{CLI}}", CLI_CMD);
475
+ .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
476
+ // a template's own notes usually say index.html; other decks may share this folder
477
+ (NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
478
+ `${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
479
+ (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
480
+ `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
481
+ `other decks may use them too.\n` : "");
482
+ /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
483
+ git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
484
+ win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
485
+ function lockedPaths() {
486
+ let others = [];
487
+ try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
488
+ return [...LOCKED, "vendor/**", ".git/**", ...others];
489
+ }
317
490
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
318
491
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
319
- MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
320
- the system prompt instead, read afresh at each session start. */
492
+ MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
493
+ into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
494
+ other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
495
+ not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
496
+ `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
321
497
  function deckInstructions() {
322
- try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
323
- catch { return ""; }
498
+ return ["CLAUDE.md", "AGENTS.md"].map((name) => {
499
+ const s = readInstructions(path.join(DECK_DIR, name));
500
+ return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
501
+ }).join("");
324
502
  }
325
- const agentTools = createDeckTools({ deckDir: DECK_DIR, readDirs: [SHOT_DIR, PDF_DIR],
326
- cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
327
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN } });
503
+ // a function: ORIGIN is known only once the port is bound
504
+ const agentEnv = () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN });
505
+ const agentTools = createDeckTools({ deckDir: DECK_DIR, deckFile: DECK_FILE, readDirs: [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS],
506
+ locked: lockedPaths, cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
507
+ env: agentEnv });
328
508
  const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
329
509
  Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
330
510
  Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
331
- Use pindeck_edit for exact replacements in index.html and theme.css. Never change brand.css.
511
+ Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
332
512
  Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
333
513
  Do not use shell commands, native file edits, or other tools to bypass these restrictions.
334
514
  When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
@@ -340,7 +520,7 @@ function makeSession(choice) {
340
520
  let initial = null;
341
521
  const options = {
342
522
  cwd: DECK_DIR,
343
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
523
+ env: agentEnv,
344
524
  onEvent: ev => {
345
525
  if (ev.type === "system" && ev.subtype === "init") initial = ev;
346
526
  if (session === agent) handleEvent(ev);
@@ -351,40 +531,57 @@ function makeSession(choice) {
351
531
  pushStatus();
352
532
  },
353
533
  };
534
+ /* Claude Code: any file in the deck folder (Edit rules cover Write too; Write is named as well, to be
535
+ explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
354
536
  const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
355
537
  instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
356
- args: ["--append-system-prompt", systemPrompt + deckInstructions(),
357
- "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands",
358
- "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Bash",
359
- "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map(f => `Edit(./${f})`), ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
360
- "--disallowedTools", ...LOCKED.flatMap(f => [`Edit(./${f})`, `Write(./${f})`]),
538
+ args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
539
+ "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
540
+ "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
541
+ "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
542
+ ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
543
+ "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
361
544
  ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
362
545
  });
363
546
  agent.initialEvent = () => initial;
364
547
  return agent;
365
548
  }
549
+ const providerAvailable = (provider) => detectCapabilities()[provider];
550
+ /* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
551
+ replaced, the current one. */
552
+ const retiring = new Set(), discoveryAgents = new Set();
553
+ function retire(agent) {
554
+ if (!agent) return;
555
+ const stopped = agent.stop();
556
+ retiring.add(stopped);
557
+ stopped.finally(() => retiring.delete(stopped));
558
+ }
366
559
  async function discoverModels() {
367
560
  if (discovering) return discovering;
368
561
  discovering = Promise.all(["claude", "codex"].map(async provider => {
562
+ if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
369
563
  const agent = makeSession({ provider, model: "", effort: null });
564
+ discoveryAgents.add(agent);
370
565
  try {
371
566
  if (provider === "codex") await agent.connect(); else await agent.start();
372
567
  catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
373
568
  } catch (e) { catalog[provider] = { models: [], error: e.message }; }
374
- finally { agent.stop(); }
569
+ finally { discoveryAgents.delete(agent); retire(agent); }
375
570
  })).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
376
571
  return discovering;
377
572
  }
378
573
  async function ensureSession() {
379
574
  if (starting) return starting;
380
- if (session?.running) return;
575
+ if (session?.running || quitting) return;
576
+ const available = providerAvailable(settings.provider);
577
+ if (!available.available) throw new Error(available.reason);
381
578
  starting = (async () => {
382
579
  if (!catalog[settings.provider].models.length) await discoverModels();
383
580
  Object.assign(settings, selectModel(catalog, settings, {}));
384
581
  const agent = makeSession({ ...settings });
385
582
  session = agent;
386
583
  try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
387
- catch (e) { agent.stop(); throw e; }
584
+ catch (e) { retire(agent); throw e; }
388
585
  })().finally(() => { starting = null; });
389
586
  return starting;
390
587
  }
@@ -415,12 +612,12 @@ async function changeSettings(requested) {
415
612
  if (reuse) candidate.settings = choice;
416
613
  session = candidate;
417
614
  if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
418
- if (!reuse) old?.stop();
615
+ if (!reuse) retire(old);
419
616
  saveChat();
420
617
  addEntry({ kind: "notice", text: changingProvider
421
618
  ? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
422
619
  : `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
423
- } catch (e) { if (session !== candidate) candidate.stop(); throw e; }
620
+ } catch (e) { if (session !== candidate) retire(candidate); throw e; }
424
621
  finally { state.busy = false; state.switching = false; pushStatus(); }
425
622
  }
426
623
 
@@ -433,7 +630,7 @@ function toolSummary(name, input = {}) {
433
630
  };
434
631
  switch (name) {
435
632
  case "pindeck_read": return { name: "Read", detail: base(input.path) };
436
- case "pindeck_edit": return { name: "Edit", detail: base(input.path) };
633
+ case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
437
634
  case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
438
635
  case "Read": return { name: "Read", detail: base(input.file_path) };
439
636
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
@@ -480,7 +677,9 @@ function handleEvent(ev) {
480
677
  }
481
678
 
482
679
  function startTurn(promptText, userEntry) {
483
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
680
+ // every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
681
+ // takes back all it changed
682
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
484
683
  state.busy = true;
485
684
  pushStatus();
486
685
  const turn = state.turn;
@@ -509,11 +708,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
509
708
  }
510
709
  toolEntries.clear();
511
710
  const eid = runEidPass();
512
- const after = snapshot(EDITABLE);
513
- const files = EDITABLE.map((f) => {
514
- const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
515
- return { file: f, added: d.added, removed: d.removed };
516
- }).filter((f) => f.added || f.removed);
711
+ const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
712
+ const before = {}, after = {};
713
+ for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
714
+ // the deck file and theme.css first, then the rest by name
715
+ const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
716
+ const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
717
+ .map((rel) => fileChange(rel, before[rel], after[rel]));
517
718
  // after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
518
719
  // deleted; undo brings them back with the turn
519
720
  const deletedFiles = [];
@@ -523,11 +724,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
523
724
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
524
725
  fs.rmSync(abs, { force: true });
525
726
  }
526
- const t = { id: turn.id, kind: "agent", provider: settings.provider, before: turn.before, after, files, deletedFiles, undone: false };
727
+ const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
527
728
  if (files.length) { state.turns.push(t); state.redo = []; }
528
729
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
529
730
  denials, restored, newIds: eid.assigned.length });
530
731
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
732
+ if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
733
+ `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"}.` });
531
734
  pushStatus();
532
735
  }
533
736
 
@@ -586,7 +789,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
586
789
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
587
790
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
588
791
  if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
589
- "use these paths relative to index.html):\n" + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
792
+ `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
590
793
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
591
794
  }
592
795
 
@@ -595,7 +798,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
595
798
  screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
596
799
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
597
800
  const bad = (t, status = 400) => ({ status, text: t });
598
- if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
801
+ if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
599
802
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
600
803
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
601
804
  [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
@@ -613,24 +816,31 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
613
816
  if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
614
817
  const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
615
818
  await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
616
- if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
819
+ if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
617
820
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
618
821
  if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
619
822
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
620
823
  const buf = fs.readFileSync(tmp);
621
824
  fs.rmSync(tmp, { force: true });
622
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
623
- ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
624
- fs.writeFileSync(path.join(DECK_DIR, out), buf);
825
+ writeDeckFile(out, buf);
625
826
  state.pdfFigures.add(out);
626
827
  return { text: `${out} (${size})` };
627
828
  }
628
829
 
629
- /* ---- File watching -------------------------------------------------------------------------- */
630
- let changed = new Set(), flush = null, eidTimer = null;
631
- fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
632
- if (!file || /(^|\/)\.|\.bak$|~$|^export[\/]/.test(file)) return;
633
- changed.add(file.split(path.sep).join("/"));
830
+ /* ---- File watching --------------------------------------------------------------------------
831
+ Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
832
+ events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
833
+ editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
834
+ files), it is started again and the editor told to reload everything. */
835
+ let changed = new Set(), flush = null, eidTimer = null, watcher = null;
836
+ function onFileEvent(_ev, file) {
837
+ if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
838
+ file = String(file).split(path.sep).join("/");
839
+ if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
840
+ changed.add(file);
841
+ scheduleFlush();
842
+ }
843
+ function scheduleFlush() {
634
844
  clearTimeout(flush);
635
845
  flush = setTimeout(() => {
636
846
  const files = [...changed];
@@ -646,14 +856,71 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
646
856
  if (external) {
647
857
  state.notes = [...new Set([...state.notes,
648
858
  "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
649
- // new elements from a hand edit get ids once the file is quiet
859
+ // new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
650
860
  clearTimeout(eidTimer);
651
- eidTimer = setTimeout(() => { if (!state.busy) runEidPass(); }, 800);
861
+ const seen = hash(readDeck(DECK_FILE) ?? "");
862
+ const pass = () => {
863
+ if (state.busy) return;
864
+ const now = readDeck(DECK_FILE);
865
+ if (now == null || hash(now) !== seen) return; // still being written; its own event comes
866
+ runEidPass();
867
+ };
868
+ eidTimer = setTimeout(pass, 800);
652
869
  }
653
870
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
654
- broadcast("files", { files, manual });
871
+ broadcast("files", { files, manual, rev: deckRev() });
655
872
  }, 120);
656
- });
873
+ }
874
+ /* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
875
+ rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
876
+ every folder (inotify on directories) keeps working; folders created later are added as they appear.
877
+ Hidden folders (.git) and node_modules are skipped. */
878
+ function watchFolders(root, onEvent) {
879
+ const watchers = new Map(), tree = new EventEmitter();
880
+ const add = (dir) => {
881
+ if (watchers.has(dir)) return;
882
+ let w;
883
+ try {
884
+ w = fs.watch(dir, (ev, name) => {
885
+ if (name == null) return onEvent(ev, null);
886
+ const abs = path.join(dir, String(name));
887
+ if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
888
+ onEvent(ev, path.relative(root, abs));
889
+ });
890
+ } catch (e) {
891
+ if (dir === root) throw e;
892
+ return;
893
+ }
894
+ w.on("error", (e) => {
895
+ try { w.close(); } catch {}
896
+ watchers.delete(dir);
897
+ if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
898
+ });
899
+ watchers.set(dir, w);
900
+ };
901
+ const scan = (dir) => {
902
+ add(dir);
903
+ let entries = [];
904
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
905
+ for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
906
+ };
907
+ scan(root);
908
+ tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
909
+ return tree;
910
+ }
911
+ function startWatcher() {
912
+ watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
913
+ : fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
914
+ watcher.on("error", (e) => {
915
+ console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
916
+ try { watcher.close(); } catch {}
917
+ setTimeout(() => {
918
+ if (quitting) return;
919
+ try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
920
+ broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
921
+ }, 1000);
922
+ });
923
+ }
657
924
 
658
925
  /* ---- Hand edits ----------------------------------------------------------------------------------------- */
659
926
  function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
@@ -690,21 +957,42 @@ function deletePlan(eid) {
690
957
  return { ...target, html: rest, files };
691
958
  }
692
959
 
693
- /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------- */
960
+ /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
961
+ Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
962
+ every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
963
+ files, and the pictures or audio files they would delete or bring back. Anything else means the files
964
+ were changed since, by hand or by another program, and going back would silently throw that work away.
965
+ Then nothing is written and the answer is 409 with the files that differ. */
966
+ function undoConflicts(t, direction) {
967
+ const expect = direction === "undo" ? t.after : t.before;
968
+ const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
969
+ // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
970
+ // ones (which must be unchanged); redo the other way round
971
+ const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
972
+ const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
973
+ for (const { rel, data } of comeBack) {
974
+ const abs = path.join(DECK_DIR, rel);
975
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
976
+ }
977
+ for (const { rel, data } of go) {
978
+ const abs = path.join(DECK_DIR, rel);
979
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
980
+ }
981
+ return files;
982
+ }
983
+ function conflict(res, direction, files) {
984
+ json(res, 409, { code: "diverged", files,
985
+ error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
986
+ `it would overwrite those changes. Reload to see the files as they are.` });
987
+ }
694
988
  function undo(res) {
695
989
  if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
696
990
  const t = liveTurns().at(-1);
697
991
  if (!t) return json(res, 409, { error: "Nothing to undo." });
698
- const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
699
- for (const f of EDITABLE) {
700
- if (readDeck(f) === t.before[f]) continue;
701
- if (t.before[f] != null) writeDeck(f, t.before[f]);
702
- else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
703
- }
704
- for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
705
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
706
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
707
- }
992
+ const diverged = undoConflicts(t, "undo");
993
+ if (diverged.length) return conflict(res, "undo", diverged);
994
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
995
+ for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
708
996
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
709
997
  t.undone = true;
710
998
  state.redo.push(t);
@@ -712,7 +1000,6 @@ function undo(res) {
712
1000
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
713
1001
  : `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.`);
714
1002
  broadcast("undone", { turnId: t.id, undone: true });
715
- if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
716
1003
  pushStatus();
717
1004
  return json(res, 200, {});
718
1005
  }
@@ -724,7 +1011,6 @@ function undo(res) {
724
1011
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
725
1012
  const IMPORT_MAX = 500 * 1024 * 1024;
726
1013
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
727
- fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
728
1014
 
729
1015
  function startImport(name, buf) {
730
1016
  const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
@@ -771,12 +1057,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
771
1057
  const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
772
1058
  const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
773
1059
  const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
774
- for (const f of r.files) {
775
- const abs = path.join(DECK_DIR, f.rel);
776
- fs.mkdirSync(path.dirname(abs), { recursive: true });
777
- ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
778
- fs.writeFileSync(abs, f.data);
779
- }
1060
+ for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
780
1061
  const all = findSlides(parse(html));
781
1062
  const first = afterIdx + 1, last = afterIdx + r.fragments.length;
782
1063
  const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
@@ -834,43 +1115,142 @@ async function refineImport(imp, pdf, plans, eids, first) {
834
1115
 
835
1116
  /* ---- HTTP ------------------------------------------------------------------------------------- */
836
1117
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
837
- const HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
1118
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
1119
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
1120
+ const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
1121
+
1122
+ /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
1123
+ where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
1124
+ code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
1125
+ and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
1126
+ carries this server's port. */
1127
+ const loginCodes = new Map(); // code -> expiry (ms)
1128
+ function loginCode(ttl) {
1129
+ const now = Date.now();
1130
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
1131
+ const code = crypto.randomBytes(16).toString("hex");
1132
+ loginCodes.set(code, now + ttl);
1133
+ return code;
1134
+ }
1135
+ const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
1136
+ const cookieName = () => `pindeck-${PORT}`;
1137
+ const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
1138
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
1139
+ function cookie(req, name) {
1140
+ for (const part of String(req.headers.cookie || "").split(";")) {
1141
+ const i = part.indexOf("=");
1142
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
1143
+ }
1144
+ return null;
1145
+ }
1146
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
1147
+ const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
1148
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
1149
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
1150
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
1151
+ PinDeck checkout).</p>`;
1152
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
1153
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
1154
+ function login(res, url) {
1155
+ const code = url.searchParams.get("code") || "";
1156
+ const exp = loginCodes.get(code);
1157
+ loginCodes.delete(code);
1158
+ if (!exp || exp < Date.now()) {
1159
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1160
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
1161
+ }
1162
+ let next = url.searchParams.get("next") || "/";
1163
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
1164
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
1165
+ "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
1166
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
1167
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
1168
+ }
1169
+ /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
1170
+ its command line: the page sends it to a one-time login code, which leads on to the target. */
1171
+ function chromeStart(profile, target) {
1172
+ fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
1173
+ const start = path.join(profile, "pindeck-start.html");
1174
+ const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
1175
+ fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
1176
+ return pathToFileURL(start).href;
1177
+ }
838
1178
 
839
- function serveFile(res, file, transform) {
840
- fs.readFile(file, (err, data) => {
1179
+ /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
1180
+ Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
1181
+ function serveFile(res, file, transform, req) {
1182
+ const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
1183
+ if (transform) return fs.readFile(file, (err, data) => {
841
1184
  if (err) return text(res, 404, "Not found");
842
- const ext = path.extname(file).slice(1).toLowerCase();
843
- res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream", "cache-control": "no-store" });
844
- res.end(transform ? transform(String(data)) : data);
1185
+ const out = Buffer.from(transform(String(data)));
1186
+ res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
1187
+ res.end(out);
1188
+ });
1189
+ fs.stat(file, (err, st) => {
1190
+ if (err || !st.isFile()) return text(res, 404, "Not found");
1191
+ const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
1192
+ let start = 0, end = st.size - 1, code = 200;
1193
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1194
+ if (range && (range[1] || range[2])) {
1195
+ if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
1196
+ else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
1197
+ if (start > end || start >= st.size) {
1198
+ res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
1199
+ return res.end();
1200
+ }
1201
+ code = 206;
1202
+ headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
1203
+ }
1204
+ headers["content-length"] = st.size ? end - start + 1 : 0;
1205
+ res.writeHead(code, headers);
1206
+ if (!st.size) return res.end();
1207
+ const stream = fs.createReadStream(file, { start, end });
1208
+ stream.on("error", () => res.destroy());
1209
+ res.on("close", () => stream.destroy());
1210
+ stream.pipe(res);
845
1211
  });
846
1212
  }
847
1213
 
1214
+ let ready = false;
848
1215
  const server = http.createServer(async (req, res) => {
849
- // only pages opened as 127.0.0.1 or localhost: another site's name pointed at 127.0.0.1 (DNS rebinding)
850
- // must not read the editor page, its token or the deck
851
- if (!HOSTS.has(String(req.headers.host || "").toLowerCase())) return text(res, 403, "Bad host");
1216
+ if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1217
+ const host = String(req.headers.host || "").toLowerCase();
1218
+ if (!goodHost(host)) return text(res, 403, "Bad host");
852
1219
  const url = new URL(req.url, ORIGIN);
853
1220
  const p = url.pathname;
854
1221
  try {
855
- // UI and deck files (read-only)
856
- if (req.method === "GET" && (p === "/" || p === "/index.html"))
857
- return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), (s) => s.replace("%%TOKEN%%", TOKEN));
1222
+ if (req.method === "GET" && p === "/login") return login(res, url);
1223
+ // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1224
+ const ok = authed(req);
1225
+ if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1226
+ if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1227
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1228
+ }
858
1229
  if (req.method === "GET" && p.startsWith("/editor/")) {
859
1230
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
860
- return f ? serveFile(res, f) : text(res, 403, "Forbidden");
1231
+ return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
861
1232
  }
1233
+ if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
862
1234
  let snap;
863
- if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
1235
+ if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
864
1236
  const html = snapshots.get(snap[1]);
865
1237
  if (!html) return text(res, 404, "Not found");
866
1238
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
867
1239
  return res.end(html);
868
1240
  }
869
- if (req.method === "GET" && p.startsWith("/deck/")) {
870
- const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
871
- if (!f) return text(res, 403, "Forbidden");
1241
+ // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1242
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1243
+ if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1244
+ res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1245
+ return res.end();
1246
+ }
1247
+ const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
1248
+ : p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
1249
+ if (deckPath === null) return text(res, 403, "Forbidden");
1250
+ if (deckPath) {
1251
+ const f = deckPath;
872
1252
  const mode = url.searchParams.get("editor");
873
- if (!mode || !f.endsWith(".html")) return serveFile(res, f);
1253
+ if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
874
1254
  return serveFile(res, f, (s) => {
875
1255
  // headless runs must not stream media, or Chrome never finishes the page
876
1256
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -879,23 +1259,24 @@ const server = http.createServer(async (req, res) => {
879
1259
  });
880
1260
  }
881
1261
 
882
- // API: token required; POSTs must come from the editor page itself
1262
+ // API: token required (header or login cookie); POSTs must come from the editor page itself
883
1263
  if (!p.startsWith("/api/")) return text(res, 404, "Not found");
884
- const tok = req.headers["x-editor-token"] || url.searchParams.get("t");
885
- if (tok !== TOKEN) return text(res, 403, "Bad token");
1264
+ if (!ok) return text(res, 403, "Bad token");
886
1265
  const origin = req.headers.origin;
887
- if (req.method === "POST" && origin && origin !== ORIGIN && origin !== `http://localhost:${PORT}`)
1266
+ if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
888
1267
  return text(res, 403, "Bad origin");
889
1268
 
890
1269
  if (p === "/api/events") {
891
1270
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
892
- res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
1271
+ res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
893
1272
  turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
894
1273
  clients.add(res);
895
1274
  req.on("close", () => clients.delete(res));
896
1275
  return;
897
1276
  }
898
1277
  if (p === "/api/ping") return json(res, 200, { ok: true });
1278
+ if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1279
+ if (p === "/api/status") return json(res, 200, status());
899
1280
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
900
1281
  if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
901
1282
  if (p === "/api/measure-result" && req.method === "POST") {
@@ -907,6 +1288,9 @@ const server = http.createServer(async (req, res) => {
907
1288
 
908
1289
  if (p === "/api/message" && req.method === "POST") {
909
1290
  if (state.busy) return json(res, 409, { error: "The assistant is still working." });
1291
+ // checked again here: the claude or codex command may have been installed (or removed) since the start
1292
+ const backend = providerAvailable(settings.provider);
1293
+ if (!backend.available) return json(res, 503, { code: `${settings.provider}-unavailable`, error: backend.reason });
910
1294
  const b = await body(req);
911
1295
  if (state.busy) return json(res, 409, { error: "The assistant is still working." });
912
1296
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
@@ -953,18 +1337,16 @@ const server = http.createServer(async (req, res) => {
953
1337
  const buf = Buffer.concat(chunks);
954
1338
  if (kind === "pdf") {
955
1339
  if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
956
- fs.mkdirSync(PDF_DIR, { recursive: true });
1340
+ fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
957
1341
  const abs = attachPath(original, PDF_DIR);
958
- fs.writeFileSync(abs, buf);
1342
+ fs.writeFileSync(abs, buf, { mode: 0o600 });
959
1343
  state.uploads.add(abs);
960
1344
  let info = null;
961
- if (process.platform === "darwin") { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
1345
+ if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
962
1346
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
963
1347
  }
964
1348
  const rel = attachPath(original);
965
- fs.mkdirSync(path.join(DECK_DIR, "figures"), { recursive: true });
966
- ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
967
- fs.writeFileSync(path.join(DECK_DIR, rel), buf);
1349
+ writeDeckFile(rel, buf);
968
1350
  state.uploads.add(rel);
969
1351
  return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
970
1352
  }
@@ -987,6 +1369,7 @@ const server = http.createServer(async (req, res) => {
987
1369
  if (p === "/api/slide-hidden" && req.method === "POST") {
988
1370
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
989
1371
  const b = await body(req);
1372
+ if (staleEdit(res, b)) return;
990
1373
  const before = snapshot(EDITABLE);
991
1374
  const slides = findSlides(parse(before[DECK_FILE]));
992
1375
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
@@ -1002,6 +1385,7 @@ const server = http.createServer(async (req, res) => {
1002
1385
  if (p === "/api/move-slide" && req.method === "POST") {
1003
1386
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1004
1387
  const b = await body(req);
1388
+ if (staleEdit(res, b)) return;
1005
1389
  const before = snapshot(EDITABLE);
1006
1390
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
1007
1391
  if (r.error) return json(res, 409, { error: r.error });
@@ -1014,6 +1398,7 @@ const server = http.createServer(async (req, res) => {
1014
1398
  if (p === "/api/add-slide" && req.method === "POST") {
1015
1399
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1016
1400
  const b = await body(req);
1401
+ if (staleEdit(res, b)) return;
1017
1402
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
1018
1403
  const before = snapshot(EDITABLE);
1019
1404
  const r = insertSlide(before[DECK_FILE], b.kind, b.after);
@@ -1028,6 +1413,7 @@ const server = http.createServer(async (req, res) => {
1028
1413
  return json(res, 200, { turnId, index: r.index, eid });
1029
1414
  }
1030
1415
  if (p === "/api/import" && req.method === "POST") {
1416
+ if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
1031
1417
  const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
1032
1418
  if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
1033
1419
  const chunks = [];
@@ -1053,6 +1439,7 @@ const server = http.createServer(async (req, res) => {
1053
1439
  if (p === "/api/import-apply" && req.method === "POST") {
1054
1440
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1055
1441
  const b = await body(req);
1442
+ if (staleEdit(res, b)) return;
1056
1443
  const imp = lastImport;
1057
1444
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
1058
1445
  const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
@@ -1068,7 +1455,7 @@ const server = http.createServer(async (req, res) => {
1068
1455
  if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
1069
1456
  Promise.resolve(session.interrupt()).catch(() => {});
1070
1457
  setTimeout(() => {
1071
- if (state.busy && state.turn === interruptedTurn) { session.stop(); finishTurn({ error: "Stopped." }); }
1458
+ if (state.busy && state.turn === interruptedTurn) { session?.stop(); finishTurn({ error: "Stopped." }); }
1072
1459
  }, 5000); // force only if the interrupted turn does not answer
1073
1460
  }
1074
1461
  return json(res, 200, {});
@@ -1087,7 +1474,7 @@ const server = http.createServer(async (req, res) => {
1087
1474
  if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
1088
1475
  const t = state.turns.find((t) => t.id === +m[1]);
1089
1476
  if (!t) return text(res, 404, "No such change");
1090
- return text(res, 200, EDITABLE.map((f) => unifiedDiff(t.before[f] ?? "", t.after[f] ?? "", f).text).filter(Boolean).join("\n\n"));
1477
+ return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
1091
1478
  }
1092
1479
  if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
1093
1480
  const t = liveTurns().at(-1);
@@ -1099,18 +1486,12 @@ const server = http.createServer(async (req, res) => {
1099
1486
  if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
1100
1487
  const t = state.redo.at(-1);
1101
1488
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1102
- if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
1103
- state.redo = [];
1104
- pushStatus();
1105
- return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
1106
- }
1489
+ const diverged = undoConflicts(t, "redo");
1490
+ if (diverged.length) return conflict(res, "redo", diverged);
1107
1491
  state.redo.pop();
1108
- for (const f of EDITABLE) if (t.after[f] != null) writeDeck(f, t.after[f]);
1492
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1109
1493
  for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1110
- for (const { rel, data } of t.addedFiles || []) {
1111
- fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
1112
- fs.writeFileSync(path.join(DECK_DIR, rel), data);
1113
- }
1494
+ for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1114
1495
  t.undone = false;
1115
1496
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1116
1497
  : `The user redid your change from turn ${t.id} after undoing it.`);
@@ -1128,6 +1509,7 @@ const server = http.createServer(async (req, res) => {
1128
1509
  if (p === "/api/delete" && req.method === "POST") {
1129
1510
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1130
1511
  const b = await body(req);
1512
+ if (staleEdit(res, b)) return;
1131
1513
  const plan = deletePlan(b.eid);
1132
1514
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1133
1515
  const before = snapshot(EDITABLE);
@@ -1147,6 +1529,7 @@ const server = http.createServer(async (req, res) => {
1147
1529
  if (p === "/api/edit" && req.method === "POST") {
1148
1530
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1149
1531
  const b = await body(req);
1532
+ if (staleEdit(res, b)) return;
1150
1533
  const before = snapshot(EDITABLE);
1151
1534
  const r = setStyles(before[DECK_FILE], b.ops || []);
1152
1535
  if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
@@ -1168,6 +1551,7 @@ const server = http.createServer(async (req, res) => {
1168
1551
  if (p === "/api/notes" && req.method === "POST") {
1169
1552
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1170
1553
  const b = await body(req);
1554
+ if (staleEdit(res, b)) return;
1171
1555
  const before = snapshot(EDITABLE);
1172
1556
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
1173
1557
  if (!r) return json(res, 404, { error: "Slide not found in the file." });
@@ -1182,6 +1566,7 @@ const server = http.createServer(async (req, res) => {
1182
1566
  if (p === "/api/text" && req.method === "POST") {
1183
1567
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1184
1568
  const b = await body(req);
1569
+ if (staleEdit(res, b)) return;
1185
1570
  const before = snapshot(EDITABLE);
1186
1571
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
1187
1572
  if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1197,6 +1582,7 @@ const server = http.createServer(async (req, res) => {
1197
1582
  if (p === "/api/split" && req.method === "POST") {
1198
1583
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1199
1584
  const b = await body(req);
1585
+ if (staleEdit(res, b)) return;
1200
1586
  const before = snapshot(EDITABLE);
1201
1587
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1202
1588
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1209,6 +1595,7 @@ const server = http.createServer(async (req, res) => {
1209
1595
  if (p === "/api/remove" && req.method === "POST") {
1210
1596
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1211
1597
  const b = await body(req);
1598
+ if (staleEdit(res, b)) return;
1212
1599
  const before = snapshot(EDITABLE);
1213
1600
  const html = removeElement(before[DECK_FILE], b.eid);
1214
1601
  if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1220,13 +1607,14 @@ const server = http.createServer(async (req, res) => {
1220
1607
  if (p === "/api/export" && req.method === "POST") {
1221
1608
  const b = await body(req);
1222
1609
  try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
1223
- catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1610
+ catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
1224
1611
  }
1225
1612
 
1226
1613
  // CLI endpoints (used by both backends via cli.mjs)
1227
1614
  if (p === "/api/pdf") {
1228
1615
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1229
1616
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
1617
+ if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
1230
1618
  const r = await exportPdf(kind, url.searchParams.get("all") === "1");
1231
1619
  return text(res, 200, r.path);
1232
1620
  }
@@ -1243,8 +1631,12 @@ const server = http.createServer(async (req, res) => {
1243
1631
  if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1244
1632
  if (!slide.eid) runEidPass();
1245
1633
  const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
1246
- if (p === "/api/shot") return text(res, 200, await takeShot(s2));
1247
- return text(res, 200, formatLayout(await measure(s2)));
1634
+ if (p === "/api/shot") {
1635
+ if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
1636
+ return text(res, 200, await takeShot(s2));
1637
+ }
1638
+ try { return text(res, 200, formatLayout(await measure(s2))); }
1639
+ catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
1248
1640
  }
1249
1641
  return text(res, 404, "Not found");
1250
1642
  } catch (e) {
@@ -1255,13 +1647,38 @@ const server = http.createServer(async (req, res) => {
1255
1647
  }
1256
1648
  });
1257
1649
 
1258
- /* ---- Start ------------------------------------------------------------------------------------- */
1259
- const first = runEidPass();
1260
- if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1650
+ /* ---- Start ----------------------------------------------------------------------------------------
1651
+ The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
1652
+ the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
1653
+ let quitting = false;
1654
+ server.on("error", (e) => {
1655
+ if (ready) return console.error(e);
1656
+ console.error(e.code === "EADDRINUSE"
1657
+ ? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
1658
+ : `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
1659
+ process.exit(1);
1660
+ });
1261
1661
  server.listen(PORT, "127.0.0.1", () => {
1262
- console.log(`PinDeck: ${ORIGIN}/`);
1662
+ PORT = server.address().port;
1663
+ ORIGIN = `http://127.0.0.1:${PORT}`;
1664
+ try {
1665
+ writeServerInfo();
1666
+ fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
1667
+ const first = runEidPass();
1668
+ if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1669
+ startWatcher();
1670
+ } catch (e) {
1671
+ console.error(`PinDeck could not open the deck: ${e.message}`);
1672
+ removeServerInfo();
1673
+ process.exit(1);
1674
+ }
1675
+ ready = true;
1676
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1677
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1263
1678
  console.log(`Deck: ${DECK_DIR}`);
1264
- if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1679
+ console.log(`Server: ${CAPS.label}`);
1680
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1681
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1265
1682
  discoverModels().then(() => {
1266
1683
  if (!settings.model) {
1267
1684
  const models = catalog[settings.provider].models;
@@ -1271,6 +1688,22 @@ server.listen(PORT, "127.0.0.1", () => {
1271
1688
  pushStatus();
1272
1689
  });
1273
1690
  });
1274
- const quit = () => { session?.stop(); setTimeout(() => process.exit(0), 300); };
1691
+ /* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
1692
+ SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
1693
+ async function quit() {
1694
+ if (quitting) process.exit(1);
1695
+ quitting = true;
1696
+ try { watcher?.close(); } catch {}
1697
+ clearTimeout(flush); clearTimeout(eidTimer);
1698
+ for (const res of clients) res.end();
1699
+ for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
1700
+ server.close();
1701
+ server.closeAllConnections?.();
1702
+ removeServerInfo();
1703
+ for (const agent of [...discoveryAgents, session]) retire(agent);
1704
+ await Promise.race([Promise.all(retiring), new Promise((r) => setTimeout(r, 6000))]);
1705
+ if (chatTimer) writeChat();
1706
+ process.exit(0);
1707
+ }
1275
1708
  process.on("SIGINT", quit);
1276
1709
  process.on("SIGTERM", quit);