pindeck 2.8.1 → 3.0.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
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  /* PinDeck: local server.
3
3
 
4
- pindeck [deck-dir] [--port 5180] [--model <name>] (or: node server.mjs ...)
4
+ pindeck [deck-dir] [--port 5180] [--provider claude|codex] [--model <name>] (or: node server.mjs ...)
5
5
 
6
6
  - serves the editor UI and the deck (with a bridge script injected in edit mode)
7
7
  - pushes file changes to the browser (server-sent events) for live reload
8
- - runs one long-lived Claude Code process for the chat sidebar
9
- - snapshots files around every Claude turn for diffs and undo
10
- - answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for Claude's self-checks, exports and PDF figures
8
+ - runs one long-lived backend process for the chat sidebar
9
+ - snapshots files around every assistant turn for diffs and undo
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
- - stores figures and audio attached to chat messages in <deck>/figures/ for Claude to use, and attached
12
+ - stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
13
13
  PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
14
14
  - imports slides from PowerPoint files in the deck's own layouts
15
15
  Listens on 127.0.0.1 only; every API call needs the per-run token.
@@ -25,6 +25,9 @@ import { fileURLToPath } from "node:url";
25
25
  import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
26
26
  import { unifiedDiff } from "./lib/diff.mjs";
27
27
  import { ClaudeSession } from "./lib/claude.mjs";
28
+ import { CodexSession } from "./lib/codex.mjs";
29
+ import { createDeckTools } from "./lib/agent-tools.mjs";
30
+ import { migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
28
31
  import { formatLayout, formatPin } from "./lib/layout.mjs";
29
32
  import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
30
33
  import { readPptx } from "./lib/pptx.mjs";
@@ -32,7 +35,7 @@ import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./
32
35
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
33
36
 
34
37
  /* ---- Config ------------------------------------------------------------------ */
35
- const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
38
+ const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--provider claude|codex] [--model <name>]
36
39
 
37
40
  Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
38
41
  The folder must contain index.html.`;
@@ -41,12 +44,19 @@ const argv = process.argv.slice(2);
41
44
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
42
45
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
43
46
  const PORT = +opt("--port", process.env.PORT || 5180);
44
- const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
45
- const DECK_DIR = path.resolve(argv[0] || process.cwd());
47
+ if (process.platform !== "darwin") {
48
+ const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
49
+ await showPlatformNotice(PORT);
50
+ process.exit(0);
51
+ }
52
+ const PROVIDER = opt("--provider", "");
53
+ const MODEL = opt("--model", "");
54
+ const deckPath = path.resolve(argv[0] || process.cwd());
55
+ const DECK_DIR = fs.existsSync(deckPath) ? fs.realpathSync(deckPath) : deckPath;
46
56
  const DECK_FILE = "index.html";
47
- const EDITABLE = ["index.html", "theme.css"]; // Claude may edit these
57
+ const EDITABLE = ["index.html", "theme.css"]; // Both backends may edit these
48
58
  const LOCKED = ["brand.css"]; // never, enforced twice
49
- const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
59
+ const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
50
60
  const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
51
61
  crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
52
62
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
@@ -62,6 +72,7 @@ if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
62
72
  process.exit(1);
63
73
  }
64
74
  fs.mkdirSync(SHOT_DIR, { recursive: true });
75
+ fs.mkdirSync(PDF_DIR, { recursive: true });
65
76
  const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
66
77
  fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
67
78
  fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
@@ -250,20 +261,21 @@ function exportPdf(kind, includeHidden = false) {
250
261
  return job;
251
262
  }
252
263
 
253
- /* ---- Chat state and the Claude Code session --------------------------------------------- */
264
+ /* ---- Chat state and the selected backend session --------------------------------------------- */
254
265
  const CHAT_FILE = path.join(STATE_DIR, "chat.json");
255
266
  const state = {
256
- chat: [], seq: 0, busy: false, model: MODEL || null, sessionId: null,
267
+ chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
257
268
  turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
258
269
  uploads: new Set(), // files uploaded for the next message; removable until it is sent
259
270
  pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
260
271
  };
261
272
  try {
262
273
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
263
- Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
274
+ Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
264
275
  } catch {}
265
- const saveChat = () => fs.writeFile(CHAT_FILE,
266
- JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId }), () => {});
276
+ const saveChat = () => fs.writeFileSync(CHAT_FILE,
277
+ JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
278
+ provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
267
279
  function addEntry(entry) {
268
280
  entry.id = ++state.seq;
269
281
  entry.time = Date.now();
@@ -273,28 +285,30 @@ function addEntry(entry) {
273
285
  return entry;
274
286
  }
275
287
  const liveTurns = () => state.turns.filter((t) => !t.undone);
276
- const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "Claude's change") : null);
277
- /* Model and effort for the Claude Code session. ~/.pindeck/config.json holds them: PinDeck creates it
278
- with the defaults below, you can edit it, and a choice in the chat header's menu is saved there too,
279
- so the file always shows what PinDeck uses. --model on the command line applies to one run only. */
280
- const MODELS = ["opus", "sonnet"];
281
- const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
282
- const DEFAULTS = { model: "sonnet", effort: "medium" };
288
+ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
289
+ /* The menu persists provider, model and effort. CLI overrides apply to this run only.
290
+ The v3 preview uses its own state directory so v2 can keep running independently. */
283
291
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
284
- const settings = { ...DEFAULTS };
285
292
  let fileConfig = {};
286
293
  try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
287
- catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
288
- if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
289
- else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
290
- if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
291
- else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
292
- const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
293
- if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings(); // create or complete the file
294
- if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
295
-
296
- const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
297
- running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
294
+ catch (e) { if (e.code !== "ENOENT") console.warn("Invalid PinDeck configuration; using defaults."); }
295
+ const settings = migrateSettings(fileConfig);
296
+ let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
297
+ // Preserve the legacy selection as the first model-specific preference.
298
+ if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
299
+ if (PROVIDER) {
300
+ if (!["claude", "codex"].includes(PROVIDER)) throw new Error("Use --provider claude or codex.");
301
+ settings.provider = PROVIDER;
302
+ if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
303
+ }
304
+ if (MODEL) settings.model = MODEL;
305
+ // A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
306
+ if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
307
+ state.provider = settings.provider;
308
+ let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
309
+ let session = null, starting = null;
310
+ const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
311
+ running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE, chrome: !!CHROME,
298
312
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
299
313
  const pushStatus = () => broadcast("status", status());
300
314
 
@@ -308,32 +322,106 @@ function deckInstructions() {
308
322
  try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
309
323
  catch { return ""; }
310
324
  }
311
- let stderrTail = "";
312
- const session = new ClaudeSession({
313
- cwd: DECK_DIR,
314
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
315
- args: () => [
316
- "--append-system-prompt", systemPrompt + deckInstructions(),
317
- "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
318
- "--permission-mode", "default",
319
- "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map((f) => `Edit(./${f})`), `Bash(${CLI_CMD}:*)`,
320
- "--disallowedTools", ...LOCKED.flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
321
- ...(settings.model ? ["--model", settings.model] : []),
322
- ...(settings.effort ? ["--effort", settings.effort] : []),
323
- ],
324
- onStderr: (s) => { stderrTail = (stderrTail + s).slice(-2000); },
325
- onEvent: handleEvent,
326
- onExit: (code, signal, { stale } = {}) => {
327
- if (state.busy && state.turn && !stale) {
328
- const why = code == null && signal == null ? "could not start" : `stopped (${signal || "exit " + code})`;
329
- const hint = /ENOENT/.test(stderrTail) ? " Is the claude command installed and on the PATH (or set CLAUDE_BIN)?" : "";
330
- finishTurn({ error: `Claude Code ${why}. ${stderrTail.trim().split("\n").pop() || ""}${hint}` });
331
- }
332
- pushStatus();
333
- },
334
- });
335
- function ensureSession() {
336
- if (!session.running) { stderrTail = ""; session.start(state.sessionId); pushStatus(); }
325
+ const agentTools = createDeckTools({ deckDir: DECK_DIR, readDirs: [SHOT_DIR, PDF_DIR],
326
+ cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
327
+ env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN } });
328
+ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
329
+ Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
330
+ Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
331
+ Use pindeck_edit for exact replacements in index.html and theme.css. Never change brand.css.
332
+ Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
333
+ Do not use shell commands, native file edits, or other tools to bypass these restrictions.
334
+ When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
335
+ Read README.md and the deck instructions below for conventions. Use layout after every visual change.
336
+ PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
337
+
338
+ ## Hand edits`) + deckInstructions();
339
+ function makeSession(choice) {
340
+ let initial = null;
341
+ const options = {
342
+ cwd: DECK_DIR,
343
+ env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
344
+ onEvent: ev => {
345
+ if (ev.type === "system" && ev.subtype === "init") initial = ev;
346
+ if (session === agent) handleEvent(ev);
347
+ },
348
+ onExit: error => {
349
+ if (session !== agent) return;
350
+ if (state.turn) finishTurn({ error: error.message });
351
+ pushStatus();
352
+ },
353
+ };
354
+ const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
355
+ instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
356
+ args: ["--append-system-prompt", systemPrompt + deckInstructions(),
357
+ "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands",
358
+ "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Bash",
359
+ "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map(f => `Edit(./${f})`), ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
360
+ "--disallowedTools", ...LOCKED.flatMap(f => [`Edit(./${f})`, `Write(./${f})`]),
361
+ ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
362
+ });
363
+ agent.initialEvent = () => initial;
364
+ return agent;
365
+ }
366
+ async function discoverModels() {
367
+ if (discovering) return discovering;
368
+ discovering = Promise.all(["claude", "codex"].map(async provider => {
369
+ const agent = makeSession({ provider, model: "", effort: null });
370
+ try {
371
+ if (provider === "codex") await agent.connect(); else await agent.start();
372
+ catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
373
+ } catch (e) { catalog[provider] = { models: [], error: e.message }; }
374
+ finally { agent.stop(); }
375
+ })).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
376
+ return discovering;
377
+ }
378
+ async function ensureSession() {
379
+ if (starting) return starting;
380
+ if (session?.running) return;
381
+ starting = (async () => {
382
+ if (!catalog[settings.provider].models.length) await discoverModels();
383
+ Object.assign(settings, selectModel(catalog, settings, {}));
384
+ const agent = makeSession({ ...settings });
385
+ session = agent;
386
+ try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
387
+ catch (e) { agent.stop(); throw e; }
388
+ })().finally(() => { starting = null; });
389
+ return starting;
390
+ }
391
+ async function changeSettings(requested) {
392
+ if (state.busy || starting) throw new Error("Wait until the current request finishes before switching.");
393
+ const choice = selectModel(catalog, settings, requested, effortPreferences);
394
+ if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
395
+ const changingProvider = choice.provider !== settings.provider;
396
+ if (changingProvider && !["summary", "fresh"].includes(requested.handover)) throw new Error("Choose whether to pass a summary or start fresh.");
397
+ const old = session;
398
+ // Codex owns a single writer per thread. Model/effort are turn parameters, so keep its writer.
399
+ const reuse = !changingProvider && choice.provider === "codex" && old?.running;
400
+ const candidate = reuse ? old : makeSession(choice);
401
+ state.busy = true; state.switching = true; pushStatus();
402
+ try {
403
+ if (!reuse) await candidate.start(changingProvider ? null : state.sessionId);
404
+ if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
405
+ const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
406
+ if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
407
+ const nextPreferences = { ...effortPreferences };
408
+ if (settings.effort) nextPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
409
+ if (choice.effort) nextPreferences[`${choice.provider}:${choice.model}`] = choice.effort;
410
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...fileConfig, ...choice, effortPreferences: nextPreferences }, null, 2) + "\n");
411
+ effortPreferences = nextPreferences;
412
+ Object.assign(settings, choice);
413
+ state.provider = choice.provider;
414
+ if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; }
415
+ if (reuse) candidate.settings = choice;
416
+ session = candidate;
417
+ if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
418
+ if (!reuse) old?.stop();
419
+ saveChat();
420
+ addEntry({ kind: "notice", text: changingProvider
421
+ ? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
422
+ : `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
423
+ } catch (e) { if (session !== candidate) candidate.stop(); throw e; }
424
+ finally { state.busy = false; state.switching = false; pushStatus(); }
337
425
  }
338
426
 
339
427
  function toolSummary(name, input = {}) {
@@ -344,6 +432,9 @@ function toolSummary(name, input = {}) {
344
432
  return !r || r.startsWith("..") ? path.basename(p) : r;
345
433
  };
346
434
  switch (name) {
435
+ case "pindeck_read": return { name: "Read", detail: base(input.path) };
436
+ case "pindeck_edit": return { name: "Edit", detail: base(input.path) };
437
+ case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
347
438
  case "Read": return { name: "Read", detail: base(input.file_path) };
348
439
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
349
440
  case "Bash": {
@@ -392,8 +483,14 @@ function startTurn(promptText, userEntry) {
392
483
  state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
393
484
  state.busy = true;
394
485
  pushStatus();
395
- ensureSession();
396
- session.send([{ type: "text", text: promptText }]);
486
+ const turn = state.turn;
487
+ const context = state.handover ? state.handover + "\n\n" + promptText : promptText;
488
+ ensureSession().then(async () => {
489
+ if (state.turn !== turn || turn.cancelled) return;
490
+ const sendingSession = session;
491
+ await session.send([{ type: "text", text: context }]);
492
+ if (session === sendingSession) { state.handover = ""; saveChat(); }
493
+ }).catch(e => { if (state.turn === turn) finishTurn({ error: e.message }); });
397
494
  }
398
495
 
399
496
  function finishTurn({ error, cost, duration, denials = [] }) {
@@ -407,7 +504,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
407
504
  const now = readDeck(f);
408
505
  if (now === turn.before[f]) continue;
409
506
  if (turn.before[f] != null) writeDeck(f, turn.before[f]);
410
- else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that Claude created
507
+ else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
411
508
  restored.push(f);
412
509
  }
413
510
  toolEntries.clear();
@@ -417,7 +514,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
417
514
  const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
418
515
  return { file: f, added: d.added, removed: d.removed };
419
516
  }).filter((f) => f.added || f.removed);
420
- // after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
517
+ // after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
421
518
  // deleted; undo brings them back with the turn
422
519
  const deletedFiles = [];
423
520
  for (const rel of turn.importedFiles || []) {
@@ -426,7 +523,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
426
523
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
427
524
  fs.rmSync(abs, { force: true });
428
525
  }
429
- const t = { id: turn.id, kind: "claude", before: turn.before, after, files, deletedFiles, undone: false };
526
+ const t = { id: turn.id, kind: "agent", provider: settings.provider, before: turn.before, after, files, deletedFiles, undone: false };
430
527
  if (files.length) { state.turns.push(t); state.redo = []; }
431
528
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
432
529
  denials, restored, newIds: eid.assigned.length });
@@ -436,7 +533,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
436
533
 
437
534
  /* ---- Attachments: figures, audio and PDFs added to a chat message ------------------------------------
438
535
  Figures and audio go into <deck>/figures/ (paths relative to the deck). PDFs are reading material for
439
- Claude (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
536
+ the assistant (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
440
537
  (absolute paths); `cli.mjs pdf-figure` renders a page or part of one into figures/ when it is needed. */
441
538
  const ATTACH_TYPES = {
442
539
  image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"],
@@ -495,8 +592,8 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
495
592
 
496
593
  /* `cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px]`: renders a PDF page, or a box
497
594
  of it given as fractions of the page from the top left, to PNG. Without a figures/ name it goes to the
498
- screenshots folder, for Claude to look at before choosing; with one, into the deck. */
499
- async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
595
+ screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
596
+ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
500
597
  const bad = (t, status = 400) => ({ status, text: t });
501
598
  if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
502
599
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
@@ -518,6 +615,7 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
518
615
  await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
519
616
  if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
520
617
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
618
+ if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
521
619
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
522
620
  const buf = fs.readFileSync(tmp);
523
621
  fs.rmSync(tmp, { force: true });
@@ -566,7 +664,7 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
566
664
  files: [{ file: DECK_FILE, added: d.added, removed: d.removed }], undone: false };
567
665
  state.turns.push(t);
568
666
  state.redo = [];
569
- // chat: false keeps the edit out of the chat (it stays in the undo history and Claude is still told)
667
+ // chat: false keeps the edit out of the chat (it stays in the undo history and the assistant is still told)
570
668
  if (chat) addEntry({ kind: "edit", turnId: t.id, label, files: t.files });
571
669
  else state.seq++; // keep turn ids unique: they share the counter with chat entries
572
670
  state.notes.push(note);
@@ -592,9 +690,9 @@ function deletePlan(eid) {
592
690
  return { ...target, html: rest, files };
593
691
  }
594
692
 
595
- /* ---- Undo (Claude's turns and hand edits share one history) ---------------------------------------- */
693
+ /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------- */
596
694
  function undo(res) {
597
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
695
+ if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
598
696
  const t = liveTurns().at(-1);
599
697
  if (!t) return json(res, 409, { error: "Nothing to undo." });
600
698
  const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
@@ -697,7 +795,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
697
795
  missing: r.plans.flatMap((p) => p.figures.filter((f) => f.missing)).length };
698
796
  }
699
797
 
700
- /* The hand-off: Claude gets the slides the import could only approximate, each with a picture of the
798
+ /* The hand-off: the assistant gets the slides the import could only approximate, each with a picture of the
701
799
  original slide, and improves them in the deck's style. Returns the number of slides handed over. */
702
800
  async function refineImport(imp, pdf, plans, eids, first) {
703
801
  const todo = plans.map((p, i) => ({ p, index: first + i, eid: eids[i] })).filter((x) => x.p.complex.length);
@@ -808,8 +906,9 @@ const server = http.createServer(async (req, res) => {
808
906
  }
809
907
 
810
908
  if (p === "/api/message" && req.method === "POST") {
811
- if (state.busy) return json(res, 409, { error: "Claude is still working." });
909
+ if (state.busy) return json(res, 409, { error: "The assistant is still working." });
812
910
  const b = await body(req);
911
+ if (state.busy) return json(res, 409, { error: "The assistant is still working." });
813
912
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
814
913
  // what the prompt reads from pins and the selection must be there
815
914
  const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
@@ -817,22 +916,28 @@ const server = http.createServer(async (req, res) => {
817
916
  if (!placed(b.selection)) b.selection = null;
818
917
  if (!Array.isArray(b.layouts)) b.layouts = [];
819
918
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
820
- let screenshot = null;
821
- const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
822
- if (shotSlide) {
823
- try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
824
- catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
825
- }
826
- const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
827
- // only files PinDeck itself stored can be attached
828
- b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
829
- b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
830
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
831
- attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
832
- selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
833
- pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
834
- startTurn(buildPrompt({ ...b, screenshot }), entry);
835
- return json(res, 200, { id: entry.id });
919
+ const preparation = { cancelled: false };
920
+ state.preparing = preparation;
921
+ state.busy = true; pushStatus();
922
+ try {
923
+ let screenshot = null;
924
+ const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
925
+ if (shotSlide) {
926
+ try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
927
+ catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
928
+ }
929
+ if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
930
+ const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
931
+ // only files PinDeck itself stored can be attached
932
+ b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
933
+ b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
934
+ const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
935
+ attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
936
+ selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
937
+ pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
938
+ startTurn(buildPrompt({ ...b, screenshot }), entry);
939
+ return json(res, 200, { id: entry.id });
940
+ } finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
836
941
  }
837
942
  if (p === "/api/upload" && req.method === "POST") {
838
943
  const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
@@ -872,22 +977,15 @@ const server = http.createServer(async (req, res) => {
872
977
  }
873
978
  return json(res, 200, {});
874
979
  }
980
+ if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
875
981
  if (p === "/api/settings" && req.method === "POST") {
876
982
  const b = await body(req);
877
- const model = b.model ?? settings.model, effort = b.effort ?? settings.effort;
878
- if (!MODELS.includes(model) || !EFFORTS.includes(effort)) return json(res, 400, { error: "Unknown model or effort." });
879
- if (model === settings.model && effort === settings.effort) return json(res, 200, { settings });
880
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished, then change the model or effort." });
881
- Object.assign(settings, { model, effort });
882
- saveSettings();
883
- // restart the session with the new flags; --resume keeps the conversation
884
- if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
885
- addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
886
- pushStatus();
887
- return json(res, 200, { settings });
983
+ if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
984
+ try { await changeSettings(b); return json(res, 200, { settings }); }
985
+ catch (e) { return json(res, 400, { error: e.message }); }
888
986
  }
889
987
  if (p === "/api/slide-hidden" && req.method === "POST") {
890
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
988
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
891
989
  const b = await body(req);
892
990
  const before = snapshot(EDITABLE);
893
991
  const slides = findSlides(parse(before[DECK_FILE]));
@@ -902,7 +1000,7 @@ const server = http.createServer(async (req, res) => {
902
1000
  return json(res, 200, { turnId, index });
903
1001
  }
904
1002
  if (p === "/api/move-slide" && req.method === "POST") {
905
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1003
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
906
1004
  const b = await body(req);
907
1005
  const before = snapshot(EDITABLE);
908
1006
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
@@ -914,7 +1012,7 @@ const server = http.createServer(async (req, res) => {
914
1012
  return json(res, 200, { turnId, index: r.index });
915
1013
  }
916
1014
  if (p === "/api/add-slide" && req.method === "POST") {
917
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1015
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
918
1016
  const b = await body(req);
919
1017
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
920
1018
  const before = snapshot(EDITABLE);
@@ -942,7 +1040,7 @@ const server = http.createServer(async (req, res) => {
942
1040
  let imp;
943
1041
  try { imp = startImport(name, Buffer.concat(chunks)); }
944
1042
  catch (e) { return json(res, 400, { error: `${name} could not be read: ${e.message}` }); }
945
- // what the import can only approximate, so the dialog can offer Claude's help with it
1043
+ // what the import can only approximate, so the dialog can offer the assistant's help with it
946
1044
  const complex = new Map(planSlides(imp.model, imp.model.slides.map((s) => s.n), { drawings: renderAvailable() }).map((p) => [p.n, p.complex]));
947
1045
  return json(res, 200, { id: imp.id, name, render: renderAvailable(),
948
1046
  slides: imp.model.slides.map((s) => ({ n: s.n, title: s.title, subtitle: s.subtitle, hidden: s.hidden, kind: s.kind, complex: complex.get(s.n) || [] })) });
@@ -953,7 +1051,7 @@ const server = http.createServer(async (req, res) => {
953
1051
  return serveFile(res, path.join(imp.dir, `thumb-${n}.png`));
954
1052
  }
955
1053
  if (p === "/api/import-apply" && req.method === "POST") {
956
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1054
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
957
1055
  const b = await body(req);
958
1056
  const imp = lastImport;
959
1057
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
@@ -963,21 +1061,25 @@ const server = http.createServer(async (req, res) => {
963
1061
  catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
964
1062
  }
965
1063
  if (p === "/api/stop" && req.method === "POST") {
966
- if (state.busy && session.running) {
1064
+ if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
1065
+ if (state.busy && state.turn) {
967
1066
  const interruptedTurn = state.turn;
968
- session.interrupt();
1067
+ interruptedTurn.cancelled = true;
1068
+ if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
1069
+ Promise.resolve(session.interrupt()).catch(() => {});
969
1070
  setTimeout(() => {
970
- if (state.busy && state.turn === interruptedTurn) session.stop();
1071
+ if (state.busy && state.turn === interruptedTurn) { session.stop(); finishTurn({ error: "Stopped." }); }
971
1072
  }, 5000); // force only if the interrupted turn does not answer
972
1073
  }
973
1074
  return json(res, 200, {});
974
1075
  }
975
1076
  if (p === "/api/new-chat" && req.method === "POST") {
976
- session.stop();
977
- Object.assign(state, { chat: [], sessionId: null, turns: [], redo: [], turn: null, busy: false, notes: [] });
1077
+ if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
1078
+ session?.stop();
1079
+ Object.assign(state, { chat: [], sessionId: null, handover: "", turns: [], redo: [], turn: null, busy: false, notes: [] });
978
1080
  saveChat();
979
1081
  broadcast("reset", { chat: [] });
980
- ensureSession();
1082
+ ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
981
1083
  pushStatus();
982
1084
  return json(res, 200, {});
983
1085
  }
@@ -994,7 +1096,7 @@ const server = http.createServer(async (req, res) => {
994
1096
  }
995
1097
  if (p === "/api/undo" && req.method === "POST") return undo(res);
996
1098
  if (p === "/api/redo" && req.method === "POST") {
997
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
1099
+ if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
998
1100
  const t = state.redo.at(-1);
999
1101
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1000
1102
  if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
@@ -1024,7 +1126,7 @@ const server = http.createServer(async (req, res) => {
1024
1126
  return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
1025
1127
  }
1026
1128
  if (p === "/api/delete" && req.method === "POST") {
1027
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1129
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1028
1130
  const b = await body(req);
1029
1131
  const plan = deletePlan(b.eid);
1030
1132
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
@@ -1043,7 +1145,7 @@ const server = http.createServer(async (req, res) => {
1043
1145
 
1044
1146
  // Hand edits from the editor: drag/resize (inline styles) and text
1045
1147
  if (p === "/api/edit" && req.method === "POST") {
1046
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1148
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1047
1149
  const b = await body(req);
1048
1150
  const before = snapshot(EDITABLE);
1049
1151
  const r = setStyles(before[DECK_FILE], b.ops || []);
@@ -1064,7 +1166,7 @@ const server = http.createServer(async (req, res) => {
1064
1166
  return n ? json(res, 200, n) : json(res, 404, { error: "Slide not found in the file." });
1065
1167
  }
1066
1168
  if (p === "/api/notes" && req.method === "POST") {
1067
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1169
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1068
1170
  const b = await body(req);
1069
1171
  const before = snapshot(EDITABLE);
1070
1172
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
@@ -1078,7 +1180,7 @@ const server = http.createServer(async (req, res) => {
1078
1180
  return json(res, 200, { turnId });
1079
1181
  }
1080
1182
  if (p === "/api/text" && req.method === "POST") {
1081
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1183
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1082
1184
  const b = await body(req);
1083
1185
  const before = snapshot(EDITABLE);
1084
1186
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
@@ -1093,7 +1195,7 @@ const server = http.createServer(async (req, res) => {
1093
1195
 
1094
1196
  // Enter in a bullet: the bullet keeps the text before the caret, a new bullet after it gets the rest
1095
1197
  if (p === "/api/split" && req.method === "POST") {
1096
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1198
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1097
1199
  const b = await body(req);
1098
1200
  const before = snapshot(EDITABLE);
1099
1201
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
@@ -1105,7 +1207,7 @@ const server = http.createServer(async (req, res) => {
1105
1207
  return json(res, 200, { turnId, eid: r2.eid });
1106
1208
  }
1107
1209
  if (p === "/api/remove" && req.method === "POST") {
1108
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1210
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1109
1211
  const b = await body(req);
1110
1212
  const before = snapshot(EDITABLE);
1111
1213
  const html = removeElement(before[DECK_FILE], b.eid);
@@ -1121,7 +1223,7 @@ const server = http.createServer(async (req, res) => {
1121
1223
  catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1122
1224
  }
1123
1225
 
1124
- // CLI endpoints (used by Claude Code via cli.mjs)
1226
+ // CLI endpoints (used by both backends via cli.mjs)
1125
1227
  if (p === "/api/pdf") {
1126
1228
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1127
1229
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
@@ -1129,7 +1231,8 @@ const server = http.createServer(async (req, res) => {
1129
1231
  return text(res, 200, r.path);
1130
1232
  }
1131
1233
  if (p === "/api/pdf-figure") {
1132
- const r = await pdfFigure(Object.fromEntries(url.searchParams));
1234
+ const turn = state.turn;
1235
+ const r = await pdfFigure({ ...Object.fromEntries(url.searchParams), cancelled: () => !!turn && (turn.cancelled || state.turn !== turn) });
1133
1236
  return text(res, r.status || 200, r.text);
1134
1237
  }
1135
1238
  if (p === "/api/slides") {
@@ -1159,8 +1262,15 @@ server.listen(PORT, "127.0.0.1", () => {
1159
1262
  console.log(`PinDeck: ${ORIGIN}/`);
1160
1263
  console.log(`Deck: ${DECK_DIR}`);
1161
1264
  if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1162
- ensureSession();
1265
+ discoverModels().then(() => {
1266
+ if (!settings.model) {
1267
+ const models = catalog[settings.provider].models;
1268
+ const first = models.find(m => m.isDefault) || models[0];
1269
+ if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
1270
+ }
1271
+ pushStatus();
1272
+ });
1163
1273
  });
1164
- const quit = () => { session.stop(); setTimeout(() => process.exit(0), 300); };
1274
+ const quit = () => { session?.stop(); setTimeout(() => process.exit(0), 300); };
1165
1275
  process.on("SIGINT", quit);
1166
1276
  process.on("SIGTERM", quit);