pindeck 3.0.0-beta.1 → 3.0.0-beta.3

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,17 +12,19 @@
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";
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 { CodexSession } from "./lib/codex.mjs";
@@ -33,65 +35,195 @@ 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. A deck made otherwise names its slides in its <head> with
49
+ <meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
50
+ login prints a new one-time login link for the PinDeck running for that deck
51
+ (without a deck: for the PinDeck started last).
52
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
53
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
54
+ "assetRoots" in ~/.pindeck-v3/config.json.`;
42
55
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
43
56
  const argv = process.argv.slice(2);
44
57
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
45
58
  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") {
59
+ const portArg = opt("--port", process.env.PORT || 5180);
60
+ const assetArgs = [];
61
+ for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
62
+ let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
63
+ if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
64
+ if (!isSupportedPlatform(process.platform)) {
48
65
  const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
49
66
  await showPlatformNotice(PORT);
50
67
  process.exit(0);
51
68
  }
69
+ const LOGIN = argv[0] === "login";
70
+ if (LOGIN) argv.shift();
52
71
  const PROVIDER = opt("--provider", "");
53
72
  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
73
+ /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
74
+ that is not a folder is a file, so a missing file gets a clear message. */
75
+ const TARGET = path.resolve(argv[0] || process.cwd());
76
+ const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
77
+ const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
78
+ const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
79
+ const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
80
+ const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
81
+ const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
58
82
  const LOCKED = ["brand.css"]; // never, enforced twice
59
83
  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);
84
+ /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
85
+ histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
86
+ const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
87
+ crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
62
88
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
63
89
  const SHOT_DIR = path.join(STATE_DIR, "shots");
64
90
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
91
+ /* pindeck login: a new one-time login link from the running server, asked with the token in its server
92
+ file (readable only by this user) */
93
+ if (LOGIN) {
94
+ const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
95
+ let info = null;
96
+ for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
97
+ if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
98
+ try {
99
+ const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
100
+ if (!res.ok) throw new Error(res.status);
101
+ console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
102
+ process.exit(0);
103
+ } catch {
104
+ console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
105
+ process.exit(1);
106
+ }
107
+ }
65
108
  const TOKEN = crypto.randomBytes(16).toString("hex");
66
- const ORIGIN = `http://127.0.0.1:${PORT}`;
109
+ let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
67
110
  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}`;
111
+ const CLI_CMD = cliCommand(CLI_REL);
112
+ const CAPS = detectCapabilities();
69
113
 
70
- if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
71
- console.error(`No ${DECK_FILE} in ${DECK_DIR}\n\n${USAGE}`);
114
+ /* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
115
+ function checkDeck() {
116
+ const file = path.join(DECK_DIR, DECK_FILE);
117
+ if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
118
+ try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
119
+ catch { return `${file} cannot be read and written (check its permissions).`; }
120
+ // saving replaces the file through a temporary file next to it, so the folder must be writable too
121
+ try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
122
+ catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
123
+ return null;
124
+ }
125
+ const deckProblem = checkDeck();
126
+ if (deckProblem) {
127
+ console.error(`${deckProblem}\n\n${USAGE}`);
128
+ process.exit(1);
129
+ }
130
+ // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
131
+ const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
132
+ const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
133
+ if (frameworkProblem) {
134
+ console.error(frameworkProblem);
72
135
  process.exit(1);
73
136
  }
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
137
+ /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
138
+ port is bound (see Start), so a failed start leaves no state behind. */
139
+ const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
140
+ function writeServerInfo() {
141
+ for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
142
+ const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
143
+ for (const f of SERVER_FILES) {
144
+ fs.writeFileSync(f, info, { mode: 0o600 });
145
+ fs.chmodSync(f, 0o600);
146
+ }
147
+ }
148
+ /* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
149
+ function removeServerInfo() {
150
+ for (const f of SERVER_FILES) {
151
+ try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
152
+ }
153
+ }
79
154
 
80
155
  /* ---- Small helpers --------------------------------------------------------------- */
81
156
  const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
82
157
  mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
83
158
  jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
84
159
  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" };
160
+ ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
161
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
86
162
  const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
87
163
  const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
164
+ /* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
165
+ their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
166
+ the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
167
+ screenshot is the slide itself. */
168
+ function deckSlides() {
169
+ const tree = parse(readDeck(DECK_FILE) || "");
170
+ const selector = slideSelector(tree);
171
+ return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
172
+ }
173
+ /* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
174
+ start-up check's message, which the editor shows and Claude is told */
175
+ function deckProblemNow() {
176
+ const html = readDeck(DECK_FILE);
177
+ return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
178
+ }
179
+ let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
88
180
  const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
89
181
  function writeDeck(f, content, manual = false) {
90
182
  ourWrites.set(f, { hash: hash(content), manual });
91
- fs.writeFileSync(path.join(DECK_DIR, f), content);
183
+ writeAtomic(path.join(DECK_DIR, f), content);
184
+ }
185
+ /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
186
+ function writeDeckFile(rel, data, manual = true) {
187
+ const abs = path.join(DECK_DIR, rel);
188
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
189
+ ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
190
+ writeAtomic(abs, data);
92
191
  }
93
192
  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)); };
193
+ /* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
194
+ a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
195
+ const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
196
+ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
197
+ const asText = (v) => (v == null ? "" : String(v));
198
+ const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
199
+ function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
200
+ if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
201
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
202
+ writeDeck(rel, v);
203
+ }
204
+ function fileChange(rel, before, after) {
205
+ if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
206
+ const d = unifiedDiff(asText(before), asText(after), rel);
207
+ return { file: rel, added: d.added, removed: d.removed };
208
+ }
209
+ const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
210
+ ? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
211
+ : unifiedDiff(asText(before), asText(after), rel).text);
212
+ /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
213
+ const deckRev = () => revision(DECK_DIR, EDITABLE);
214
+ const json = (res, code, obj) => {
215
+ res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
216
+ res.end(JSON.stringify(obj));
217
+ };
218
+ /* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
219
+ written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
220
+ notes sent while the tab closes) are not checked. */
221
+ function staleEdit(res, b) {
222
+ if (b.rev == null || b.rev === deckRev()) return false;
223
+ json(res, 409, { code: "stale", rev: deckRev(),
224
+ error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
225
+ return true;
226
+ }
95
227
  const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
96
228
  async function body(req) {
97
229
  let s = "";
@@ -115,12 +247,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
115
247
  let backedUp = false;
116
248
  function runEidPass() {
117
249
  const html = readDeck(DECK_FILE);
250
+ if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
118
251
  const r = assignEids(html);
119
252
  if (r.changed) {
120
253
  if (!backedUp) {
121
254
  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);
255
+ fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
256
+ fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
124
257
  backedUp = true;
125
258
  }
126
259
  writeDeck(DECK_FILE, r.html);
@@ -137,11 +270,12 @@ function runEidPass() {
137
270
  let gitFilter = null; // null: not checked yet
138
271
  const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
139
272
  function refreshGit() {
140
- const run = () => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
273
+ // another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
274
+ const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
141
275
  if (e1 || !cleaned) return;
142
276
  git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
143
277
  const staged = entry.split(/\s+/)[1];
144
- if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
278
+ if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
145
279
  });
146
280
  });
147
281
  if (gitFilter === null) {
@@ -163,23 +297,29 @@ function resolveSlide(spec) {
163
297
  }
164
298
 
165
299
  /* ---- 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));
300
+ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
172
301
  /* Headless Chrome on macOS often lingers long after it has written its output, so we
173
302
  watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
174
303
  let chromeSeq = 0;
304
+ /* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
305
+ a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
306
+ to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
307
+ function sized(url, size) {
308
+ const u = new URL(url);
309
+ u.searchParams.set("width", size.width);
310
+ u.searchParams.set("height", size.height);
311
+ return u.href;
312
+ }
175
313
  function chrome(args, done) {
176
- if (!CHROME) return Promise.reject(new Error("No Chrome/Chromium found (set CHROME_BIN)."));
314
+ if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
177
315
  return new Promise((resolve, reject) => {
178
316
  // a profile per run: concurrent runs must not share one
179
317
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
318
+ const { size } = deckSlides();
180
319
  const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
181
320
  "--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]);
321
+ `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
322
+ ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
183
323
  let out = "", err = "", finished = false;
184
324
  const finish = (fn, v) => {
185
325
  if (finished) return;
@@ -193,12 +333,13 @@ function chrome(args, done) {
193
333
  proc.stdout.on("data", (d) => { out += d; check(); });
194
334
  proc.stderr.on("data", (d) => { err += d; check(); });
195
335
  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."))));
336
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
337
+ proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
197
338
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
198
339
  });
199
340
  }
200
341
  const deckUrl = (mode, params = {}) =>
201
- `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
342
+ `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
202
343
  async function takeShot(slide) {
203
344
  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
345
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
@@ -232,26 +373,31 @@ function measureInEditor(slide) {
232
373
  pending.set(id, { resolve, reject, t });
233
374
  });
234
375
  }
235
- const measure = (slide) => measureInEditor(slide).catch(() => measureHeadless(slide));
376
+ const measure = (slide) => measureInEditor(slide).catch((e) => {
377
+ if (CAPS.headlessLayout.available) return measureHeadless(slide);
378
+ throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
379
+ `headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
380
+ });
236
381
 
237
382
  /* PDF export. handout: one page per slide, build steps in their final state.
238
383
  presentation: one page per build step (a slide with two clicks gives three pages).
239
384
  Files go to <deck>/export/. One export at a time. */
240
385
  let exportQueue = Promise.resolve(), snapSeq = 0;
241
- const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
386
+ const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
242
387
  function exportPdf(kind, includeHidden = false) {
243
- if (!CHROME) return Promise.reject(new Error("PDF export needs Chrome or Chromium (set CHROME_BIN)."));
388
+ if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
244
389
  const split = kind === "presentation";
245
- const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
390
+ const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
246
391
  const out = path.join(DECK_DIR, "export", name);
247
392
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
248
393
  const run = async () => {
249
394
  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"));
395
+ const layoutProfile = profile("layout"), printProfile = profile("print");
396
+ const html = await printLayout(CHROME, chromeStart(layoutProfile,
397
+ `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
252
398
  const id = `${process.pid}-${++snapSeq}`;
253
399
  snapshots.set(id, html);
254
- try { await printPdf(CHROME, `${ORIGIN}/deck/__pindeck-print-${id}.html`, out, profile("print")); }
400
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
255
401
  finally { snapshots.delete(id); }
256
402
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
257
403
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -273,9 +419,17 @@ try {
273
419
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
274
420
  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
421
  } 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) }));
422
+ /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
423
+ let chatTimer = null;
424
+ function writeChat() {
425
+ clearTimeout(chatTimer);
426
+ chatTimer = null;
427
+ try {
428
+ writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
429
+ provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
430
+ } catch (e) { console.error(e.message); }
431
+ }
432
+ const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
279
433
  function addEntry(entry) {
280
434
  entry.id = ++state.seq;
281
435
  entry.time = Date.now();
@@ -291,56 +445,120 @@ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") :
291
445
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
292
446
  let fileConfig = {};
293
447
  try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
294
- catch (e) { if (e.code !== "ENOENT") console.warn("Invalid PinDeck configuration; using defaults."); }
448
+ catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
295
449
  const settings = migrateSettings(fileConfig);
296
450
  let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
297
451
  // Preserve the legacy selection as the first model-specific preference.
298
452
  if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
299
453
  if (PROVIDER) {
300
- if (!["claude", "codex"].includes(PROVIDER)) throw new Error("Use --provider claude or codex.");
454
+ if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
301
455
  settings.provider = PROVIDER;
302
456
  if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
303
457
  }
304
- if (MODEL) settings.model = MODEL;
458
+ if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
305
459
  // A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
306
460
  if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
307
461
  state.provider = settings.provider;
462
+ /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
463
+ deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
464
+ it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
465
+ or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
466
+ a symlink that leads anywhere else is refused. */
467
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
468
+ const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
469
+ const abs = path.resolve(expandHome(String(r)));
470
+ let st = null;
471
+ try { st = fs.statSync(abs); } catch {}
472
+ if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
473
+ return abs;
474
+ });
475
+ const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
476
+ const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
477
+ const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
478
+ const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
479
+ const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
480
+ /* The deck page's URL, and the folder URL its relative paths resolve against */
481
+ const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
482
+ : `/deck/${encodeURIComponent(DECK_FILE)}`;
483
+ const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
484
+ /* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
485
+ followed. null: refuse. A missing file passes, and is answered 404. */
486
+ function servable(base, rel) {
487
+ const f = safeJoin(base, rel);
488
+ if (!f) return null;
489
+ let real;
490
+ try { real = fs.realpathSync(f); } catch { return f; }
491
+ return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
492
+ }
493
+
308
494
  let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
309
495
  let session = null, starting = null;
310
496
  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,
497
+ running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
498
+ chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
499
+ capabilities: publicCapabilities(CAPS), rev: deckRev(),
312
500
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
313
501
  const pushStatus = () => broadcast("status", status());
314
502
 
315
503
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
316
- .replaceAll("{{CLI}}", CLI_CMD);
504
+ .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
505
+ // a template's own notes usually say index.html; other decks may share this folder
506
+ (NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
507
+ `${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
508
+ (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
509
+ `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
510
+ `other decks may use them too.\n` : "");
511
+ /* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
512
+ function slidesInstructions() {
513
+ const { selector } = deckSlides();
514
+ return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
515
+ `CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
516
+ `instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
517
+ `a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
518
+ `left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
519
+ }
520
+ /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
521
+ git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
522
+ win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
523
+ function lockedPaths() {
524
+ let others = [];
525
+ try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
526
+ return [...LOCKED, "vendor/**", ".git/**", ...others];
527
+ }
317
528
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
318
529
  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. */
530
+ MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
531
+ into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
532
+ other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
533
+ not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
534
+ `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
321
535
  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 ""; }
536
+ return ["CLAUDE.md", "AGENTS.md"].map((name) => {
537
+ const s = readInstructions(path.join(DECK_DIR, name));
538
+ return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
539
+ }).join("");
324
540
  }
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 } });
541
+ // a function: ORIGIN is known only once the port is bound
542
+ const agentEnv = () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN });
543
+ const agentTools = createDeckTools({ deckDir: DECK_DIR, deckFile: DECK_FILE, readDirs: [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS],
544
+ locked: lockedPaths, cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
545
+ env: agentEnv });
328
546
  const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
329
547
  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
548
  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.
549
+ 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
550
  Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
333
551
  Do not use shell commands, native file edits, or other tools to bypass these restrictions.
334
552
  When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
335
553
  Read README.md and the deck instructions below for conventions. Use layout after every visual change.
336
554
  PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
337
555
 
338
- ## Hand edits`) + deckInstructions();
556
+ ## Hand edits`) + slidesInstructions() + deckInstructions();
339
557
  function makeSession(choice) {
340
558
  let initial = null;
341
559
  const options = {
342
560
  cwd: DECK_DIR,
343
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
561
+ env: agentEnv,
344
562
  onEvent: ev => {
345
563
  if (ev.type === "system" && ev.subtype === "init") initial = ev;
346
564
  if (session === agent) handleEvent(ev);
@@ -351,40 +569,57 @@ function makeSession(choice) {
351
569
  pushStatus();
352
570
  },
353
571
  };
572
+ /* Claude Code: any file in the deck folder (Edit rules cover Write too; Write is named as well, to be
573
+ explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
354
574
  const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
355
575
  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})`]),
576
+ args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
577
+ "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
578
+ "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
579
+ "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
580
+ ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
581
+ "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
361
582
  ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
362
583
  });
363
584
  agent.initialEvent = () => initial;
364
585
  return agent;
365
586
  }
587
+ const providerAvailable = (provider) => detectCapabilities()[provider];
588
+ /* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
589
+ replaced, the current one. */
590
+ const retiring = new Set(), discoveryAgents = new Set();
591
+ function retire(agent) {
592
+ if (!agent) return;
593
+ const stopped = agent.stop();
594
+ retiring.add(stopped);
595
+ stopped.finally(() => retiring.delete(stopped));
596
+ }
366
597
  async function discoverModels() {
367
598
  if (discovering) return discovering;
368
599
  discovering = Promise.all(["claude", "codex"].map(async provider => {
600
+ if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
369
601
  const agent = makeSession({ provider, model: "", effort: null });
602
+ discoveryAgents.add(agent);
370
603
  try {
371
604
  if (provider === "codex") await agent.connect(); else await agent.start();
372
605
  catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
373
606
  } catch (e) { catalog[provider] = { models: [], error: e.message }; }
374
- finally { agent.stop(); }
607
+ finally { discoveryAgents.delete(agent); retire(agent); }
375
608
  })).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
376
609
  return discovering;
377
610
  }
378
611
  async function ensureSession() {
379
612
  if (starting) return starting;
380
- if (session?.running) return;
613
+ if (session?.running || quitting) return;
614
+ const available = providerAvailable(settings.provider);
615
+ if (!available.available) throw new Error(available.reason);
381
616
  starting = (async () => {
382
617
  if (!catalog[settings.provider].models.length) await discoverModels();
383
618
  Object.assign(settings, selectModel(catalog, settings, {}));
384
619
  const agent = makeSession({ ...settings });
385
620
  session = agent;
386
621
  try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
387
- catch (e) { agent.stop(); throw e; }
622
+ catch (e) { retire(agent); throw e; }
388
623
  })().finally(() => { starting = null; });
389
624
  return starting;
390
625
  }
@@ -415,12 +650,12 @@ async function changeSettings(requested) {
415
650
  if (reuse) candidate.settings = choice;
416
651
  session = candidate;
417
652
  if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
418
- if (!reuse) old?.stop();
653
+ if (!reuse) retire(old);
419
654
  saveChat();
420
655
  addEntry({ kind: "notice", text: changingProvider
421
656
  ? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
422
657
  : `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
423
- } catch (e) { if (session !== candidate) candidate.stop(); throw e; }
658
+ } catch (e) { if (session !== candidate) retire(candidate); throw e; }
424
659
  finally { state.busy = false; state.switching = false; pushStatus(); }
425
660
  }
426
661
 
@@ -433,7 +668,7 @@ function toolSummary(name, input = {}) {
433
668
  };
434
669
  switch (name) {
435
670
  case "pindeck_read": return { name: "Read", detail: base(input.path) };
436
- case "pindeck_edit": return { name: "Edit", detail: base(input.path) };
671
+ case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
437
672
  case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
438
673
  case "Read": return { name: "Read", detail: base(input.file_path) };
439
674
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
@@ -480,7 +715,9 @@ function handleEvent(ev) {
480
715
  }
481
716
 
482
717
  function startTurn(promptText, userEntry) {
483
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
718
+ // every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
719
+ // takes back all it changed
720
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
484
721
  state.busy = true;
485
722
  pushStatus();
486
723
  const turn = state.turn;
@@ -509,11 +746,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
509
746
  }
510
747
  toolEntries.clear();
511
748
  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);
749
+ const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
750
+ const before = {}, after = {};
751
+ for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
752
+ // the deck file and theme.css first, then the rest by name
753
+ const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
754
+ const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
755
+ .map((rel) => fileChange(rel, before[rel], after[rel]));
517
756
  // after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
518
757
  // deleted; undo brings them back with the turn
519
758
  const deletedFiles = [];
@@ -523,11 +762,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
523
762
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
524
763
  fs.rmSync(abs, { force: true });
525
764
  }
526
- const t = { id: turn.id, kind: "agent", provider: settings.provider, before: turn.before, after, files, deletedFiles, undone: false };
765
+ const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
527
766
  if (files.length) { state.turns.push(t); state.redo = []; }
528
767
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
529
768
  denials, restored, newIds: eid.assigned.length });
530
769
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
770
+ if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
771
+ `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
772
  pushStatus();
532
773
  }
533
774
 
@@ -586,7 +827,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
586
827
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
587
828
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
588
829
  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"));
830
+ `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
590
831
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
591
832
  }
592
833
 
@@ -595,7 +836,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
595
836
  screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
596
837
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
597
838
  const bad = (t, status = 400) => ({ status, text: t });
598
- if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
839
+ if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
599
840
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
600
841
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
601
842
  [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
@@ -613,24 +854,31 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
613
854
  if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
614
855
  const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
615
856
  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);
857
+ if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
617
858
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
618
859
  if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
619
860
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
620
861
  const buf = fs.readFileSync(tmp);
621
862
  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);
863
+ writeDeckFile(out, buf);
625
864
  state.pdfFigures.add(out);
626
865
  return { text: `${out} (${size})` };
627
866
  }
628
867
 
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("/"));
868
+ /* ---- File watching --------------------------------------------------------------------------
869
+ Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
870
+ events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
871
+ editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
872
+ files), it is started again and the editor told to reload everything. */
873
+ let changed = new Set(), flush = null, eidTimer = null, watcher = null;
874
+ function onFileEvent(_ev, file) {
875
+ if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
876
+ file = String(file).split(path.sep).join("/");
877
+ if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
878
+ changed.add(file);
879
+ scheduleFlush();
880
+ }
881
+ function scheduleFlush() {
634
882
  clearTimeout(flush);
635
883
  flush = setTimeout(() => {
636
884
  const files = [...changed];
@@ -646,14 +894,79 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
646
894
  if (external) {
647
895
  state.notes = [...new Set([...state.notes,
648
896
  "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
897
+ // new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
650
898
  clearTimeout(eidTimer);
651
- eidTimer = setTimeout(() => { if (!state.busy) runEidPass(); }, 800);
899
+ const seen = hash(readDeck(DECK_FILE) ?? "");
900
+ const pass = () => {
901
+ if (state.busy) return;
902
+ const now = readDeck(DECK_FILE);
903
+ if (now == null || hash(now) !== seen) return; // still being written; its own event comes
904
+ runEidPass();
905
+ };
906
+ eidTimer = setTimeout(pass, 800);
907
+ }
908
+ if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
909
+ const now = { selector: deckSlides().selector, problem: deckProblemNow() };
910
+ if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
911
+ if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
912
+ deckNow = now;
913
+ pushStatus();
914
+ }
652
915
  }
653
916
  // manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
654
- broadcast("files", { files, manual });
917
+ broadcast("files", { files, manual, rev: deckRev() });
655
918
  }, 120);
656
- });
919
+ }
920
+ /* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
921
+ rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
922
+ every folder (inotify on directories) keeps working; folders created later are added as they appear.
923
+ Hidden folders (.git) and node_modules are skipped. */
924
+ function watchFolders(root, onEvent) {
925
+ const watchers = new Map(), tree = new EventEmitter();
926
+ const add = (dir) => {
927
+ if (watchers.has(dir)) return;
928
+ let w;
929
+ try {
930
+ w = fs.watch(dir, (ev, name) => {
931
+ if (name == null) return onEvent(ev, null);
932
+ const abs = path.join(dir, String(name));
933
+ if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
934
+ onEvent(ev, path.relative(root, abs));
935
+ });
936
+ } catch (e) {
937
+ if (dir === root) throw e;
938
+ return;
939
+ }
940
+ w.on("error", (e) => {
941
+ try { w.close(); } catch {}
942
+ watchers.delete(dir);
943
+ if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
944
+ });
945
+ watchers.set(dir, w);
946
+ };
947
+ const scan = (dir) => {
948
+ add(dir);
949
+ let entries = [];
950
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
951
+ for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
952
+ };
953
+ scan(root);
954
+ tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
955
+ return tree;
956
+ }
957
+ function startWatcher() {
958
+ watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
959
+ : fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
960
+ watcher.on("error", (e) => {
961
+ console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
962
+ try { watcher.close(); } catch {}
963
+ setTimeout(() => {
964
+ if (quitting) return;
965
+ try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
966
+ broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
967
+ }, 1000);
968
+ });
969
+ }
657
970
 
658
971
  /* ---- Hand edits ----------------------------------------------------------------------------------------- */
659
972
  function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
@@ -690,21 +1003,42 @@ function deletePlan(eid) {
690
1003
  return { ...target, html: rest, files };
691
1004
  }
692
1005
 
693
- /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------- */
1006
+ /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
1007
+ Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
1008
+ every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
1009
+ files, and the pictures or audio files they would delete or bring back. Anything else means the files
1010
+ were changed since, by hand or by another program, and going back would silently throw that work away.
1011
+ Then nothing is written and the answer is 409 with the files that differ. */
1012
+ function undoConflicts(t, direction) {
1013
+ const expect = direction === "undo" ? t.after : t.before;
1014
+ const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
1015
+ // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
1016
+ // ones (which must be unchanged); redo the other way round
1017
+ const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
1018
+ const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
1019
+ for (const { rel, data } of comeBack) {
1020
+ const abs = path.join(DECK_DIR, rel);
1021
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
1022
+ }
1023
+ for (const { rel, data } of go) {
1024
+ const abs = path.join(DECK_DIR, rel);
1025
+ if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
1026
+ }
1027
+ return files;
1028
+ }
1029
+ function conflict(res, direction, files) {
1030
+ json(res, 409, { code: "diverged", files,
1031
+ error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
1032
+ `it would overwrite those changes. Reload to see the files as they are.` });
1033
+ }
694
1034
  function undo(res) {
695
1035
  if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
696
1036
  const t = liveTurns().at(-1);
697
1037
  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
- }
1038
+ const diverged = undoConflicts(t, "undo");
1039
+ if (diverged.length) return conflict(res, "undo", diverged);
1040
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
1041
+ for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
708
1042
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
709
1043
  t.undone = true;
710
1044
  state.redo.push(t);
@@ -712,7 +1046,6 @@ function undo(res) {
712
1046
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
713
1047
  : `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
1048
  broadcast("undone", { turnId: t.id, undone: true });
715
- if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
716
1049
  pushStatus();
717
1050
  return json(res, 200, {});
718
1051
  }
@@ -724,7 +1057,6 @@ function undo(res) {
724
1057
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
725
1058
  const IMPORT_MAX = 500 * 1024 * 1024;
726
1059
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
727
- fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
728
1060
 
729
1061
  function startImport(name, buf) {
730
1062
  const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
@@ -771,12 +1103,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
771
1103
  const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
772
1104
  const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
773
1105
  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
- }
1106
+ for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
780
1107
  const all = findSlides(parse(html));
781
1108
  const first = afterIdx + 1, last = afterIdx + r.fragments.length;
782
1109
  const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
@@ -834,68 +1161,172 @@ async function refineImport(imp, pdf, plans, eids, first) {
834
1161
 
835
1162
  /* ---- HTTP ------------------------------------------------------------------------------------- */
836
1163
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
837
- const HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
1164
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
1165
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
1166
+ const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
838
1167
 
839
- function serveFile(res, file, transform) {
840
- fs.readFile(file, (err, data) => {
1168
+ /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
1169
+ where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
1170
+ code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
1171
+ and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
1172
+ carries this server's port. */
1173
+ const loginCodes = new Map(); // code -> expiry (ms)
1174
+ function loginCode(ttl) {
1175
+ const now = Date.now();
1176
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
1177
+ const code = crypto.randomBytes(16).toString("hex");
1178
+ loginCodes.set(code, now + ttl);
1179
+ return code;
1180
+ }
1181
+ const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
1182
+ const cookieName = () => `pindeck-${PORT}`;
1183
+ const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
1184
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
1185
+ function cookie(req, name) {
1186
+ for (const part of String(req.headers.cookie || "").split(";")) {
1187
+ const i = part.indexOf("=");
1188
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
1189
+ }
1190
+ return null;
1191
+ }
1192
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
1193
+ const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
1194
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
1195
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
1196
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
1197
+ PinDeck checkout).</p>`;
1198
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
1199
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
1200
+ function login(res, url) {
1201
+ const code = url.searchParams.get("code") || "";
1202
+ const exp = loginCodes.get(code);
1203
+ loginCodes.delete(code);
1204
+ if (!exp || exp < Date.now()) {
1205
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1206
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
1207
+ }
1208
+ let next = url.searchParams.get("next") || "/";
1209
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
1210
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
1211
+ "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
1212
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
1213
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
1214
+ }
1215
+ /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
1216
+ its command line: the page sends it to a one-time login code, which leads on to the target. */
1217
+ function chromeStart(profile, target) {
1218
+ fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
1219
+ const start = path.join(profile, "pindeck-start.html");
1220
+ const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
1221
+ fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
1222
+ return pathToFileURL(start).href;
1223
+ }
1224
+
1225
+ /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
1226
+ Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
1227
+ function serveFile(res, file, transform, req) {
1228
+ const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
1229
+ if (transform) return fs.readFile(file, (err, data) => {
841
1230
  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);
1231
+ const out = Buffer.from(transform(String(data)));
1232
+ res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
1233
+ res.end(out);
1234
+ });
1235
+ fs.stat(file, (err, st) => {
1236
+ if (err || !st.isFile()) return text(res, 404, "Not found");
1237
+ const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
1238
+ let start = 0, end = st.size - 1, code = 200;
1239
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1240
+ if (range && (range[1] || range[2])) {
1241
+ if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
1242
+ else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
1243
+ if (start > end || start >= st.size) {
1244
+ res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
1245
+ return res.end();
1246
+ }
1247
+ code = 206;
1248
+ headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
1249
+ }
1250
+ headers["content-length"] = st.size ? end - start + 1 : 0;
1251
+ res.writeHead(code, headers);
1252
+ if (!st.size) return res.end();
1253
+ const stream = fs.createReadStream(file, { start, end });
1254
+ stream.on("error", () => res.destroy());
1255
+ res.on("close", () => stream.destroy());
1256
+ stream.pipe(res);
845
1257
  });
846
1258
  }
847
1259
 
1260
+ let ready = false;
848
1261
  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");
1262
+ if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1263
+ const host = String(req.headers.host || "").toLowerCase();
1264
+ if (!goodHost(host)) return text(res, 403, "Bad host");
852
1265
  const url = new URL(req.url, ORIGIN);
853
1266
  const p = url.pathname;
854
1267
  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));
1268
+ if (req.method === "GET" && p === "/login") return login(res, url);
1269
+ // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1270
+ const ok = authed(req);
1271
+ if (req.method === "GET" && (p === "/" || p === "/index.html")) {
1272
+ if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
1273
+ return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
1274
+ }
858
1275
  if (req.method === "GET" && p.startsWith("/editor/")) {
859
1276
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
860
- return f ? serveFile(res, f) : text(res, 403, "Forbidden");
1277
+ return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
861
1278
  }
1279
+ if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
862
1280
  let snap;
863
- if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
1281
+ if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
864
1282
  const html = snapshots.get(snap[1]);
865
1283
  if (!html) return text(res, 404, "Not found");
866
1284
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
867
1285
  return res.end(html);
868
1286
  }
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");
1287
+ // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1288
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1289
+ if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1290
+ res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1291
+ return res.end();
1292
+ }
1293
+ const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
1294
+ : p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
1295
+ if (deckPath === null) return text(res, 403, "Forbidden");
1296
+ if (deckPath) {
1297
+ const f = deckPath;
872
1298
  const mode = url.searchParams.get("editor");
873
- if (!mode || !f.endsWith(".html")) return serveFile(res, f);
1299
+ if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
874
1300
  return serveFile(res, f, (s) => {
875
1301
  // headless runs must not stream media, or Chrome never finishes the page
876
1302
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
877
- if (mode === "pdf") return s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1303
+ if (mode === "pdf") {
1304
+ s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
1305
+ // 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
1306
+ return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1307
+ }
878
1308
  return s.replace(/<\/body>/i, BRIDGE_TAG);
879
1309
  });
880
1310
  }
881
1311
 
882
- // API: token required; POSTs must come from the editor page itself
1312
+ // API: token required (header or login cookie); POSTs must come from the editor page itself
883
1313
  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");
1314
+ if (!ok) return text(res, 403, "Bad token");
886
1315
  const origin = req.headers.origin;
887
- if (req.method === "POST" && origin && origin !== ORIGIN && origin !== `http://localhost:${PORT}`)
1316
+ if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
888
1317
  return text(res, 403, "Bad origin");
889
1318
 
890
1319
  if (p === "/api/events") {
891
1320
  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(),
1321
+ res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
893
1322
  turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
894
1323
  clients.add(res);
895
1324
  req.on("close", () => clients.delete(res));
896
1325
  return;
897
1326
  }
898
1327
  if (p === "/api/ping") return json(res, 200, { ok: true });
1328
+ if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1329
+ if (p === "/api/status") return json(res, 200, status());
899
1330
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
900
1331
  if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
901
1332
  if (p === "/api/measure-result" && req.method === "POST") {
@@ -907,6 +1338,9 @@ const server = http.createServer(async (req, res) => {
907
1338
 
908
1339
  if (p === "/api/message" && req.method === "POST") {
909
1340
  if (state.busy) return json(res, 409, { error: "The assistant is still working." });
1341
+ // checked again here: the claude or codex command may have been installed (or removed) since the start
1342
+ const backend = providerAvailable(settings.provider);
1343
+ if (!backend.available) return json(res, 503, { code: `${settings.provider}-unavailable`, error: backend.reason });
910
1344
  const b = await body(req);
911
1345
  if (state.busy) return json(res, 409, { error: "The assistant is still working." });
912
1346
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
@@ -953,18 +1387,16 @@ const server = http.createServer(async (req, res) => {
953
1387
  const buf = Buffer.concat(chunks);
954
1388
  if (kind === "pdf") {
955
1389
  if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
956
- fs.mkdirSync(PDF_DIR, { recursive: true });
1390
+ fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
957
1391
  const abs = attachPath(original, PDF_DIR);
958
- fs.writeFileSync(abs, buf);
1392
+ fs.writeFileSync(abs, buf, { mode: 0o600 });
959
1393
  state.uploads.add(abs);
960
1394
  let info = null;
961
- if (process.platform === "darwin") { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
1395
+ if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
962
1396
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
963
1397
  }
964
1398
  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);
1399
+ writeDeckFile(rel, buf);
968
1400
  state.uploads.add(rel);
969
1401
  return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
970
1402
  }
@@ -987,21 +1419,25 @@ const server = http.createServer(async (req, res) => {
987
1419
  if (p === "/api/slide-hidden" && req.method === "POST") {
988
1420
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
989
1421
  const b = await body(req);
1422
+ if (staleEdit(res, b)) return;
990
1423
  const before = snapshot(EDITABLE);
991
- const slides = findSlides(parse(before[DECK_FILE]));
1424
+ const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
992
1425
  const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
993
1426
  if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
994
1427
  const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
995
1428
  if (!r.changed) return json(res, 200, { unchanged: true });
1429
+ const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
1430
+ : "skipped when presenting and in PDF exports";
996
1431
  const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
997
1432
  `The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
998
- `data-visibility="hidden": they stay in the file but are skipped when presenting and in PDF exports.`,
1433
+ `data-visibility="hidden": they stay in the file but are ${skipped}.`,
999
1434
  { manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
1000
1435
  return json(res, 200, { turnId, index });
1001
1436
  }
1002
1437
  if (p === "/api/move-slide" && req.method === "POST") {
1003
1438
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1004
1439
  const b = await body(req);
1440
+ if (staleEdit(res, b)) return;
1005
1441
  const before = snapshot(EDITABLE);
1006
1442
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
1007
1443
  if (r.error) return json(res, 409, { error: r.error });
@@ -1014,6 +1450,7 @@ const server = http.createServer(async (req, res) => {
1014
1450
  if (p === "/api/add-slide" && req.method === "POST") {
1015
1451
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1016
1452
  const b = await body(req);
1453
+ if (staleEdit(res, b)) return;
1017
1454
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
1018
1455
  const before = snapshot(EDITABLE);
1019
1456
  const r = insertSlide(before[DECK_FILE], b.kind, b.after);
@@ -1028,6 +1465,7 @@ const server = http.createServer(async (req, res) => {
1028
1465
  return json(res, 200, { turnId, index: r.index, eid });
1029
1466
  }
1030
1467
  if (p === "/api/import" && req.method === "POST") {
1468
+ if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
1031
1469
  const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
1032
1470
  if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
1033
1471
  const chunks = [];
@@ -1053,6 +1491,7 @@ const server = http.createServer(async (req, res) => {
1053
1491
  if (p === "/api/import-apply" && req.method === "POST") {
1054
1492
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1055
1493
  const b = await body(req);
1494
+ if (staleEdit(res, b)) return;
1056
1495
  const imp = lastImport;
1057
1496
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
1058
1497
  const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
@@ -1068,7 +1507,7 @@ const server = http.createServer(async (req, res) => {
1068
1507
  if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
1069
1508
  Promise.resolve(session.interrupt()).catch(() => {});
1070
1509
  setTimeout(() => {
1071
- if (state.busy && state.turn === interruptedTurn) { session.stop(); finishTurn({ error: "Stopped." }); }
1510
+ if (state.busy && state.turn === interruptedTurn) { session?.stop(); finishTurn({ error: "Stopped." }); }
1072
1511
  }, 5000); // force only if the interrupted turn does not answer
1073
1512
  }
1074
1513
  return json(res, 200, {});
@@ -1087,7 +1526,7 @@ const server = http.createServer(async (req, res) => {
1087
1526
  if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
1088
1527
  const t = state.turns.find((t) => t.id === +m[1]);
1089
1528
  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"));
1529
+ return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
1091
1530
  }
1092
1531
  if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
1093
1532
  const t = liveTurns().at(-1);
@@ -1099,18 +1538,12 @@ const server = http.createServer(async (req, res) => {
1099
1538
  if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
1100
1539
  const t = state.redo.at(-1);
1101
1540
  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
- }
1541
+ const diverged = undoConflicts(t, "redo");
1542
+ if (diverged.length) return conflict(res, "redo", diverged);
1107
1543
  state.redo.pop();
1108
- for (const f of EDITABLE) if (t.after[f] != null) writeDeck(f, t.after[f]);
1544
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1109
1545
  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
- }
1546
+ for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1114
1547
  t.undone = false;
1115
1548
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1116
1549
  : `The user redid your change from turn ${t.id} after undoing it.`);
@@ -1128,6 +1561,7 @@ const server = http.createServer(async (req, res) => {
1128
1561
  if (p === "/api/delete" && req.method === "POST") {
1129
1562
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1130
1563
  const b = await body(req);
1564
+ if (staleEdit(res, b)) return;
1131
1565
  const plan = deletePlan(b.eid);
1132
1566
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
1133
1567
  const before = snapshot(EDITABLE);
@@ -1147,6 +1581,7 @@ const server = http.createServer(async (req, res) => {
1147
1581
  if (p === "/api/edit" && req.method === "POST") {
1148
1582
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1149
1583
  const b = await body(req);
1584
+ if (staleEdit(res, b)) return;
1150
1585
  const before = snapshot(EDITABLE);
1151
1586
  const r = setStyles(before[DECK_FILE], b.ops || []);
1152
1587
  if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
@@ -1168,6 +1603,7 @@ const server = http.createServer(async (req, res) => {
1168
1603
  if (p === "/api/notes" && req.method === "POST") {
1169
1604
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1170
1605
  const b = await body(req);
1606
+ if (staleEdit(res, b)) return;
1171
1607
  const before = snapshot(EDITABLE);
1172
1608
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
1173
1609
  if (!r) return json(res, 404, { error: "Slide not found in the file." });
@@ -1182,6 +1618,7 @@ const server = http.createServer(async (req, res) => {
1182
1618
  if (p === "/api/text" && req.method === "POST") {
1183
1619
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1184
1620
  const b = await body(req);
1621
+ if (staleEdit(res, b)) return;
1185
1622
  const before = snapshot(EDITABLE);
1186
1623
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
1187
1624
  if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1197,6 +1634,7 @@ const server = http.createServer(async (req, res) => {
1197
1634
  if (p === "/api/split" && req.method === "POST") {
1198
1635
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1199
1636
  const b = await body(req);
1637
+ if (staleEdit(res, b)) return;
1200
1638
  const before = snapshot(EDITABLE);
1201
1639
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1202
1640
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1209,6 +1647,7 @@ const server = http.createServer(async (req, res) => {
1209
1647
  if (p === "/api/remove" && req.method === "POST") {
1210
1648
  if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1211
1649
  const b = await body(req);
1650
+ if (staleEdit(res, b)) return;
1212
1651
  const before = snapshot(EDITABLE);
1213
1652
  const html = removeElement(before[DECK_FILE], b.eid);
1214
1653
  if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
@@ -1220,13 +1659,14 @@ const server = http.createServer(async (req, res) => {
1220
1659
  if (p === "/api/export" && req.method === "POST") {
1221
1660
  const b = await body(req);
1222
1661
  try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
1223
- catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1662
+ catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
1224
1663
  }
1225
1664
 
1226
1665
  // CLI endpoints (used by both backends via cli.mjs)
1227
1666
  if (p === "/api/pdf") {
1228
1667
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1229
1668
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
1669
+ if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
1230
1670
  const r = await exportPdf(kind, url.searchParams.get("all") === "1");
1231
1671
  return text(res, 200, r.path);
1232
1672
  }
@@ -1236,15 +1676,21 @@ const server = http.createServer(async (req, res) => {
1236
1676
  return text(res, r.status || 200, r.text);
1237
1677
  }
1238
1678
  if (p === "/api/slides") {
1239
- return text(res, 200, slideList().map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1679
+ const list = slideList(), why = !list.length && deckProblemNow();
1680
+ if (why) return text(res, 409, why);
1681
+ return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
1240
1682
  }
1241
1683
  if (p === "/api/layout" || p === "/api/shot") {
1242
1684
  const slide = resolveSlide(url.searchParams.get("slide"));
1243
- if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1685
+ if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
1244
1686
  if (!slide.eid) runEidPass();
1245
1687
  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)));
1688
+ if (p === "/api/shot") {
1689
+ if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
1690
+ return text(res, 200, await takeShot(s2));
1691
+ }
1692
+ try { return text(res, 200, formatLayout(await measure(s2))); }
1693
+ catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
1248
1694
  }
1249
1695
  return text(res, 404, "Not found");
1250
1696
  } catch (e) {
@@ -1255,13 +1701,38 @@ const server = http.createServer(async (req, res) => {
1255
1701
  }
1256
1702
  });
1257
1703
 
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")}).`);
1704
+ /* ---- Start ----------------------------------------------------------------------------------------
1705
+ The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
1706
+ the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
1707
+ let quitting = false;
1708
+ server.on("error", (e) => {
1709
+ if (ready) return console.error(e);
1710
+ console.error(e.code === "EADDRINUSE"
1711
+ ? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
1712
+ : `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
1713
+ process.exit(1);
1714
+ });
1261
1715
  server.listen(PORT, "127.0.0.1", () => {
1262
- console.log(`PinDeck: ${ORIGIN}/`);
1716
+ PORT = server.address().port;
1717
+ ORIGIN = `http://127.0.0.1:${PORT}`;
1718
+ try {
1719
+ writeServerInfo();
1720
+ fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
1721
+ const first = runEidPass();
1722
+ if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1723
+ startWatcher();
1724
+ } catch (e) {
1725
+ console.error(`PinDeck could not open the deck: ${e.message}`);
1726
+ removeServerInfo();
1727
+ process.exit(1);
1728
+ }
1729
+ ready = true;
1730
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1731
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1263
1732
  console.log(`Deck: ${DECK_DIR}`);
1264
- if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1733
+ console.log(`Server: ${CAPS.label}`);
1734
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1735
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1265
1736
  discoverModels().then(() => {
1266
1737
  if (!settings.model) {
1267
1738
  const models = catalog[settings.provider].models;
@@ -1271,6 +1742,22 @@ server.listen(PORT, "127.0.0.1", () => {
1271
1742
  pushStatus();
1272
1743
  });
1273
1744
  });
1274
- const quit = () => { session?.stop(); setTimeout(() => process.exit(0), 300); };
1745
+ /* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
1746
+ SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
1747
+ async function quit() {
1748
+ if (quitting) process.exit(1);
1749
+ quitting = true;
1750
+ try { watcher?.close(); } catch {}
1751
+ clearTimeout(flush); clearTimeout(eidTimer);
1752
+ for (const res of clients) res.end();
1753
+ for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
1754
+ server.close();
1755
+ server.closeAllConnections?.();
1756
+ removeServerInfo();
1757
+ for (const agent of [...discoveryAgents, session]) retire(agent);
1758
+ await Promise.race([Promise.all(retiring), new Promise((r) => setTimeout(r, 6000))]);
1759
+ if (chatTimer) writeChat();
1760
+ process.exit(0);
1761
+ }
1275
1762
  process.on("SIGINT", quit);
1276
1763
  process.on("SIGTERM", quit);