pindeck 2.8.2 → 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.`;
@@ -46,12 +49,14 @@ if (process.platform !== "darwin") {
46
49
  await showPlatformNotice(PORT);
47
50
  process.exit(0);
48
51
  }
49
- const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
50
- const DECK_DIR = path.resolve(argv[0] || process.cwd());
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;
51
56
  const DECK_FILE = "index.html";
52
- const EDITABLE = ["index.html", "theme.css"]; // Claude may edit these
57
+ const EDITABLE = ["index.html", "theme.css"]; // Both backends may edit these
53
58
  const LOCKED = ["brand.css"]; // never, enforced twice
54
- 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");
55
60
  const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
56
61
  crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
57
62
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
@@ -67,6 +72,7 @@ if (!fs.existsSync(path.join(DECK_DIR, DECK_FILE))) {
67
72
  process.exit(1);
68
73
  }
69
74
  fs.mkdirSync(SHOT_DIR, { recursive: true });
75
+ fs.mkdirSync(PDF_DIR, { recursive: true });
70
76
  const serverInfo = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
71
77
  fs.writeFileSync(path.join(STATE_DIR, "server.json"), serverInfo);
72
78
  fs.writeFileSync(path.join(HOME_DIR, "server.json"), serverInfo); // the CLI's fallback: most recent server
@@ -255,20 +261,21 @@ function exportPdf(kind, includeHidden = false) {
255
261
  return job;
256
262
  }
257
263
 
258
- /* ---- Chat state and the Claude Code session --------------------------------------------- */
264
+ /* ---- Chat state and the selected backend session --------------------------------------------- */
259
265
  const CHAT_FILE = path.join(STATE_DIR, "chat.json");
260
266
  const state = {
261
- 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: [],
262
268
  turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
263
269
  uploads: new Set(), // files uploaded for the next message; removable until it is sent
264
270
  pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
265
271
  };
266
272
  try {
267
273
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
268
- 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 || [] });
269
275
  } catch {}
270
- const saveChat = () => fs.writeFile(CHAT_FILE,
271
- 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) }));
272
279
  function addEntry(entry) {
273
280
  entry.id = ++state.seq;
274
281
  entry.time = Date.now();
@@ -278,28 +285,30 @@ function addEntry(entry) {
278
285
  return entry;
279
286
  }
280
287
  const liveTurns = () => state.turns.filter((t) => !t.undone);
281
- const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "Claude's change") : null);
282
- /* Model and effort for the Claude Code session. ~/.pindeck/config.json holds them: PinDeck creates it
283
- with the defaults below, you can edit it, and a choice in the chat header's menu is saved there too,
284
- so the file always shows what PinDeck uses. --model on the command line applies to one run only. */
285
- const MODELS = ["opus", "sonnet"];
286
- const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
287
- 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. */
288
291
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
289
- const settings = { ...DEFAULTS };
290
292
  let fileConfig = {};
291
293
  try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
292
- catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
293
- if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
294
- else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
295
- if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
296
- else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
297
- const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
298
- if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings(); // create or complete the file
299
- if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
300
-
301
- const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
302
- 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,
303
312
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
304
313
  const pushStatus = () => broadcast("status", status());
305
314
 
@@ -313,32 +322,106 @@ function deckInstructions() {
313
322
  try { return `\n\n## The deck's CLAUDE.md\n${fs.readFileSync(path.join(DECK_DIR, "CLAUDE.md"), "utf8").trim()}\n`; }
314
323
  catch { return ""; }
315
324
  }
316
- let stderrTail = "";
317
- const session = new ClaudeSession({
318
- cwd: DECK_DIR,
319
- env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
320
- args: () => [
321
- "--append-system-prompt", systemPrompt + deckInstructions(),
322
- "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
323
- "--permission-mode", "default",
324
- "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map((f) => `Edit(./${f})`), `Bash(${CLI_CMD}:*)`,
325
- "--disallowedTools", ...LOCKED.flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
326
- ...(settings.model ? ["--model", settings.model] : []),
327
- ...(settings.effort ? ["--effort", settings.effort] : []),
328
- ],
329
- onStderr: (s) => { stderrTail = (stderrTail + s).slice(-2000); },
330
- onEvent: handleEvent,
331
- onExit: (code, signal, { stale } = {}) => {
332
- if (state.busy && state.turn && !stale) {
333
- const why = code == null && signal == null ? "could not start" : `stopped (${signal || "exit " + code})`;
334
- const hint = /ENOENT/.test(stderrTail) ? " Is the claude command installed and on the PATH (or set CLAUDE_BIN)?" : "";
335
- finishTurn({ error: `Claude Code ${why}. ${stderrTail.trim().split("\n").pop() || ""}${hint}` });
336
- }
337
- pushStatus();
338
- },
339
- });
340
- function ensureSession() {
341
- 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(); }
342
425
  }
343
426
 
344
427
  function toolSummary(name, input = {}) {
@@ -349,6 +432,9 @@ function toolSummary(name, input = {}) {
349
432
  return !r || r.startsWith("..") ? path.basename(p) : r;
350
433
  };
351
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(" ") };
352
438
  case "Read": return { name: "Read", detail: base(input.file_path) };
353
439
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
354
440
  case "Bash": {
@@ -397,8 +483,14 @@ function startTurn(promptText, userEntry) {
397
483
  state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
398
484
  state.busy = true;
399
485
  pushStatus();
400
- ensureSession();
401
- 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 }); });
402
494
  }
403
495
 
404
496
  function finishTurn({ error, cost, duration, denials = [] }) {
@@ -412,7 +504,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
412
504
  const now = readDeck(f);
413
505
  if (now === turn.before[f]) continue;
414
506
  if (turn.before[f] != null) writeDeck(f, turn.before[f]);
415
- 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
416
508
  restored.push(f);
417
509
  }
418
510
  toolEntries.clear();
@@ -422,7 +514,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
422
514
  const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
423
515
  return { file: f, added: d.added, removed: d.removed };
424
516
  }).filter((f) => f.added || f.removed);
425
- // 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
426
518
  // deleted; undo brings them back with the turn
427
519
  const deletedFiles = [];
428
520
  for (const rel of turn.importedFiles || []) {
@@ -431,7 +523,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
431
523
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
432
524
  fs.rmSync(abs, { force: true });
433
525
  }
434
- 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 };
435
527
  if (files.length) { state.turns.push(t); state.redo = []; }
436
528
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
437
529
  denials, restored, newIds: eid.assigned.length });
@@ -441,7 +533,7 @@ function finishTurn({ error, cost, duration, denials = [] }) {
441
533
 
442
534
  /* ---- Attachments: figures, audio and PDFs added to a chat message ------------------------------------
443
535
  Figures and audio go into <deck>/figures/ (paths relative to the deck). PDFs are reading material for
444
- 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
445
537
  (absolute paths); `cli.mjs pdf-figure` renders a page or part of one into figures/ when it is needed. */
446
538
  const ATTACH_TYPES = {
447
539
  image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"],
@@ -500,8 +592,8 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
500
592
 
501
593
  /* `cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px]`: renders a PDF page, or a box
502
594
  of it given as fractions of the page from the top left, to PNG. Without a figures/ name it goes to the
503
- screenshots folder, for Claude to look at before choosing; with one, into the deck. */
504
- 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 }) {
505
597
  const bad = (t, status = 400) => ({ status, text: t });
506
598
  if (process.platform !== "darwin") return bad("Rendering PDF pages needs macOS (PDFKit).", 501);
507
599
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
@@ -523,6 +615,7 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
523
615
  await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
524
616
  if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
525
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);
526
619
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
527
620
  const buf = fs.readFileSync(tmp);
528
621
  fs.rmSync(tmp, { force: true });
@@ -571,7 +664,7 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
571
664
  files: [{ file: DECK_FILE, added: d.added, removed: d.removed }], undone: false };
572
665
  state.turns.push(t);
573
666
  state.redo = [];
574
- // 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)
575
668
  if (chat) addEntry({ kind: "edit", turnId: t.id, label, files: t.files });
576
669
  else state.seq++; // keep turn ids unique: they share the counter with chat entries
577
670
  state.notes.push(note);
@@ -597,9 +690,9 @@ function deletePlan(eid) {
597
690
  return { ...target, html: rest, files };
598
691
  }
599
692
 
600
- /* ---- Undo (Claude's turns and hand edits share one history) ---------------------------------------- */
693
+ /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------- */
601
694
  function undo(res) {
602
- 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." });
603
696
  const t = liveTurns().at(-1);
604
697
  if (!t) return json(res, 409, { error: "Nothing to undo." });
605
698
  const edited = EDITABLE.some((f) => readDeck(f) !== t.after[f]);
@@ -702,7 +795,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
702
795
  missing: r.plans.flatMap((p) => p.figures.filter((f) => f.missing)).length };
703
796
  }
704
797
 
705
- /* 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
706
799
  original slide, and improves them in the deck's style. Returns the number of slides handed over. */
707
800
  async function refineImport(imp, pdf, plans, eids, first) {
708
801
  const todo = plans.map((p, i) => ({ p, index: first + i, eid: eids[i] })).filter((x) => x.p.complex.length);
@@ -813,8 +906,9 @@ const server = http.createServer(async (req, res) => {
813
906
  }
814
907
 
815
908
  if (p === "/api/message" && req.method === "POST") {
816
- 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." });
817
910
  const b = await body(req);
911
+ if (state.busy) return json(res, 409, { error: "The assistant is still working." });
818
912
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
819
913
  // what the prompt reads from pins and the selection must be there
820
914
  const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
@@ -822,22 +916,28 @@ const server = http.createServer(async (req, res) => {
822
916
  if (!placed(b.selection)) b.selection = null;
823
917
  if (!Array.isArray(b.layouts)) b.layouts = [];
824
918
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
825
- let screenshot = null;
826
- const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
827
- if (shotSlide) {
828
- try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
829
- catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
830
- }
831
- const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
832
- // only files PinDeck itself stored can be attached
833
- b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
834
- b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
835
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
836
- attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
837
- selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
838
- pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
839
- startTurn(buildPrompt({ ...b, screenshot }), entry);
840
- 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(); } }
841
941
  }
842
942
  if (p === "/api/upload" && req.method === "POST") {
843
943
  const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
@@ -877,22 +977,15 @@ const server = http.createServer(async (req, res) => {
877
977
  }
878
978
  return json(res, 200, {});
879
979
  }
980
+ if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
880
981
  if (p === "/api/settings" && req.method === "POST") {
881
982
  const b = await body(req);
882
- const model = b.model ?? settings.model, effort = b.effort ?? settings.effort;
883
- if (!MODELS.includes(model) || !EFFORTS.includes(effort)) return json(res, 400, { error: "Unknown model or effort." });
884
- if (model === settings.model && effort === settings.effort) return json(res, 200, { settings });
885
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished, then change the model or effort." });
886
- Object.assign(settings, { model, effort });
887
- saveSettings();
888
- // restart the session with the new flags; --resume keeps the conversation
889
- if (session.running) { session.stop(); setTimeout(ensureSession, 300); }
890
- addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
891
- pushStatus();
892
- 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 }); }
893
986
  }
894
987
  if (p === "/api/slide-hidden" && req.method === "POST") {
895
- 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." });
896
989
  const b = await body(req);
897
990
  const before = snapshot(EDITABLE);
898
991
  const slides = findSlides(parse(before[DECK_FILE]));
@@ -907,7 +1000,7 @@ const server = http.createServer(async (req, res) => {
907
1000
  return json(res, 200, { turnId, index });
908
1001
  }
909
1002
  if (p === "/api/move-slide" && req.method === "POST") {
910
- 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." });
911
1004
  const b = await body(req);
912
1005
  const before = snapshot(EDITABLE);
913
1006
  const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
@@ -919,7 +1012,7 @@ const server = http.createServer(async (req, res) => {
919
1012
  return json(res, 200, { turnId, index: r.index });
920
1013
  }
921
1014
  if (p === "/api/add-slide" && req.method === "POST") {
922
- 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." });
923
1016
  const b = await body(req);
924
1017
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
925
1018
  const before = snapshot(EDITABLE);
@@ -947,7 +1040,7 @@ const server = http.createServer(async (req, res) => {
947
1040
  let imp;
948
1041
  try { imp = startImport(name, Buffer.concat(chunks)); }
949
1042
  catch (e) { return json(res, 400, { error: `${name} could not be read: ${e.message}` }); }
950
- // 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
951
1044
  const complex = new Map(planSlides(imp.model, imp.model.slides.map((s) => s.n), { drawings: renderAvailable() }).map((p) => [p.n, p.complex]));
952
1045
  return json(res, 200, { id: imp.id, name, render: renderAvailable(),
953
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) || [] })) });
@@ -958,7 +1051,7 @@ const server = http.createServer(async (req, res) => {
958
1051
  return serveFile(res, path.join(imp.dir, `thumb-${n}.png`));
959
1052
  }
960
1053
  if (p === "/api/import-apply" && req.method === "POST") {
961
- 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." });
962
1055
  const b = await body(req);
963
1056
  const imp = lastImport;
964
1057
  if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
@@ -968,21 +1061,25 @@ const server = http.createServer(async (req, res) => {
968
1061
  catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
969
1062
  }
970
1063
  if (p === "/api/stop" && req.method === "POST") {
971
- if (state.busy && session.running) {
1064
+ if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
1065
+ if (state.busy && state.turn) {
972
1066
  const interruptedTurn = state.turn;
973
- 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(() => {});
974
1070
  setTimeout(() => {
975
- if (state.busy && state.turn === interruptedTurn) session.stop();
1071
+ if (state.busy && state.turn === interruptedTurn) { session.stop(); finishTurn({ error: "Stopped." }); }
976
1072
  }, 5000); // force only if the interrupted turn does not answer
977
1073
  }
978
1074
  return json(res, 200, {});
979
1075
  }
980
1076
  if (p === "/api/new-chat" && req.method === "POST") {
981
- session.stop();
982
- 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: [] });
983
1080
  saveChat();
984
1081
  broadcast("reset", { chat: [] });
985
- ensureSession();
1082
+ ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
986
1083
  pushStatus();
987
1084
  return json(res, 200, {});
988
1085
  }
@@ -999,7 +1096,7 @@ const server = http.createServer(async (req, res) => {
999
1096
  }
1000
1097
  if (p === "/api/undo" && req.method === "POST") return undo(res);
1001
1098
  if (p === "/api/redo" && req.method === "POST") {
1002
- 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." });
1003
1100
  const t = state.redo.at(-1);
1004
1101
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1005
1102
  if (EDITABLE.some((f) => (readDeck(f) ?? null) !== (t.before[f] ?? null))) {
@@ -1029,7 +1126,7 @@ const server = http.createServer(async (req, res) => {
1029
1126
  return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
1030
1127
  }
1031
1128
  if (p === "/api/delete" && req.method === "POST") {
1032
- 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." });
1033
1130
  const b = await body(req);
1034
1131
  const plan = deletePlan(b.eid);
1035
1132
  if (!plan) return json(res, 404, { error: "Element not found in the file." });
@@ -1048,7 +1145,7 @@ const server = http.createServer(async (req, res) => {
1048
1145
 
1049
1146
  // Hand edits from the editor: drag/resize (inline styles) and text
1050
1147
  if (p === "/api/edit" && req.method === "POST") {
1051
- 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." });
1052
1149
  const b = await body(req);
1053
1150
  const before = snapshot(EDITABLE);
1054
1151
  const r = setStyles(before[DECK_FILE], b.ops || []);
@@ -1069,7 +1166,7 @@ const server = http.createServer(async (req, res) => {
1069
1166
  return n ? json(res, 200, n) : json(res, 404, { error: "Slide not found in the file." });
1070
1167
  }
1071
1168
  if (p === "/api/notes" && req.method === "POST") {
1072
- 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." });
1073
1170
  const b = await body(req);
1074
1171
  const before = snapshot(EDITABLE);
1075
1172
  const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
@@ -1083,7 +1180,7 @@ const server = http.createServer(async (req, res) => {
1083
1180
  return json(res, 200, { turnId });
1084
1181
  }
1085
1182
  if (p === "/api/text" && req.method === "POST") {
1086
- 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." });
1087
1184
  const b = await body(req);
1088
1185
  const before = snapshot(EDITABLE);
1089
1186
  const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
@@ -1098,7 +1195,7 @@ const server = http.createServer(async (req, res) => {
1098
1195
 
1099
1196
  // Enter in a bullet: the bullet keeps the text before the caret, a new bullet after it gets the rest
1100
1197
  if (p === "/api/split" && req.method === "POST") {
1101
- 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." });
1102
1199
  const b = await body(req);
1103
1200
  const before = snapshot(EDITABLE);
1104
1201
  const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
@@ -1110,7 +1207,7 @@ const server = http.createServer(async (req, res) => {
1110
1207
  return json(res, 200, { turnId, eid: r2.eid });
1111
1208
  }
1112
1209
  if (p === "/api/remove" && req.method === "POST") {
1113
- 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." });
1114
1211
  const b = await body(req);
1115
1212
  const before = snapshot(EDITABLE);
1116
1213
  const html = removeElement(before[DECK_FILE], b.eid);
@@ -1126,7 +1223,7 @@ const server = http.createServer(async (req, res) => {
1126
1223
  catch (e) { return json(res, 500, { error: String(e.message || e) }); }
1127
1224
  }
1128
1225
 
1129
- // CLI endpoints (used by Claude Code via cli.mjs)
1226
+ // CLI endpoints (used by both backends via cli.mjs)
1130
1227
  if (p === "/api/pdf") {
1131
1228
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1132
1229
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
@@ -1134,7 +1231,8 @@ const server = http.createServer(async (req, res) => {
1134
1231
  return text(res, 200, r.path);
1135
1232
  }
1136
1233
  if (p === "/api/pdf-figure") {
1137
- 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) });
1138
1236
  return text(res, r.status || 200, r.text);
1139
1237
  }
1140
1238
  if (p === "/api/slides") {
@@ -1164,8 +1262,15 @@ server.listen(PORT, "127.0.0.1", () => {
1164
1262
  console.log(`PinDeck: ${ORIGIN}/`);
1165
1263
  console.log(`Deck: ${DECK_DIR}`);
1166
1264
  if (!CHROME) console.log("No Chrome found: screenshots are disabled (set CHROME_BIN).");
1167
- 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
+ });
1168
1273
  });
1169
- const quit = () => { session.stop(); setTimeout(() => process.exit(0), 300); };
1274
+ const quit = () => { session?.stop(); setTimeout(() => process.exit(0), 300); };
1170
1275
  process.on("SIGINT", quit);
1171
1276
  process.on("SIGTERM", quit);