pindeck 2.9.0 → 3.0.0-beta.2

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 | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>] (or: node server.mjs ...)
5
5
 
6
6
  - serves the editor UI and the deck (with a bridge script injected in edit mode)
7
7
  - pushes file changes to the browser (server-sent events) for live reload
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; the editor, the deck and every API call need the per-run token (a login
@@ -27,27 +27,37 @@ import { fileURLToPath, pathToFileURL } from "node:url";
27
27
  import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
28
28
  import { unifiedDiff } from "./lib/diff.mjs";
29
29
  import { ClaudeSession } from "./lib/claude.mjs";
30
+ import { CodexSession } from "./lib/codex.mjs";
31
+ import { createDeckTools } from "./lib/agent-tools.mjs";
32
+ import { migrateSettings, selectModel, handoverSummary, curateModels } from "./lib/models.mjs";
30
33
  import { formatLayout, formatPin } from "./lib/layout.mjs";
31
34
  import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs";
32
35
  import { readPptx } from "./lib/pptx.mjs";
33
36
  import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
34
37
  import { buildImport, planSlides } from "./lib/pptx-import.mjs";
35
38
  import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
36
- import { writeAtomic, sameContent, revision } from "./lib/deck-files.mjs";
39
+ import { unsupportedMessage } from "./lib/frameworks.mjs";
40
+ import { readInstructions } from "./lib/instructions.mjs";
41
+ import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
37
42
 
38
43
  /* ---- Config ------------------------------------------------------------------ */
39
- const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
40
- pindeck login [deck-folder]
44
+ const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
45
+ pindeck login [deck-folder | deck-file.html]
41
46
 
42
- Opens the reveal.js deck in deck-folder (default: the current folder) in PinDeck.
43
- The folder must contain index.html.
47
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
48
+ folder), which then opens its index.html.
44
49
  login prints a new one-time login link for the PinDeck running for that deck
45
- (without a folder: for the PinDeck started last).`;
50
+ (without a deck: for the PinDeck started last).
51
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
52
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
53
+ "assetRoots" in ~/.pindeck-v3/config.json.`;
46
54
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
47
55
  const argv = process.argv.slice(2);
48
56
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
49
57
  const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
50
58
  const portArg = opt("--port", process.env.PORT || 5180);
59
+ const assetArgs = [];
60
+ for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
51
61
  let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
52
62
  if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
53
63
  if (!isSupportedPlatform(process.platform)) {
@@ -57,14 +67,23 @@ if (!isSupportedPlatform(process.platform)) {
57
67
  }
58
68
  const LOGIN = argv[0] === "login";
59
69
  if (LOGIN) argv.shift();
60
- const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
61
- const DECK_DIR = path.resolve(argv[0] || process.cwd());
62
- const DECK_FILE = "index.html";
63
- const EDITABLE = ["index.html", "theme.css"]; // Claude may edit these
70
+ const PROVIDER = opt("--provider", "");
71
+ const MODEL = opt("--model", "");
72
+ /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
73
+ that is not a folder is a file, so a missing file gets a clear message. */
74
+ const TARGET = path.resolve(argv[0] || process.cwd());
75
+ const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
76
+ const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
77
+ const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
78
+ const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
79
+ const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
80
+ const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
64
81
  const LOCKED = ["brand.css"]; // never, enforced twice
65
- const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
66
- const deckKey = path.basename(DECK_DIR).replace(/[^\w.-]+/g, "_") + "-" +
67
- crypto.createHash("sha1").update(DECK_DIR).digest("hex").slice(0, 8);
82
+ const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
83
+ /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
84
+ histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
85
+ const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
86
+ crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
68
87
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
69
88
  const SHOT_DIR = path.join(STATE_DIR, "shots");
70
89
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
@@ -107,6 +126,12 @@ if (deckProblem) {
107
126
  console.error(`${deckProblem}\n\n${USAGE}`);
108
127
  process.exit(1);
109
128
  }
129
+ // only reveal.js for now: another framework's deck is left exactly as it is (no ids, no state)
130
+ const frameworkProblem = unsupportedMessage(fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8"), DECK_FILE);
131
+ if (frameworkProblem) {
132
+ console.error(frameworkProblem);
133
+ process.exit(1);
134
+ }
110
135
  /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
111
136
  port is bound (see Start), so a failed start leaves no state behind. */
112
137
  const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
@@ -147,6 +172,25 @@ function writeDeckFile(rel, data, manual = true) {
147
172
  writeAtomic(abs, data);
148
173
  }
149
174
  const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
175
+ /* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
176
+ a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
177
+ const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
178
+ const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
179
+ const asText = (v) => (v == null ? "" : String(v));
180
+ const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
181
+ function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
182
+ if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
183
+ fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
184
+ writeDeck(rel, v);
185
+ }
186
+ function fileChange(rel, before, after) {
187
+ if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
188
+ const d = unifiedDiff(asText(before), asText(after), rel);
189
+ return { file: rel, added: d.added, removed: d.removed };
190
+ }
191
+ const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
192
+ ? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
193
+ : unifiedDiff(asText(before), asText(after), rel).text);
150
194
  /* The revision of the editable files: sent with every API answer, checked on the editor's changes */
151
195
  const deckRev = () => revision(DECK_DIR, EDITABLE);
152
196
  const json = (res, code, obj) => {
@@ -267,7 +311,7 @@ function chrome(args, done) {
267
311
  });
268
312
  }
269
313
  const deckUrl = (mode, params = {}) =>
270
- `${ORIGIN}/deck/${DECK_FILE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
314
+ `${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
271
315
  async function takeShot(slide) {
272
316
  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 }));
273
317
  const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
@@ -311,21 +355,21 @@ const measure = (slide) => measureInEditor(slide).catch((e) => {
311
355
  presentation: one page per build step (a slide with two clicks gives three pages).
312
356
  Files go to <deck>/export/. One export at a time. */
313
357
  let exportQueue = Promise.resolve(), snapSeq = 0;
314
- const snapshots = new Map(); // id -> static print layout, served at /deck/__pindeck-print-<id>.html
358
+ const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
315
359
  function exportPdf(kind, includeHidden = false) {
316
360
  if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
317
361
  const split = kind === "presentation";
318
- const name = `${path.basename(DECK_DIR)}-${split ? "presentation" : "handout"}.pdf`;
362
+ const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
319
363
  const out = path.join(DECK_DIR, "export", name);
320
364
  const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
321
365
  const run = async () => {
322
366
  fs.mkdirSync(path.dirname(out), { recursive: true });
323
367
  const layoutProfile = profile("layout"), printProfile = profile("print");
324
368
  const html = await printLayout(CHROME, chromeStart(layoutProfile,
325
- `${ORIGIN}/deck/${DECK_FILE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
369
+ `${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
326
370
  const id = `${process.pid}-${++snapSeq}`;
327
371
  snapshots.set(id, html);
328
- try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}/deck/__pindeck-print-${id}.html`), out, printProfile); }
372
+ try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
329
373
  finally { snapshots.delete(id); }
330
374
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
331
375
  url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
@@ -335,25 +379,27 @@ function exportPdf(kind, includeHidden = false) {
335
379
  return job;
336
380
  }
337
381
 
338
- /* ---- Chat state and the Claude Code session --------------------------------------------- */
382
+ /* ---- Chat state and the selected backend session --------------------------------------------- */
339
383
  const CHAT_FILE = path.join(STATE_DIR, "chat.json");
340
384
  const state = {
341
- chat: [], seq: 0, busy: false, model: MODEL || null, sessionId: null,
385
+ chat: [], seq: 0, busy: false, model: MODEL || null, switching: false, preparing: null, sessionId: null, provider: "claude", handover: "", sessionArchive: [],
342
386
  turns: [], redo: [], turn: null, notes: [], pins: [], current: null,
343
387
  uploads: new Set(), // files uploaded for the next message; removable until it is sent
344
388
  pdfFigures: new Set(), // figures/ files written by pdf-figure, which it may overwrite
345
389
  };
346
390
  try {
347
391
  const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
348
- Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
392
+ Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
349
393
  } catch {}
350
394
  /* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
351
395
  let chatTimer = null;
352
396
  function writeChat() {
353
397
  clearTimeout(chatTimer);
354
398
  chatTimer = null;
355
- try { writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId })); }
356
- catch (e) { console.error(e.message); }
399
+ try {
400
+ writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
401
+ provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
402
+ } catch (e) { console.error(e.message); }
357
403
  }
358
404
  const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
359
405
  function addEntry(entry) {
@@ -365,78 +411,214 @@ function addEntry(entry) {
365
411
  return entry;
366
412
  }
367
413
  const liveTurns = () => state.turns.filter((t) => !t.undone);
368
- const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "Claude's change") : null);
369
- /* Model and effort for the Claude Code session. ~/.pindeck/config.json holds them: PinDeck creates it
370
- with the defaults below, you can edit it, and a choice in the chat header's menu is saved there too,
371
- so the file always shows what PinDeck uses. --model on the command line applies to one run only. */
372
- const MODELS = ["opus", "sonnet"];
373
- const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
374
- const DEFAULTS = { model: "sonnet", effort: "medium" };
414
+ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") : null);
415
+ /* The menu persists provider, model and effort. CLI overrides apply to this run only.
416
+ The v3 preview uses its own state directory so v2 can keep running independently. */
375
417
  const CONFIG_FILE = path.join(HOME_DIR, "config.json");
376
- const settings = { ...DEFAULTS };
377
418
  let fileConfig = {};
378
419
  try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
379
420
  catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
380
- if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
381
- else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
382
- if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
383
- else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
384
- const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
385
- const completeSettings = () => { // create or complete the file (at start, once the port is bound)
386
- if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
387
- };
421
+ const settings = migrateSettings(fileConfig);
422
+ let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
423
+ // Preserve the legacy selection as the first model-specific preference.
424
+ if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
425
+ if (PROVIDER) {
426
+ if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
427
+ settings.provider = PROVIDER;
428
+ if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
429
+ }
388
430
  if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
431
+ // A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
432
+ if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
433
+ state.provider = settings.provider;
434
+ /* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
435
+ deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
436
+ it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
437
+ or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
438
+ a symlink that leads anywhere else is refused. */
439
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
440
+ const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
441
+ const abs = path.resolve(expandHome(String(r)));
442
+ let st = null;
443
+ try { st = fs.statSync(abs); } catch {}
444
+ if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
445
+ return abs;
446
+ });
447
+ const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
448
+ const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
449
+ const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
450
+ const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
451
+ const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
452
+ /* The deck page's URL, and the folder URL its relative paths resolve against */
453
+ const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
454
+ : `/deck/${encodeURIComponent(DECK_FILE)}`;
455
+ const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
456
+ /* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
457
+ followed. null: refuse. A missing file passes, and is answered 404. */
458
+ function servable(base, rel) {
459
+ const f = safeJoin(base, rel);
460
+ if (!f) return null;
461
+ let real;
462
+ try { real = fs.realpathSync(f); } catch { return f; }
463
+ return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
464
+ }
389
465
 
390
- const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
391
- running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
392
- capabilities: publicCapabilities(CAPS), rev: deckRev(),
466
+ let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
467
+ let session = null, starting = null;
468
+ const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
469
+ running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
470
+ chrome: CAPS.screenshot.available, capabilities: publicCapabilities(CAPS), rev: deckRev(),
393
471
  undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
394
472
  const pushStatus = () => broadcast("status", status());
395
473
 
396
474
  const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
397
- .replaceAll("{{CLI}}", CLI_CMD) + capabilityPrompt(CAPS, CLI_CMD);
475
+ .replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
476
+ // a template's own notes usually say index.html; other decks may share this folder
477
+ (NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
478
+ `${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
479
+ (ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
480
+ `them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
481
+ `other decks may use them too.\n` : "");
482
+ /* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
483
+ git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
484
+ win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
485
+ function lockedPaths() {
486
+ let others = [];
487
+ try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
488
+ return [...LOCKED, "vendor/**", ".git/**", ...others];
489
+ }
398
490
  /* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
399
491
  or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
400
- MCP servers have no place in a slide editor). That also skips the deck's CLAUDE.md, so it goes into
401
- the system prompt instead, read afresh at each session start, together with the deck's AGENTS.md (the
402
- name other coding agents read). Decks copied from the example call the CLI as `node ../cli.mjs`, which
403
- is right only next to PinDeck: that becomes this server's command. */
492
+ MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
493
+ into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
494
+ other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
495
+ not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
496
+ `node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
404
497
  function deckInstructions() {
405
498
  return ["CLAUDE.md", "AGENTS.md"].map((name) => {
406
- let s;
407
- try { s = fs.readFileSync(path.join(DECK_DIR, name), "utf8").trim(); } catch { return ""; }
499
+ const s = readInstructions(path.join(DECK_DIR, name));
408
500
  return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
409
501
  }).join("");
410
502
  }
411
- let stderrTail = "";
412
- const session = new ClaudeSession({
413
- cwd: DECK_DIR,
414
- env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
415
- args: () => [
416
- "--append-system-prompt", systemPrompt + deckInstructions(),
417
- "--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
418
- "--permission-mode", "default",
419
- "--allowedTools", "Read", "Glob", "Grep", ...EDITABLE.map((f) => `Edit(./${f})`), `Bash(${CLI_CMD}:*)`,
420
- "--disallowedTools", ...LOCKED.flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
421
- ...(settings.model ? ["--model", settings.model] : []),
422
- ...(settings.effort ? ["--effort", settings.effort] : []),
423
- ],
424
- onStderr: (s) => { stderrTail = (stderrTail + s).slice(-2000); },
425
- onEvent: handleEvent,
426
- onExit: (code, signal, { stale } = {}) => {
427
- if (state.busy && state.turn && !stale) {
428
- const why = code == null && signal == null ? "could not start" : `stopped (${signal || "exit " + code})`;
429
- const hint = /ENOENT/.test(stderrTail) ? " Is the claude command installed and on the PATH (or set CLAUDE_BIN)?" : "";
430
- finishTurn({ error: `Claude Code ${why}. ${stderrTail.trim().split("\n").pop() || ""}${hint}` });
431
- }
432
- pushStatus();
433
- },
434
- });
435
- function ensureSession() {
436
- if (session.running || quitting || !detectCapabilities().claude.available) return;
437
- stderrTail = "";
438
- session.start(state.sessionId);
439
- pushStatus();
503
+ // a function: ORIGIN is known only once the port is bound
504
+ const agentEnv = () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN });
505
+ const agentTools = createDeckTools({ deckDir: DECK_DIR, deckFile: DECK_FILE, readDirs: [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS],
506
+ locked: lockedPaths, cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
507
+ env: agentEnv });
508
+ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
509
+ 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.
510
+ Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
511
+ Use pindeck_edit for exact replacements in ${DECK_FILE}, theme.css and the deck's other files, and pindeck_write to create a new file in the deck folder. Never change brand.css.
512
+ Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
513
+ Do not use shell commands, native file edits, or other tools to bypass these restrictions.
514
+ When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
515
+ Read README.md and the deck instructions below for conventions. Use layout after every visual change.
516
+ PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
517
+
518
+ ## Hand edits`) + deckInstructions();
519
+ function makeSession(choice) {
520
+ let initial = null;
521
+ const options = {
522
+ cwd: DECK_DIR,
523
+ env: agentEnv,
524
+ onEvent: ev => {
525
+ if (ev.type === "system" && ev.subtype === "init") initial = ev;
526
+ if (session === agent) handleEvent(ev);
527
+ },
528
+ onExit: error => {
529
+ if (session !== agent) return;
530
+ if (state.turn) finishTurn({ error: error.message });
531
+ pushStatus();
532
+ },
533
+ };
534
+ /* Claude Code: any file in the deck folder (Edit rules cover Write too; Write is named as well, to be
535
+ explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
536
+ const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
537
+ instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
538
+ args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
539
+ "--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
540
+ "--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
541
+ "--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
542
+ ...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
543
+ "--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
544
+ ...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
545
+ });
546
+ agent.initialEvent = () => initial;
547
+ return agent;
548
+ }
549
+ const providerAvailable = (provider) => detectCapabilities()[provider];
550
+ /* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
551
+ replaced, the current one. */
552
+ const retiring = new Set(), discoveryAgents = new Set();
553
+ function retire(agent) {
554
+ if (!agent) return;
555
+ const stopped = agent.stop();
556
+ retiring.add(stopped);
557
+ stopped.finally(() => retiring.delete(stopped));
558
+ }
559
+ async function discoverModels() {
560
+ if (discovering) return discovering;
561
+ discovering = Promise.all(["claude", "codex"].map(async provider => {
562
+ if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
563
+ const agent = makeSession({ provider, model: "", effort: null });
564
+ discoveryAgents.add(agent);
565
+ try {
566
+ if (provider === "codex") await agent.connect(); else await agent.start();
567
+ catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
568
+ } catch (e) { catalog[provider] = { models: [], error: e.message }; }
569
+ finally { discoveryAgents.delete(agent); retire(agent); }
570
+ })).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
571
+ return discovering;
572
+ }
573
+ async function ensureSession() {
574
+ if (starting) return starting;
575
+ if (session?.running || quitting) return;
576
+ const available = providerAvailable(settings.provider);
577
+ if (!available.available) throw new Error(available.reason);
578
+ starting = (async () => {
579
+ if (!catalog[settings.provider].models.length) await discoverModels();
580
+ Object.assign(settings, selectModel(catalog, settings, {}));
581
+ const agent = makeSession({ ...settings });
582
+ session = agent;
583
+ try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
584
+ catch (e) { retire(agent); throw e; }
585
+ })().finally(() => { starting = null; });
586
+ return starting;
587
+ }
588
+ async function changeSettings(requested) {
589
+ if (state.busy || starting) throw new Error("Wait until the current request finishes before switching.");
590
+ const choice = selectModel(catalog, settings, requested, effortPreferences);
591
+ if (Object.keys(choice).every(k => choice[k] === settings[k])) return;
592
+ const changingProvider = choice.provider !== settings.provider;
593
+ if (changingProvider && !["summary", "fresh"].includes(requested.handover)) throw new Error("Choose whether to pass a summary or start fresh.");
594
+ const old = session;
595
+ // Codex owns a single writer per thread. Model/effort are turn parameters, so keep its writer.
596
+ const reuse = !changingProvider && choice.provider === "codex" && old?.running;
597
+ const candidate = reuse ? old : makeSession(choice);
598
+ state.busy = true; state.switching = true; pushStatus();
599
+ try {
600
+ if (!reuse) await candidate.start(changingProvider ? null : state.sessionId);
601
+ if (!candidate.running) throw new Error("The new backend stopped before it was ready.");
602
+ const summary = changingProvider && requested.handover === "summary" ? handoverSummary(state.chat) : "";
603
+ if (changingProvider && state.sessionId) state.sessionArchive.push({ provider: state.provider, id: state.sessionId });
604
+ const nextPreferences = { ...effortPreferences };
605
+ if (settings.effort) nextPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
606
+ if (choice.effort) nextPreferences[`${choice.provider}:${choice.model}`] = choice.effort;
607
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...fileConfig, ...choice, effortPreferences: nextPreferences }, null, 2) + "\n");
608
+ effortPreferences = nextPreferences;
609
+ Object.assign(settings, choice);
610
+ state.provider = choice.provider;
611
+ if (changingProvider) { state.sessionId = candidate.sessionId || null; state.handover = summary; state.model = null; }
612
+ if (reuse) candidate.settings = choice;
613
+ session = candidate;
614
+ if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
615
+ if (!reuse) retire(old);
616
+ saveChat();
617
+ addEntry({ kind: "notice", text: changingProvider
618
+ ? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
619
+ : `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
620
+ } catch (e) { if (session !== candidate) retire(candidate); throw e; }
621
+ finally { state.busy = false; state.switching = false; pushStatus(); }
440
622
  }
441
623
 
442
624
  function toolSummary(name, input = {}) {
@@ -447,6 +629,9 @@ function toolSummary(name, input = {}) {
447
629
  return !r || r.startsWith("..") ? path.basename(p) : r;
448
630
  };
449
631
  switch (name) {
632
+ case "pindeck_read": return { name: "Read", detail: base(input.path) };
633
+ case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
634
+ case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
450
635
  case "Read": return { name: "Read", detail: base(input.file_path) };
451
636
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
452
637
  case "Bash": {
@@ -492,12 +677,19 @@ function handleEvent(ev) {
492
677
  }
493
678
 
494
679
  function startTurn(promptText, userEntry) {
495
- state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]) };
680
+ // every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
681
+ // takes back all it changed
682
+ state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
496
683
  state.busy = true;
497
684
  pushStatus();
498
- ensureSession();
499
- if (!session.running) return finishTurn({ error: detectCapabilities().claude.reason || "Claude Code could not be started." });
500
- session.send([{ type: "text", text: promptText }]);
685
+ const turn = state.turn;
686
+ const context = state.handover ? state.handover + "\n\n" + promptText : promptText;
687
+ ensureSession().then(async () => {
688
+ if (state.turn !== turn || turn.cancelled) return;
689
+ const sendingSession = session;
690
+ await session.send([{ type: "text", text: context }]);
691
+ if (session === sendingSession) { state.handover = ""; saveChat(); }
692
+ }).catch(e => { if (state.turn === turn) finishTurn({ error: e.message }); });
501
693
  }
502
694
 
503
695
  function finishTurn({ error, cost, duration, denials = [] }) {
@@ -511,17 +703,19 @@ function finishTurn({ error, cost, duration, denials = [] }) {
511
703
  const now = readDeck(f);
512
704
  if (now === turn.before[f]) continue;
513
705
  if (turn.before[f] != null) writeDeck(f, turn.before[f]);
514
- else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that Claude created
706
+ else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // a locked file that the assistant created
515
707
  restored.push(f);
516
708
  }
517
709
  toolEntries.clear();
518
710
  const eid = runEidPass();
519
- const after = snapshot(EDITABLE);
520
- const files = EDITABLE.map((f) => {
521
- const d = unifiedDiff(turn.before[f] ?? "", after[f] ?? "", f);
522
- return { file: f, added: d.added, removed: d.removed };
523
- }).filter((f) => f.added || f.removed);
524
- // after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
711
+ const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
712
+ const before = {}, after = {};
713
+ for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
714
+ // the deck file and theme.css first, then the rest by name
715
+ const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
716
+ const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
717
+ .map((rel) => fileChange(rel, before[rel], after[rel]));
718
+ // after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
525
719
  // deleted; undo brings them back with the turn
526
720
  const deletedFiles = [];
527
721
  for (const rel of turn.importedFiles || []) {
@@ -530,17 +724,19 @@ function finishTurn({ error, cost, duration, denials = [] }) {
530
724
  deletedFiles.push({ rel, data: fs.readFileSync(abs) });
531
725
  fs.rmSync(abs, { force: true });
532
726
  }
533
- const t = { id: turn.id, kind: "claude", before: turn.before, after, files, deletedFiles, undone: false };
727
+ const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
534
728
  if (files.length) { state.turns.push(t); state.redo = []; }
535
729
  addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
536
730
  denials, restored, newIds: eid.assigned.length });
537
731
  if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
732
+ if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
733
+ `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"}.` });
538
734
  pushStatus();
539
735
  }
540
736
 
541
737
  /* ---- Attachments: figures, audio and PDFs added to a chat message ------------------------------------
542
738
  Figures and audio go into <deck>/figures/ (paths relative to the deck). PDFs are reading material for
543
- Claude (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
739
+ the assistant (a paper, a report, a figure or table to take over) and stay outside the deck, in PDF_DIR
544
740
  (absolute paths); `cli.mjs pdf-figure` renders a page or part of one into figures/ when it is needed. */
545
741
  const ATTACH_TYPES = {
546
742
  image: ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"],
@@ -593,14 +789,14 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
593
789
  for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
594
790
  if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
595
791
  if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
596
- "use these paths relative to index.html):\n" + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
792
+ `use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
597
793
  return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
598
794
  }
599
795
 
600
796
  /* `cli.mjs pdf-figure <pdf> <page> [x,y,w,h] [figures/name.png] [--width px]`: renders a PDF page, or a box
601
797
  of it given as fractions of the page from the top left, to PNG. Without a figures/ name it goes to the
602
- screenshots folder, for Claude to look at before choosing; with one, into the deck. */
603
- async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
798
+ screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
799
+ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
604
800
  const bad = (t, status = 400) => ({ status, text: t });
605
801
  if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
606
802
  const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
@@ -620,8 +816,9 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
620
816
  if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
621
817
  const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
622
818
  await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
623
- if (!fs.existsSync(tmp)) return bad("PDFKit did not write the picture.", 500);
819
+ if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
624
820
  const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
821
+ if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
625
822
  if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
626
823
  const buf = fs.readFileSync(tmp);
627
824
  fs.rmSync(tmp, { force: true });
@@ -734,7 +931,7 @@ function recordHandEdit(before, html, label, note, { manual = true, deletedFiles
734
931
  files: [{ file: DECK_FILE, added: d.added, removed: d.removed }], undone: false };
735
932
  state.turns.push(t);
736
933
  state.redo = [];
737
- // chat: false keeps the edit out of the chat (it stays in the undo history and Claude is still told)
934
+ // chat: false keeps the edit out of the chat (it stays in the undo history and the assistant is still told)
738
935
  if (chat) addEntry({ kind: "edit", turnId: t.id, label, files: t.files });
739
936
  else state.seq++; // keep turn ids unique: they share the counter with chat entries
740
937
  state.notes.push(note);
@@ -760,7 +957,7 @@ function deletePlan(eid) {
760
957
  return { ...target, html: rest, files };
761
958
  }
762
959
 
763
- /* ---- Undo (Claude's turns and hand edits share one history) ----------------------------------------
960
+ /* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
764
961
  Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
765
962
  every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
766
963
  files, and the pictures or audio files they would delete or bring back. Anything else means the files
@@ -768,7 +965,7 @@ function deletePlan(eid) {
768
965
  Then nothing is written and the answer is 409 with the files that differ. */
769
966
  function undoConflicts(t, direction) {
770
967
  const expect = direction === "undo" ? t.after : t.before;
771
- const files = EDITABLE.filter((f) => (readDeck(f) ?? null) !== (expect[f] ?? null));
968
+ const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
772
969
  // undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
773
970
  // ones (which must be unchanged); redo the other way round
774
971
  const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
@@ -789,16 +986,12 @@ function conflict(res, direction, files) {
789
986
  `it would overwrite those changes. Reload to see the files as they are.` });
790
987
  }
791
988
  function undo(res) {
792
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
989
+ if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
793
990
  const t = liveTurns().at(-1);
794
991
  if (!t) return json(res, 409, { error: "Nothing to undo." });
795
992
  const diverged = undoConflicts(t, "undo");
796
993
  if (diverged.length) return conflict(res, "undo", diverged);
797
- for (const f of EDITABLE) {
798
- if (readDeck(f) === t.before[f]) continue;
799
- if (t.before[f] != null) writeDeck(f, t.before[f]);
800
- else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
801
- }
994
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
802
995
  for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
803
996
  for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
804
997
  t.undone = true;
@@ -883,7 +1076,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
883
1076
  missing: r.plans.flatMap((p) => p.figures.filter((f) => f.missing)).length };
884
1077
  }
885
1078
 
886
- /* The hand-off: Claude gets the slides the import could only approximate, each with a picture of the
1079
+ /* The hand-off: the assistant gets the slides the import could only approximate, each with a picture of the
887
1080
  original slide, and improves them in the deck's style. Returns the number of slides handed over. */
888
1081
  async function refineImport(imp, pdf, plans, eids, first) {
889
1082
  const todo = plans.map((p, i) => ({ p, index: first + i, eid: eids[i] })).filter((x) => x.p.complex.length);
@@ -1037,19 +1230,27 @@ const server = http.createServer(async (req, res) => {
1037
1230
  const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
1038
1231
  return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
1039
1232
  }
1040
- if (p.startsWith("/deck/") && !ok) return text(res, 403, "Not logged in");
1233
+ if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
1041
1234
  let snap;
1042
- if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
1235
+ if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
1043
1236
  const html = snapshots.get(snap[1]);
1044
1237
  if (!html) return text(res, 404, "Not found");
1045
1238
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1046
1239
  return res.end(html);
1047
1240
  }
1048
- if (req.method === "GET" && p.startsWith("/deck/")) {
1049
- const f = safeJoin(DECK_DIR, p.slice(6) || DECK_FILE);
1050
- if (!f) return text(res, 403, "Forbidden");
1241
+ // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1242
+ // /files/, so that ../ paths reach the root: /deck/ alone redirects there.
1243
+ if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1244
+ res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
1245
+ return res.end();
1246
+ }
1247
+ const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
1248
+ : p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
1249
+ if (deckPath === null) return text(res, 403, "Forbidden");
1250
+ if (deckPath) {
1251
+ const f = deckPath;
1051
1252
  const mode = url.searchParams.get("editor");
1052
- if (!mode || !f.endsWith(".html")) return serveFile(res, f, null, req);
1253
+ if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
1053
1254
  return serveFile(res, f, (s) => {
1054
1255
  // headless runs must not stream media, or Chrome never finishes the page
1055
1256
  if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
@@ -1086,11 +1287,12 @@ const server = http.createServer(async (req, res) => {
1086
1287
  }
1087
1288
 
1088
1289
  if (p === "/api/message" && req.method === "POST") {
1089
- if (state.busy) return json(res, 409, { error: "Claude is still working." });
1090
- // checked again here: the claude command may have been installed (or removed) since the start
1091
- const claude = detectCapabilities().claude;
1092
- if (!claude.available) return json(res, 503, { code: "claude-unavailable", error: claude.reason });
1290
+ if (state.busy) return json(res, 409, { error: "The assistant is still working." });
1291
+ // checked again here: the claude or codex command may have been installed (or removed) since the start
1292
+ const backend = providerAvailable(settings.provider);
1293
+ if (!backend.available) return json(res, 503, { code: `${settings.provider}-unavailable`, error: backend.reason });
1093
1294
  const b = await body(req);
1295
+ if (state.busy) return json(res, 409, { error: "The assistant is still working." });
1094
1296
  if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
1095
1297
  // what the prompt reads from pins and the selection must be there
1096
1298
  const placed = (x) => x && typeof x.eid === "string" && x.slide && Number.isInteger(x.slide.index) && x.box;
@@ -1098,22 +1300,28 @@ const server = http.createServer(async (req, res) => {
1098
1300
  if (!placed(b.selection)) b.selection = null;
1099
1301
  if (!Array.isArray(b.layouts)) b.layouts = [];
1100
1302
  if (b.current && !Number.isInteger(b.current.index)) b.current = null;
1101
- let screenshot = null;
1102
- const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
1103
- if (shotSlide) {
1104
- try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
1105
- catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
1106
- }
1107
- const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
1108
- // only files PinDeck itself stored can be attached
1109
- b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1110
- b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1111
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1112
- attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1113
- selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1114
- pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
1115
- startTurn(buildPrompt({ ...b, screenshot }), entry);
1116
- return json(res, 200, { id: entry.id });
1303
+ const preparation = { cancelled: false };
1304
+ state.preparing = preparation;
1305
+ state.busy = true; pushStatus();
1306
+ try {
1307
+ let screenshot = null;
1308
+ const shotSlide = typeof b.screenshot === "object" && b.screenshot?.eid ? b.screenshot : b.screenshot ? b.current : null;
1309
+ if (shotSlide) {
1310
+ try { screenshot = { slide: shotSlide.index + 1, path: await takeShot(shotSlide) }; }
1311
+ catch (e) { addEntry({ kind: "notice", text: "Screenshot failed: " + e.message }); }
1312
+ }
1313
+ if (preparation.cancelled) return json(res, 409, { error: "Stopped before sending the message." });
1314
+ const label = (p) => (p.tag?.startsWith("footer") ? "footer" : p.eid === p.slide.eid ? "slide" : p.eid.replace(/^[^/]+\//, ""));
1315
+ // only files PinDeck itself stored can be attached
1316
+ b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1317
+ b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1318
+ const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1319
+ attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1320
+ selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1321
+ pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
1322
+ startTurn(buildPrompt({ ...b, screenshot }), entry);
1323
+ return json(res, 200, { id: entry.id });
1324
+ } finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
1117
1325
  }
1118
1326
  if (p === "/api/upload" && req.method === "POST") {
1119
1327
  const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
@@ -1151,22 +1359,15 @@ const server = http.createServer(async (req, res) => {
1151
1359
  }
1152
1360
  return json(res, 200, {});
1153
1361
  }
1362
+ if (p === "/api/models" && req.method === "GET") return json(res, 200, await discoverModels());
1154
1363
  if (p === "/api/settings" && req.method === "POST") {
1155
1364
  const b = await body(req);
1156
- const model = b.model ?? settings.model, effort = b.effort ?? settings.effort;
1157
- if (!MODELS.includes(model) || !EFFORTS.includes(effort)) return json(res, 400, { error: "Unknown model or effort." });
1158
- if (model === settings.model && effort === settings.effort) return json(res, 200, { settings });
1159
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished, then change the model or effort." });
1160
- Object.assign(settings, { model, effort });
1161
- saveSettings();
1162
- // restart the session with the new flags; --resume keeps the conversation
1163
- if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
1164
- addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
1165
- pushStatus();
1166
- return json(res, 200, { settings });
1365
+ if (state.busy || starting) return json(res, 409, { error: "Wait until the current request finishes before switching." });
1366
+ try { await changeSettings(b); return json(res, 200, { settings }); }
1367
+ catch (e) { return json(res, 400, { error: e.message }); }
1167
1368
  }
1168
1369
  if (p === "/api/slide-hidden" && req.method === "POST") {
1169
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1370
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1170
1371
  const b = await body(req);
1171
1372
  if (staleEdit(res, b)) return;
1172
1373
  const before = snapshot(EDITABLE);
@@ -1182,7 +1383,7 @@ const server = http.createServer(async (req, res) => {
1182
1383
  return json(res, 200, { turnId, index });
1183
1384
  }
1184
1385
  if (p === "/api/move-slide" && req.method === "POST") {
1185
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1386
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1186
1387
  const b = await body(req);
1187
1388
  if (staleEdit(res, b)) return;
1188
1389
  const before = snapshot(EDITABLE);
@@ -1195,7 +1396,7 @@ const server = http.createServer(async (req, res) => {
1195
1396
  return json(res, 200, { turnId, index: r.index });
1196
1397
  }
1197
1398
  if (p === "/api/add-slide" && req.method === "POST") {
1198
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1399
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1199
1400
  const b = await body(req);
1200
1401
  if (staleEdit(res, b)) return;
1201
1402
  if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
@@ -1225,7 +1426,7 @@ const server = http.createServer(async (req, res) => {
1225
1426
  let imp;
1226
1427
  try { imp = startImport(name, Buffer.concat(chunks)); }
1227
1428
  catch (e) { return json(res, 400, { error: `${name} could not be read: ${e.message}` }); }
1228
- // what the import can only approximate, so the dialog can offer Claude's help with it
1429
+ // what the import can only approximate, so the dialog can offer the assistant's help with it
1229
1430
  const complex = new Map(planSlides(imp.model, imp.model.slides.map((s) => s.n), { drawings: renderAvailable() }).map((p) => [p.n, p.complex]));
1230
1431
  return json(res, 200, { id: imp.id, name, render: renderAvailable(),
1231
1432
  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) || [] })) });
@@ -1236,7 +1437,7 @@ const server = http.createServer(async (req, res) => {
1236
1437
  return serveFile(res, path.join(imp.dir, `thumb-${n}.png`));
1237
1438
  }
1238
1439
  if (p === "/api/import-apply" && req.method === "POST") {
1239
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1440
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1240
1441
  const b = await body(req);
1241
1442
  if (staleEdit(res, b)) return;
1242
1443
  const imp = lastImport;
@@ -1247,21 +1448,25 @@ const server = http.createServer(async (req, res) => {
1247
1448
  catch (e) { console.error(e); return json(res, 500, { error: String(e.message || e) }); }
1248
1449
  }
1249
1450
  if (p === "/api/stop" && req.method === "POST") {
1250
- if (state.busy && session.running) {
1451
+ if (state.preparing) { state.preparing.cancelled = true; return json(res, 200, {}); }
1452
+ if (state.busy && state.turn) {
1251
1453
  const interruptedTurn = state.turn;
1252
- session.interrupt();
1454
+ interruptedTurn.cancelled = true;
1455
+ if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
1456
+ Promise.resolve(session.interrupt()).catch(() => {});
1253
1457
  setTimeout(() => {
1254
- if (state.busy && state.turn === interruptedTurn) session.stop();
1458
+ if (state.busy && state.turn === interruptedTurn) { session?.stop(); finishTurn({ error: "Stopped." }); }
1255
1459
  }, 5000); // force only if the interrupted turn does not answer
1256
1460
  }
1257
1461
  return json(res, 200, {});
1258
1462
  }
1259
1463
  if (p === "/api/new-chat" && req.method === "POST") {
1260
- session.stop();
1261
- Object.assign(state, { chat: [], sessionId: null, turns: [], redo: [], turn: null, busy: false, notes: [] });
1464
+ if (state.busy || starting) return json(res, 409, { error: "Stop the current request before starting a new chat." });
1465
+ session?.stop();
1466
+ Object.assign(state, { chat: [], sessionId: null, handover: "", turns: [], redo: [], turn: null, busy: false, notes: [] });
1262
1467
  saveChat();
1263
1468
  broadcast("reset", { chat: [] });
1264
- ensureSession();
1469
+ ensureSession().catch(e => addEntry({ kind: "notice", text: e.message }));
1265
1470
  pushStatus();
1266
1471
  return json(res, 200, {});
1267
1472
  }
@@ -1269,7 +1474,7 @@ const server = http.createServer(async (req, res) => {
1269
1474
  if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
1270
1475
  const t = state.turns.find((t) => t.id === +m[1]);
1271
1476
  if (!t) return text(res, 404, "No such change");
1272
- return text(res, 200, EDITABLE.map((f) => unifiedDiff(t.before[f] ?? "", t.after[f] ?? "", f).text).filter(Boolean).join("\n\n"));
1477
+ return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
1273
1478
  }
1274
1479
  if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
1275
1480
  const t = liveTurns().at(-1);
@@ -1278,17 +1483,13 @@ const server = http.createServer(async (req, res) => {
1278
1483
  }
1279
1484
  if (p === "/api/undo" && req.method === "POST") return undo(res);
1280
1485
  if (p === "/api/redo" && req.method === "POST") {
1281
- if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
1486
+ if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
1282
1487
  const t = state.redo.at(-1);
1283
1488
  if (!t) return json(res, 409, { error: "Nothing to redo." });
1284
1489
  const diverged = undoConflicts(t, "redo");
1285
1490
  if (diverged.length) return conflict(res, "redo", diverged);
1286
1491
  state.redo.pop();
1287
- for (const f of EDITABLE) {
1288
- if (readDeck(f) === t.after[f]) continue;
1289
- if (t.after[f] != null) writeDeck(f, t.after[f]);
1290
- else fs.rmSync(path.join(DECK_DIR, f), { force: true });
1291
- }
1492
+ for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
1292
1493
  for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
1293
1494
  for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
1294
1495
  t.undone = false;
@@ -1306,7 +1507,7 @@ const server = http.createServer(async (req, res) => {
1306
1507
  return json(res, 200, { eid: plan.eid, tag: plan.tag, files: plan.files });
1307
1508
  }
1308
1509
  if (p === "/api/delete" && req.method === "POST") {
1309
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1510
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1310
1511
  const b = await body(req);
1311
1512
  if (staleEdit(res, b)) return;
1312
1513
  const plan = deletePlan(b.eid);
@@ -1326,7 +1527,7 @@ const server = http.createServer(async (req, res) => {
1326
1527
 
1327
1528
  // Hand edits from the editor: drag/resize (inline styles) and text
1328
1529
  if (p === "/api/edit" && req.method === "POST") {
1329
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1530
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1330
1531
  const b = await body(req);
1331
1532
  if (staleEdit(res, b)) return;
1332
1533
  const before = snapshot(EDITABLE);
@@ -1348,7 +1549,7 @@ const server = http.createServer(async (req, res) => {
1348
1549
  return n ? json(res, 200, n) : json(res, 404, { error: "Slide not found in the file." });
1349
1550
  }
1350
1551
  if (p === "/api/notes" && req.method === "POST") {
1351
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1552
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1352
1553
  const b = await body(req);
1353
1554
  if (staleEdit(res, b)) return;
1354
1555
  const before = snapshot(EDITABLE);
@@ -1363,7 +1564,7 @@ const server = http.createServer(async (req, res) => {
1363
1564
  return json(res, 200, { turnId });
1364
1565
  }
1365
1566
  if (p === "/api/text" && req.method === "POST") {
1366
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1567
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1367
1568
  const b = await body(req);
1368
1569
  if (staleEdit(res, b)) return;
1369
1570
  const before = snapshot(EDITABLE);
@@ -1379,7 +1580,7 @@ const server = http.createServer(async (req, res) => {
1379
1580
 
1380
1581
  // Enter in a bullet: the bullet keeps the text before the caret, a new bullet after it gets the rest
1381
1582
  if (p === "/api/split" && req.method === "POST") {
1382
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1583
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1383
1584
  const b = await body(req);
1384
1585
  if (staleEdit(res, b)) return;
1385
1586
  const before = snapshot(EDITABLE);
@@ -1392,7 +1593,7 @@ const server = http.createServer(async (req, res) => {
1392
1593
  return json(res, 200, { turnId, eid: r2.eid });
1393
1594
  }
1394
1595
  if (p === "/api/remove" && req.method === "POST") {
1395
- if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
1596
+ if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
1396
1597
  const b = await body(req);
1397
1598
  if (staleEdit(res, b)) return;
1398
1599
  const before = snapshot(EDITABLE);
@@ -1409,7 +1610,7 @@ const server = http.createServer(async (req, res) => {
1409
1610
  catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
1410
1611
  }
1411
1612
 
1412
- // CLI endpoints (used by Claude Code via cli.mjs)
1613
+ // CLI endpoints (used by both backends via cli.mjs)
1413
1614
  if (p === "/api/pdf") {
1414
1615
  const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
1415
1616
  if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
@@ -1418,7 +1619,8 @@ const server = http.createServer(async (req, res) => {
1418
1619
  return text(res, 200, r.path);
1419
1620
  }
1420
1621
  if (p === "/api/pdf-figure") {
1421
- const r = await pdfFigure(Object.fromEntries(url.searchParams));
1622
+ const turn = state.turn;
1623
+ const r = await pdfFigure({ ...Object.fromEntries(url.searchParams), cancelled: () => !!turn && (turn.cancelled || state.turn !== turn) });
1422
1624
  return text(res, r.status || 200, r.text);
1423
1625
  }
1424
1626
  if (p === "/api/slides") {
@@ -1461,7 +1663,6 @@ server.listen(PORT, "127.0.0.1", () => {
1461
1663
  ORIGIN = `http://127.0.0.1:${PORT}`;
1462
1664
  try {
1463
1665
  writeServerInfo();
1464
- completeSettings();
1465
1666
  fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
1466
1667
  const first = runEidPass();
1467
1668
  if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
@@ -1478,9 +1679,16 @@ server.listen(PORT, "127.0.0.1", () => {
1478
1679
  console.log(`Server: ${CAPS.label}`);
1479
1680
  for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1480
1681
  if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
1481
- ensureSession();
1682
+ discoverModels().then(() => {
1683
+ if (!settings.model) {
1684
+ const models = catalog[settings.provider].models;
1685
+ const first = models.find(m => m.isDefault) || models[0];
1686
+ if (first) { settings.model = first.id; settings.effort = first.defaultEffort; }
1687
+ }
1688
+ pushStatus();
1689
+ });
1482
1690
  });
1483
- /* Ctrl+C or SIGTERM: stop taking requests, let Claude Code end (it gets end of input, then SIGTERM, then
1691
+ /* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
1484
1692
  SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
1485
1693
  async function quit() {
1486
1694
  if (quitting) process.exit(1);
@@ -1492,7 +1700,8 @@ async function quit() {
1492
1700
  server.close();
1493
1701
  server.closeAllConnections?.();
1494
1702
  removeServerInfo();
1495
- await Promise.race([session.stop(), new Promise((r) => setTimeout(r, 6000))]);
1703
+ for (const agent of [...discoveryAgents, session]) retire(agent);
1704
+ await Promise.race([Promise.all(retiring), new Promise((r) => setTimeout(r, 6000))]);
1496
1705
  if (chatTimer) writeChat();
1497
1706
  process.exit(0);
1498
1707
  }