pindeck 3.1.0-beta.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -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,13 +38,16 @@ 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";
42
- import { deckTarget, homeDir } from "./lib/deck-target.mjs";
43
- import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.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";
44
46
  import { USAGE } from "./lib/usage.mjs";
45
47
 
46
48
  /* ---- Config ------------------------------------------------------------------ */
47
- /* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in,
48
- prints the links and owns ~/.pindeck-v3/server.json; this server tells it its address over IPC. */
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. */
49
51
  const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
50
52
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
51
53
  const argv = process.argv.slice(2);
@@ -65,6 +67,8 @@ const LOGIN = argv[0] === "login";
65
67
  if (LOGIN) argv.shift();
66
68
  const PROVIDER = opt("--provider", "");
67
69
  const MODEL = opt("--model", "");
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}.`);
68
72
  /* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
69
73
  const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
70
74
  const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
@@ -74,24 +78,15 @@ const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
74
78
  const SHOT_DIR = path.join(STATE_DIR, "shots");
75
79
  const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
76
80
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
77
- /* pindeck login: a new one-time login link from the running server, asked with the token in its server
78
- file (readable only by this user) */
79
- if (LOGIN) {
80
- const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
81
- let info = null;
82
- for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
83
- if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
84
- try {
85
- const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
86
- if (!res.ok) throw new Error(res.status);
87
- console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
88
- process.exit(0);
89
- } catch {
90
- console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
91
- process.exit(1);
92
- }
93
- }
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);
94
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";
95
90
  let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
96
91
  const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
97
92
  const CLI_CMD = cliCommand(CLI_REL);
@@ -101,6 +96,8 @@ const CAPS = detectCapabilities();
101
96
  function checkDeck() {
102
97
  const file = path.join(DECK_DIR, DECK_FILE);
103
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.";
104
101
  try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
105
102
  catch { return `${file} cannot be read and written (check its permissions).`; }
106
103
  // saving replaces the file through a temporary file next to it, so the folder must be writable too
@@ -108,6 +105,8 @@ function checkDeck() {
108
105
  catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
109
106
  return null;
110
107
  }
108
+ const homeNote = !FRONT && homeWarning(HOME_DIR); // behind the front, the front says it
109
+ if (homeNote) console.warn(homeNote);
111
110
  const deckProblem = checkDeck();
112
111
  if (deckProblem) {
113
112
  console.error(`${deckProblem}\n\n${USAGE}`);
@@ -115,29 +114,44 @@ if (deckProblem) {
115
114
  }
116
115
  // reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
117
116
  const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
117
+ const ENDS_HTML = /<\/html>\s*$/i;
118
118
  const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
119
119
  if (frameworkProblem) {
120
120
  console.error(frameworkProblem);
121
121
  process.exit(1);
122
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); }
123
133
  /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
124
- port is bound (see Start), so a failed start leaves no state behind. */
125
- // the second: the CLI's fallback. Behind the front none: its own server.json leads pindeck login to the front
126
- // (a deck server's port is no use to a browser behind a tunnel), and the assistant's CLI gets this server's address.
127
- const SERVER_FILES = FRONT ? [] : [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")];
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 = () => {};
128
138
  function writeServerInfo() {
129
139
  for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
130
- const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
131
- for (const f of SERVER_FILES) {
132
- fs.writeFileSync(f, info, { mode: 0o600 });
133
- 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
134
147
  }
148
+ unregister = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, key: deckKey, deckDir: DECK_DIR, deckFile: DECK_FILE,
149
+ ...(FRONT ? { behindFront: true } : {}) });
135
150
  }
136
- /* 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. */
137
152
  function removeServerInfo() {
138
- for (const f of SERVER_FILES) {
139
- try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
140
- }
153
+ unregister();
154
+ try { if (JSON.parse(fs.readFileSync(LOCK_FILE, "utf8")).pid === process.pid) fs.rmSync(LOCK_FILE, { force: true }); } catch {}
141
155
  }
142
156
 
143
157
  /* ---- Small helpers --------------------------------------------------------------- */
@@ -170,9 +184,23 @@ function writeDeck(f, content, manual = false) {
170
184
  ourWrites.set(f, { hash: hash(content), manual });
171
185
  writeAtomic(path.join(DECK_DIR, f), content);
172
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 });
173
200
  /* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
174
201
  function writeDeckFile(rel, data, manual = true) {
175
- const abs = path.join(DECK_DIR, rel);
202
+ const abs = deckPath(rel);
203
+ state.turn?.touched.add(rel);
176
204
  fs.mkdirSync(path.dirname(abs), { recursive: true });
177
205
  ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
178
206
  writeAtomic(abs, data);
@@ -185,7 +213,7 @@ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
185
213
  const asText = (v) => (v == null ? "" : String(v));
186
214
  const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
187
215
  function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
188
- if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
216
+ if (v == null) return removeDeckFile(rel);
189
217
  fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
190
218
  writeDeck(rel, v);
191
219
  }
@@ -298,13 +326,13 @@ function sized(url, size) {
298
326
  u.searchParams.set("height", size.height);
299
327
  return u.href;
300
328
  }
301
- function chrome(args, done) {
329
+ function chrome(args, done, signal) {
302
330
  if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
303
331
  return new Promise((resolve, reject) => {
304
332
  // a profile per run: concurrent runs must not share one
305
333
  const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
306
334
  const { size } = deckSlides();
307
- 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,
308
336
  "--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
309
337
  `--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
310
338
  ...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
@@ -322,17 +350,22 @@ function chrome(args, done) {
322
350
  proc.stderr.on("data", (d) => { err += d; check(); });
323
351
  proc.on("error", (e) => finish(reject, e));
324
352
  // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
325
- 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)}` : "."}`))));
326
355
  const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
356
+ signal?.addEventListener("abort", () => finish(reject, new Error("Stopped.")), { once: true });
327
357
  });
328
358
  }
329
359
  const deckUrl = (mode, params = {}) =>
330
- `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
331
- async function takeShot(slide) {
332
- 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 }));
333
366
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
334
367
  await chrome([`--screenshot=${out}`, deckUrl("shot", { slide: slide.eid, pins: JSON.stringify(pins) })],
335
- (_o, err) => /bytes written to file/.test(err) && fs.existsSync(out));
368
+ (_o, err) => /bytes written to file/.test(err) && fs.existsSync(out), signal);
336
369
  if (!fs.existsSync(out)) throw new Error("Chrome did not write the screenshot.");
337
370
  // keep the folder small
338
371
  const old = fs.readdirSync(SHOT_DIR).map((f) => path.join(SHOT_DIR, f))
@@ -376,16 +409,17 @@ function exportPdf(kind, includeHidden = false) {
376
409
  if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
377
410
  const split = kind === "presentation";
378
411
  const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
379
- const out = path.join(DECK_DIR, "export", name);
412
+ const out = path.join(DECK_DIR, "export", name); // checked when the export runs (deckPath)
380
413
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
381
414
  const run = async () => {
415
+ deckPath(`export/${name}`);
382
416
  fs.mkdirSync(path.dirname(out), { recursive: true });
383
417
  const layoutProfile = profile("layout"), printProfile = profile("print");
384
418
  const html = await printLayout(CHROME, chromeStart(layoutProfile,
385
- `${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);
386
420
  const id = `${process.pid}-${++snapSeq}`;
387
421
  snapshots.set(id, html);
388
- 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); }
389
423
  finally { snapshots.delete(id); }
390
424
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
391
425
  url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -399,13 +433,15 @@ function exportPdf(kind, includeHidden = false) {
399
433
  const CHAT_FILE = path.join(STATE_DIR, "chat.json");
400
434
  const state = {
401
435
  chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
402
- 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
403
438
  uploads: new Set(), // files uploaded for the next message; removable until it is sent
404
439
  pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
405
440
  };
406
441
  try {
407
442
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
408
- 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 });
409
445
  } catch {}
410
446
  /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
411
447
  let chatTimer = null;
@@ -414,7 +450,8 @@ function writeChat() {
414
450
  chatTimer = null;
415
451
  try {
416
452
  writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
417
- 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 }));
418
455
  } catch (e) { console.error(e.message); }
419
456
  }
420
457
  const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
@@ -428,38 +465,59 @@ function addEntry(entry) {
428
465
  }
429
466
  const liveTurns = () => state.turns.filter((t) => !t.undone);
430
467
  const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
431
- /* The menu persists provider, model and effort. CLI overrides apply to this run only.
432
- 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. */
433
472
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
434
- let fileConfig = {};
435
- try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
436
- catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
437
- const settings = migrateSettings(fileConfig);
438
- let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
439
- // Preserve the legacy selection as the first model-specific preference.
440
- 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)
441
495
  if (PROVIDER) {
442
- if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
443
- settings.provider = PROVIDER;
444
- 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.` });
445
507
  }
446
- if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
447
- // A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
448
- if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
449
508
  state.provider = settings.provider;
450
509
  /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
451
510
  deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
452
511
  it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
453
512
  or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
454
513
  a symlink that leads anywhere else is refused. */
455
- const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
456
- const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
457
- const abs = path.resolve(expandHome(String(r)));
458
- let st = null;
459
- try { st = fs.statSync(abs); } catch {}
460
- if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
461
- return abs;
462
- });
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
+ }
463
521
  const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
464
522
  const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
465
523
  const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
@@ -479,14 +537,22 @@ function servable(base, rel) {
479
537
  return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
480
538
  }
481
539
 
482
- let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
540
+ let catalog = { claude: { models: [] }, codex: { models: [] } };
483
541
  let session = null, starting = null;
484
- 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 },
485
543
  running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
486
544
  chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
487
545
  capabilities: publicCapabilities(CAPS), rev: deckRev(),
488
- undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
489
- 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
+ };
490
556
 
491
557
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
492
558
  .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
@@ -509,9 +575,50 @@ function slidesInstructions() {
509
575
  git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
510
576
  win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
511
577
  function lockedPaths() {
512
- let others = [];
513
- try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
514
- 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;
515
622
  }
516
623
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
517
624
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
@@ -582,9 +689,25 @@ function retire(agent) {
582
689
  retiring.add(stopped);
583
690
  stopped.finally(() => retiring.delete(stopped));
584
691
  }
585
- async function discoverModels() {
586
- if (discovering) return discovering;
587
- 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 () => {
588
711
  if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
589
712
  const agent = makeSession({ provider, model: "", effort: null });
590
713
  discoveryAgents.add(agent);
@@ -593,8 +716,36 @@ async function discoverModels() {
593
716
  catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
594
717
  } catch (e) { catalog[provider] = { models: [], error: e.message }; }
595
718
  finally { discoveryAgents.delete(agent); retire(agent); }
596
- })).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
597
- 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
+ }
598
749
  }
599
750
  async function ensureSession() {
600
751
  if (starting) return starting;
@@ -602,17 +753,37 @@ async function ensureSession() {
602
753
  const available = providerAvailable(settings.provider);
603
754
  if (!available.available) throw new Error(available.reason);
604
755
  starting = (async () => {
605
- if (!catalog[settings.provider].models.length) await discoverModels();
756
+ if (!catalog[settings.provider].models.length) await discoverModels({ providers: [settings.provider] });
757
+ settle();
606
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
+ }
607
765
  const agent = makeSession({ ...settings });
608
766
  session = agent;
609
- 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
+ }
610
773
  catch (e) { retire(agent); throw e; }
611
774
  })().finally(() => { starting = null; });
612
775
  return starting;
613
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
+ }
614
784
  async function changeSettings(requested) {
615
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
616
787
  const choice = selectModel(catalog, settings, requested, effortPreferences);
617
788
  if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
618
789
  const changingProvider = choice.provider !== settings.provider;
@@ -627,21 +798,22 @@ async function changeSettings(requested) {
627
798
  if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
628
799
  const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
629
800
  if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
630
- const nextPreferences = { ...effortPreferences };
631
- if (settings.effort) nextPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
632
- if (choice.effort) nextPreferences[`${choice.provider}:${choice.model}`] = choice.effort;
633
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...fileConfig, ...choice, effortPreferences: nextPreferences }, null, 2) + "\n");
634
- 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 };
635
806
  Object.assign(settings, choice);
807
+ deckSettings = { ...choice };
636
808
  state.provider = choice.provider;
637
- 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++; }
638
810
  if (reuse) candidate.settings = choice;
639
811
  session = candidate;
640
812
  if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
641
813
  if (!reuse) retire(old);
642
814
  saveChat();
643
- addEntry({ kind: "notice", text: changingProvider
644
- ? `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"}.`
645
817
  : `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
646
818
  } catch (e) { if (session !== candidate) retire(candidate); throw e; }
647
819
  finally { state.busy = false; state.switching = false; pushStatus(); }
@@ -676,6 +848,15 @@ function toolSummary(name, input = {}) {
676
848
  }
677
849
  }
678
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
+ };
679
860
  function handleEvent(ev) {
680
861
  if (ev.type === "system" && ev.subtype === "init") {
681
862
  state.model = ev.model || state.model;
@@ -685,7 +866,11 @@ function handleEvent(ev) {
685
866
  } else if (ev.type === "assistant" && state.turn) {
686
867
  for (const b of ev.message?.content || []) {
687
868
  if (b.type === "text" && b.text.trim()) addEntry({ kind: "assistant", text: b.text.trim() });
688
- 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
+ }
689
874
  }
690
875
  } else if (ev.type === "user" && state.turn) {
691
876
  for (const b of ev.message?.content || []) {
@@ -707,7 +892,9 @@ function handleEvent(ev) {
707
892
  function startTurn(promptText, userEntry) {
708
893
  // every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
709
894
  // takes back all it changed
710
- 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);
711
898
  state.busy = true;
712
899
  pushStatus();
713
900
  const turn = state.turn;
@@ -734,9 +921,26 @@ function finishTurn({ error, cost, duration, denials = [] }) {
734
921
  else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
735
922
  restored.push(f);
736
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
+ }
737
933
  toolEntries.clear();
738
934
  const eid = runEidPass();
739
- 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));
740
944
  const before = {}, after = {};
741
945
  for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
742
946
  // the deck file and theme.css first, then the rest by name
@@ -752,11 +956,14 @@ function finishTurn({ error, cost, duration, denials = [] }) {
752
956
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
753
957
  fs.rmSync(abs, { force: true });
754
958
  }
755
- 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 };
756
960
  if (files.length) { state.turns.push(t); state.redo = []; }
757
961
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
758
962
  denials, restored, newIds: eid.assigned.length });
759
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"}).` });
760
967
  if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
761
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"}.` });
762
969
  pushStatus();
@@ -802,7 +1009,7 @@ function formatAttachment(a) {
802
1009
  const formatDrop = (a) => (a.droppedAt?.slide ? ` Dropped onto slide ${a.droppedAt.slide.index + 1} (${a.droppedAt.slide.eid}) at ` +
803
1010
  `x=${a.droppedAt.point.x} y=${a.droppedAt.point.y}${a.droppedAt.eid ? `, over ${a.droppedAt.eid}` : ""}: probably where the user wants it.` : "");
804
1011
 
805
- function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, attachments = [] }) {
1012
+ function buildPrompt({ text: userText, pins = [], selection, layouts = [], current, screenshot, shotFailed, attachments = [] }) {
806
1013
  const ctx = [];
807
1014
  if (current) ctx.push(`The user is looking at slide ${current.index + 1} (${current.eid}).`);
808
1015
  if (state.notes.length) {
@@ -815,7 +1022,9 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
815
1022
  `box x=${selection.box.x} y=${selection.box.y} w=${selection.box.w} h=${selection.box.h} on slide ${selection.slide.index + 1}.`);
816
1023
  if (pins.length) ctx.push("Pins (numbered clicks; the user refers to them by number):\n" + pins.map(formatPin).join("\n"));
817
1024
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
818
- 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.`);
819
1028
  if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
820
1029
  `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
821
1030
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
@@ -827,9 +1036,11 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
827
1036
  async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
828
1037
  const bad = (t, status = 400) => ({ status, text: t });
829
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)
830
1041
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
831
1042
  .map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
832
- [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)));
833
1044
  if (!abs) return bad(`No PDF "${file}": use the path from the attached files, or a .pdf in the deck folder.`, 404);
834
1045
  const info = await pdfInfo(abs, STATE_DIR);
835
1046
  const n = Number(page);
@@ -884,13 +1095,19 @@ function scheduleFlush() {
884
1095
  if (external) {
885
1096
  state.notes = [...new Set([...state.notes,
886
1097
  "The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
887
- // 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)
888
1099
  clearTimeout(eidTimer);
889
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)
890
1103
  const pass = () => {
891
1104
  if (state.busy) return;
892
1105
  const now = readDeck(DECK_FILE);
893
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
894
1111
  runEidPass();
895
1112
  };
896
1113
  eidTimer = setTimeout(pass, 800);
@@ -976,22 +1193,40 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
976
1193
  }
977
1194
 
978
1195
  /* What deleting an element would remove, and which of its files nothing else uses. Files outside the
979
- 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. */
980
1198
  function deletePlan(eid) {
981
1199
  const html = readDeck(DECK_FILE);
982
1200
  const target = deleteTarget(html, eid);
983
1201
  if (!target) return null;
984
1202
  const rest = removeElement(html, target.eid);
985
1203
  const css = (readDeck("theme.css") || "") + (readDeck("brand.css") || "");
1204
+ const others = otherDeckRefs();
986
1205
  const files = fileRefs(target.outer).flatMap((ref) => {
987
1206
  const abs = path.resolve(DECK_DIR, ref);
988
1207
  const rel = path.relative(DECK_DIR, abs).split(path.sep).join("/");
989
1208
  if (rel.startsWith("..") || rel.startsWith("vendor/") || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) return [];
990
- const usedElsewhere = fileRefs(rest).some((r) => path.resolve(DECK_DIR, r) === abs) || css.includes(ref) || css.includes(rel);
991
- 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 }];
992
1213
  });
993
1214
  return { ...target, html: rest, files };
994
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
+ }
995
1230
 
996
1231
  /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
997
1232
  Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
@@ -1006,6 +1241,8 @@ function undoConflicts(t, direction) {
1006
1241
  // ones (which must be unchanged); redo the other way round
1007
1242
  const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
1008
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); } }
1009
1246
  for (const { rel, data } of comeBack) {
1010
1247
  const abs = path.join(DECK_DIR, rel);
1011
1248
  if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
@@ -1021,6 +1258,13 @@ function conflict(res, direction, files) {
1021
1258
  error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
1022
1259
  `it would overwrite those changes. Reload to see the files as they are.` });
1023
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;
1024
1268
  function undo(res) {
1025
1269
  if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
1026
1270
  const t = liveTurns().at(-1);
@@ -1029,12 +1273,15 @@ function undo(res) {
1029
1273
  if (diverged.length) return conflict(res, "undo", diverged);
1030
1274
  for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
1031
1275
  for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
1032
- 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
1033
1277
  t.undone = true;
1278
+ markUndone(t);
1034
1279
  state.redo.push(t);
1280
+ const names = t.files.map((f) => f.file);
1035
1281
  state.notes.push(t.kind === "manual"
1036
1282
  ? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
1037
- : `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.`);
1038
1285
  broadcast("undone", { turnId: t.id, undone: true });
1039
1286
  pushStatus();
1040
1287
  return json(res, 200, {});
@@ -1043,7 +1290,7 @@ function undo(res) {
1043
1290
  /* ---- PowerPoint import -----------------------------------------------------------------------------
1044
1291
  Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
1045
1292
  makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
1046
- Only the latest upload is kept, in ~/.pindeck/decks/<deck>/imports/. */
1293
+ Only the latest upload is kept, in ~/.pindeck-v3/decks/<deck>/imports/. */
1047
1294
  const IMPORT_DIR = path.join(STATE_DIR, "imports");
1048
1295
  const IMPORT_MAX = 500 * 1024 * 1024;
1049
1296
  let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
@@ -1155,28 +1402,36 @@ const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
1155
1402
  there, and the front sends this server's token in a header. */
1156
1403
  const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
1157
1404
  /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
1158
- 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();
1159
1410
  function chromeStart(profile, target) {
1160
1411
  fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
1161
1412
  const start = path.join(profile, "pindeck-start.html");
1162
- 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;");
1163
1418
  fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
1164
1419
  return pathToFileURL(start).href;
1165
1420
  }
1166
1421
 
1167
1422
  /* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
1168
1423
  Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
1169
- function serveFile(res, file, transform, req) {
1424
+ function serveFile(res, file, transform, req, extra = {}) {
1170
1425
  const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
1171
1426
  if (transform) return fs.readFile(file, (err, data) => {
1172
1427
  if (err) return text(res, 404, "Not found");
1173
1428
  const out = Buffer.from(transform(String(data)));
1174
- 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 });
1175
1430
  res.end(out);
1176
1431
  });
1177
1432
  fs.stat(file, (err, st) => {
1178
1433
  if (err || !st.isFile()) return text(res, 404, "Not found");
1179
- 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 };
1180
1435
  let start = 0, end = st.size - 1, code = 200;
1181
1436
  const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
1182
1437
  if (range && (range[1] || range[2])) {
@@ -1200,14 +1455,29 @@ function serveFile(res, file, transform, req) {
1200
1455
  }
1201
1456
 
1202
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": "*" } : {}) });
1203
1465
  const server = http.createServer(async (req, res) => {
1204
1466
  if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
1205
1467
  const host = String(req.headers.host || "").toLowerCase();
1206
1468
  if (!goodHost(host)) return text(res, 403, "Bad host");
1207
- const url = new URL(req.url, ORIGIN);
1208
- const p = url.pathname;
1209
1469
  try {
1470
+ const url = new URL(req.url, ORIGIN);
1471
+ let p = url.pathname;
1210
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
+ }
1211
1481
  // UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
1212
1482
  const ok = authed(req);
1213
1483
  if (req.method === "GET" && (p === "/" || p === "/index.html")) {
@@ -1218,18 +1488,25 @@ const server = http.createServer(async (req, res) => {
1218
1488
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
1219
1489
  return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1220
1490
  }
1221
- 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");
1222
1492
  let snap;
1223
1493
  if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
1224
1494
  const html = snapshots.get(snap[1]);
1225
1495
  if (!html) return text(res, 404, "Not found");
1226
- 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'" });
1227
1499
  return res.end(html);
1228
1500
  }
1229
- // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1230
- // /files/, so that ../ paths reach the root: /deck/ alone redirects there (a relative redirect, which
1231
- // stays under the front's /d/<deck>/).
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
+ };
1232
1508
  if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1509
+ if (!viewing) return toView(DECK_PAGE);
1233
1510
  res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
1234
1511
  return res.end();
1235
1512
  }
@@ -1238,8 +1515,9 @@ const server = http.createServer(async (req, res) => {
1238
1515
  if (deckPath === null) return text(res, 403, "Forbidden");
1239
1516
  if (deckPath) {
1240
1517
  const f = deckPath;
1518
+ if (!viewing && /\.html$/i.test(f)) return toView(p);
1241
1519
  const mode = url.searchParams.get("editor");
1242
- 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));
1243
1521
  return serveFile(res, f, (s) => {
1244
1522
  // headless runs must not stream media, or Chrome never finishes the page
1245
1523
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -1249,8 +1527,10 @@ const server = http.createServer(async (req, res) => {
1249
1527
  return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
1250
1528
  }
1251
1529
  return s.replace(/<\/body>/i, BRIDGE_TAG);
1252
- });
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)
1253
1532
  }
1533
+ if (viewing) return text(res, 404, "Not found");
1254
1534
 
1255
1535
  // /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
1256
1536
  if (req.method === "GET" && p.startsWith("/shots/")) {
@@ -1278,11 +1558,16 @@ const server = http.createServer(async (req, res) => {
1278
1558
  req.on("close", () => clients.delete(res));
1279
1559
  return;
1280
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
+ }
1281
1567
  if (p === "/api/ping") return json(res, 200, { ok: true });
1282
1568
  if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
1283
1569
  if (p === "/api/status") return json(res, 200, status());
1284
1570
  if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
1285
- if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
1286
1571
  if (p === "/api/measure-result" && req.method === "POST") {
1287
1572
  const b = await body(req);
1288
1573
  const w = pending.get(b.id);
@@ -1304,27 +1589,34 @@ const server = http.createServer(async (req, res) => {
1304
1589
  if (!placed(b.selection)) b.selection = null;
1305
1590
  if (!Array.isArray(b.layouts)) b.layouts = [];
1306
1591
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
1307
- const preparation = { cancelled: false };
1592
+ const preparation = { cancelled: false, controller: new AbortController() };
1308
1593
  state.preparing = preparation;
1309
1594
  state.busy = true; pushStatus();
1310
1595
  try {
1311
- let screenshot = null;
1596
+ let screenshot = null, shotFailed = null;
1312
1597
  const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
1313
1598
  if (shotSlide) {
1314
- try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
1315
- 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." });
1316
1606
  }
1317
- if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
1318
1607
  const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
1319
1608
  // only files PinDeck itself stored can be attached
1320
1609
  b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1321
1610
  b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1611
+ state.lastPins = b.pins;
1612
+ state.preparing = null;
1322
1613
  const entry = addEntry({ kind: "user", text: b.text,
1323
1614
  screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
1324
1615
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1325
1616
  selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1326
1617
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
1327
- 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);
1328
1620
  return json(res, 200, { id: entry.id });
1329
1621
  } finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
1330
1622
  }
@@ -1351,7 +1643,7 @@ const server = http.createServer(async (req, res) => {
1351
1643
  return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
1352
1644
  }
1353
1645
  const rel = attachPath(original);
1354
- writeDeckFile(rel, buf);
1646
+ try { writeDeckFile(rel, buf); } catch (e) { return json(res, e.status || 500, { error: e.message }); }
1355
1647
  state.uploads.add(rel);
1356
1648
  return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
1357
1649
  }
@@ -1364,7 +1656,7 @@ const server = http.createServer(async (req, res) => {
1364
1656
  }
1365
1657
  return json(res, 200, {});
1366
1658
  }
1367
- 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
1368
1660
  if (p === "/api/settings" && req.method === "POST") {
1369
1661
  const b = await body(req);
1370
1662
  if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
@@ -1455,7 +1747,7 @@ const server = http.createServer(async (req, res) => {
1455
1747
  catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
1456
1748
  }
1457
1749
  if (p === "/api/stop" && req.method === "POST") {
1458
- 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, {}); }
1459
1751
  if (state.busy && state.turn) {
1460
1752
  const interruptedTurn = state.turn;
1461
1753
  interruptedTurn.cancelled = true;
@@ -1470,7 +1762,9 @@ const server = http.createServer(async (req, res) => {
1470
1762
  if (p === "/api/new-chat" && req.method === "POST") {
1471
1763
  if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
1472
1764
  session?.stop();
1473
- 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 });
1474
1768
  saveChat();
1475
1769
  broadcast("reset", { chat: [] });
1476
1770
  ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
@@ -1497,11 +1791,13 @@ const server = http.createServer(async (req, res) => {
1497
1791
  if (diverged.length) return conflict(res, "redo", diverged);
1498
1792
  state.redo.pop();
1499
1793
  for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1500
- for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1794
+ for (const { rel } of t.deletedFiles || []) removeDeckFile(rel);
1501
1795
  for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1502
1796
  t.undone = false;
1797
+ markUndone(t);
1503
1798
  state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
1504
- : `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.`);
1505
1801
  broadcast("undone", { turnId: t.id, undone: false });
1506
1802
  pushStatus();
1507
1803
  return json(res, 200, {});
@@ -1528,7 +1824,7 @@ const server = http.createServer(async (req, res) => {
1528
1824
  const turnId = recordHandEdit(before, plan.html, label,
1529
1825
  `The user deleted ${plan.eid} (${plan.tag}) by hand` + (deletedFiles.length ? `, together with the file(s) ${deletedFiles.map((f) => f.rel).join(", ")}` : "") + ".",
1530
1826
  { manual: false, deletedFiles });
1531
- for (const f of deletedFiles) fs.rmSync(path.join(DECK_DIR, f.rel), { force: true });
1827
+ for (const f of deletedFiles) removeDeckFile(f.rel);
1532
1828
  return json(res, 200, { turnId, deleted: plan.eid, files: deletedFiles.map((f) => f.rel) });
1533
1829
  }
1534
1830
 
@@ -1547,7 +1843,9 @@ const server = http.createServer(async (req, res) => {
1547
1843
  return json(res, 200, { turnId });
1548
1844
  }
1549
1845
  if (p === "/api/source") {
1550
- 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);
1551
1849
  return html == null ? json(res, 404, { error: "Element not found in the file." }) : json(res, 200, { html });
1552
1850
  }
1553
1851
  // Speaker notes of a slide: read for the notes field, saved as a hand edit
@@ -1593,9 +1891,11 @@ const server = http.createServer(async (req, res) => {
1593
1891
  const before = snapshot(EDITABLE);
1594
1892
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
1595
1893
  if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
1596
- 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 });
1597
1897
  const short = (e) => e.replace(/^[^/]+\//, "/");
1598
- 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)}`,
1599
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)}".`);
1600
1900
  return json(res, 200, { turnId, eid: r2.eid });
1601
1901
  }
@@ -1650,9 +1950,11 @@ const server = http.createServer(async (req, res) => {
1650
1950
  return text(res, 404, "Not found");
1651
1951
  } catch (e) {
1652
1952
  // malformed JSON or %-encoding in a request is the caller's mistake, not a server error
1653
- const bad = e instanceof SyntaxError || e instanceof URIError || e.message === "body too large";
1654
- if (!bad) console.error(e);
1655
- 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));
1656
1958
  }
1657
1959
  });
1658
1960
 
@@ -1677,7 +1979,7 @@ server.listen(PORT, "127.0.0.1", () => {
1677
1979
  if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
1678
1980
  startWatcher();
1679
1981
  } catch (e) {
1680
- 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}`);
1681
1983
  removeServerInfo();
1682
1984
  process.exit(1);
1683
1985
  }
@@ -1689,19 +1991,14 @@ server.listen(PORT, "127.0.0.1", () => {
1689
1991
  } else {
1690
1992
  console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1691
1993
  console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1994
+ openOnEnter(() => loginUrl(loginCode(3600000)));
1692
1995
  console.log(`Deck: ${DECK_DIR}`);
1693
1996
  console.log(`Server: ${CAPS.label}`);
1694
1997
  for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1695
1998
  if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1696
1999
  }
1697
- discoverModels().then(() => {
1698
- if (!settings.model) {
1699
- const models = catalog[settings.provider].models;
1700
- const first = models.find(m => m.isDefault) || models[0];
1701
- if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
1702
- }
1703
- pushStatus();
1704
- });
2000
+ for (const n of startNotices) addEntry(n);
2001
+ discoverModels().then(() => { settle(); pushStatus(); });
1705
2002
  });
1706
2003
  /* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
1707
2004
  SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */