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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -10,16 +10,15 @@
10
10
  - answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for the assistant's self-checks, exports and PDF figures
11
11
  - exports the deck as a PDF handout or a PDF presentation (build steps on separate pages)
12
12
  - stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
13
- PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
13
+ PDFs (papers, reports, figures) in ~/.pindeck-v3, outside the deck
14
14
  - imports slides from PowerPoint files in the deck's own layouts
15
15
  Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
16
16
  cookie in the browser, a header for the CLI).
17
- Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
17
+ Runtime files (chat, screenshots, backups) live in ~/.pindeck-v3, never in the deck or the package. */
18
18
 
19
19
  import http from "node:http";
20
20
  import { EventEmitter } from "node:events";
21
21
  import fs from "node:fs";
22
- import os from "node:os";
23
22
  import path from "node:path";
24
23
  import crypto from "node:crypto";
25
24
  import { spawn, execFile } from "node:child_process";
@@ -29,9 +28,9 @@ import { unifiedDiff } from "./lib/diff.mjs";
29
28
  import { ClaudeSession } from "./lib/claude.mjs";
30
29
  import { CodexSession } from "./lib/codex.mjs";
31
30
  import { createDeckTools } from "./lib/agent-tools.mjs";
32
- import { migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
31
+ import { PROVIDERS, migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
33
32
  import { formatLayout, formatPin } from "./lib/layout.mjs";
34
- import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
33
+ import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
35
34
  import { readPptx } from "./lib/pptx.mjs";
36
35
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
37
36
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
@@ -39,19 +38,17 @@ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capability
39
38
  import { unsupportedMessage } from "./lib/frameworks.mjs";
40
39
  import { readInstructions } from "./lib/instructions.mjs";
41
40
  import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
41
+ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
42
+ import { listDecks, linkProblem } from "./lib/deck-list.mjs";
43
+ import { assetRoots } from "./lib/asset-roots.mjs";
44
+ import { registerServer, printLogin, alive } from "./lib/servers.mjs";
45
+ import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
46
+ import { USAGE } from "./lib/usage.mjs";
42
47
 
43
48
  /* ---- Config ------------------------------------------------------------------ */
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]
46
-
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.`;
49
+ /* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in and
50
+ prints the links; this server tells it its address over IPC. */
51
+ const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
55
52
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
56
53
  const argv = process.argv.slice(2);
57
54
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
@@ -70,42 +67,26 @@ const LOGIN = argv[0] === "login";
70
67
  if (LOGIN) argv.shift();
71
68
  const PROVIDER = opt("--provider", "");
72
69
  const MODEL = opt("--model", "");
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
70
+ if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
71
+ console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
72
+ /* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
73
+ const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
81
74
  const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
82
75
  const LOCKED = ["brand.css"]; // never, enforced twice
83
- const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
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);
76
+ const HOME_DIR = homeDir();
88
77
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
89
78
  const SHOT_DIR = path.join(STATE_DIR, "shots");
79
+ const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
90
80
  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
- }
81
+ /* pindeck login: a new one-time login link from the PinDeck that serves the deck (see lib/servers.mjs) */
82
+ if (LOGIN) await printLogin(HOME_DIR, argv[0] ? deckTarget(argv[0]) : null);
108
83
  const TOKEN = crypto.randomBytes(16).toString("hex");
84
+ /* The deck's pages are untrusted content: a deck (or a template, or a script the assistant wrote) must not
85
+ reach the API, the chat or other decks. They are served with a sandbox policy, which gives them an origin
86
+ of their own (no cookie, no token, no access to the editor page), under /v/<VIEW_TOKEN>/: a path that
87
+ lets them load their own files, and nothing else. */
88
+ const VIEW = `/v/${crypto.randomBytes(16).toString("hex")}`;
89
+ const SANDBOX = "sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads allow-presentation";
109
90
  let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
110
91
  const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
111
92
  const CLI_CMD = cliCommand(CLI_REL);
@@ -115,6 +96,8 @@ const CAPS = detectCapabilities();
115
96
  function checkDeck() {
116
97
  const file = path.join(DECK_DIR, DECK_FILE);
117
98
  if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
99
+ const linked = linkProblem(DECK_DIR, DECK_FILE);
100
+ if (linked) return linked + " Nothing was changed.";
118
101
  try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
119
102
  catch { return `${file} cannot be read and written (check its permissions).`; }
120
103
  // saving replaces the file through a temporary file next to it, so the folder must be writable too
@@ -122,6 +105,8 @@ function checkDeck() {
122
105
  catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
123
106
  return null;
124
107
  }
108
+ const homeNote = !FRONT && homeWarning(HOME_DIR); // behind the front, the front says it
109
+ if (homeNote) console.warn(homeNote);
125
110
  const deckProblem = checkDeck();
126
111
  if (deckProblem) {
127
112
  console.error(`${deckProblem}\n\n${USAGE}`);
@@ -129,27 +114,44 @@ if (deckProblem) {
129
114
  }
130
115
  // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
131
116
  const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
117
+ const ENDS_HTML = /<\/html>\s*$/i;
132
118
  const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
133
119
  if (frameworkProblem) {
134
120
  console.error(frameworkProblem);
135
121
  process.exit(1);
136
122
  }
123
+ /* One PinDeck per deck: two deck servers on one state folder would overwrite each other's chat and undo. The
124
+ one that has the deck open holds STATE_DIR/open.json; a second one says where the deck is open and stops,
125
+ before anything is written. */
126
+ const LOCK_FILE = path.join(STATE_DIR, "open.json");
127
+ const lockHolder = () => {
128
+ try { const o = JSON.parse(fs.readFileSync(LOCK_FILE, "utf8")); return o.pid !== process.pid && alive(o.pid) ? o : null; } catch { return null; }
129
+ };
130
+ const openElsewhere = (o) => `${path.join(DECK_DIR, DECK_FILE)} is open in another PinDeck already (${o.url}, process ${o.pid}). ` +
131
+ `Use that one, or stop it first: two PinDecks on one deck would overwrite each other's chat and undo.`;
132
+ if (lockHolder()) { console.error(openElsewhere(lockHolder())); process.exit(1); }
137
133
  /* 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
134
+ port is bound (see Start), so a failed start leaves no state behind. The server file (lib/servers.mjs) lets
135
+ pindeck login and cli.mjs find this server; behind the front, login goes to the front (a deck server's port
136
+ is no use to a browser behind a tunnel), and the assistant's CLI gets this server's address. */
137
+ let unregister = () => {};
140
138
  function writeServerInfo() {
141
139
  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);
140
+ const lock = JSON.stringify({ pid: process.pid, url: process.env.PINDECK_FRONT_URL || ORIGIN });
141
+ try { fs.writeFileSync(LOCK_FILE, lock, { flag: "wx", mode: 0o600 }); }
142
+ catch (e) {
143
+ if (e.code !== "EEXIST") throw e;
144
+ const other = lockHolder();
145
+ if (other) throw Object.assign(new Error(openElsewhere(other)), { plain: true });
146
+ fs.writeFileSync(LOCK_FILE, lock, { mode: 0o600 }); // left by a PinDeck that is gone
146
147
  }
148
+ unregister = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, key: deckKey, deckDir: DECK_DIR, deckFile: DECK_FILE,
149
+ ...(FRONT ? { behindFront: true } : {}) });
147
150
  }
148
- /* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
151
+ /* On exit: only our own files, never those of a PinDeck started since for this or another deck. */
149
152
  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
+ unregister();
154
+ try { if (JSON.parse(fs.readFileSync(LOCK_FILE, "utf8")).pid === process.pid) fs.rmSync(LOCK_FILE, { force: true }); } catch {}
153
155
  }
154
156
 
155
157
  /* ---- Small helpers --------------------------------------------------------------- */
@@ -182,9 +184,23 @@ function writeDeck(f, content, manual = false) {
182
184
  ourWrites.set(f, { hash: hash(content), manual });
183
185
  writeAtomic(path.join(DECK_DIR, f), content);
184
186
  }
187
+ /* A file PinDeck itself writes or deletes in the deck folder (an upload, a picture, an export, a file Undo
188
+ brings back or removes) must be there once symlinks are followed: a figures/ or export/ that links
189
+ elsewhere is not written through, as serving refuses to read through it. */
190
+ function deckPath(rel) {
191
+ const abs = path.join(DECK_DIR, rel);
192
+ let p = abs;
193
+ while (!fs.existsSync(p) && path.dirname(p) !== p) p = path.dirname(p);
194
+ const real = realOr(p), deck = realOr(DECK_DIR);
195
+ if (real !== deck && !real.startsWith(deck + path.sep))
196
+ throw Object.assign(new Error(`${rel} leads out of the deck folder (through a symlink): PinDeck does not write or delete there.`), { status: 409 });
197
+ return abs;
198
+ }
199
+ const removeDeckFile = (rel) => fs.rmSync(deckPath(rel), { force: true });
185
200
  /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
186
201
  function writeDeckFile(rel, data, manual = true) {
187
- const abs = path.join(DECK_DIR, rel);
202
+ const abs = deckPath(rel);
203
+ state.turn?.touched.add(rel);
188
204
  fs.mkdirSync(path.dirname(abs), { recursive: true });
189
205
  ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
190
206
  writeAtomic(abs, data);
@@ -197,7 +213,7 @@ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
197
213
  const asText = (v) => (v == null ? "" : String(v));
198
214
  const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
199
215
  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 });
216
+ if (v == null) return removeDeckFile(rel);
201
217
  fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
202
218
  writeDeck(rel, v);
203
219
  }
@@ -310,13 +326,13 @@ function sized(url, size) {
310
326
  u.searchParams.set("height", size.height);
311
327
  return u.href;
312
328
  }
313
- function chrome(args, done) {
329
+ function chrome(args, done, signal) {
314
330
  if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
315
331
  return new Promise((resolve, reject) => {
316
332
  // a profile per run: concurrent runs must not share one
317
333
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
318
334
  const { size } = deckSlides();
319
- const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
335
+ const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", ...ROOT_ARGS,
320
336
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
321
337
  `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
322
338
  ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
@@ -334,17 +350,22 @@ function chrome(args, done) {
334
350
  proc.stderr.on("data", (d) => { err += d; check(); });
335
351
  proc.on("error", (e) => finish(reject, e));
336
352
  // "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."))));
353
+ proc.on("close", () => (done(out, err) ? finish(resolve, out)
354
+ : finish(reject, new Error(`Chrome exited without output${chromeReason(err) ? `: ${chromeReason(err)}` : "."}`))));
338
355
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
356
+ signal?.addEventListener("abort", () => finish(reject, new Error("Stopped.")), { once: true });
339
357
  });
340
358
  }
341
359
  const deckUrl = (mode, params = {}) =>
342
- `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
343
- async function takeShot(slide) {
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 }));
360
+ `${ORIGIN}${VIEW}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
361
+ /* A screenshot of a slide with the pins of a message on it: those the message carries when the user asks for
362
+ one, those of the latest message when the assistant does (cli.mjs shot). Never pins the editor no longer
363
+ shows, or another tab's. */
364
+ async function takeShot(slide, pins = state.lastPins, signal) {
365
+ pins = pins.filter((p) => p.slide?.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
345
366
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
346
367
  await chrome([`--screenshot=${out}`, deckUrl("shot", { slide: slide.eid, pins: JSON.stringify(pins) })],
347
- (_o, err) => /bytes written to file/.test(err) && fs.existsSync(out));
368
+ (_o, err) => /bytes written to file/.test(err) && fs.existsSync(out), signal);
348
369
  if (!fs.existsSync(out)) throw new Error("Chrome did not write the screenshot.");
349
370
  // keep the folder small
350
371
  const old = fs.readdirSync(SHOT_DIR).map((f) => path.join(SHOT_DIR, f))
@@ -388,19 +409,20 @@ function exportPdf(kind, includeHidden = false) {
388
409
  if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
389
410
  const split = kind === "presentation";
390
411
  const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
391
- const out = path.join(DECK_DIR, "export", name);
412
+ const out = path.join(DECK_DIR, "export", name); // checked when the export runs (deckPath)
392
413
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
393
414
  const run = async () => {
415
+ deckPath(`export/${name}`);
394
416
  fs.mkdirSync(path.dirname(out), { recursive: true });
395
417
  const layoutProfile = profile("layout"), printProfile = profile("print");
396
418
  const html = await printLayout(CHROME, chromeStart(layoutProfile,
397
- `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
419
+ `${ORIGIN}${VIEW}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
398
420
  const id = `${process.pid}-${++snapSeq}`;
399
421
  snapshots.set(id, html);
400
- try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
422
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${VIEW}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
401
423
  finally { snapshots.delete(id); }
402
424
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
403
- url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
425
+ url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
404
426
  };
405
427
  const job = exportQueue.then(run, run);
406
428
  exportQueue = job.catch(() => {});
@@ -411,13 +433,15 @@ function exportPdf(kind, includeHidden = false) {
411
433
  const CHAT_FILE = path.join(STATE_DIR, "chat.json");
412
434
  const state = {
413
435
  chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
414
- turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
436
+ turns: [], redo: [], turn: null, notes: [], lastPins: [], current: null,
437
+ conversation: 1, // counts the conversations of this run: a new chat or another provider starts the next
415
438
  uploads: new Set(), // files uploaded for the next message; removable until it is sent
416
439
  pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
417
440
  };
418
441
  try {
419
442
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
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 || [] });
443
+ Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude",
444
+ handover: saved.handover || "", sessionArchive: saved.sessionArchive || [], saved: saved.settings || null, hasChat: true });
421
445
  } catch {}
422
446
  /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
423
447
  let chatTimer = null;
@@ -426,7 +450,8 @@ function writeChat() {
426
450
  chatTimer = null;
427
451
  try {
428
452
  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) }));
453
+ provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50),
454
+ settings: deckSettings ?? undefined }));
430
455
  } catch (e) { console.error(e.message); }
431
456
  }
432
457
  const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
@@ -440,38 +465,59 @@ function addEntry(entry) {
440
465
  }
441
466
  const liveTurns = () => state.turns.filter((t) => !t.undone);
442
467
  const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
443
- /* The menu persists provider, model and effort. CLI overrides apply to this run only.
444
- The v3 preview uses its own state directory so v2 can keep running independently. */
468
+ /* Provider, model and effort belong to each deck, with its conversation: saved in its chat.json when chosen
469
+ in the menu, so another deck's choice never moves this deck's conversation to another backend. config.json
470
+ holds the last choice, which a new deck starts with, and each model's preferred effort. --provider and
471
+ --model apply to this run only. The v3 preview uses its own state directory so v2 can keep running. */
445
472
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
446
- let fileConfig = {};
447
- try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
448
- catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
449
- const settings = migrateSettings(fileConfig);
450
- let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
451
- // Preserve the legacy selection as the first model-specific preference.
452
- if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
473
+ const isObject = (o) => !!o && typeof o === "object" && !Array.isArray(o);
474
+ function readConfig(warn = false) {
475
+ try {
476
+ const c = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
477
+ if (isObject(c)) return c;
478
+ if (warn) console.warn(`${CONFIG_FILE} does not hold settings ({ ... }); using the defaults.`);
479
+ } catch (e) { if (warn && e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
480
+ return {};
481
+ }
482
+ const preferencesOf = (c) => Object.fromEntries(Object.entries(isObject(c.effortPreferences) ? c.effortPreferences : {}).filter(([, v]) => typeof v === "string"));
483
+ const fileConfig = readConfig(true);
484
+ let effortPreferences = preferencesOf(fileConfig);
485
+ const defaults = migrateSettings(fileConfig);
486
+ // the legacy single effort (v2): the preference of the model it was chosen for
487
+ if (fileConfig.effort && defaults.model) effortPreferences[`${defaults.provider}:${defaults.model}`] ??= defaults.effort;
488
+ let deckSettings = isObject(state.saved) && PROVIDERS.includes(state.saved.provider) ? migrateSettings(state.saved) : null;
489
+ const settings = deckSettings ? { ...deckSettings }
490
+ // a chat saved before decks kept their own settings: its provider, with that provider's default model
491
+ : state.hasChat && state.provider !== defaults.provider ? { provider: state.provider, model: state.provider === "claude" ? "sonnet" : "", effort: null }
492
+ : { ...defaults };
493
+ const ownSettings = { ...settings }; // without --provider and --model, which apply to this run only
494
+ const startNotices = []; // added to the chat once the port is bound (a failed start writes nothing)
453
495
  if (PROVIDER) {
454
- if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
455
- settings.provider = PROVIDER;
456
- if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
496
+ if (!PROVIDERS.includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
497
+ if (PROVIDER !== settings.provider) Object.assign(settings, { provider: PROVIDER, model: PROVIDER === "claude" ? "sonnet" : "", effort: null });
498
+ }
499
+ if (MODEL && MODEL !== settings.model) Object.assign(settings, { model: MODEL, effort: null }); // this run only, not saved
500
+ if (settings.effort == null) settings.effort = effortPreferences[`${settings.provider}:${settings.model}`] ?? null;
501
+ // A saved conversation belongs to its provider: --provider for another one starts a new conversation, and says so
502
+ if (state.provider !== settings.provider && (state.sessionId || state.chat.length)) {
503
+ if (state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
504
+ state.sessionId = null; state.handover = "";
505
+ const why = PROVIDER === settings.provider ? `for this run (--provider ${PROVIDER})` : "(this deck's own choice)";
506
+ startNotices.push({ kind: "notice", fresh: true, text: `Switched to ${providerName(settings.provider)} ${why} · New conversation.` });
457
507
  }
458
- if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
459
- // A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
460
- if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
461
508
  state.provider = settings.provider;
462
509
  /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
463
510
  deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
464
511
  it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
465
512
  or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
466
513
  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
- });
514
+ // on its own a missing one stops the start; behind the front, which lists decks only from roots that exist,
515
+ // one that is gone since the front started is left out with a warning, so the deck still opens
516
+ const { roots: ASSET_ROOTS, missing: missingRoots } = assetRoots(assetArgs, fileConfig);
517
+ for (const r of missingRoots) {
518
+ if (!FRONT) { console.error(`Asset root ${r} is not a folder.\n\n${USAGE}`); process.exit(2); }
519
+ console.warn(`Asset root ${r} is not a folder (any more): left out.`);
520
+ }
475
521
  const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
476
522
  const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
477
523
  const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
@@ -491,14 +537,22 @@ function servable(base, rel) {
491
537
  return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
492
538
  }
493
539
 
494
- let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
540
+ let catalog = { claude: { models: [] }, codex: { models: [] } };
495
541
  let session = null, starting = null;
496
- const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
542
+ const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
497
543
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
498
544
  chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
499
545
  capabilities: publicCapabilities(CAPS), rev: deckRev(),
500
- undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
501
- const pushStatus = () => broadcast("status", status());
546
+ undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)) });
547
+ /* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
548
+ changed since the last push: the editor keeps the last ones it got. */
549
+ let catalogSent = "";
550
+ const pushStatus = () => {
551
+ const st = status(), lists = JSON.stringify(catalog);
552
+ if (lists === catalogSent) delete st.models;
553
+ catalogSent = lists;
554
+ broadcast("status", st);
555
+ };
502
556
 
503
557
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
504
558
  .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
@@ -521,9 +575,50 @@ function slidesInstructions() {
521
575
  git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
522
576
  win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
523
577
  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];
578
+ const { files, folders } = otherDecks();
579
+ return [...LOCKED, "vendor/**", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
580
+ }
581
+ /* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
582
+ own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
583
+ function otherDecks() {
584
+ let files = [];
585
+ try { files = fs.readdirSync(DECK_DIR).filter((f) => /\.html$/i.test(f) && f !== DECK_FILE); } catch {}
586
+ const folders = [...new Set(listDecks([DECK_DIR]).filter((d) => !d.problem && d.dir !== DECK_DIR)
587
+ .map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
588
+ return { files, folders };
589
+ }
590
+ const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
591
+
592
+ /* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
593
+ another may change them too. Each deck server keeps the times of its turns in a file of its own under
594
+ turns/<folder>/; a turn that overlapped another deck's keeps only the files its own assistant said it wrote. */
595
+ const TURNS_DIR = path.join(HOME_DIR, "turns", hash(DECK_DIR).slice(0, 12));
596
+ const TURNS_FILE = path.join(TURNS_DIR, `${deckKey}.json`);
597
+ let turnTimes = []; // [{ start, end }] of this deck's recent turns
598
+ function logTurnTime(t) {
599
+ const now = Date.now();
600
+ turnTimes = turnTimes.filter((x) => x === t || (x.end ?? now) > now - 3600000).slice(-20);
601
+ if (!turnTimes.includes(t)) turnTimes.push(t);
602
+ try {
603
+ fs.mkdirSync(TURNS_DIR, { recursive: true, mode: 0o700 });
604
+ writeAtomic(TURNS_FILE, JSON.stringify({ deck: DECK_FILE === "index.html" ? path.basename(DECK_DIR) : DECK_FILE, pid: process.pid, turns: turnTimes }));
605
+ } catch (e) { console.error(e.message); }
606
+ }
607
+ /* The other decks whose assistant worked in this folder at some time since start: their names */
608
+ function othersWorkedSince(start) {
609
+ const now = Date.now(), names = [];
610
+ let files = [];
611
+ try { files = fs.readdirSync(TURNS_DIR).filter((f) => f.endsWith(".json") && f !== `${deckKey}.json`); } catch {}
612
+ for (const f of files) {
613
+ try {
614
+ const o = JSON.parse(fs.readFileSync(path.join(TURNS_DIR, f), "utf8"));
615
+ let alive = true;
616
+ try { process.kill(o.pid, 0); } catch (e) { alive = e.code === "EPERM"; }
617
+ // a turn still open in a server that is gone ended when that server did, at the latest
618
+ if (o.turns.some((x) => (x.end ?? (alive ? now : x.start)) >= start)) names.push(o.deck);
619
+ } catch {}
620
+ }
621
+ return names;
527
622
  }
528
623
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
529
624
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
@@ -594,9 +689,25 @@ function retire(agent) {
594
689
  retiring.add(stopped);
595
690
  stopped.finally(() => retiring.delete(stopped));
596
691
  }
597
- async function discoverModels() {
598
- if (discovering) return discovering;
599
- discovering = Promise.all(["claude", "codex"].map(async provider => {
692
+ /* What each backend offers. Asking means starting a backend process, so it is done once per provider at a
693
+ time, and the answer is shared: through models.json in the state folder, for the other deck servers too,
694
+ for 10 minutes (Refresh models asks again). A message waits only for its own provider's list. */
695
+ const MODELS_FILE = path.join(HOME_DIR, "models.json"), MODELS_TTL = 10 * 60000;
696
+ const asking = {}; // provider -> promise of its discovery
697
+ function cachedModels() {
698
+ try {
699
+ const c = JSON.parse(fs.readFileSync(MODELS_FILE, "utf8"));
700
+ return Date.now() - c.time < MODELS_TTL && c.catalog && typeof c.catalog === "object" ? c.catalog : {};
701
+ } catch { return {}; }
702
+ }
703
+ function cacheModels() {
704
+ const ok = Object.fromEntries(Object.entries(catalog).filter(([, c]) => c.models.length && !c.error));
705
+ if (!Object.keys(ok).length) return;
706
+ try { writeAtomic(MODELS_FILE, JSON.stringify({ time: Date.now(), catalog: { ...cachedModels(), ...ok } })); } catch {}
707
+ }
708
+ function discoverProvider(provider) {
709
+ if (asking[provider]) return asking[provider];
710
+ asking[provider] = (async () => {
600
711
  if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
601
712
  const agent = makeSession({ provider, model: "", effort: null });
602
713
  discoveryAgents.add(agent);
@@ -605,8 +716,36 @@ async function discoverModels() {
605
716
  catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
606
717
  } catch (e) { catalog[provider] = { models: [], error: e.message }; }
607
718
  finally { discoveryAgents.delete(agent); retire(agent); }
608
- })).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
609
- return discovering;
719
+ })().finally(() => { delete asking[provider]; });
720
+ return asking[provider];
721
+ }
722
+ /* providers: which to ask; fresh: ask the backends even when another deck server asked lately */
723
+ async function discoverModels({ providers = ["claude", "codex"], fresh = false } = {}) {
724
+ const cached = fresh ? {} : cachedModels();
725
+ await Promise.all(providers.map((p) => {
726
+ if (cached[p]?.models?.length && providerAvailable(p).available) { catalog[p] = cached[p]; return null; }
727
+ return discoverProvider(p);
728
+ }));
729
+ cacheModels();
730
+ pushStatus();
731
+ return catalog;
732
+ }
733
+ /* The model and effort as they will be used, once the backend has said what it offers: a saved or given
734
+ model it does not offer gives way to its default model (and the chat says so), an effort the model does
735
+ not offer to the model's preferred or default one. Shown before the first message is sent. */
736
+ function settle() {
737
+ const { models, error } = catalog[settings.provider];
738
+ if (!models.length || error) return; // discovery failed: its error answers the next message
739
+ let model = models.find((m) => m.id === settings.model);
740
+ if (!model) {
741
+ model = models.find((m) => m.isDefault) || models.find((m) => m.curated) || models[0];
742
+ if (settings.model) addEntry({ kind: "notice", text: `The model "${settings.model}" is not available from ${providerName(settings.provider)} now: using ${model.label} instead.` });
743
+ Object.assign(settings, { model: model.id, effort: null });
744
+ }
745
+ if (!model.efforts.includes(settings.effort)) {
746
+ const preferred = effortPreferences[`${settings.provider}:${model.id}`];
747
+ settings.effort = model.efforts.includes(preferred) ? preferred : model.defaultEffort ?? null;
748
+ }
610
749
  }
611
750
  async function ensureSession() {
612
751
  if (starting) return starting;
@@ -614,17 +753,37 @@ async function ensureSession() {
614
753
  const available = providerAvailable(settings.provider);
615
754
  if (!available.available) throw new Error(available.reason);
616
755
  starting = (async () => {
617
- if (!catalog[settings.provider].models.length) await discoverModels();
756
+ if (!catalog[settings.provider].models.length) await discoverModels({ providers: [settings.provider] });
757
+ settle();
618
758
  Object.assign(settings, selectModel(catalog, settings, {}));
759
+ // a deck without a choice of its own keeps the one its conversation starts with, not the next one another
760
+ // deck saves in config.json; a run-only --provider or --model is not kept
761
+ if (!deckSettings) {
762
+ deckSettings = PROVIDER || MODEL ? { ...ownSettings } : { provider: settings.provider, model: settings.model, effort: settings.effort };
763
+ saveChat();
764
+ }
619
765
  const agent = makeSession({ ...settings });
620
766
  session = agent;
621
- try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
767
+ try {
768
+ await agent.start(state.sessionId);
769
+ // the session's own start lists the models too: the freshest list there is
770
+ if (agent.models?.length) { catalog[settings.provider] = { models: curateModels(settings.provider, agent.models), error: null }; cacheModels(); }
771
+ if (session === agent) pushStatus();
772
+ }
622
773
  catch (e) { retire(agent); throw e; }
623
774
  })().finally(() => { starting = null; });
624
775
  return starting;
625
776
  }
777
+ function providerName(p) { return p === "codex" ? "OpenAI / ChatGPT" : "Claude"; }
778
+ /* config.json as it is now (another deck may have written it since this one started), with this choice and
779
+ its effort preferences merged in, in one atomic write */
780
+ function saveConfig(choice, preferences) {
781
+ const now = readConfig();
782
+ writeAtomic(CONFIG_FILE, JSON.stringify({ ...now, ...choice, effortPreferences: { ...preferencesOf(now), ...preferences } }, null, 2) + "\n");
783
+ }
626
784
  async function changeSettings(requested) {
627
785
  if (state.busy || starting) throw new Error("Wait until the current request finishes before switching.");
786
+ effortPreferences = { ...effortPreferences, ...preferencesOf(readConfig()) }; // as other decks left them
628
787
  const choice = selectModel(catalog, settings, requested, effortPreferences);
629
788
  if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
630
789
  const changingProvider = choice.provider !== settings.provider;
@@ -639,21 +798,22 @@ async function changeSettings(requested) {
639
798
  if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
640
799
  const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
641
800
  if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
642
- const nextPreferences = { ...effortPreferences };
643
- if (settings.effort) nextPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
644
- if (choice.effort) nextPreferences[`${choice.provider}:${choice.model}`] = choice.effort;
645
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...fileConfig, ...choice, effortPreferences: nextPreferences }, null, 2) + "\n");
646
- effortPreferences = nextPreferences;
801
+ const changed = {}; // only the preferences this choice sets: the model left, and the model chosen
802
+ if (settings.effort) changed[`${settings.provider}:${settings.model}`] = settings.effort;
803
+ if (choice.effort) changed[`${choice.provider}:${choice.model}`] = choice.effort;
804
+ try { saveConfig(choice, changed); } catch (e) { console.error(e.message); }
805
+ effortPreferences = { ...effortPreferences, ...changed };
647
806
  Object.assign(settings, choice);
807
+ deckSettings = { ...choice };
648
808
  state.provider = choice.provider;
649
- if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; }
809
+ if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; state.conversation++; }
650
810
  if (reuse) candidate.settings = choice;
651
811
  session = candidate;
652
812
  if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
653
813
  if (!reuse) retire(old);
654
814
  saveChat();
655
- addEntry({ kind: "notice", text: changingProvider
656
- ? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
815
+ addEntry({ kind: "notice", ...(changingProvider && !summary ? { fresh: true } : {}), text: changingProvider
816
+ ? `Switched to ${providerName(choice.provider)} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
657
817
  : `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
658
818
  } catch (e) { if (session !== candidate) retire(candidate); throw e; }
659
819
  finally { state.busy = false; state.switching = false; pushStatus(); }
@@ -666,11 +826,13 @@ function toolSummary(name, input = {}) {
666
826
  const r = path.relative(DECK_DIR, p);
667
827
  return !r || r.startsWith("..") ? path.basename(p) : r;
668
828
  };
829
+ // a picture in the screenshots folder: the chat shows it
830
+ const shotOf = (p) => (p && realOr(path.dirname(path.resolve(p))) === realOr(SHOT_DIR) && SHOT_NAME.test(path.basename(p)) ? { shot: path.basename(p) } : {});
669
831
  switch (name) {
670
- case "pindeck_read": return { name: "Read", detail: base(input.path) };
832
+ case "pindeck_read": return { name: "Read", detail: base(input.path), ...shotOf(input.path) };
671
833
  case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
672
834
  case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
673
- case "Read": return { name: "Read", detail: base(input.file_path) };
835
+ case "Read": return { name: "Read", detail: base(input.file_path), ...shotOf(input.file_path) };
674
836
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
675
837
  case "Bash": {
676
838
  // a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
@@ -686,6 +848,15 @@ function toolSummary(name, input = {}) {
686
848
  }
687
849
  }
688
850
  const toolEntries = new Map(); // tool_use_id -> entry
851
+ // the tools that write a file, and the argument that names it (Claude's, then those of PinDeck's Codex bridge)
852
+ const WRITES = { Edit: "file_path", MultiEdit: "file_path", Write: "file_path", NotebookEdit: "notebook_path",
853
+ pindeck_edit: "path", pindeck_write: "path" };
854
+ const DECK_REAL = realOr(DECK_DIR);
855
+ const deckRel = (p) => {
856
+ const abs = path.resolve(DECK_DIR, p);
857
+ const rel = path.relative(abs.startsWith(DECK_REAL + path.sep) ? DECK_REAL : DECK_DIR, abs);
858
+ return rel.split(path.sep).join("/");
859
+ };
689
860
  function handleEvent(ev) {
690
861
  if (ev.type === "system" && ev.subtype === "init") {
691
862
  state.model = ev.model || state.model;
@@ -695,7 +866,11 @@ function handleEvent(ev) {
695
866
  } else if (ev.type === "assistant" && state.turn) {
696
867
  for (const b of ev.message?.content || []) {
697
868
  if (b.type === "text" && b.text.trim()) addEntry({ kind: "assistant", text: b.text.trim() });
698
- if (b.type === "tool_use") toolEntries.set(b.id, addEntry({ kind: "tool", ...toolSummary(b.name, b.input) }));
869
+ if (b.type === "tool_use") {
870
+ toolEntries.set(b.id, addEntry({ kind: "tool", ...toolSummary(b.name, b.input) }));
871
+ const file = WRITES[b.name] && b.input?.[WRITES[b.name]];
872
+ if (typeof file === "string") state.turn.touched.add(deckRel(file));
873
+ }
699
874
  }
700
875
  } else if (ev.type === "user" && state.turn) {
701
876
  for (const b of ev.message?.content || []) {
@@ -717,7 +892,9 @@ function handleEvent(ev) {
717
892
  function startTurn(promptText, userEntry) {
718
893
  // every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
719
894
  // takes back all it changed
720
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
895
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), gitConfig: readDeck(".git/config"), scan: scanDeck(), touched: new Set(),
896
+ time: { start: Date.now(), end: null } };
897
+ logTurnTime(state.turn.time);
721
898
  state.busy = true;
722
899
  pushStatus();
723
900
  const turn = state.turn;
@@ -744,9 +921,26 @@ function finishTurn({ error, cost, duration, denials = [] }) {
744
921
  else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
745
922
  restored.push(f);
746
923
  }
924
+ // .git/config can make git run commands (filters, fsmonitor), and PinDeck runs git after the id pass: an
925
+ // assistant must not change it, by whatever path or spelling it got there
926
+ if (readDeck(".git/config") !== turn.gitConfig) {
927
+ try {
928
+ if (turn.gitConfig == null) fs.rmSync(path.join(DECK_DIR, ".git/config"), { force: true });
929
+ else fs.writeFileSync(path.join(DECK_DIR, ".git/config"), turn.gitConfig);
930
+ restored.push(".git/config");
931
+ } catch (e) { console.error(e.message); }
932
+ }
747
933
  toolEntries.clear();
748
934
  const eid = runEidPass();
749
- const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
935
+ const found = scanChanges(turn.scan, scanDeck());
936
+ // another deck's assistant worked in this folder meanwhile: only what this one wrote (and its deck file)
937
+ const others = othersWorkedSince(turn.time.start);
938
+ turn.time.end = Date.now();
939
+ logTurnTime(turn.time);
940
+ const ours = (rel) => !others.length || rel === DECK_FILE || turn.touched.has(rel);
941
+ const changed = Object.fromEntries(Object.entries(found.changed).filter(([rel]) => ours(rel)));
942
+ const tooLarge = found.tooLarge.filter(ours);
943
+ const notOurs = [...Object.keys(found.changed), ...found.tooLarge].filter((rel) => !ours(rel));
750
944
  const before = {}, after = {};
751
945
  for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
752
946
  // the deck file and theme.css first, then the rest by name
@@ -762,11 +956,14 @@ function finishTurn({ error, cost, duration, denials = [] }) {
762
956
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
763
957
  fs.rmSync(abs, { force: true });
764
958
  }
765
- const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
959
+ const t = { id: turn.id, kind: "agent", provider: settings.provider, conversation: state.conversation, before, after, files, deletedFiles, undone: false };
766
960
  if (files.length) { state.turns.push(t); state.redo = []; }
767
961
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
768
962
  denials, restored, newIds: eid.assigned.length });
769
963
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
964
+ if (notOurs.length) addEntry({ kind: "notice", text: `${notOurs.join(", ")} changed during this turn while the assistant of ` +
965
+ `${others.join(", ")} was working in this folder too, and the assistant here did not say it wrote ${notOurs.length > 1 ? "them" : "it"}: ` +
966
+ `not part of this change (Undo leaves ${notOurs.length > 1 ? "them" : "it"} as ${notOurs.length > 1 ? "they are" : "it is"}).` });
770
967
  if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
771
968
  `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"}.` });
772
969
  pushStatus();
@@ -812,7 +1009,7 @@ function formatAttachment(a) {
812
1009
  const formatDrop = (a) => (a.droppedAt?.slide ? ` Dropped onto slide ${a.droppedAt.slide.index + 1} (${a.droppedAt.slide.eid}) at ` +
813
1010
  `x=${a.droppedAt.point.x} y=${a.droppedAt.point.y}${a.droppedAt.eid ? `, over ${a.droppedAt.eid}` : ""}: probably where the user wants it.` : "");
814
1011
 
815
- function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, attachments = [] }) {
1012
+ function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, shotFailed, attachments = [] }) {
816
1013
  const ctx = [];
817
1014
  if (current) ctx.push(`The user is looking at slide ${current.index + 1} (${current.eid}).`);
818
1015
  if (state.notes.length) {
@@ -825,7 +1022,9 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
825
1022
  `box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
826
1023
  if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
827
1024
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
828
- if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
1025
+ if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide}${screenshot.pins ? " with the pin markers" : ""}: ${screenshot.path} (Read it).`);
1026
+ if (shotFailed) ctx.push(`The user asked for a screenshot of slide ${shotFailed.slide}, but it could not be taken (${shotFailed.error}). ` +
1027
+ `Take one with \`${CLI_CMD} shot ${shotFailed.slide}\` if you need to see the slide.`);
829
1028
  if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
830
1029
  `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
831
1030
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
@@ -837,9 +1036,11 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
837
1036
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
838
1037
  const bad = (t, status = 400) => ({ status, text: t });
839
1038
  if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
1039
+ // compared by real path: the Codex bridge names files by theirs, and the deck's path may go through a
1040
+ // symlink (~/Dropbox, /tmp on macOS)
840
1041
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
841
1042
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
842
- [DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
1043
+ [DECK_DIR, PDF_DIR].map(realOr).some((d) => realOr(f).startsWith(d + path.sep)));
843
1044
  if (!abs) return bad(`No PDF "${file}": use the path from the attached files, or a .pdf in the deck folder.`, 404);
844
1045
  const info = await pdfInfo(abs, STATE_DIR);
845
1046
  const n = Number(page);
@@ -894,13 +1095,19 @@ function scheduleFlush() {
894
1095
  if (external) {
895
1096
  state.notes = [...new Set([...state.notes,
896
1097
  "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
897
- // new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
1098
+ // new elements from a hand edit get ids once the file is quiet: present, and unchanged since (see pass)
898
1099
  clearTimeout(eidTimer);
899
1100
  const seen = hash(readDeck(DECK_FILE) ?? "");
1101
+ // a program that writes the file in pieces, with a pause, must not find it replaced halfway (its rest
1102
+ // would go to the old file): unchanged for 2 s, and complete (ending in </html>, if the deck did)
900
1103
  const pass = () => {
901
1104
  if (state.busy) return;
902
1105
  const now = readDeck(DECK_FILE);
903
1106
  if (now == null || hash(now) !== seen) return; // still being written; its own event comes
1107
+ let age = 0;
1108
+ try { age = Date.now() - fs.statSync(path.join(DECK_DIR, DECK_FILE)).mtimeMs; } catch { return; }
1109
+ if (age < 2000) { eidTimer = setTimeout(pass, 2000 - age); return; }
1110
+ if (ENDS_HTML.test(deckAtStart) && !ENDS_HTML.test(now)) return; // incomplete: its next save comes
904
1111
  runEidPass();
905
1112
  };
906
1113
  eidTimer = setTimeout(pass, 800);
@@ -986,22 +1193,40 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
986
1193
  }
987
1194
 
988
1195
  /* What deleting an element would remove, and which of its files nothing else uses. Files outside the
989
- deck folder, in vendor/, or still referenced elsewhere (index.html, theme.css) are never offered. */
1196
+ deck folder, in vendor/, or still referenced elsewhere (the rest of the deck, theme.css, brand.css, or
1197
+ another deck in the folder or one folder down, which may share figures/) are never offered. */
990
1198
  function deletePlan(eid) {
991
1199
  const html = readDeck(DECK_FILE);
992
1200
  const target = deleteTarget(html, eid);
993
1201
  if (!target) return null;
994
1202
  const rest = removeElement(html, target.eid);
995
1203
  const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
1204
+ const others = otherDeckRefs();
996
1205
  const files = fileRefs(target.outer).flatMap((ref) => {
997
1206
  const abs = path.resolve(DECK_DIR, ref);
998
1207
  const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
999
1208
  if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
1000
- const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel);
1001
- return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere }];
1209
+ try { deckPath(rel); } catch { return []; } // through a symlink out of the deck: never offered
1210
+ const usedBy = others.filter((o) => o.uses(abs)).map((o) => o.name);
1211
+ const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel) || usedBy.length > 0;
1212
+ return [{ rel, bytes: fs.statSync(abs).size, usedElsewhere, usedBy }];
1002
1213
  });
1003
1214
  return { ...target, html: rest, files };
1004
1215
  }
1216
+ /* The other decks of this folder and one folder down, each with a test of whether it refers to a file: in its
1217
+ HTML (relative to its own folder), or in the stylesheets beside it */
1218
+ function otherDeckRefs() {
1219
+ const own = path.join(DECK_DIR, DECK_FILE);
1220
+ const decks = [...fs.readdirSync(DECK_DIR).filter((f) => /\.html$/i.test(f)).map((f) => ({ dir: DECK_DIR, file: f })),
1221
+ ...listDecks([DECK_DIR]).filter((d) => d.dir !== DECK_DIR)].filter((d) => path.join(d.dir, d.file) !== own);
1222
+ return decks.map(({ dir, file }) => {
1223
+ const read = (f) => { try { return fs.statSync(f).size <= 5e6 ? fs.readFileSync(f, "utf8") : ""; } catch { return ""; } };
1224
+ const refs = new Set(fileRefs(read(path.join(dir, file))).map((r) => { try { return path.resolve(dir, decodeURI(r)); } catch { return path.resolve(dir, r); } }));
1225
+ const css = dir === DECK_DIR ? "" : read(path.join(dir, "theme.css")) + read(path.join(dir, "brand.css"));
1226
+ return { name: path.relative(DECK_DIR, path.join(dir, file)).split(path.sep).join("/"),
1227
+ uses: (abs) => refs.has(abs) || (!!css && css.includes(path.relative(dir, abs).split(path.sep).join("/"))) };
1228
+ });
1229
+ }
1005
1230
 
1006
1231
  /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
1007
1232
  Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
@@ -1016,6 +1241,8 @@ function undoConflicts(t, direction) {
1016
1241
  // ones (which must be unchanged); redo the other way round
1017
1242
  const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
1018
1243
  const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
1244
+ // a file to bring back or remove through a symlink out of the deck: not done (deckPath), so not begun
1245
+ for (const { rel } of [...comeBack, ...go]) { try { deckPath(rel); } catch { files.push(rel); } }
1019
1246
  for (const { rel, data } of comeBack) {
1020
1247
  const abs = path.join(DECK_DIR, rel);
1021
1248
  if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
@@ -1031,6 +1258,13 @@ function conflict(res, direction, files) {
1031
1258
  error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
1032
1259
  `it would overwrite those changes. Reload to see the files as they are.` });
1033
1260
  }
1261
+ /* The chat entry of a change keeps whether it is undone: the undo history ends with the run, the chat does not */
1262
+ function markUndone(t) {
1263
+ const e = state.chat.find((x) => x.turnId === t.id && (x.kind === "turn" || x.kind === "edit"));
1264
+ if (e) { e.undone = t.undone; saveChat(); }
1265
+ }
1266
+ // an assistant turn made in the conversation going on now (not before a new chat or a provider switch)
1267
+ const byThisChat = (t) => t.conversation === state.conversation;
1034
1268
  function undo(res) {
1035
1269
  if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
1036
1270
  const t = liveTurns().at(-1);
@@ -1039,12 +1273,15 @@ function undo(res) {
1039
1273
  if (diverged.length) return conflict(res, "undo", diverged);
1040
1274
  for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
1041
1275
  for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
1042
- for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
1276
+ for (const { rel } of t.addedFiles || []) removeDeckFile(rel); // imported figures go
1043
1277
  t.undone = true;
1278
+ markUndone(t);
1044
1279
  state.redo.push(t);
1280
+ const names = t.files.map((f) => f.file);
1045
1281
  state.notes.push(t.kind === "manual"
1046
1282
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
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.`);
1283
+ : `The user undid ${byThisChat(t) ? "your change from turn" : "a change made before this conversation began, in turn"} ${t.id}; ` +
1284
+ `${names.join(" and ")} ${names.length > 1 ? "are" : "is"} back to the state before it. Re-read before editing.`);
1048
1285
  broadcast("undone", { turnId: t.id, undone: true });
1049
1286
  pushStatus();
1050
1287
  return json(res, 200, {});
@@ -1053,7 +1290,7 @@ function undo(res) {
1053
1290
  /* ---- PowerPoint import -----------------------------------------------------------------------------
1054
1291
  Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
1055
1292
  makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
1056
- Only the latest upload is kept, in ~/.pindeck/decks/<deck>/imports/. */
1293
+ Only the latest upload is kept, in ~/.pindeck-v3/decks/<deck>/imports/. */
1057
1294
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
1058
1295
  const IMPORT_MAX = 500 * 1024 * 1024;
1059
1296
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
@@ -1161,80 +1398,40 @@ async function refineImport(imp, pdf, plans, eids, first) {
1161
1398
 
1162
1399
  /* ---- HTTP ------------------------------------------------------------------------------------- */
1163
1400
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
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);
1167
-
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
- }
1401
+ /* Who may read the editor, the deck and the API: see lib/login.mjs. Behind the front, browsers log in
1402
+ there, and the front sends this server's token in a header. */
1403
+ const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
1215
1404
  /* 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. */
1405
+ its command line. It never logs in: its targets are the deck's pages under /v/, which need no cookie, so a
1406
+ deck's script there has no way to the API. Chrome's command-line screenshot, DOM dump and print do not
1407
+ work on a page with a sandbox policy, so the page it asks for is served without one, once: the target
1408
+ carries a key that works for one request (headlessKeys). */
1409
+ const headlessKeys = new Set();
1217
1410
  function chromeStart(profile, target) {
1218
1411
  fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
1219
1412
  const start = path.join(profile, "pindeck-start.html");
1220
- const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
1413
+ const key = crypto.randomBytes(16).toString("hex"), u = new URL(target);
1414
+ headlessKeys.add(key);
1415
+ setTimeout(() => headlessKeys.delete(key), 120000).unref();
1416
+ u.searchParams.set("headless", key);
1417
+ const to = u.href.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
1221
1418
  fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
1222
1419
  return pathToFileURL(start).href;
1223
1420
  }
1224
1421
 
1225
1422
  /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
1226
1423
  Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
1227
- function serveFile(res, file, transform, req) {
1424
+ function serveFile(res, file, transform, req, extra = {}) {
1228
1425
  const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
1229
1426
  if (transform) return fs.readFile(file, (err, data) => {
1230
1427
  if (err) return text(res, 404, "Not found");
1231
1428
  const out = Buffer.from(transform(String(data)));
1232
- res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
1429
+ res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store", ...extra });
1233
1430
  res.end(out);
1234
1431
  });
1235
1432
  fs.stat(file, (err, st) => {
1236
1433
  if (err || !st.isFile()) return text(res, 404, "Not found");
1237
- const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
1434
+ const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store", ...extra };
1238
1435
  let start = 0, end = st.size - 1, code = 200;
1239
1436
  const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1240
1437
  if (range && (range[1] || range[2])) {
@@ -1258,14 +1455,29 @@ function serveFile(res, file, transform, req) {
1258
1455
  }
1259
1456
 
1260
1457
  let ready = false;
1458
+ const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
1459
+ const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
1460
+ /* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
1461
+ read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret. */
1462
+ const SCRIPTABLE = /\.(html|svg|xml)$/i;
1463
+ const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
1464
+ ...(viewing ? { "access-control-allow-origin": "*" } : {}) });
1261
1465
  const server = http.createServer(async (req, res) => {
1262
1466
  if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1263
1467
  const host = String(req.headers.host || "").toLowerCase();
1264
1468
  if (!goodHost(host)) return text(res, 403, "Bad host");
1265
- const url = new URL(req.url, ORIGIN);
1266
- const p = url.pathname;
1267
1469
  try {
1470
+ const url = new URL(req.url, ORIGIN);
1471
+ let p = url.pathname;
1268
1472
  if (req.method === "GET" && p === "/login") return login(res, url);
1473
+ // /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
1474
+ let viewing = false;
1475
+ if (p.startsWith("/v/")) {
1476
+ if (!p.startsWith(VIEW + "/") || (req.method !== "GET" && req.method !== "HEAD")) return text(res, 403, "Forbidden");
1477
+ p = p.slice(VIEW.length);
1478
+ if (!p.startsWith("/deck/") && !p.startsWith("/files/")) return text(res, 404, "Not found");
1479
+ viewing = true;
1480
+ }
1269
1481
  // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1270
1482
  const ok = authed(req);
1271
1483
  if (req.method === "GET" && (p === "/" || p === "/index.html")) {
@@ -1276,18 +1488,26 @@ const server = http.createServer(async (req, res) => {
1276
1488
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
1277
1489
  return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1278
1490
  }
1279
- if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
1491
+ if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok && !viewing) return text(res, 403, "Not logged in");
1280
1492
  let snap;
1281
1493
  if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
1282
1494
  const html = snapshots.get(snap[1]);
1283
1495
  if (!html) return text(res, 404, "Not found");
1284
- res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1496
+ // a static print layout (its scripts were removed): no scripts at all, instead of the sandbox Chrome cannot print
1497
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...deckHeaders(".css", viewing),
1498
+ "content-security-policy": "script-src 'none'" });
1285
1499
  return res.end(html);
1286
1500
  }
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.
1501
+ // A deck page asked for outside /v/ (the editor's frames ask for deck/?editor=main, Present for deck/):
1502
+ // sent there, with a relative redirect that stays under the front's /d/<deck>/. /deck/ alone is the deck
1503
+ // page; with an asset root that page lives under /files/, so that ../ paths reach the root.
1504
+ const toView = (target) => {
1505
+ res.writeHead(302, { location: "../".repeat(p.split("/").length - 2) + VIEW.slice(1) + target + url.search, "cache-control": "no-store" });
1506
+ res.end();
1507
+ };
1289
1508
  if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1290
- res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1509
+ if (!viewing) return toView(DECK_PAGE);
1510
+ res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
1291
1511
  return res.end();
1292
1512
  }
1293
1513
  const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
@@ -1295,8 +1515,9 @@ const server = http.createServer(async (req, res) => {
1295
1515
  if (deckPath === null) return text(res, 403, "Forbidden");
1296
1516
  if (deckPath) {
1297
1517
  const f = deckPath;
1518
+ if (!viewing && /\.html$/i.test(f)) return toView(p);
1298
1519
  const mode = url.searchParams.get("editor");
1299
- if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
1520
+ if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
1300
1521
  return serveFile(res, f, (s) => {
1301
1522
  // headless runs must not stream media, or Chrome never finishes the page
1302
1523
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -1306,7 +1527,20 @@ const server = http.createServer(async (req, res) => {
1306
1527
  return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1307
1528
  }
1308
1529
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1309
- });
1530
+ }, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
1531
+ ? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
1532
+ }
1533
+ if (viewing) return text(res, 404, "Not found");
1534
+
1535
+ // /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
1536
+ if (req.method === "GET" && p.startsWith("/shots/")) {
1537
+ if (!ok) return text(res, 403, "Not logged in");
1538
+ const name = decodeURIComponent(p.slice(7));
1539
+ if (!SHOT_NAME.test(name)) return text(res, 403, "Forbidden");
1540
+ const f = path.join(SHOT_DIR, name);
1541
+ let real;
1542
+ try { real = fs.realpathSync(f); } catch { return text(res, 404, "Not found"); }
1543
+ return path.dirname(real) === realOr(SHOT_DIR) ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1310
1544
  }
1311
1545
 
1312
1546
  // API: token required (header or login cookie); POSTs must come from the editor page itself
@@ -1324,11 +1558,16 @@ const server = http.createServer(async (req, res) => {
1324
1558
  req.on("close", () => clients.delete(res));
1325
1559
  return;
1326
1560
  }
1561
+ // the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
1562
+ if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
1563
+ const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
1564
+ `or start PinDeck for the deck where it is now.`;
1565
+ return CLI_ROUTES.test(p) ? text(res, 409, why) : json(res, 409, { code: "deck-gone", error: why });
1566
+ }
1327
1567
  if (p === "/api/ping") return json(res, 200, { ok: true });
1328
1568
  if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1329
1569
  if (p === "/api/status") return json(res, 200, status());
1330
1570
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
1331
- if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
1332
1571
  if (p === "/api/measure-result" && req.method === "POST") {
1333
1572
  const b = await body(req);
1334
1573
  const w = pending.get(b.id);
@@ -1350,26 +1589,34 @@ const server = http.createServer(async (req, res) => {
1350
1589
  if (!placed(b.selection)) b.selection = null;
1351
1590
  if (!Array.isArray(b.layouts)) b.layouts = [];
1352
1591
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
1353
- const preparation = { cancelled: false };
1592
+ const preparation = { cancelled: false, controller: new AbortController() };
1354
1593
  state.preparing = preparation;
1355
1594
  state.busy = true; pushStatus();
1356
1595
  try {
1357
- let screenshot = null;
1596
+ let screenshot = null, shotFailed = null;
1358
1597
  const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
1359
1598
  if (shotSlide) {
1360
- try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
1361
- catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
1599
+ const pins = b.pins.filter((p) => p.slide.eid === shotSlide.eid);
1600
+ try { screenshot = { slide: shotSlide.index + 1, pins: pins.length, path: await takeShot(shotSlide, pins, preparation.controller.signal) }; }
1601
+ catch (e) { shotFailed = { slide: shotSlide.index + 1, error: e.message }; }
1602
+ }
1603
+ if (preparation.cancelled) {
1604
+ if (screenshot) fs.rmSync(screenshot.path, { force: true });
1605
+ return json(res, 409, { code: "stopped", error: "Stopped before sending the message." });
1362
1606
  }
1363
- if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
1364
1607
  const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
1365
1608
  // only files PinDeck itself stored can be attached
1366
1609
  b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1367
1610
  b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1368
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1611
+ state.lastPins = b.pins;
1612
+ state.preparing = null;
1613
+ const entry = addEntry({ kind: "user", text: b.text,
1614
+ screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
1369
1615
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1370
1616
  selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1371
1617
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
1372
- startTurn(buildPrompt({ ...b, screenshot }), entry);
1618
+ if (shotFailed) addEntry({ kind: "notice", text: `Screenshot of slide ${shotFailed.slide} failed: ${shotFailed.error}` });
1619
+ startTurn(buildPrompt({ ...b, screenshot, shotFailed }), entry);
1373
1620
  return json(res, 200, { id: entry.id });
1374
1621
  } finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
1375
1622
  }
@@ -1396,9 +1643,9 @@ const server = http.createServer(async (req, res) => {
1396
1643
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
1397
1644
  }
1398
1645
  const rel = attachPath(original);
1399
- writeDeckFile(rel, buf);
1646
+ try { writeDeckFile(rel, buf); } catch (e) { return json(res, e.status || 500, { error: e.message }); }
1400
1647
  state.uploads.add(rel);
1401
- return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
1648
+ return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
1402
1649
  }
1403
1650
  if (p === "/api/upload-remove" && req.method === "POST") {
1404
1651
  const b = await body(req);
@@ -1409,7 +1656,7 @@ const server = http.createServer(async (req, res) => {
1409
1656
  }
1410
1657
  return json(res, 200, {});
1411
1658
  }
1412
- if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
1659
+ if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels({ fresh: true })); // Refresh models
1413
1660
  if (p === "/api/settings" && req.method === "POST") {
1414
1661
  const b = await body(req);
1415
1662
  if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
@@ -1500,7 +1747,7 @@ const server = http.createServer(async (req, res) => {
1500
1747
  catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
1501
1748
  }
1502
1749
  if (p === "/api/stop" && req.method === "POST") {
1503
- if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
1750
+ if (state.preparing) { state.preparing.cancelled = true; state.preparing.controller.abort(); return json(res, 200, {}); }
1504
1751
  if (state.busy && state.turn) {
1505
1752
  const interruptedTurn = state.turn;
1506
1753
  interruptedTurn.cancelled = true;
@@ -1515,7 +1762,9 @@ const server = http.createServer(async (req, res) => {
1515
1762
  if (p === "/api/new-chat" && req.method === "POST") {
1516
1763
  if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
1517
1764
  session?.stop();
1518
- Object.assign(state, { chat: [], sessionId: null, handover: "", turns: [], redo: [], turn: null, busy: false, notes: [] });
1765
+ // the conversation starts anew; the slides, and their undo and redo, stay as they are
1766
+ if (state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
1767
+ Object.assign(state, { chat: [], sessionId: null, handover: "", turn: null, busy: false, notes: [], conversation: state.conversation + 1 });
1519
1768
  saveChat();
1520
1769
  broadcast("reset", { chat: [] });
1521
1770
  ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
@@ -1542,11 +1791,13 @@ const server = http.createServer(async (req, res) => {
1542
1791
  if (diverged.length) return conflict(res, "redo", diverged);
1543
1792
  state.redo.pop();
1544
1793
  for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1545
- for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1794
+ for (const { rel } of t.deletedFiles || []) removeDeckFile(rel);
1546
1795
  for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1547
1796
  t.undone = false;
1797
+ markUndone(t);
1548
1798
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1549
- : `The user redid your change from turn ${t.id} after undoing it.`);
1799
+ : byThisChat(t) ? `The user redid your change from turn ${t.id} after undoing it.`
1800
+ : `The user redid a change made before this conversation began (turn ${t.id}): ${t.files.map((f) => f.file).join(" and ")} as that change left ${t.files.length > 1 ? "them" : "it"}. Re-read before editing.`);
1550
1801
  broadcast("undone", { turnId: t.id, undone: false });
1551
1802
  pushStatus();
1552
1803
  return json(res, 200, {});
@@ -1573,7 +1824,7 @@ const server = http.createServer(async (req, res) => {
1573
1824
  const turnId = recordHandEdit(before, plan.html, label,
1574
1825
  `The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
1575
1826
  { manual: false, deletedFiles });
1576
- for (const f of deletedFiles) fs.rmSync(path.join(DECK_DIR, f.rel), { force: true });
1827
+ for (const f of deletedFiles) removeDeckFile(f.rel);
1577
1828
  return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
1578
1829
  }
1579
1830
 
@@ -1592,7 +1843,9 @@ const server = http.createServer(async (req, res) => {
1592
1843
  return json(res, 200, { turnId });
1593
1844
  }
1594
1845
  if (p === "/api/source") {
1595
- const html = getInner(readDeck(DECK_FILE), url.searchParams.get("eid"), JSON.parse(url.searchParams.get("path") || "[]"));
1846
+ let where = [];
1847
+ try { where = JSON.parse(url.searchParams.get("path") || "[]"); } catch {}
1848
+ const html = getInner(readDeck(DECK_FILE), url.searchParams.get("eid"), where);
1596
1849
  return html == null ? json(res, 404, { error: "Element not found in the file." }) : json(res, 200, { html });
1597
1850
  }
1598
1851
  // Speaker notes of a slide: read for the notes field, saved as a hand edit
@@ -1638,9 +1891,11 @@ const server = http.createServer(async (req, res) => {
1638
1891
  const before = snapshot(EDITABLE);
1639
1892
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1640
1893
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
1641
- const r2 = insertAfter(r1.html, b.eid, { tag: "li", cls: b.cls || "", inner: String(b.newHtml ?? ""), newEid: b.newEid });
1894
+ // the new bullet's elements get ids of their own: never a copy of one from the bullet it was split from
1895
+ const inner = String(b.newHtml ?? "").replace(/\sdata-eid="[^"]*"/g, "");
1896
+ const r2 = insertAfter(r1.html, b.eid, { tag: "li", cls: b.cls || "", inner, newEid: b.newEid });
1642
1897
  const short = (e) => e.replace(/^[^/]+\//, "/");
1643
- const turnId = recordHandEdit(before, r2.html, `New bullet after ${short(b.eid)}`,
1898
+ const turnId = recordHandEdit(before, assignEids(r2.html).html, `New bullet after ${short(b.eid)}`,
1644
1899
  `The user added a bullet (${r2.eid}) after ${b.eid} by pressing Enter while editing; ${b.eid} now reads "${String(b.text || "").slice(0, 200)}".`);
1645
1900
  return json(res, 200, { turnId, eid: r2.eid });
1646
1901
  }
@@ -1695,9 +1950,11 @@ const server = http.createServer(async (req, res) => {
1695
1950
  return text(res, 404, "Not found");
1696
1951
  } catch (e) {
1697
1952
  // malformed JSON or %-encoding in a request is the caller's mistake, not a server error
1698
- const bad = e instanceof SyntaxError || e instanceof URIError || e.message === "body too large";
1699
- if (!bad) console.error(e);
1700
- if (!res.headersSent) text(res, bad ? 400 : 500, String(e.message || e));
1953
+ const large = e.message === "body too large", bad = large || e instanceof SyntaxError || e instanceof URIError;
1954
+ if (!bad && !e.status) console.error(e);
1955
+ // the rest of a body too large is not read: the connection is not used again
1956
+ if (large && !res.headersSent) res.setHeader("connection", "close");
1957
+ if (!res.headersSent) text(res, e.status || (large ? 413 : bad ? 400 : 500), String(e.message || e));
1701
1958
  }
1702
1959
  });
1703
1960
 
@@ -1722,25 +1979,26 @@ server.listen(PORT, "127.0.0.1", () => {
1722
1979
  if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1723
1980
  startWatcher();
1724
1981
  } catch (e) {
1725
- console.error(`PinDeck could not open the deck: ${e.message}`);
1982
+ console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
1726
1983
  removeServerInfo();
1727
1984
  process.exit(1);
1728
1985
  }
1729
1986
  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)");
1732
- console.log(`Deck: ${DECK_DIR}`);
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})`);
1736
- discoverModels().then(() => {
1737
- if (!settings.model) {
1738
- const models = catalog[settings.provider].models;
1739
- const first = models.find(m => m.isDefault) || models[0];
1740
- if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
1741
- }
1742
- pushStatus();
1743
- });
1987
+ if (FRONT) {
1988
+ // the front prints the login link and the capabilities; it ends this server when it goes, even killed
1989
+ process.send({ ready: { url: ORIGIN, token: TOKEN } });
1990
+ process.on("disconnect", () => { if (!quitting) quit(); }); // a second quit() would exit at once
1991
+ } else {
1992
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1993
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1994
+ openOnEnter(() => loginUrl(loginCode(3600000)));
1995
+ console.log(`Deck: ${DECK_DIR}`);
1996
+ console.log(`Server: ${CAPS.label}`);
1997
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1998
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1999
+ }
2000
+ for (const n of startNotices) addEntry(n);
2001
+ discoverModels().then(() => { settle(); pushStatus(); });
1744
2002
  });
1745
2003
  /* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
1746
2004
  SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */