pindeck 3.3.0 → 4.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/CHANGELOG.md +121 -0
- package/README.md +33 -724
- package/cli.mjs +8 -1
- package/docs/configuration.md +51 -0
- package/docs/how-it-works.md +56 -0
- package/docs/linux-server.md +36 -0
- package/docs/preparing-decks.md +88 -0
- package/docs/using.md +45 -0
- package/front.mjs +3 -2
- package/lib/agent-tools.mjs +2 -2
- package/lib/capabilities.mjs +13 -0
- package/lib/claude.mjs +1 -1
- package/lib/deck-list.mjs +5 -3
- package/lib/deck-target.mjs +9 -5
- package/lib/eids.mjs +8 -4
- package/lib/login.mjs +11 -2
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +11 -2
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +19 -9
- package/public/editor.css +41 -7
- package/public/editor.js +262 -51
- package/public/index.html +24 -4
- package/server.mjs +258 -31
package/server.mjs
CHANGED
|
@@ -10,20 +10,20 @@
|
|
|
10
10
|
- answers `cli.mjs layout|shot|slides|pdf|pdf-figure` for the assistant's self-checks, exports and PDF figures
|
|
11
11
|
- exports the deck as a PDF handout or a PDF presentation (build steps on separate pages)
|
|
12
12
|
- stores figures and audio attached to chat messages in <deck>/figures/ for the assistant to use, and attached
|
|
13
|
-
PDFs (papers, reports, figures) in ~/.pindeck-
|
|
13
|
+
PDFs (papers, reports, figures) in ~/.pindeck-v4, outside the deck
|
|
14
14
|
- imports slides from PowerPoint files in the deck's own layouts
|
|
15
15
|
Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
|
|
16
16
|
cookie in the browser, a header for the CLI).
|
|
17
|
-
Runtime files (chat, screenshots, backups) live in ~/.pindeck-
|
|
17
|
+
Runtime files (chat, screenshots, backups) live in ~/.pindeck-v4, never in the deck or the package. */
|
|
18
18
|
|
|
19
19
|
import http from "node:http";
|
|
20
20
|
import { EventEmitter } from "node:events";
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import crypto from "node:crypto";
|
|
24
|
-
import { spawn, execFile } from "node:child_process";
|
|
24
|
+
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
25
25
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
-
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd, decode } from "./lib/eids.mjs";
|
|
27
27
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
28
28
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
29
29
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -34,7 +34,7 @@ import { printLayout, printPdf, chromeReason, ROOT_ARGS, PDF_HEAD_TAG, PDF_PAGE_
|
|
|
34
34
|
import { readPptx } from "./lib/pptx.mjs";
|
|
35
35
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
36
36
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
37
|
-
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
37
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, useCliCommand, cliRefusal, findExecutable } from "./lib/capabilities.mjs";
|
|
38
38
|
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
39
39
|
import { readInstructions } from "./lib/instructions.mjs";
|
|
40
40
|
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
@@ -42,8 +42,9 @@ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
|
|
|
42
42
|
import { listDecks, linkProblem } from "./lib/deck-list.mjs";
|
|
43
43
|
import { assetRoots } from "./lib/asset-roots.mjs";
|
|
44
44
|
import { registerServer, printLogin, alive } from "./lib/servers.mjs";
|
|
45
|
-
import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
45
|
+
import { goodHost, presentHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
|
|
46
46
|
import { USAGE } from "./lib/usage.mjs";
|
|
47
|
+
import { indexDeck, commentFromPin, locate, loadReview, saveReview, packet, request as commentsRequest, reviewFiles } from "./lib/review.mjs";
|
|
47
48
|
|
|
48
49
|
/* ---- Config ------------------------------------------------------------------ */
|
|
49
50
|
/* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in and
|
|
@@ -70,9 +71,21 @@ const MODEL = opt("--model", "");
|
|
|
70
71
|
if (process.env.CLAUDE_MODEL && !LOGIN && !FRONT) // PinDeck 2 read it; behind the front, the front says so
|
|
71
72
|
console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
|
|
72
73
|
/* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
|
|
73
|
-
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey, source: SOURCE } = deckTarget(argv[0] || process.cwd());
|
|
75
|
+
/* A Quarto deck (SOURCE: talk.qmd): DECK_FILE is what Quarto renders from it, build output. The assistant edits
|
|
76
|
+
the sources; PinDeck renders after every change (see Quarto below) and adds its element ids to the output. */
|
|
77
|
+
const OUTPUTS = SOURCE ? [DECK_FILE, DECK_FILE.replace(/\.html$/i, "_files"), ".quarto"] : [];
|
|
78
|
+
const isOutput = (rel) => OUTPUTS.some((o) => rel === o || rel.startsWith(o + "/"));
|
|
79
|
+
const EDITABLE = SOURCE ? [SOURCE, DECK_FILE] : [DECK_FILE, "theme.css"]; // both backends may edit these (a Quarto deck: its source)
|
|
80
|
+
/* A Quarto deck's format extensions (_extensions/[<org>/]<name>/, e.g. Ocean) carry their own brand.css and vendor/ */
|
|
81
|
+
function extensionBrands() {
|
|
82
|
+
try {
|
|
83
|
+
return fs.readdirSync(path.join(DECK_DIR, "_extensions"), { recursive: true }).map((f) => String(f).split(path.sep).join("/"))
|
|
84
|
+
.filter((f) => /(^|\/)brand\.css$/.test(f) && !f.includes("vendor/")).map((f) => `_extensions/${f}`);
|
|
85
|
+
} catch { return []; }
|
|
86
|
+
}
|
|
87
|
+
const BRANDS = SOURCE ? extensionBrands() : [];
|
|
88
|
+
const LOCKED = ["brand.css", ...BRANDS]; // never, enforced twice
|
|
76
89
|
const HOME_DIR = homeDir();
|
|
77
90
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
78
91
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
@@ -92,6 +105,34 @@ const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(
|
|
|
92
105
|
const CLI_CMD = cliCommand(CLI_REL);
|
|
93
106
|
const CAPS = detectCapabilities();
|
|
94
107
|
|
|
108
|
+
/* ---- Quarto ----------------------------------------------------------------------------------------
|
|
109
|
+
`quarto render <source>` makes DECK_FILE. One render at a time; a change to a source while one runs renders
|
|
110
|
+
again when it ends. What the assistant checks (shot, layout, slides, pdf) waits for the render, so it always
|
|
111
|
+
sees its last edit. Quarto's own errors go to the chat, and to the assistant. */
|
|
112
|
+
const QUARTO = SOURCE ? findExecutable(process.env.QUARTO_BIN || "quarto") : null;
|
|
113
|
+
const RENDER_TIMEOUT = 180000;
|
|
114
|
+
const SOURCE_FILE = /\.(qmd|md|scss|css|ya?ml|bib|csl|lua|js|html?|png|jpe?g|gif|svg|webp|pdf|mp4|wav|mp3)$/i; // what a render reads
|
|
115
|
+
/* Quarto's message, without colours and its JavaScript stack: what says what is wrong in the source */
|
|
116
|
+
const quartoError = (stderr) => `Quarto could not render ${SOURCE}:\n` + String(stderr).replace(/\x1b\[[0-9;]*m/g, "")
|
|
117
|
+
.split(/\nStack trace:|\n\s+at /)[0].trim().split("\n").slice(-25).join("\n");
|
|
118
|
+
function renderSync() {
|
|
119
|
+
console.log(`Rendering ${SOURCE} with Quarto…`);
|
|
120
|
+
try { execFileSync(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, stdio: ["ignore", "ignore", "pipe"], timeout: RENDER_TIMEOUT }); }
|
|
121
|
+
catch (e) { return quartoError(e.stderr || e.message); }
|
|
122
|
+
return fs.existsSync(path.join(DECK_DIR, DECK_FILE)) ? null
|
|
123
|
+
: `Quarto rendered ${SOURCE}, but not to ${DECK_FILE} beside it (an output-dir in _quarto.yml?). PinDeck needs the HTML next to its source.`;
|
|
124
|
+
}
|
|
125
|
+
let startRenderError = null; // a failed render at start with an older output: PinDeck opens that, the assistant fixes the source
|
|
126
|
+
if (SOURCE && !LOGIN) {
|
|
127
|
+
if (!QUARTO.path) { console.error(`${SOURCE} is a Quarto deck, and Quarto is not installed (${QUARTO.reason}). Install it from https://quarto.org.`); process.exit(1); }
|
|
128
|
+
const mtime = (f) => { try { return fs.statSync(path.join(DECK_DIR, f)).mtimeMs; } catch { return 0; } };
|
|
129
|
+
if (mtime(DECK_FILE) < mtime(SOURCE)) {
|
|
130
|
+
const failed = renderSync();
|
|
131
|
+
if (failed && !fs.existsSync(path.join(DECK_DIR, DECK_FILE))) { console.error(failed); process.exit(1); }
|
|
132
|
+
if (failed) { console.error(failed + `\nOpening the last render of ${SOURCE}.`); startRenderError = failed; }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
95
136
|
/* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
|
|
96
137
|
function checkDeck() {
|
|
97
138
|
const file = path.join(DECK_DIR, DECK_FILE);
|
|
@@ -304,13 +345,14 @@ function refreshGit() {
|
|
|
304
345
|
}
|
|
305
346
|
function slideList() {
|
|
306
347
|
const html = readDeck(DECK_FILE);
|
|
307
|
-
return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null }));
|
|
348
|
+
return findSlides(parse(html)).map((s, index) => ({ index, eid: s.attrs["data-eid"]?.value || null, id: s.attrs.id?.value || null }));
|
|
308
349
|
}
|
|
309
350
|
function resolveSlide(spec) {
|
|
310
351
|
const slides = slideList();
|
|
311
352
|
if (!spec || spec === "current") return slides[state.current?.index ?? 0] || slides[0];
|
|
312
353
|
if (/^\d+$/.test(spec)) return slides[+spec - 1];
|
|
313
|
-
|
|
354
|
+
// or the slide's own id attribute (Quarto's heading anchor, which stays when the title changes)
|
|
355
|
+
return slides.find((s) => s.eid === spec) || slides.find((s) => s.id === spec) || slides.find((s) => s.eid && s.eid.startsWith(spec));
|
|
314
356
|
}
|
|
315
357
|
|
|
316
358
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
@@ -543,8 +585,9 @@ let session = null, starting = null;
|
|
|
543
585
|
const status = () => ({ busy: state.busy, switching: state.switching, preparing: !!state.preparing, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
544
586
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
545
587
|
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
546
|
-
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
547
|
-
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1))
|
|
588
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(), view: VIEW.slice(1) + "/",
|
|
589
|
+
undo: changeLabel(liveTurns().at(-1)), undoId: liveTurns().at(-1)?.id ?? null, redo: changeLabel(state.redo.at(-1)),
|
|
590
|
+
source: SOURCE, rendering: !!rendering, renderError });
|
|
548
591
|
/* Pushed to the editors on every change. The model lists (both providers' catalogs) go along only when they
|
|
549
592
|
changed since the last push: the editor keeps the last ones it got. */
|
|
550
593
|
let catalogSent = "";
|
|
@@ -563,6 +606,19 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
|
|
|
563
606
|
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
564
607
|
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
565
608
|
`other decks may use them too.\n` : "");
|
|
609
|
+
/* A Quarto deck: what to edit, and how its output relates to the source */
|
|
610
|
+
const quartoInstructions = () => SOURCE ? `\n\n## This deck is made with Quarto
|
|
611
|
+
${DECK_FILE} is rendered by Quarto from ${SOURCE}: it is build output, locked, and overwritten by every render. Make every ` +
|
|
612
|
+
`change in ${SOURCE} (Quarto Markdown: \`##\` headings start slides and become the slides' id attributes, \`::: {.notes}\` holds ` +
|
|
613
|
+
`speaker notes, \`::: {.incremental}\` and \`. . .\` make build steps, \`{ojs}\` cells are Observable JS; when you change a ` +
|
|
614
|
+
`slide's title, keep its id by adding \`{#<old id>}\` to the heading, so comments and links still find the slide), or in the files it uses ` +
|
|
615
|
+
`(its .scss theme, figures). Where these instructions say to edit ${DECK_FILE} or theme.css, edit ${SOURCE} or its stylesheet ` +
|
|
616
|
+
`instead. Pins and element ids (data-eid) name elements of the rendered HTML: read ${DECK_FILE} to see what they are, then ` +
|
|
617
|
+
`find the matching text in ${SOURCE}. PinDeck renders after every change you save; ${CLI_CMD} shot, layout and slides wait ` +
|
|
618
|
+
`for that render, and report Quarto's error if it fails. Interactive OJS output is drawn in the browser, so screenshots show it ` +
|
|
619
|
+
`only once its libraries have loaded.\n` + (BRANDS.length ? `The deck's look comes from a Quarto format extension (` +
|
|
620
|
+
BRANDS.map((b) => b.replace(/\/brand\.css$/, "/")).join(", ") + `): read its README.md for the slide layouts and classes. ` +
|
|
621
|
+
`Its brand.css and vendor/ are locked, like brand.css in an HTML deck; its theme.css takes the talk's own styles.\n` : "") : "";
|
|
566
622
|
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
567
623
|
function slidesInstructions() {
|
|
568
624
|
const { selector } = deckSlides();
|
|
@@ -577,7 +633,7 @@ function slidesInstructions() {
|
|
|
577
633
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
578
634
|
function lockedPaths() {
|
|
579
635
|
const { files, folders } = otherDecks();
|
|
580
|
-
return [...LOCKED, "vendor/**", ".git", ".git/**", ...files, ...folders.map((f) => `${f}/**`)];
|
|
636
|
+
return [...LOCKED, "vendor/**", ...BRANDS.map((b) => b.replace(/brand\.css$/, "vendor/**")), ".git", ".git/**", ".pindeck", ".pindeck/**", ...OUTPUTS.flatMap((o) => [o, `${o}/**`]), ...files, ...folders.map((f) => `${f}/**`)];
|
|
581
637
|
}
|
|
582
638
|
/* The other decks in this deck's folder: its other .html files, and the subfolders that hold a deck of their
|
|
583
639
|
own (as the deck menu lists them). Neither the assistant nor this deck's undo touches them. */
|
|
@@ -588,7 +644,7 @@ function otherDecks() {
|
|
|
588
644
|
.map((d) => path.relative(DECK_DIR, d.dir).split(path.sep).join("/")))];
|
|
589
645
|
return { files, folders };
|
|
590
646
|
}
|
|
591
|
-
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders] }); };
|
|
647
|
+
const scanDeck = () => { const { files, folders } = otherDecks(); return scanFiles(DECK_DIR, { skip: [...files, ...folders, ...OUTPUTS] }); };
|
|
592
648
|
|
|
593
649
|
/* Decks that share a folder share its files (theme.css, figures/): while the assistant of one works, that of
|
|
594
650
|
another may change them too. Each deck server keeps the times of its turns in a file of its own under
|
|
@@ -625,12 +681,12 @@ function othersWorkedSince(start) {
|
|
|
625
681
|
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
626
682
|
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
627
683
|
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
628
|
-
not loaded; a deck imports shared rules explicitly.
|
|
629
|
-
|
|
684
|
+
not loaded; a deck imports shared rules explicitly. The CLI path written there becomes this server's
|
|
685
|
+
command (useCliCommand). */
|
|
630
686
|
function deckInstructions() {
|
|
631
687
|
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
632
688
|
const s = readInstructions(path.join(DECK_DIR, name));
|
|
633
|
-
return s ? `\n\n## The deck's ${name}\n${s
|
|
689
|
+
return s ? `\n\n## The deck's ${name}\n${useCliCommand(s, CLI_CMD)}\n` : "";
|
|
634
690
|
}).join("");
|
|
635
691
|
}
|
|
636
692
|
// a function: ORIGIN is known only once the port is bound
|
|
@@ -642,13 +698,13 @@ const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?#
|
|
|
642
698
|
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.
|
|
643
699
|
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
644
700
|
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.
|
|
645
|
-
Use pindeck_command with separate command and args for layout, shot, slides, pdf
|
|
701
|
+
Use pindeck_command with separate command and args for layout, shot, slides, pdf, pdf-figure, resolve or reply.
|
|
646
702
|
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
647
703
|
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
648
704
|
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
649
705
|
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
650
706
|
|
|
651
|
-
## Hand edits`) + slidesInstructions() + deckInstructions();
|
|
707
|
+
## Hand edits`) + slidesInstructions() + quartoInstructions() + deckInstructions();
|
|
652
708
|
function makeSession(choice) {
|
|
653
709
|
let initial = null;
|
|
654
710
|
const options = {
|
|
@@ -668,11 +724,13 @@ function makeSession(choice) {
|
|
|
668
724
|
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
669
725
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
670
726
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
671
|
-
|
|
727
|
+
// a tool the rules do not allow comes to PinDeck (--permission-prompt-tool stdio), always refused: with the right command if it was the CLI
|
|
728
|
+
denyMessage: (r) => r?.tool_name === "Bash" ? cliRefusal(r.input?.command, CLI_CMD) : null,
|
|
729
|
+
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + quartoInstructions() + deckInstructions(),
|
|
672
730
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
673
|
-
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
731
|
+
"--permission-mode", "default", "--permission-prompt-tool", "stdio", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
674
732
|
"--allowedTools", ...readRules(), "Edit(./**)", "Write(./**)",
|
|
675
|
-
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
733
|
+
...["layout", "shot", "slides", "pdf", "pdf-figure", "resolve", "reply"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
676
734
|
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
677
735
|
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
678
736
|
});
|
|
@@ -846,7 +904,7 @@ function toolSummary(name, input = {}) {
|
|
|
846
904
|
// must show as the script, or a refused script looks like a refused layout check
|
|
847
905
|
const cmd = String(input.command || "").trim();
|
|
848
906
|
const m = cmd.startsWith(CLI_CMD + " ") && cmd.slice(CLI_CMD.length).match(/^\s+([\w-]+)\s*([^\n;&|<>`$]*)$/);
|
|
849
|
-
const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure" };
|
|
907
|
+
const names = { shot: "Screenshot", layout: "Check layout", "pdf-figure": "PDF figure", resolve: "Resolve comment", reply: "Reply to comment" };
|
|
850
908
|
return m ? { name: names[m[1]] || m[1], detail: m[1] === "pdf-figure" ? m[2].trim().replace(/^\S*\//, "") : m[2].trim() }
|
|
851
909
|
: { name: "Run", detail: cmd.split("\n")[0].slice(0, 80) };
|
|
852
910
|
}
|
|
@@ -1080,6 +1138,98 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
1080
1138
|
return { text: `${out} (${size})` };
|
|
1081
1139
|
}
|
|
1082
1140
|
|
|
1141
|
+
/* ---- Review comments (lib/review.mjs) ---------------------------------------------------------------
|
|
1142
|
+
Kept in <deck folder>/.pindeck/: never a deck file, never in an undo, locked for the assistant. The packet
|
|
1143
|
+
next to them is written once there are comments, and again whenever they or the deck change. An agent outside
|
|
1144
|
+
PinDeck may set a comment's status in the file; the editor follows. A file that cannot be read is left as it
|
|
1145
|
+
is: comments are then off, with the reason. */
|
|
1146
|
+
const REVIEW = reviewFiles(DECK_DIR, DECK_FILE);
|
|
1147
|
+
let review = null, reviewProblem = null, reviewSaved = null;
|
|
1148
|
+
function readReview() {
|
|
1149
|
+
try { review = loadReview(REVIEW.json); reviewProblem = null; }
|
|
1150
|
+
catch (e) { review = null; reviewProblem = `The comments file ${REVIEW.json} cannot be read (${e.message}): fix or remove it.`; }
|
|
1151
|
+
try { reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); } catch { reviewSaved = null; }
|
|
1152
|
+
}
|
|
1153
|
+
readReview();
|
|
1154
|
+
const deckIndex = () => indexDeck(readDeck(DECK_FILE) || "");
|
|
1155
|
+
// the Quarto source's text, for the source lines of comments (null for an HTML deck)
|
|
1156
|
+
const sourceText = () => { try { return SOURCE ? fs.readFileSync(path.join(DECK_DIR, SOURCE), "utf8") : null; } catch { return null; } };
|
|
1157
|
+
function commentsView(deck = deckIndex()) {
|
|
1158
|
+
if (!review) return { comments: [], problem: reviewProblem };
|
|
1159
|
+
return { file: path.relative(DECK_DIR, REVIEW.json), packet: path.relative(DECK_DIR, REVIEW.packet), problem: null,
|
|
1160
|
+
comments: review.comments.map((c, i) => {
|
|
1161
|
+
const loc = locate(deck, c);
|
|
1162
|
+
return { ...c, n: i + 1, loc: { status: loc.status, index: loc.slide?.index ?? null, slideEid: loc.slide?.eid ?? null, eid: loc.eid, now: loc.now } };
|
|
1163
|
+
}) };
|
|
1164
|
+
}
|
|
1165
|
+
function writePacket(deck = deckIndex()) {
|
|
1166
|
+
if (!review || (!review.comments.length && !fs.existsSync(REVIEW.packet))) return;
|
|
1167
|
+
fs.mkdirSync(path.dirname(REVIEW.packet), { recursive: true });
|
|
1168
|
+
writeAtomic(REVIEW.packet, packet(review, deck, { deckFile: DECK_FILE, source: SOURCE, srcText: sourceText(), commentsFile: path.relative(DECK_DIR, REVIEW.json) }));
|
|
1169
|
+
}
|
|
1170
|
+
function commentsChanged({ save = true } = {}) {
|
|
1171
|
+
const deck = deckIndex();
|
|
1172
|
+
if (save) { saveReview(REVIEW.json, review); reviewSaved = fs.readFileSync(REVIEW.json, "utf8"); }
|
|
1173
|
+
try { writePacket(deck); } catch (e) { console.error(e.message); }
|
|
1174
|
+
const view = commentsView(deck);
|
|
1175
|
+
broadcast("comments", view);
|
|
1176
|
+
return view;
|
|
1177
|
+
}
|
|
1178
|
+
// edits of the file by another program (an agent marks a comment resolved): polled, which survives replaced files
|
|
1179
|
+
function syncReview() {
|
|
1180
|
+
let now = null;
|
|
1181
|
+
try { now = fs.readFileSync(REVIEW.json, "utf8"); } catch {}
|
|
1182
|
+
if (now === reviewSaved) return false;
|
|
1183
|
+
readReview();
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
fs.watchFile(REVIEW.json, { interval: 1000 }, () => { if (syncReview()) commentsChanged({ save: false }); });
|
|
1187
|
+
|
|
1188
|
+
/* Rendering while PinDeck runs (see Quarto above): started by a change to a source file */
|
|
1189
|
+
let rendering = null, renderAgain = false, renderError = startRenderError, renderTimer = null, renderStarted = Date.now();
|
|
1190
|
+
if (renderError) {
|
|
1191
|
+
addEntry({ kind: "notice", text: renderError + `\nThe deck shown is the last render that worked.` });
|
|
1192
|
+
state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
|
|
1193
|
+
}
|
|
1194
|
+
function render() {
|
|
1195
|
+
clearTimeout(renderTimer);
|
|
1196
|
+
renderTimer = null;
|
|
1197
|
+
if (rendering) { renderAgain = true; return rendering; }
|
|
1198
|
+
renderStarted = Date.now();
|
|
1199
|
+
rendering = new Promise((done) => {
|
|
1200
|
+
execFile(QUARTO.path, ["render", SOURCE], { cwd: DECK_DIR, timeout: RENDER_TIMEOUT, maxBuffer: 1 << 24 }, (err, _out, stderr) => {
|
|
1201
|
+
const was = renderError;
|
|
1202
|
+
renderError = err ? quartoError(stderr || err.message) : null;
|
|
1203
|
+
if (renderError && renderError !== was) {
|
|
1204
|
+
addEntry({ kind: "notice", text: renderError });
|
|
1205
|
+
state.notes.push(renderError + "\nFix the source; PinDeck renders again after every change.");
|
|
1206
|
+
} else if (!renderError && was) addEntry({ kind: "notice", text: `${SOURCE} renders again.` });
|
|
1207
|
+
if (!err) runEidPass(); // the fresh output gets its element ids (and the editor reloads it)
|
|
1208
|
+
done();
|
|
1209
|
+
});
|
|
1210
|
+
}).finally(() => {
|
|
1211
|
+
rendering = null;
|
|
1212
|
+
if (renderAgain) { renderAgain = false; render(); }
|
|
1213
|
+
pushStatus();
|
|
1214
|
+
});
|
|
1215
|
+
pushStatus();
|
|
1216
|
+
return rendering;
|
|
1217
|
+
}
|
|
1218
|
+
function sourceChanged() {
|
|
1219
|
+
clearTimeout(renderTimer);
|
|
1220
|
+
renderTimer = setTimeout(render, 150); // an editor's save is often several events
|
|
1221
|
+
}
|
|
1222
|
+
/* Before the assistant looks at the deck: a pending or running render ends first, and a source saved since the
|
|
1223
|
+
last render began (its file event may not have come yet) is rendered now */
|
|
1224
|
+
async function rendered() {
|
|
1225
|
+
if (!SOURCE) return null;
|
|
1226
|
+
let saved = 0;
|
|
1227
|
+
try { saved = fs.statSync(path.join(DECK_DIR, SOURCE)).mtimeMs; } catch {}
|
|
1228
|
+
if (renderTimer || saved > renderStarted) render();
|
|
1229
|
+
while (rendering) await rendering;
|
|
1230
|
+
return renderError;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1083
1233
|
/* ---- File watching --------------------------------------------------------------------------
|
|
1084
1234
|
Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
|
|
1085
1235
|
events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
|
|
@@ -1090,6 +1240,10 @@ function onFileEvent(_ev, file) {
|
|
|
1090
1240
|
if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
|
|
1091
1241
|
file = String(file).split(path.sep).join("/");
|
|
1092
1242
|
if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
|
|
1243
|
+
if (SOURCE) {
|
|
1244
|
+
if (isOutput(file)) { if (rendering || file !== DECK_FILE) return; } // a render's own writes: its id pass reloads
|
|
1245
|
+
else if (SOURCE_FILE.test(file)) sourceChanged();
|
|
1246
|
+
}
|
|
1093
1247
|
changed.add(file);
|
|
1094
1248
|
scheduleFlush();
|
|
1095
1249
|
}
|
|
@@ -1126,6 +1280,7 @@ function scheduleFlush() {
|
|
|
1126
1280
|
};
|
|
1127
1281
|
eidTimer = setTimeout(pass, 800);
|
|
1128
1282
|
}
|
|
1283
|
+
if (files.includes(DECK_FILE) && review?.comments.length) commentsChanged({ save: false }); // found again, or flagged
|
|
1129
1284
|
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
1130
1285
|
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
1131
1286
|
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
@@ -1300,7 +1455,7 @@ function undo(res) {
|
|
|
1300
1455
|
/* ---- PowerPoint import -----------------------------------------------------------------------------
|
|
1301
1456
|
Upload: the file is read at once (slide list for the dialog); in the background PowerPoint or Keynote
|
|
1302
1457
|
makes a PDF of it, from which come the thumbnails and, on import, the pictures of diagrams.
|
|
1303
|
-
Only the latest upload is kept, in ~/.pindeck-
|
|
1458
|
+
Only the latest upload is kept, in ~/.pindeck-v4/decks/<deck>/imports/. */
|
|
1304
1459
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
1305
1460
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
1306
1461
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
@@ -1466,10 +1621,12 @@ function serveFile(res, file, transform, req, extra = {}) {
|
|
|
1466
1621
|
let ready = false;
|
|
1467
1622
|
const NEEDS_DECK = /^\/api\/(message|slides|layout|shot|pdf|pdf-figure|edit|text|split|remove|notes|delete|delete-info|source|slide-hidden|move-slide|add-slide|import-apply|export)$/;
|
|
1468
1623
|
const CLI_ROUTES = /^\/api\/(slides|layout|shot|pdf|pdf-figure)$/; // answered as text, for cli.mjs
|
|
1624
|
+
const HAND_EDITS = /^\/api\/(edit|text|split|remove|notes|delete|slide-hidden|move-slide|add-slide|import|import-apply)$/; // refused for a Quarto deck
|
|
1469
1625
|
/* Scriptable files (HTML, SVG) from the deck get the sandbox, wherever they are asked for. Under /v/ they are
|
|
1470
|
-
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1626
|
+
read by documents of that sandboxed origin, so fonts and module scripts need CORS; the path is the secret.
|
|
1627
|
+
On localhost (Present) there is no sandbox: no login works there (see presentHost). */
|
|
1471
1628
|
const SCRIPTABLE = /\.(html|svg|xml)$/i;
|
|
1472
|
-
const deckHeaders = (f, viewing) => ({ ...(SCRIPTABLE.test(f) ? { "content-security-policy": SANDBOX } : {}),
|
|
1629
|
+
const deckHeaders = (f, viewing, open) => ({ ...(SCRIPTABLE.test(f) && !open ? { "content-security-policy": SANDBOX } : {}),
|
|
1473
1630
|
...(viewing ? { "access-control-allow-origin": "*" } : {}) });
|
|
1474
1631
|
const server = http.createServer(async (req, res) => {
|
|
1475
1632
|
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
@@ -1478,7 +1635,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1478
1635
|
try {
|
|
1479
1636
|
const url = new URL(req.url, ORIGIN);
|
|
1480
1637
|
let p = url.pathname;
|
|
1481
|
-
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1638
|
+
if (req.method === "GET" && p === "/login") return login(res, url, host);
|
|
1482
1639
|
// /v/<token>/deck/... and /v/<token>/files/...: the deck's pages and files, read-only, nothing else
|
|
1483
1640
|
let viewing = false;
|
|
1484
1641
|
if (p.startsWith("/v/")) {
|
|
@@ -1526,7 +1683,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1526
1683
|
const f = deckPath;
|
|
1527
1684
|
if (!viewing && /\.html$/i.test(f)) return toView(p);
|
|
1528
1685
|
const mode = url.searchParams.get("editor");
|
|
1529
|
-
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing));
|
|
1686
|
+
if (!mode || !/\.html$/i.test(f)) return serveFile(res, f, null, req, deckHeaders(f, viewing, presentHost(host)));
|
|
1530
1687
|
return serveFile(res, f, (s) => {
|
|
1531
1688
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1532
1689
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
@@ -1537,7 +1694,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1537
1694
|
}
|
|
1538
1695
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1539
1696
|
}, req, ["shot", "dump", "pdf"].includes(mode) && headlessKeys.delete(url.searchParams.get("headless"))
|
|
1540
|
-
? deckHeaders(".css", viewing) : deckHeaders(f, viewing)); // headless Chrome's page, once (see chromeStart)
|
|
1697
|
+
? deckHeaders(".css", viewing) : deckHeaders(f, viewing, presentHost(host))); // headless Chrome's page, once (see chromeStart)
|
|
1541
1698
|
}
|
|
1542
1699
|
if (viewing) return text(res, 404, "Not found");
|
|
1543
1700
|
|
|
@@ -1568,6 +1725,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1568
1725
|
return;
|
|
1569
1726
|
}
|
|
1570
1727
|
// the deck file gone (moved, renamed, deleted) while PinDeck runs: say so, instead of failing on it
|
|
1728
|
+
// a Quarto deck is rendered from its source: no hand edits of the output (the next render would undo them)
|
|
1729
|
+
if (SOURCE && req.method === "POST" && HAND_EDITS.test(p))
|
|
1730
|
+
return json(res, 409, { code: "quarto", error: `${DECK_FILE} is rendered by Quarto from ${SOURCE}: ask the assistant, or edit ${SOURCE} yourself.` });
|
|
1731
|
+
// what the assistant checks is the deck rendered from its last edit
|
|
1732
|
+
if (SOURCE && CLI_ROUTES.test(p)) {
|
|
1733
|
+
const failed = await rendered();
|
|
1734
|
+
if (failed) return text(res, 409, failed + `\nFix ${SOURCE}; PinDeck renders again after every change.`);
|
|
1735
|
+
}
|
|
1571
1736
|
if (NEEDS_DECK.test(p) && readDeck(DECK_FILE) == null) {
|
|
1572
1737
|
const why = `${path.join(DECK_DIR, DECK_FILE)} is not there any more (moved, renamed or deleted). Put it back, ` +
|
|
1573
1738
|
`or start PinDeck for the deck where it is now.`;
|
|
@@ -1629,6 +1794,65 @@ const server = http.createServer(async (req, res) => {
|
|
|
1629
1794
|
return json(res, 200, { id: entry.id });
|
|
1630
1795
|
} finally { state.preparing = null; if (!state.turn) { state.busy = false; pushStatus(); } }
|
|
1631
1796
|
}
|
|
1797
|
+
if (p === "/api/comments" && req.method === "GET") return json(res, 200, commentsView());
|
|
1798
|
+
// `cli.mjs resolve <id or C3> [what was done]`: the assistant (or a terminal) marks a comment resolved;
|
|
1799
|
+
// `cli.mjs reply <id or C3> <text>`: it answers one it leaves open (why not, a question), in the comment's thread
|
|
1800
|
+
if (p === "/api/resolve" || p === "/api/reply") {
|
|
1801
|
+
syncReview(); // the poll may not have seen another program's edit yet: saving now would undo it
|
|
1802
|
+
if (!review) return text(res, 409, reviewProblem);
|
|
1803
|
+
const want = String(url.searchParams.get("id") || "").trim();
|
|
1804
|
+
const n = /^C(\d+)$/i.exec(want)?.[1];
|
|
1805
|
+
const c = n ? review.comments[+n - 1] : review.comments.find((x) => x.id === want);
|
|
1806
|
+
if (!c) return text(res, 404, `No comment "${want}". Comment ids look like c-1a2b3c4d (or C3, its number in the editor).`);
|
|
1807
|
+
const note = String(url.searchParams.get("note") || "").trim().slice(0, 2000);
|
|
1808
|
+
const by = state.busy ? "assistant" : "command line";
|
|
1809
|
+
const name = `C${review.comments.indexOf(c) + 1} (${c.id})`;
|
|
1810
|
+
if (p === "/api/reply") {
|
|
1811
|
+
if (!note) return text(res, 400, "Write the reply after the comment id.");
|
|
1812
|
+
(c.replies ||= []).push({ by, text: note, at: new Date().toISOString() });
|
|
1813
|
+
} else {
|
|
1814
|
+
c.status = "resolved";
|
|
1815
|
+
c.resolvedBy = by;
|
|
1816
|
+
c.resolution = note.slice(0, 500) || null;
|
|
1817
|
+
}
|
|
1818
|
+
c.updated = new Date().toISOString();
|
|
1819
|
+
commentsChanged();
|
|
1820
|
+
return text(res, 200, p === "/api/reply" ? `Replied to ${name}; it stays open.` : `Resolved ${name}.`);
|
|
1821
|
+
}
|
|
1822
|
+
if (p === "/api/comments-request" && req.method === "GET") {
|
|
1823
|
+
if (!review) return json(res, 409, { error: reviewProblem });
|
|
1824
|
+
return json(res, 200, { text: commentsRequest(review, deckIndex(), { source: SOURCE, srcText: sourceText() }) });
|
|
1825
|
+
}
|
|
1826
|
+
if (/^\/api\/comment(-update|-delete)?$/.test(p) && req.method === "POST") {
|
|
1827
|
+
syncReview();
|
|
1828
|
+
if (!review) return json(res, 409, { error: reviewProblem });
|
|
1829
|
+
const b = await body(req);
|
|
1830
|
+
if (p === "/api/comment") {
|
|
1831
|
+
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Write the comment first." });
|
|
1832
|
+
let c;
|
|
1833
|
+
try { c = commentFromPin(deckIndex(), { pin: b.pin || null, slide: b.slide }, b.text); }
|
|
1834
|
+
catch (e) { return json(res, 409, { error: e.message }); }
|
|
1835
|
+
review.comments.push(c);
|
|
1836
|
+
return json(res, 200, { id: c.id, ...commentsChanged() });
|
|
1837
|
+
}
|
|
1838
|
+
const c = review.comments.find((x) => x.id === b.id);
|
|
1839
|
+
if (!c) return json(res, 404, { error: "No such comment (removed meanwhile?)." });
|
|
1840
|
+
if (p === "/api/comment-delete") review.comments = review.comments.filter((x) => x !== c);
|
|
1841
|
+
else {
|
|
1842
|
+
if (typeof b.text === "string") { if (!b.text.trim()) return json(res, 400, { error: "A comment cannot be empty." }); c.text = b.text.trim(); }
|
|
1843
|
+
if (typeof b.reply === "string") {
|
|
1844
|
+
if (!b.reply.trim()) return json(res, 400, { error: "Write the reply first." });
|
|
1845
|
+
(c.replies ||= []).push({ by: "you", text: b.reply.trim(), at: new Date().toISOString() });
|
|
1846
|
+
}
|
|
1847
|
+
if (b.status === "open" || b.status === "resolved") {
|
|
1848
|
+
c.status = b.status;
|
|
1849
|
+
c.resolvedBy = b.status === "resolved" ? "you" : undefined;
|
|
1850
|
+
if (b.status === "open") c.resolution = undefined;
|
|
1851
|
+
}
|
|
1852
|
+
c.updated = new Date().toISOString();
|
|
1853
|
+
}
|
|
1854
|
+
return json(res, 200, commentsChanged());
|
|
1855
|
+
}
|
|
1632
1856
|
if (p === "/api/upload" && req.method === "POST") {
|
|
1633
1857
|
const original = decodeURIComponent(String(req.headers["x-filename"] || "file"));
|
|
1634
1858
|
const kind = attachKind(original);
|
|
@@ -1872,8 +2096,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1872
2096
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1873
2097
|
const n = findSlides(parse(before[DECK_FILE])).findIndex((s) => s.attrs["data-eid"]?.value === b.eid) + 1;
|
|
1874
2098
|
const text = String(b.text || "").trim();
|
|
2099
|
+
const said = b.html ? decode(text.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "")).trim() : text; // Claude reads the words, not the tags
|
|
1875
2100
|
const turnId = recordHandEdit(before, r.html, text ? `Edited the notes of slide ${n}` : `Removed the notes of slide ${n}`,
|
|
1876
|
-
text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${
|
|
2101
|
+
text ? `The user edited the speaker notes of slide ${n} (${b.eid}) by hand. They now read: "${said.slice(0, 300)}".`
|
|
1877
2102
|
: `The user removed the speaker notes of slide ${n} (${b.eid}).`); // manual: the slide itself does not change
|
|
1878
2103
|
return json(res, 200, { turnId });
|
|
1879
2104
|
}
|
|
@@ -1987,6 +2212,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1987
2212
|
const first = runEidPass();
|
|
1988
2213
|
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1989
2214
|
startWatcher();
|
|
2215
|
+
try { writePacket(); } catch (e) { console.error(e.message); }
|
|
1990
2216
|
} catch (e) {
|
|
1991
2217
|
console.error(e.plain ? e.message : `PinDeck could not open the deck: ${e.message}`);
|
|
1992
2218
|
removeServerInfo();
|
|
@@ -2015,6 +2241,7 @@ async function quit() {
|
|
|
2015
2241
|
if (quitting) process.exit(1);
|
|
2016
2242
|
quitting = true;
|
|
2017
2243
|
try { watcher?.close(); } catch {}
|
|
2244
|
+
fs.unwatchFile(REVIEW.json);
|
|
2018
2245
|
clearTimeout(flush); clearTimeout(eidTimer);
|
|
2019
2246
|
for (const res of clients) res.end();
|
|
2020
2247
|
for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
|