pindeck 3.0.0-beta.1 → 3.0.0-beta.3
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/README.md +267 -45
- package/cli.mjs +6 -3
- package/lib/agent-process.mjs +7 -3
- package/lib/agent-tools.mjs +32 -9
- package/lib/capabilities.mjs +121 -0
- package/lib/claude.mjs +7 -3
- package/lib/codex.mjs +4 -2
- package/lib/deck-files.mjs +94 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/models.mjs +2 -1
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +1 -0
- package/public/editor.js +85 -24
- package/public/index.html +2 -2
- package/server.mjs +660 -173
package/server.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* PinDeck: local server.
|
|
3
3
|
|
|
4
|
-
pindeck [deck-dir] [--port 5180] [--provider claude|codex] [--model <name>]
|
|
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
|
|
@@ -12,17 +12,19 @@
|
|
|
12
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
|
-
Listens on 127.0.0.1 only; every API call
|
|
15
|
+
Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
|
|
16
|
+
cookie in the browser, a header for the CLI).
|
|
16
17
|
Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
|
|
17
18
|
|
|
18
19
|
import http from "node:http";
|
|
20
|
+
import { EventEmitter } from "node:events";
|
|
19
21
|
import fs from "node:fs";
|
|
20
22
|
import os from "node:os";
|
|
21
23
|
import path from "node:path";
|
|
22
24
|
import crypto from "node:crypto";
|
|
23
25
|
import { spawn, execFile } from "node:child_process";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
28
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
27
29
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
28
30
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -33,65 +35,195 @@ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs
|
|
|
33
35
|
import { readPptx } from "./lib/pptx.mjs";
|
|
34
36
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
35
37
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
38
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.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";
|
|
36
42
|
|
|
37
43
|
/* ---- Config ------------------------------------------------------------------ */
|
|
38
|
-
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--provider claude|codex] [--model <name>]
|
|
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]
|
|
39
46
|
|
|
40
|
-
Opens
|
|
41
|
-
|
|
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. A deck made otherwise names its slides in its <head> with
|
|
49
|
+
<meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
|
|
50
|
+
login prints a new one-time login link for the PinDeck running for that deck
|
|
51
|
+
(without a deck: for the PinDeck started last).
|
|
52
|
+
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
53
|
+
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
54
|
+
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
|
42
55
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
43
56
|
const argv = process.argv.slice(2);
|
|
44
57
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
45
58
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
46
|
-
const
|
|
47
|
-
|
|
59
|
+
const portArg = opt("--port", process.env.PORT || 5180);
|
|
60
|
+
const assetArgs = [];
|
|
61
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
|
|
62
|
+
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
63
|
+
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
64
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
48
65
|
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
49
66
|
await showPlatformNotice(PORT);
|
|
50
67
|
process.exit(0);
|
|
51
68
|
}
|
|
69
|
+
const LOGIN = argv[0] === "login";
|
|
70
|
+
if (LOGIN) argv.shift();
|
|
52
71
|
const PROVIDER = opt("--provider", "");
|
|
53
72
|
const MODEL = opt("--model", "");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
73
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
|
|
74
|
+
that is not a folder is a file, so a missing file gets a clear message. */
|
|
75
|
+
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
76
|
+
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
77
|
+
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
78
|
+
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
79
|
+
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
80
|
+
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
81
|
+
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
58
82
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
59
83
|
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
85
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
86
|
+
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
87
|
+
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
62
88
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
63
89
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
64
90
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
91
|
+
/* pindeck login: a new one-time login link from the running server, asked with the token in its server
|
|
92
|
+
file (readable only by this user) */
|
|
93
|
+
if (LOGIN) {
|
|
94
|
+
const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
|
|
95
|
+
let info = null;
|
|
96
|
+
for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
|
|
97
|
+
if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
|
|
100
|
+
if (!res.ok) throw new Error(res.status);
|
|
101
|
+
console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
} catch {
|
|
104
|
+
console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
65
108
|
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
66
|
-
|
|
109
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
67
110
|
const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
|
|
68
|
-
const CLI_CMD =
|
|
111
|
+
const CLI_CMD = cliCommand(CLI_REL);
|
|
112
|
+
const CAPS = detectCapabilities();
|
|
69
113
|
|
|
70
|
-
|
|
71
|
-
|
|
114
|
+
/* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
|
|
115
|
+
function checkDeck() {
|
|
116
|
+
const file = path.join(DECK_DIR, DECK_FILE);
|
|
117
|
+
if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
|
|
118
|
+
try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
|
|
119
|
+
catch { return `${file} cannot be read and written (check its permissions).`; }
|
|
120
|
+
// saving replaces the file through a temporary file next to it, so the folder must be writable too
|
|
121
|
+
try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
|
|
122
|
+
catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const deckProblem = checkDeck();
|
|
126
|
+
if (deckProblem) {
|
|
127
|
+
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
131
|
+
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
132
|
+
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
133
|
+
if (frameworkProblem) {
|
|
134
|
+
console.error(frameworkProblem);
|
|
72
135
|
process.exit(1);
|
|
73
136
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
137
|
+
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
138
|
+
port is bound (see Start), so a failed start leaves no state behind. */
|
|
139
|
+
const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
|
|
140
|
+
function writeServerInfo() {
|
|
141
|
+
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
142
|
+
const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
|
|
143
|
+
for (const f of SERVER_FILES) {
|
|
144
|
+
fs.writeFileSync(f, info, { mode: 0o600 });
|
|
145
|
+
fs.chmodSync(f, 0o600);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
|
|
149
|
+
function removeServerInfo() {
|
|
150
|
+
for (const f of SERVER_FILES) {
|
|
151
|
+
try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
79
154
|
|
|
80
155
|
/* ---- Small helpers --------------------------------------------------------------- */
|
|
81
156
|
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
82
157
|
mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
|
|
83
158
|
jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
|
|
84
159
|
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
|
|
85
|
-
|
|
160
|
+
ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
|
|
161
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
|
|
86
162
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
87
163
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
164
|
+
/* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
|
|
165
|
+
their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
|
|
166
|
+
the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
|
|
167
|
+
screenshot is the slide itself. */
|
|
168
|
+
function deckSlides() {
|
|
169
|
+
const tree = parse(readDeck(DECK_FILE) || "");
|
|
170
|
+
const selector = slideSelector(tree);
|
|
171
|
+
return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
|
|
172
|
+
}
|
|
173
|
+
/* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
|
|
174
|
+
start-up check's message, which the editor shows and Claude is told */
|
|
175
|
+
function deckProblemNow() {
|
|
176
|
+
const html = readDeck(DECK_FILE);
|
|
177
|
+
return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
|
|
178
|
+
}
|
|
179
|
+
let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
|
|
88
180
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
89
181
|
function writeDeck(f, content, manual = false) {
|
|
90
182
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
91
|
-
|
|
183
|
+
writeAtomic(path.join(DECK_DIR, f), content);
|
|
184
|
+
}
|
|
185
|
+
/* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
|
|
186
|
+
function writeDeckFile(rel, data, manual = true) {
|
|
187
|
+
const abs = path.join(DECK_DIR, rel);
|
|
188
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
189
|
+
ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
|
|
190
|
+
writeAtomic(abs, data);
|
|
92
191
|
}
|
|
93
192
|
const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
|
|
94
|
-
|
|
193
|
+
/* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
|
|
194
|
+
a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
|
|
195
|
+
const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
|
|
196
|
+
const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
197
|
+
const asText = (v) => (v == null ? "" : String(v));
|
|
198
|
+
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
199
|
+
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
200
|
+
if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
201
|
+
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
202
|
+
writeDeck(rel, v);
|
|
203
|
+
}
|
|
204
|
+
function fileChange(rel, before, after) {
|
|
205
|
+
if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
|
|
206
|
+
const d = unifiedDiff(asText(before), asText(after), rel);
|
|
207
|
+
return { file: rel, added: d.added, removed: d.removed };
|
|
208
|
+
}
|
|
209
|
+
const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
|
|
210
|
+
? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
|
|
211
|
+
: unifiedDiff(asText(before), asText(after), rel).text);
|
|
212
|
+
/* The revision of the editable files: sent with every API answer, checked on the editor's changes */
|
|
213
|
+
const deckRev = () => revision(DECK_DIR, EDITABLE);
|
|
214
|
+
const json = (res, code, obj) => {
|
|
215
|
+
res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
|
|
216
|
+
res.end(JSON.stringify(obj));
|
|
217
|
+
};
|
|
218
|
+
/* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
|
|
219
|
+
written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
|
|
220
|
+
notes sent while the tab closes) are not checked. */
|
|
221
|
+
function staleEdit(res, b) {
|
|
222
|
+
if (b.rev == null || b.rev === deckRev()) return false;
|
|
223
|
+
json(res, 409, { code: "stale", rev: deckRev(),
|
|
224
|
+
error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
95
227
|
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
|
|
96
228
|
async function body(req) {
|
|
97
229
|
let s = "";
|
|
@@ -115,12 +247,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
|
|
|
115
247
|
let backedUp = false;
|
|
116
248
|
function runEidPass() {
|
|
117
249
|
const html = readDeck(DECK_FILE);
|
|
250
|
+
if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
|
|
118
251
|
const r = assignEids(html);
|
|
119
252
|
if (r.changed) {
|
|
120
253
|
if (!backedUp) {
|
|
121
254
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
122
|
-
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
|
|
123
|
-
fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
|
|
255
|
+
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
|
|
256
|
+
fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
|
|
124
257
|
backedUp = true;
|
|
125
258
|
}
|
|
126
259
|
writeDeck(DECK_FILE, r.html);
|
|
@@ -137,11 +270,12 @@ function runEidPass() {
|
|
|
137
270
|
let gitFilter = null; // null: not checked yet
|
|
138
271
|
const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
|
|
139
272
|
function refreshGit() {
|
|
140
|
-
|
|
273
|
+
// another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
|
|
274
|
+
const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
|
|
141
275
|
if (e1 || !cleaned) return;
|
|
142
276
|
git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
|
|
143
277
|
const staged = entry.split(/\s+/)[1];
|
|
144
|
-
if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
|
|
278
|
+
if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
|
|
145
279
|
});
|
|
146
280
|
});
|
|
147
281
|
if (gitFilter === null) {
|
|
@@ -163,23 +297,29 @@ function resolveSlide(spec) {
|
|
|
163
297
|
}
|
|
164
298
|
|
|
165
299
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
166
|
-
const CHROME =
|
|
167
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
168
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
169
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
170
|
-
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
171
|
-
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
|
|
300
|
+
const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
|
|
172
301
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
173
302
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
174
303
|
let chromeSeq = 0;
|
|
304
|
+
/* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
|
|
305
|
+
a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
|
|
306
|
+
to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
|
|
307
|
+
function sized(url, size) {
|
|
308
|
+
const u = new URL(url);
|
|
309
|
+
u.searchParams.set("width", size.width);
|
|
310
|
+
u.searchParams.set("height", size.height);
|
|
311
|
+
return u.href;
|
|
312
|
+
}
|
|
175
313
|
function chrome(args, done) {
|
|
176
|
-
if (!CHROME) return Promise.reject(new Error(
|
|
314
|
+
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
177
315
|
return new Promise((resolve, reject) => {
|
|
178
316
|
// a profile per run: concurrent runs must not share one
|
|
179
317
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
318
|
+
const { size } = deckSlides();
|
|
180
319
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
181
320
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
182
|
-
|
|
321
|
+
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
322
|
+
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
183
323
|
let out = "", err = "", finished = false;
|
|
184
324
|
const finish = (fn, v) => {
|
|
185
325
|
if (finished) return;
|
|
@@ -193,12 +333,13 @@ function chrome(args, done) {
|
|
|
193
333
|
proc.stdout.on("data", (d) => { out += d; check(); });
|
|
194
334
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
195
335
|
proc.on("error", (e) => finish(reject, e));
|
|
196
|
-
|
|
336
|
+
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
337
|
+
proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
|
|
197
338
|
const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
|
|
198
339
|
});
|
|
199
340
|
}
|
|
200
341
|
const deckUrl = (mode, params = {}) =>
|
|
201
|
-
`${ORIGIN}
|
|
342
|
+
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
202
343
|
async function takeShot(slide) {
|
|
203
344
|
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 }));
|
|
204
345
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
@@ -232,26 +373,31 @@ function measureInEditor(slide) {
|
|
|
232
373
|
pending.set(id, { resolve, reject, t });
|
|
233
374
|
});
|
|
234
375
|
}
|
|
235
|
-
const measure = (slide) => measureInEditor(slide).catch(() =>
|
|
376
|
+
const measure = (slide) => measureInEditor(slide).catch((e) => {
|
|
377
|
+
if (CAPS.headlessLayout.available) return measureHeadless(slide);
|
|
378
|
+
throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
|
|
379
|
+
`headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
|
|
380
|
+
});
|
|
236
381
|
|
|
237
382
|
/* PDF export. handout: one page per slide, build steps in their final state.
|
|
238
383
|
presentation: one page per build step (a slide with two clicks gives three pages).
|
|
239
384
|
Files go to <deck>/export/. One export at a time. */
|
|
240
385
|
let exportQueue = Promise.resolve(), snapSeq = 0;
|
|
241
|
-
const snapshots = new Map(); // id -> static print layout, served
|
|
386
|
+
const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
|
|
242
387
|
function exportPdf(kind, includeHidden = false) {
|
|
243
|
-
if (!
|
|
388
|
+
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
244
389
|
const split = kind === "presentation";
|
|
245
|
-
const name = `${
|
|
390
|
+
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
246
391
|
const out = path.join(DECK_DIR, "export", name);
|
|
247
392
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
248
393
|
const run = async () => {
|
|
249
394
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
250
|
-
const
|
|
251
|
-
|
|
395
|
+
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
396
|
+
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
397
|
+
`${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
252
398
|
const id = `${process.pid}-${++snapSeq}`;
|
|
253
399
|
snapshots.set(id, html);
|
|
254
|
-
try { await printPdf(CHROME, `${ORIGIN}
|
|
400
|
+
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
255
401
|
finally { snapshots.delete(id); }
|
|
256
402
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
257
403
|
url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -273,9 +419,17 @@ try {
|
|
|
273
419
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
274
420
|
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null, provider: saved.provider || "claude", handover: saved.handover || "", sessionArchive: saved.sessionArchive || [] });
|
|
275
421
|
} catch {}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
422
|
+
/* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
|
|
423
|
+
let chatTimer = null;
|
|
424
|
+
function writeChat() {
|
|
425
|
+
clearTimeout(chatTimer);
|
|
426
|
+
chatTimer = null;
|
|
427
|
+
try {
|
|
428
|
+
writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId,
|
|
429
|
+
provider: state.provider, handover: state.handover, sessionArchive: state.sessionArchive.slice(-50) }));
|
|
430
|
+
} catch (e) { console.error(e.message); }
|
|
431
|
+
}
|
|
432
|
+
const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
|
|
279
433
|
function addEntry(entry) {
|
|
280
434
|
entry.id = ++state.seq;
|
|
281
435
|
entry.time = Date.now();
|
|
@@ -291,56 +445,120 @@ const changeLabel = (t) => (t ? (t.kind === "manual" ? t.label : "AI change") :
|
|
|
291
445
|
const CONFIG_FILE = path.join(HOME_DIR, "config.json");
|
|
292
446
|
let fileConfig = {};
|
|
293
447
|
try { fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); }
|
|
294
|
-
catch (e) { if (e.code !== "ENOENT") console.warn(
|
|
448
|
+
catch (e) { if (e.code !== "ENOENT") console.warn(`${CONFIG_FILE} is not valid JSON; using the defaults.`); }
|
|
295
449
|
const settings = migrateSettings(fileConfig);
|
|
296
450
|
let effortPreferences = Object.fromEntries(Object.entries(fileConfig.effortPreferences || {}).filter(([, value]) => typeof value === "string"));
|
|
297
451
|
// Preserve the legacy selection as the first model-specific preference.
|
|
298
452
|
if (settings.effort) effortPreferences[`${settings.provider}:${settings.model}`] = settings.effort;
|
|
299
453
|
if (PROVIDER) {
|
|
300
|
-
if (!["claude", "codex"].includes(PROVIDER))
|
|
454
|
+
if (!["claude", "codex"].includes(PROVIDER)) { console.error(`Unknown provider "${PROVIDER}": use claude or codex.\n\n${USAGE}`); process.exit(2); }
|
|
301
455
|
settings.provider = PROVIDER;
|
|
302
456
|
if (!MODEL && PROVIDER !== fileConfig.provider) settings.model = PROVIDER === "claude" ? "sonnet" : "";
|
|
303
457
|
}
|
|
304
|
-
if (MODEL) settings.model = MODEL;
|
|
458
|
+
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
305
459
|
// A saved chat belongs to its provider, never to whichever CLI happens to be selected now.
|
|
306
460
|
if (state.provider !== settings.provider) { state.sessionId = null; state.handover = ""; }
|
|
307
461
|
state.provider = settings.provider;
|
|
462
|
+
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
463
|
+
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
464
|
+
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
465
|
+
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
466
|
+
a symlink that leads anywhere else is refused. */
|
|
467
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
468
|
+
const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
|
|
469
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
470
|
+
let st = null;
|
|
471
|
+
try { st = fs.statSync(abs); } catch {}
|
|
472
|
+
if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
473
|
+
return abs;
|
|
474
|
+
});
|
|
475
|
+
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
476
|
+
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
477
|
+
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
478
|
+
const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
|
|
479
|
+
const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
|
480
|
+
/* The deck page's URL, and the folder URL its relative paths resolve against */
|
|
481
|
+
const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
|
|
482
|
+
: `/deck/${encodeURIComponent(DECK_FILE)}`;
|
|
483
|
+
const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
|
|
484
|
+
/* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
|
|
485
|
+
followed. null: refuse. A missing file passes, and is answered 404. */
|
|
486
|
+
function servable(base, rel) {
|
|
487
|
+
const f = safeJoin(base, rel);
|
|
488
|
+
if (!f) return null;
|
|
489
|
+
let real;
|
|
490
|
+
try { real = fs.realpathSync(f); } catch { return f; }
|
|
491
|
+
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
492
|
+
}
|
|
493
|
+
|
|
308
494
|
let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = null;
|
|
309
495
|
let session = null, starting = null;
|
|
310
496
|
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
311
|
-
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
497
|
+
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
498
|
+
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
499
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
312
500
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
313
501
|
const pushStatus = () => broadcast("status", status());
|
|
314
502
|
|
|
315
503
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
316
|
-
.replaceAll("{{CLI}}", CLI_CMD)
|
|
504
|
+
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
505
|
+
// a template's own notes usually say index.html; other decks may share this folder
|
|
506
|
+
(NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
|
|
507
|
+
`${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
|
|
508
|
+
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
509
|
+
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
510
|
+
`other decks may use them too.\n` : "");
|
|
511
|
+
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
512
|
+
function slidesInstructions() {
|
|
513
|
+
const { selector } = deckSlides();
|
|
514
|
+
return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
|
|
515
|
+
`CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
|
|
516
|
+
`instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
|
|
517
|
+
`a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
|
|
518
|
+
`left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
|
|
519
|
+
}
|
|
520
|
+
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
521
|
+
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
522
|
+
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
523
|
+
function lockedPaths() {
|
|
524
|
+
let others = [];
|
|
525
|
+
try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
|
|
526
|
+
return [...LOCKED, "vendor/**", ".git/**", ...others];
|
|
527
|
+
}
|
|
317
528
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
318
529
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
319
|
-
MCP servers have no place in a slide editor). That also skips
|
|
320
|
-
the system prompt instead, read afresh at each session start.
|
|
530
|
+
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
531
|
+
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
532
|
+
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
533
|
+
not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
|
|
534
|
+
`node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
|
|
321
535
|
function deckInstructions() {
|
|
322
|
-
|
|
323
|
-
|
|
536
|
+
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
537
|
+
const s = readInstructions(path.join(DECK_DIR, name));
|
|
538
|
+
return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
|
|
539
|
+
}).join("");
|
|
324
540
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
541
|
+
// a function: ORIGIN is known only once the port is bound
|
|
542
|
+
const agentEnv = () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN });
|
|
543
|
+
const agentTools = createDeckTools({ deckDir: DECK_DIR, deckFile: DECK_FILE, readDirs: [SHOT_DIR, PDF_DIR, ...ASSET_ROOTS],
|
|
544
|
+
locked: lockedPaths, cliFile: path.join(EDITOR_DIR, "cli.mjs"), pdfTextFile: path.join(EDITOR_DIR, "lib/pdf-text.jxa.js"),
|
|
545
|
+
env: agentEnv });
|
|
328
546
|
const codexInstructions = () => systemPrompt.replace(/## Attached files[\s\S]*?## Hand edits/, `## Tools and attachments
|
|
329
547
|
Figures and audio are already saved under figures/. Keep their paths and use the deck's existing figure/audio patterns. Do not rename or move attachments.
|
|
330
548
|
Use pindeck_read for text files, images and PDF pages (supply page: 1, then further pages as needed).
|
|
331
|
-
Use pindeck_edit for exact replacements in
|
|
549
|
+
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.
|
|
332
550
|
Use pindeck_command with separate command and args for layout, shot, slides, pdf or pdf-figure.
|
|
333
551
|
Do not use shell commands, native file edits, or other tools to bypass these restrictions.
|
|
334
552
|
When context mentions Read, use pindeck_read. Read screenshots with pindeck_read.
|
|
335
553
|
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
336
554
|
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
337
555
|
|
|
338
|
-
## Hand edits`) + deckInstructions();
|
|
556
|
+
## Hand edits`) + slidesInstructions() + deckInstructions();
|
|
339
557
|
function makeSession(choice) {
|
|
340
558
|
let initial = null;
|
|
341
559
|
const options = {
|
|
342
560
|
cwd: DECK_DIR,
|
|
343
|
-
env:
|
|
561
|
+
env: agentEnv,
|
|
344
562
|
onEvent: ev => {
|
|
345
563
|
if (ev.type === "system" && ev.subtype === "init") initial = ev;
|
|
346
564
|
if (session === agent) handleEvent(ev);
|
|
@@ -351,40 +569,57 @@ function makeSession(choice) {
|
|
|
351
569
|
pushStatus();
|
|
352
570
|
},
|
|
353
571
|
};
|
|
572
|
+
/* Claude Code: any file in the deck folder (Edit rules cover Write too; Write is named as well, to be
|
|
573
|
+
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
354
574
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
355
575
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
356
|
-
args: ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
357
|
-
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands",
|
|
358
|
-
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Bash",
|
|
359
|
-
"--allowedTools", "Read", "Glob", "Grep",
|
|
360
|
-
"
|
|
576
|
+
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
577
|
+
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
578
|
+
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
579
|
+
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
|
|
580
|
+
...["layout", "shot", "slides", "pdf", "pdf-figure"].map(command => `Bash(${CLI_CMD} ${command}:*)`),
|
|
581
|
+
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
361
582
|
...(choice.model ? ["--model", choice.model] : []), ...(choice.effort ? ["--effort", choice.effort] : [])],
|
|
362
583
|
});
|
|
363
584
|
agent.initialEvent = () => initial;
|
|
364
585
|
return agent;
|
|
365
586
|
}
|
|
587
|
+
const providerAvailable = (provider) => detectCapabilities()[provider];
|
|
588
|
+
/* Every backend process that is stopped is waited for on exit: the discovery helpers, the session a switch
|
|
589
|
+
replaced, the current one. */
|
|
590
|
+
const retiring = new Set(), discoveryAgents = new Set();
|
|
591
|
+
function retire(agent) {
|
|
592
|
+
if (!agent) return;
|
|
593
|
+
const stopped = agent.stop();
|
|
594
|
+
retiring.add(stopped);
|
|
595
|
+
stopped.finally(() => retiring.delete(stopped));
|
|
596
|
+
}
|
|
366
597
|
async function discoverModels() {
|
|
367
598
|
if (discovering) return discovering;
|
|
368
599
|
discovering = Promise.all(["claude", "codex"].map(async provider => {
|
|
600
|
+
if (!providerAvailable(provider).available) { catalog[provider] = { models: [], error: providerAvailable(provider).reason }; return; }
|
|
369
601
|
const agent = makeSession({ provider, model: "", effort: null });
|
|
602
|
+
discoveryAgents.add(agent);
|
|
370
603
|
try {
|
|
371
604
|
if (provider === "codex") await agent.connect(); else await agent.start();
|
|
372
605
|
catalog[provider] = { models: curateModels(provider, agent.models || []), error: null };
|
|
373
606
|
} catch (e) { catalog[provider] = { models: [], error: e.message }; }
|
|
374
|
-
finally {
|
|
607
|
+
finally { discoveryAgents.delete(agent); retire(agent); }
|
|
375
608
|
})).then(() => { pushStatus(); return catalog; }).finally(() => { discovering = null; });
|
|
376
609
|
return discovering;
|
|
377
610
|
}
|
|
378
611
|
async function ensureSession() {
|
|
379
612
|
if (starting) return starting;
|
|
380
|
-
if (session?.running) return;
|
|
613
|
+
if (session?.running || quitting) return;
|
|
614
|
+
const available = providerAvailable(settings.provider);
|
|
615
|
+
if (!available.available) throw new Error(available.reason);
|
|
381
616
|
starting = (async () => {
|
|
382
617
|
if (!catalog[settings.provider].models.length) await discoverModels();
|
|
383
618
|
Object.assign(settings, selectModel(catalog, settings, {}));
|
|
384
619
|
const agent = makeSession({ ...settings });
|
|
385
620
|
session = agent;
|
|
386
621
|
try { await agent.start(state.sessionId); if (session === agent) pushStatus(); }
|
|
387
|
-
catch (e) { agent
|
|
622
|
+
catch (e) { retire(agent); throw e; }
|
|
388
623
|
})().finally(() => { starting = null; });
|
|
389
624
|
return starting;
|
|
390
625
|
}
|
|
@@ -415,12 +650,12 @@ async function changeSettings(requested) {
|
|
|
415
650
|
if (reuse) candidate.settings = choice;
|
|
416
651
|
session = candidate;
|
|
417
652
|
if (!reuse && candidate.initialEvent()) handleEvent(candidate.initialEvent());
|
|
418
|
-
if (!reuse) old
|
|
653
|
+
if (!reuse) retire(old);
|
|
419
654
|
saveChat();
|
|
420
655
|
addEntry({ kind: "notice", text: changingProvider
|
|
421
656
|
? `Switched to ${choice.provider === "codex" ? "OpenAI / ChatGPT" : "Claude"} · ${summary ? "Conversation summary will be shared" : "New conversation"}.`
|
|
422
657
|
: `Model: ${choice.model}${choice.effort ? ` · ${choice.effort} effort` : ""}. The conversation continues.` });
|
|
423
|
-
} catch (e) { if (session !== candidate) candidate
|
|
658
|
+
} catch (e) { if (session !== candidate) retire(candidate); throw e; }
|
|
424
659
|
finally { state.busy = false; state.switching = false; pushStatus(); }
|
|
425
660
|
}
|
|
426
661
|
|
|
@@ -433,7 +668,7 @@ function toolSummary(name, input = {}) {
|
|
|
433
668
|
};
|
|
434
669
|
switch (name) {
|
|
435
670
|
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
436
|
-
case "pindeck_edit": return { name: "Edit", detail: base(input.path) };
|
|
671
|
+
case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
|
|
437
672
|
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
438
673
|
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
439
674
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
@@ -480,7 +715,9 @@ function handleEvent(ev) {
|
|
|
480
715
|
}
|
|
481
716
|
|
|
482
717
|
function startTurn(promptText, userEntry) {
|
|
483
|
-
|
|
718
|
+
// every deck file as it is now: the assistant may change any of them (not brand.css or vendor/), and undo
|
|
719
|
+
// takes back all it changed
|
|
720
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
|
|
484
721
|
state.busy = true;
|
|
485
722
|
pushStatus();
|
|
486
723
|
const turn = state.turn;
|
|
@@ -509,11 +746,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
509
746
|
}
|
|
510
747
|
toolEntries.clear();
|
|
511
748
|
const eid = runEidPass();
|
|
512
|
-
const
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
749
|
+
const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
|
|
750
|
+
const before = {}, after = {};
|
|
751
|
+
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
752
|
+
// the deck file and theme.css first, then the rest by name
|
|
753
|
+
const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
|
|
754
|
+
const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
|
|
755
|
+
.map((rel) => fileChange(rel, before[rel], after[rel]));
|
|
517
756
|
// after a hand-off: imported pictures that the assistant replaced (nothing refers to them any more) are
|
|
518
757
|
// deleted; undo brings them back with the turn
|
|
519
758
|
const deletedFiles = [];
|
|
@@ -523,11 +762,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
523
762
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
524
763
|
fs.rmSync(abs, { force: true });
|
|
525
764
|
}
|
|
526
|
-
const t = { id: turn.id, kind: "agent", provider: settings.provider, before
|
|
765
|
+
const t = { id: turn.id, kind: "agent", provider: settings.provider, before, after, files, deletedFiles, undone: false };
|
|
527
766
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
528
767
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
529
768
|
denials, restored, newIds: eid.assigned.length });
|
|
530
769
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
770
|
+
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
771
|
+
`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"}.` });
|
|
531
772
|
pushStatus();
|
|
532
773
|
}
|
|
533
774
|
|
|
@@ -586,7 +827,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
586
827
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
587
828
|
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
|
|
588
829
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
589
|
-
|
|
830
|
+
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
590
831
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
591
832
|
}
|
|
592
833
|
|
|
@@ -595,7 +836,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
595
836
|
screenshots folder, for the assistant to look at before choosing; with one, into the deck. */
|
|
596
837
|
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "", cancelled = () => false }) {
|
|
597
838
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
598
|
-
if (
|
|
839
|
+
if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
|
|
599
840
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
600
841
|
.map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
|
|
601
842
|
[DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
|
|
@@ -613,24 +854,31 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
613
854
|
if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
|
|
614
855
|
const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
|
|
615
856
|
await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
|
|
616
|
-
if (!fs.existsSync(tmp)) return bad("
|
|
857
|
+
if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
|
|
617
858
|
const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
|
|
618
859
|
if (cancelled()) return bad("The request was cancelled before the figure could be saved.", 409);
|
|
619
860
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
620
861
|
const buf = fs.readFileSync(tmp);
|
|
621
862
|
fs.rmSync(tmp, { force: true });
|
|
622
|
-
|
|
623
|
-
ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
624
|
-
fs.writeFileSync(path.join(DECK_DIR, out), buf);
|
|
863
|
+
writeDeckFile(out, buf);
|
|
625
864
|
state.pdfFigures.add(out);
|
|
626
865
|
return { text: `${out} (${size})` };
|
|
627
866
|
}
|
|
628
867
|
|
|
629
|
-
/* ---- File watching --------------------------------------------------------------------------
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
868
|
+
/* ---- File watching --------------------------------------------------------------------------
|
|
869
|
+
Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
|
|
870
|
+
events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
|
|
871
|
+
editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
|
|
872
|
+
files), it is started again and the editor told to reload everything. */
|
|
873
|
+
let changed = new Set(), flush = null, eidTimer = null, watcher = null;
|
|
874
|
+
function onFileEvent(_ev, file) {
|
|
875
|
+
if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
|
|
876
|
+
file = String(file).split(path.sep).join("/");
|
|
877
|
+
if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
|
|
878
|
+
changed.add(file);
|
|
879
|
+
scheduleFlush();
|
|
880
|
+
}
|
|
881
|
+
function scheduleFlush() {
|
|
634
882
|
clearTimeout(flush);
|
|
635
883
|
flush = setTimeout(() => {
|
|
636
884
|
const files = [...changed];
|
|
@@ -646,14 +894,79 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
|
|
|
646
894
|
if (external) {
|
|
647
895
|
state.notes = [...new Set([...state.notes,
|
|
648
896
|
"The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
|
|
649
|
-
// new elements from a hand edit get ids once the file is quiet
|
|
897
|
+
// new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
|
|
650
898
|
clearTimeout(eidTimer);
|
|
651
|
-
|
|
899
|
+
const seen = hash(readDeck(DECK_FILE) ?? "");
|
|
900
|
+
const pass = () => {
|
|
901
|
+
if (state.busy) return;
|
|
902
|
+
const now = readDeck(DECK_FILE);
|
|
903
|
+
if (now == null || hash(now) !== seen) return; // still being written; its own event comes
|
|
904
|
+
runEidPass();
|
|
905
|
+
};
|
|
906
|
+
eidTimer = setTimeout(pass, 800);
|
|
907
|
+
}
|
|
908
|
+
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
909
|
+
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
910
|
+
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
911
|
+
if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
|
|
912
|
+
deckNow = now;
|
|
913
|
+
pushStatus();
|
|
914
|
+
}
|
|
652
915
|
}
|
|
653
916
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
654
|
-
broadcast("files", { files, manual });
|
|
917
|
+
broadcast("files", { files, manual, rev: deckRev() });
|
|
655
918
|
}, 120);
|
|
656
|
-
}
|
|
919
|
+
}
|
|
920
|
+
/* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
|
|
921
|
+
rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
|
|
922
|
+
every folder (inotify on directories) keeps working; folders created later are added as they appear.
|
|
923
|
+
Hidden folders (.git) and node_modules are skipped. */
|
|
924
|
+
function watchFolders(root, onEvent) {
|
|
925
|
+
const watchers = new Map(), tree = new EventEmitter();
|
|
926
|
+
const add = (dir) => {
|
|
927
|
+
if (watchers.has(dir)) return;
|
|
928
|
+
let w;
|
|
929
|
+
try {
|
|
930
|
+
w = fs.watch(dir, (ev, name) => {
|
|
931
|
+
if (name == null) return onEvent(ev, null);
|
|
932
|
+
const abs = path.join(dir, String(name));
|
|
933
|
+
if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
|
|
934
|
+
onEvent(ev, path.relative(root, abs));
|
|
935
|
+
});
|
|
936
|
+
} catch (e) {
|
|
937
|
+
if (dir === root) throw e;
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
w.on("error", (e) => {
|
|
941
|
+
try { w.close(); } catch {}
|
|
942
|
+
watchers.delete(dir);
|
|
943
|
+
if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
|
|
944
|
+
});
|
|
945
|
+
watchers.set(dir, w);
|
|
946
|
+
};
|
|
947
|
+
const scan = (dir) => {
|
|
948
|
+
add(dir);
|
|
949
|
+
let entries = [];
|
|
950
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
|
|
951
|
+
for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
|
|
952
|
+
};
|
|
953
|
+
scan(root);
|
|
954
|
+
tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
|
|
955
|
+
return tree;
|
|
956
|
+
}
|
|
957
|
+
function startWatcher() {
|
|
958
|
+
watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
|
|
959
|
+
: fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
|
|
960
|
+
watcher.on("error", (e) => {
|
|
961
|
+
console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
|
|
962
|
+
try { watcher.close(); } catch {}
|
|
963
|
+
setTimeout(() => {
|
|
964
|
+
if (quitting) return;
|
|
965
|
+
try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
|
|
966
|
+
broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
|
|
967
|
+
}, 1000);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
657
970
|
|
|
658
971
|
/* ---- Hand edits ----------------------------------------------------------------------------------------- */
|
|
659
972
|
function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
|
|
@@ -690,21 +1003,42 @@ function deletePlan(eid) {
|
|
|
690
1003
|
return { ...target, html: rest, files };
|
|
691
1004
|
}
|
|
692
1005
|
|
|
693
|
-
/* ---- Undo (assistant turns and hand edits share one history)
|
|
1006
|
+
/* ---- Undo (assistant turns and hand edits share one history) ---------------------------------------
|
|
1007
|
+
Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
|
|
1008
|
+
every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
|
|
1009
|
+
files, and the pictures or audio files they would delete or bring back. Anything else means the files
|
|
1010
|
+
were changed since, by hand or by another program, and going back would silently throw that work away.
|
|
1011
|
+
Then nothing is written and the answer is 409 with the files that differ. */
|
|
1012
|
+
function undoConflicts(t, direction) {
|
|
1013
|
+
const expect = direction === "undo" ? t.after : t.before;
|
|
1014
|
+
const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
|
|
1015
|
+
// undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
|
|
1016
|
+
// ones (which must be unchanged); redo the other way round
|
|
1017
|
+
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
1018
|
+
const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
|
|
1019
|
+
for (const { rel, data } of comeBack) {
|
|
1020
|
+
const abs = path.join(DECK_DIR, rel);
|
|
1021
|
+
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
1022
|
+
}
|
|
1023
|
+
for (const { rel, data } of go) {
|
|
1024
|
+
const abs = path.join(DECK_DIR, rel);
|
|
1025
|
+
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
1026
|
+
}
|
|
1027
|
+
return files;
|
|
1028
|
+
}
|
|
1029
|
+
function conflict(res, direction, files) {
|
|
1030
|
+
json(res, 409, { code: "diverged", files,
|
|
1031
|
+
error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
|
|
1032
|
+
`it would overwrite those changes. Reload to see the files as they are.` });
|
|
1033
|
+
}
|
|
694
1034
|
function undo(res) {
|
|
695
1035
|
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
696
1036
|
const t = liveTurns().at(-1);
|
|
697
1037
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
|
|
703
|
-
}
|
|
704
|
-
for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
|
|
705
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
706
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
707
|
-
}
|
|
1038
|
+
const diverged = undoConflicts(t, "undo");
|
|
1039
|
+
if (diverged.length) return conflict(res, "undo", diverged);
|
|
1040
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
1041
|
+
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
708
1042
|
for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
|
|
709
1043
|
t.undone = true;
|
|
710
1044
|
state.redo.push(t);
|
|
@@ -712,7 +1046,6 @@ function undo(res) {
|
|
|
712
1046
|
? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
|
|
713
1047
|
: `The user undid your change from turn ${t.id}; ${t.files.map((f) => f.file).join(" and ")} are back to the state before it. Re-read before editing.`);
|
|
714
1048
|
broadcast("undone", { turnId: t.id, undone: true });
|
|
715
|
-
if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
|
|
716
1049
|
pushStatus();
|
|
717
1050
|
return json(res, 200, {});
|
|
718
1051
|
}
|
|
@@ -724,7 +1057,6 @@ function undo(res) {
|
|
|
724
1057
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
725
1058
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
726
1059
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
727
|
-
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
728
1060
|
|
|
729
1061
|
function startImport(name, buf) {
|
|
730
1062
|
const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
|
|
@@ -771,12 +1103,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
771
1103
|
const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
|
|
772
1104
|
const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
|
|
773
1105
|
const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
|
|
774
|
-
for (const f of r.files)
|
|
775
|
-
const abs = path.join(DECK_DIR, f.rel);
|
|
776
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
777
|
-
ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
|
|
778
|
-
fs.writeFileSync(abs, f.data);
|
|
779
|
-
}
|
|
1106
|
+
for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
|
|
780
1107
|
const all = findSlides(parse(html));
|
|
781
1108
|
const first = afterIdx + 1, last = afterIdx + r.fragments.length;
|
|
782
1109
|
const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
|
|
@@ -834,68 +1161,172 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
834
1161
|
|
|
835
1162
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
836
1163
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
837
|
-
|
|
1164
|
+
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
1165
|
+
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
1166
|
+
const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
|
|
838
1167
|
|
|
839
|
-
|
|
840
|
-
|
|
1168
|
+
/* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
|
|
1169
|
+
where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
|
|
1170
|
+
code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
|
|
1171
|
+
and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
|
|
1172
|
+
carries this server's port. */
|
|
1173
|
+
const loginCodes = new Map(); // code -> expiry (ms)
|
|
1174
|
+
function loginCode(ttl) {
|
|
1175
|
+
const now = Date.now();
|
|
1176
|
+
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
1177
|
+
const code = crypto.randomBytes(16).toString("hex");
|
|
1178
|
+
loginCodes.set(code, now + ttl);
|
|
1179
|
+
return code;
|
|
1180
|
+
}
|
|
1181
|
+
const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
1182
|
+
const cookieName = () => `pindeck-${PORT}`;
|
|
1183
|
+
const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
|
|
1184
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
|
|
1185
|
+
function cookie(req, name) {
|
|
1186
|
+
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
1187
|
+
const i = part.indexOf("=");
|
|
1188
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
1189
|
+
}
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
1193
|
+
const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
1194
|
+
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
1195
|
+
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
1196
|
+
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
|
|
1197
|
+
PinDeck checkout).</p>`;
|
|
1198
|
+
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
1199
|
+
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
1200
|
+
function login(res, url) {
|
|
1201
|
+
const code = url.searchParams.get("code") || "";
|
|
1202
|
+
const exp = loginCodes.get(code);
|
|
1203
|
+
loginCodes.delete(code);
|
|
1204
|
+
if (!exp || exp < Date.now()) {
|
|
1205
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1206
|
+
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
1207
|
+
}
|
|
1208
|
+
let next = url.searchParams.get("next") || "/";
|
|
1209
|
+
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
1210
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1211
|
+
"set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
|
|
1212
|
+
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
1213
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
1214
|
+
}
|
|
1215
|
+
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1216
|
+
its command line: the page sends it to a one-time login code, which leads on to the target. */
|
|
1217
|
+
function chromeStart(profile, target) {
|
|
1218
|
+
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
1219
|
+
const start = path.join(profile, "pindeck-start.html");
|
|
1220
|
+
const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&").replace(/"/g, """);
|
|
1221
|
+
fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
|
|
1222
|
+
return pathToFileURL(start).href;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
|
|
1226
|
+
Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
|
|
1227
|
+
function serveFile(res, file, transform, req) {
|
|
1228
|
+
const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
|
|
1229
|
+
if (transform) return fs.readFile(file, (err, data) => {
|
|
841
1230
|
if (err) return text(res, 404, "Not found");
|
|
842
|
-
const
|
|
843
|
-
res.writeHead(200, { "content-type":
|
|
844
|
-
res.end(
|
|
1231
|
+
const out = Buffer.from(transform(String(data)));
|
|
1232
|
+
res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
|
|
1233
|
+
res.end(out);
|
|
1234
|
+
});
|
|
1235
|
+
fs.stat(file, (err, st) => {
|
|
1236
|
+
if (err || !st.isFile()) return text(res, 404, "Not found");
|
|
1237
|
+
const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
|
|
1238
|
+
let start = 0, end = st.size - 1, code = 200;
|
|
1239
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
|
|
1240
|
+
if (range && (range[1] || range[2])) {
|
|
1241
|
+
if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
|
|
1242
|
+
else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
|
|
1243
|
+
if (start > end || start >= st.size) {
|
|
1244
|
+
res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
|
|
1245
|
+
return res.end();
|
|
1246
|
+
}
|
|
1247
|
+
code = 206;
|
|
1248
|
+
headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
|
|
1249
|
+
}
|
|
1250
|
+
headers["content-length"] = st.size ? end - start + 1 : 0;
|
|
1251
|
+
res.writeHead(code, headers);
|
|
1252
|
+
if (!st.size) return res.end();
|
|
1253
|
+
const stream = fs.createReadStream(file, { start, end });
|
|
1254
|
+
stream.on("error", () => res.destroy());
|
|
1255
|
+
res.on("close", () => stream.destroy());
|
|
1256
|
+
stream.pipe(res);
|
|
845
1257
|
});
|
|
846
1258
|
}
|
|
847
1259
|
|
|
1260
|
+
let ready = false;
|
|
848
1261
|
const server = http.createServer(async (req, res) => {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (!
|
|
1262
|
+
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
1263
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
1264
|
+
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
852
1265
|
const url = new URL(req.url, ORIGIN);
|
|
853
1266
|
const p = url.pathname;
|
|
854
1267
|
try {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1268
|
+
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1269
|
+
// UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
|
|
1270
|
+
const ok = authed(req);
|
|
1271
|
+
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
1272
|
+
if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
|
|
1273
|
+
return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
|
|
1274
|
+
}
|
|
858
1275
|
if (req.method === "GET" && p.startsWith("/editor/")) {
|
|
859
1276
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
860
|
-
return f ? serveFile(res, f) : text(res, 403, "Forbidden");
|
|
1277
|
+
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
861
1278
|
}
|
|
1279
|
+
if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
|
|
862
1280
|
let snap;
|
|
863
|
-
if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
|
|
1281
|
+
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
864
1282
|
const html = snapshots.get(snap[1]);
|
|
865
1283
|
if (!html) return text(res, 404, "Not found");
|
|
866
1284
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
867
1285
|
return res.end(html);
|
|
868
1286
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1287
|
+
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1288
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there.
|
|
1289
|
+
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1290
|
+
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1291
|
+
return res.end();
|
|
1292
|
+
}
|
|
1293
|
+
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
1294
|
+
: p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
|
|
1295
|
+
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1296
|
+
if (deckPath) {
|
|
1297
|
+
const f = deckPath;
|
|
872
1298
|
const mode = url.searchParams.get("editor");
|
|
873
|
-
if (!mode ||
|
|
1299
|
+
if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
|
|
874
1300
|
return serveFile(res, f, (s) => {
|
|
875
1301
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
876
1302
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
877
|
-
if (mode === "pdf")
|
|
1303
|
+
if (mode === "pdf") {
|
|
1304
|
+
s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
|
|
1305
|
+
// a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
|
|
1306
|
+
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1307
|
+
}
|
|
878
1308
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
879
1309
|
});
|
|
880
1310
|
}
|
|
881
1311
|
|
|
882
|
-
// API: token required; POSTs must come from the editor page itself
|
|
1312
|
+
// API: token required (header or login cookie); POSTs must come from the editor page itself
|
|
883
1313
|
if (!p.startsWith("/api/")) return text(res, 404, "Not found");
|
|
884
|
-
|
|
885
|
-
if (tok !== TOKEN) return text(res, 403, "Bad token");
|
|
1314
|
+
if (!ok) return text(res, 403, "Bad token");
|
|
886
1315
|
const origin = req.headers.origin;
|
|
887
|
-
if (req.method === "POST" && origin && origin !==
|
|
1316
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
|
|
888
1317
|
return text(res, 403, "Bad origin");
|
|
889
1318
|
|
|
890
1319
|
if (p === "/api/events") {
|
|
891
1320
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
892
|
-
res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
|
|
1321
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
|
|
893
1322
|
turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
|
|
894
1323
|
clients.add(res);
|
|
895
1324
|
req.on("close", () => clients.delete(res));
|
|
896
1325
|
return;
|
|
897
1326
|
}
|
|
898
1327
|
if (p === "/api/ping") return json(res, 200, { ok: true });
|
|
1328
|
+
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
|
|
1329
|
+
if (p === "/api/status") return json(res, 200, status());
|
|
899
1330
|
if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
|
|
900
1331
|
if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
|
|
901
1332
|
if (p === "/api/measure-result" && req.method === "POST") {
|
|
@@ -907,6 +1338,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
907
1338
|
|
|
908
1339
|
if (p === "/api/message" && req.method === "POST") {
|
|
909
1340
|
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
1341
|
+
// checked again here: the claude or codex command may have been installed (or removed) since the start
|
|
1342
|
+
const backend = providerAvailable(settings.provider);
|
|
1343
|
+
if (!backend.available) return json(res, 503, { code: `${settings.provider}-unavailable`, error: backend.reason });
|
|
910
1344
|
const b = await body(req);
|
|
911
1345
|
if (state.busy) return json(res, 409, { error: "The assistant is still working." });
|
|
912
1346
|
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
|
|
@@ -953,18 +1387,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
953
1387
|
const buf = Buffer.concat(chunks);
|
|
954
1388
|
if (kind === "pdf") {
|
|
955
1389
|
if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
|
|
956
|
-
fs.mkdirSync(PDF_DIR, { recursive: true });
|
|
1390
|
+
fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
|
|
957
1391
|
const abs = attachPath(original, PDF_DIR);
|
|
958
|
-
fs.writeFileSync(abs, buf);
|
|
1392
|
+
fs.writeFileSync(abs, buf, { mode: 0o600 });
|
|
959
1393
|
state.uploads.add(abs);
|
|
960
1394
|
let info = null;
|
|
961
|
-
if (
|
|
1395
|
+
if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
|
|
962
1396
|
return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
|
|
963
1397
|
}
|
|
964
1398
|
const rel = attachPath(original);
|
|
965
|
-
|
|
966
|
-
ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
967
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), buf);
|
|
1399
|
+
writeDeckFile(rel, buf);
|
|
968
1400
|
state.uploads.add(rel);
|
|
969
1401
|
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
|
|
970
1402
|
}
|
|
@@ -987,21 +1419,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
987
1419
|
if (p === "/api/slide-hidden" && req.method === "POST") {
|
|
988
1420
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
989
1421
|
const b = await body(req);
|
|
1422
|
+
if (staleEdit(res, b)) return;
|
|
990
1423
|
const before = snapshot(EDITABLE);
|
|
991
|
-
const
|
|
1424
|
+
const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
|
|
992
1425
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
993
1426
|
if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
|
|
994
1427
|
const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
|
|
995
1428
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1429
|
+
const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
|
|
1430
|
+
: "skipped when presenting and in PDF exports";
|
|
996
1431
|
const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
|
|
997
1432
|
`The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
|
|
998
|
-
`data-visibility="hidden": they stay in the file but are skipped
|
|
1433
|
+
`data-visibility="hidden": they stay in the file but are ${skipped}.`,
|
|
999
1434
|
{ manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
|
|
1000
1435
|
return json(res, 200, { turnId, index });
|
|
1001
1436
|
}
|
|
1002
1437
|
if (p === "/api/move-slide" && req.method === "POST") {
|
|
1003
1438
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1004
1439
|
const b = await body(req);
|
|
1440
|
+
if (staleEdit(res, b)) return;
|
|
1005
1441
|
const before = snapshot(EDITABLE);
|
|
1006
1442
|
const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
|
|
1007
1443
|
if (r.error) return json(res, 409, { error: r.error });
|
|
@@ -1014,6 +1450,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1014
1450
|
if (p === "/api/add-slide" && req.method === "POST") {
|
|
1015
1451
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1016
1452
|
const b = await body(req);
|
|
1453
|
+
if (staleEdit(res, b)) return;
|
|
1017
1454
|
if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
|
|
1018
1455
|
const before = snapshot(EDITABLE);
|
|
1019
1456
|
const r = insertSlide(before[DECK_FILE], b.kind, b.after);
|
|
@@ -1028,6 +1465,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1028
1465
|
return json(res, 200, { turnId, index: r.index, eid });
|
|
1029
1466
|
}
|
|
1030
1467
|
if (p === "/api/import" && req.method === "POST") {
|
|
1468
|
+
if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
|
|
1031
1469
|
const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
|
|
1032
1470
|
if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
|
|
1033
1471
|
const chunks = [];
|
|
@@ -1053,6 +1491,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1053
1491
|
if (p === "/api/import-apply" && req.method === "POST") {
|
|
1054
1492
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1055
1493
|
const b = await body(req);
|
|
1494
|
+
if (staleEdit(res, b)) return;
|
|
1056
1495
|
const imp = lastImport;
|
|
1057
1496
|
if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
|
|
1058
1497
|
const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
|
|
@@ -1068,7 +1507,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1068
1507
|
if (starting || !session?.running) { session?.stop(); finishTurn({ error: "Stopped." }); return json(res, 200, {}); }
|
|
1069
1508
|
Promise.resolve(session.interrupt()).catch(() => {});
|
|
1070
1509
|
setTimeout(() => {
|
|
1071
|
-
if (state.busy && state.turn === interruptedTurn) { session
|
|
1510
|
+
if (state.busy && state.turn === interruptedTurn) { session?.stop(); finishTurn({ error: "Stopped." }); }
|
|
1072
1511
|
}, 5000); // force only if the interrupted turn does not answer
|
|
1073
1512
|
}
|
|
1074
1513
|
return json(res, 200, {});
|
|
@@ -1087,7 +1526,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1087
1526
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
|
|
1088
1527
|
const t = state.turns.find((t) => t.id === +m[1]);
|
|
1089
1528
|
if (!t) return text(res, 404, "No such change");
|
|
1090
|
-
return text(res, 200,
|
|
1529
|
+
return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
|
|
1091
1530
|
}
|
|
1092
1531
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
|
|
1093
1532
|
const t = liveTurns().at(-1);
|
|
@@ -1099,18 +1538,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1099
1538
|
if (state.busy) return json(res, 409, { error: "Wait until the assistant has finished." });
|
|
1100
1539
|
const t = state.redo.at(-1);
|
|
1101
1540
|
if (!t) return json(res, 409, { error: "Nothing to redo." });
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
pushStatus();
|
|
1105
|
-
return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
|
|
1106
|
-
}
|
|
1541
|
+
const diverged = undoConflicts(t, "redo");
|
|
1542
|
+
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1107
1543
|
state.redo.pop();
|
|
1108
|
-
for (const
|
|
1544
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1109
1545
|
for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
1110
|
-
for (const { rel, data } of t.addedFiles || [])
|
|
1111
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
1112
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
1113
|
-
}
|
|
1546
|
+
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1114
1547
|
t.undone = false;
|
|
1115
1548
|
state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
|
|
1116
1549
|
: `The user redid your change from turn ${t.id} after undoing it.`);
|
|
@@ -1128,6 +1561,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1128
1561
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1129
1562
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1130
1563
|
const b = await body(req);
|
|
1564
|
+
if (staleEdit(res, b)) return;
|
|
1131
1565
|
const plan = deletePlan(b.eid);
|
|
1132
1566
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
1133
1567
|
const before = snapshot(EDITABLE);
|
|
@@ -1147,6 +1581,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1147
1581
|
if (p === "/api/edit" && req.method === "POST") {
|
|
1148
1582
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1149
1583
|
const b = await body(req);
|
|
1584
|
+
if (staleEdit(res, b)) return;
|
|
1150
1585
|
const before = snapshot(EDITABLE);
|
|
1151
1586
|
const r = setStyles(before[DECK_FILE], b.ops || []);
|
|
1152
1587
|
if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
|
|
@@ -1168,6 +1603,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1168
1603
|
if (p === "/api/notes" && req.method === "POST") {
|
|
1169
1604
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1170
1605
|
const b = await body(req);
|
|
1606
|
+
if (staleEdit(res, b)) return;
|
|
1171
1607
|
const before = snapshot(EDITABLE);
|
|
1172
1608
|
const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
|
|
1173
1609
|
if (!r) return json(res, 404, { error: "Slide not found in the file." });
|
|
@@ -1182,6 +1618,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1182
1618
|
if (p === "/api/text" && req.method === "POST") {
|
|
1183
1619
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1184
1620
|
const b = await body(req);
|
|
1621
|
+
if (staleEdit(res, b)) return;
|
|
1185
1622
|
const before = snapshot(EDITABLE);
|
|
1186
1623
|
const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
|
|
1187
1624
|
if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1197,6 +1634,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1197
1634
|
if (p === "/api/split" && req.method === "POST") {
|
|
1198
1635
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1199
1636
|
const b = await body(req);
|
|
1637
|
+
if (staleEdit(res, b)) return;
|
|
1200
1638
|
const before = snapshot(EDITABLE);
|
|
1201
1639
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
1202
1640
|
if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1209,6 +1647,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1209
1647
|
if (p === "/api/remove" && req.method === "POST") {
|
|
1210
1648
|
if (state.busy) return json(res, 409, { error: "The assistant is working. Wait until it has finished." });
|
|
1211
1649
|
const b = await body(req);
|
|
1650
|
+
if (staleEdit(res, b)) return;
|
|
1212
1651
|
const before = snapshot(EDITABLE);
|
|
1213
1652
|
const html = removeElement(before[DECK_FILE], b.eid);
|
|
1214
1653
|
if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1220,13 +1659,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1220
1659
|
if (p === "/api/export" && req.method === "POST") {
|
|
1221
1660
|
const b = await body(req);
|
|
1222
1661
|
try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
|
|
1223
|
-
catch (e) { return json(res, 500, { error: String(e.message || e) }); }
|
|
1662
|
+
catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
|
|
1224
1663
|
}
|
|
1225
1664
|
|
|
1226
1665
|
// CLI endpoints (used by both backends via cli.mjs)
|
|
1227
1666
|
if (p === "/api/pdf") {
|
|
1228
1667
|
const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
|
|
1229
1668
|
if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
|
|
1669
|
+
if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
|
|
1230
1670
|
const r = await exportPdf(kind, url.searchParams.get("all") === "1");
|
|
1231
1671
|
return text(res, 200, r.path);
|
|
1232
1672
|
}
|
|
@@ -1236,15 +1676,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1236
1676
|
return text(res, r.status || 200, r.text);
|
|
1237
1677
|
}
|
|
1238
1678
|
if (p === "/api/slides") {
|
|
1239
|
-
|
|
1679
|
+
const list = slideList(), why = !list.length && deckProblemNow();
|
|
1680
|
+
if (why) return text(res, 409, why);
|
|
1681
|
+
return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
|
|
1240
1682
|
}
|
|
1241
1683
|
if (p === "/api/layout" || p === "/api/shot") {
|
|
1242
1684
|
const slide = resolveSlide(url.searchParams.get("slide"));
|
|
1243
|
-
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1685
|
+
if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1244
1686
|
if (!slide.eid) runEidPass();
|
|
1245
1687
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1246
|
-
if (p === "/api/shot")
|
|
1247
|
-
|
|
1688
|
+
if (p === "/api/shot") {
|
|
1689
|
+
if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
|
|
1690
|
+
return text(res, 200, await takeShot(s2));
|
|
1691
|
+
}
|
|
1692
|
+
try { return text(res, 200, formatLayout(await measure(s2))); }
|
|
1693
|
+
catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
|
|
1248
1694
|
}
|
|
1249
1695
|
return text(res, 404, "Not found");
|
|
1250
1696
|
} catch (e) {
|
|
@@ -1255,13 +1701,38 @@ const server = http.createServer(async (req, res) => {
|
|
|
1255
1701
|
}
|
|
1256
1702
|
});
|
|
1257
1703
|
|
|
1258
|
-
/* ---- Start
|
|
1259
|
-
|
|
1260
|
-
|
|
1704
|
+
/* ---- Start ----------------------------------------------------------------------------------------
|
|
1705
|
+
The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
|
|
1706
|
+
the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
|
|
1707
|
+
let quitting = false;
|
|
1708
|
+
server.on("error", (e) => {
|
|
1709
|
+
if (ready) return console.error(e);
|
|
1710
|
+
console.error(e.code === "EADDRINUSE"
|
|
1711
|
+
? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
|
|
1712
|
+
: `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
1713
|
+
process.exit(1);
|
|
1714
|
+
});
|
|
1261
1715
|
server.listen(PORT, "127.0.0.1", () => {
|
|
1262
|
-
|
|
1716
|
+
PORT = server.address().port;
|
|
1717
|
+
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
1718
|
+
try {
|
|
1719
|
+
writeServerInfo();
|
|
1720
|
+
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
1721
|
+
const first = runEidPass();
|
|
1722
|
+
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1723
|
+
startWatcher();
|
|
1724
|
+
} catch (e) {
|
|
1725
|
+
console.error(`PinDeck could not open the deck: ${e.message}`);
|
|
1726
|
+
removeServerInfo();
|
|
1727
|
+
process.exit(1);
|
|
1728
|
+
}
|
|
1729
|
+
ready = true;
|
|
1730
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1731
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1263
1732
|
console.log(`Deck: ${DECK_DIR}`);
|
|
1264
|
-
|
|
1733
|
+
console.log(`Server: ${CAPS.label}`);
|
|
1734
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1735
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1265
1736
|
discoverModels().then(() => {
|
|
1266
1737
|
if (!settings.model) {
|
|
1267
1738
|
const models = catalog[settings.provider].models;
|
|
@@ -1271,6 +1742,22 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1271
1742
|
pushStatus();
|
|
1272
1743
|
});
|
|
1273
1744
|
});
|
|
1274
|
-
|
|
1745
|
+
/* Ctrl+C or SIGTERM: stop taking requests, let the backend end (it gets end of input, then SIGTERM, then
|
|
1746
|
+
SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
|
|
1747
|
+
async function quit() {
|
|
1748
|
+
if (quitting) process.exit(1);
|
|
1749
|
+
quitting = true;
|
|
1750
|
+
try { watcher?.close(); } catch {}
|
|
1751
|
+
clearTimeout(flush); clearTimeout(eidTimer);
|
|
1752
|
+
for (const res of clients) res.end();
|
|
1753
|
+
for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
|
|
1754
|
+
server.close();
|
|
1755
|
+
server.closeAllConnections?.();
|
|
1756
|
+
removeServerInfo();
|
|
1757
|
+
for (const agent of [...discoveryAgents, session]) retire(agent);
|
|
1758
|
+
await Promise.race([Promise.all(retiring), new Promise((r) => setTimeout(r, 6000))]);
|
|
1759
|
+
if (chatTimer) writeChat();
|
|
1760
|
+
process.exit(0);
|
|
1761
|
+
}
|
|
1275
1762
|
process.on("SIGINT", quit);
|
|
1276
1763
|
process.on("SIGTERM", quit);
|