pindeck 3.1.0-beta.1 → 3.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -36
- package/cli.mjs +19 -9
- package/front.mjs +138 -43
- package/lib/agent-process.mjs +4 -1
- package/lib/agent-tools.mjs +5 -2
- package/lib/asset-roots.mjs +22 -0
- package/lib/claude.mjs +10 -2
- package/lib/deck-files.mjs +5 -3
- package/lib/deck-list.mjs +28 -5
- package/lib/deck-target.mjs +21 -3
- package/lib/eids.mjs +15 -6
- package/lib/login.mjs +15 -1
- package/lib/models.mjs +10 -4
- package/lib/pdf.mjs +18 -2
- package/lib/servers.mjs +91 -0
- package/package.json +1 -1
- package/public/bridge.js +56 -7
- package/public/editor.css +8 -2
- package/public/editor.js +190 -82
- package/public/events-worker.js +47 -0
- package/public/index.html +1 -1
- package/server.mjs +452 -155
package/lib/agent-process.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/* JSON-line subprocess transport. Requests and notifications never outlive their process. */
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import readline from 'node:readline';
|
|
4
5
|
export class AgentProcess {
|
|
@@ -21,7 +22,9 @@ export class AgentProcess {
|
|
|
21
22
|
});
|
|
22
23
|
this.proc.stderr.on('data', d => { this.stderr = (this.stderr + d).slice(-2000); onStderr(String(d)); });
|
|
23
24
|
this.proc.stdin.on('error', e => this.end(e));
|
|
24
|
-
|
|
25
|
+
// spawn reports a missing working folder as the binary's ENOENT: name the folder instead
|
|
26
|
+
this.proc.on('error', e => this.end(e.code === 'ENOENT' && cwd && !fs.existsSync(cwd)
|
|
27
|
+
? new Error(`The deck folder ${cwd} is not there any more (moved or deleted).`) : e));
|
|
25
28
|
this.proc.on('exit', (code, signal) => this.end(new Error(`Agent stopped (${signal || code}). ${this.stderr.trim()}`)));
|
|
26
29
|
}
|
|
27
30
|
write(value) { if (this.closed) throw new Error('Agent is not running.'); this.proc.stdin.write(JSON.stringify(value) + '\n'); }
|
package/lib/agent-tools.mjs
CHANGED
|
@@ -25,8 +25,11 @@ export function createDeckTools({ deckDir, readDirs = [], cliFile, env, pdfTextF
|
|
|
25
25
|
const roots = () => [deck, ...readDirs.flatMap(d => { try { return [fs.realpathSync(d)]; } catch { return []; } })];
|
|
26
26
|
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
27
27
|
const envNow = () => (typeof env === 'function' ? env() : env);
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// compared regardless of case and Unicode form: on macOS's usual volumes .GIT/config and BRAND.CSS are the
|
|
29
|
+
// locked files themselves
|
|
30
|
+
const fold = p => p.normalize('NFC').toLowerCase();
|
|
31
|
+
const isLocked = rel => (typeof locked === 'function' ? locked() : locked).map(fold)
|
|
32
|
+
.some(p => p.endsWith('/**') ? fold(rel) === p.slice(0, -3) || fold(rel).startsWith(p.slice(0, -2)) : fold(rel) === p);
|
|
30
33
|
function checked(file, write = false) {
|
|
31
34
|
if (typeof file !== 'string' || !file || file.includes('\0')) throw new Error('Invalid file path.');
|
|
32
35
|
const full = path.resolve(deck, file);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Asset roots: folders outside the deck whose files the deck may use, from --asset-root (repeatable) and
|
|
2
|
+
"assetRoots" in config.json. Shared by the front, which lists their decks, and each deck server, which
|
|
3
|
+
serves their files, so both read them alike. */
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
10
|
+
|
|
11
|
+
/* { roots, missing }: absolute paths, each once, in the order given; missing: those that are not folders */
|
|
12
|
+
export function assetRoots(args = [], config = {}) {
|
|
13
|
+
const roots = [], missing = [];
|
|
14
|
+
for (const r of [...args, ...(Array.isArray(config?.assetRoots) ? config.assetRoots : [])]) {
|
|
15
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
16
|
+
if (roots.includes(abs) || missing.includes(abs)) continue;
|
|
17
|
+
let dir = false;
|
|
18
|
+
try { dir = fs.statSync(abs).isDirectory(); } catch {}
|
|
19
|
+
(dir ? roots : missing).push(abs);
|
|
20
|
+
}
|
|
21
|
+
return { roots, missing };
|
|
22
|
+
}
|
package/lib/claude.mjs
CHANGED
|
@@ -12,11 +12,19 @@ export class ClaudeSession {
|
|
|
12
12
|
if (this.running) return;
|
|
13
13
|
this.stopped = false;
|
|
14
14
|
const env = typeof this.env === 'function' ? this.env() : this.env;
|
|
15
|
+
/* The sign-in check: "not signed in" only when the CLI says so. A CLI without `auth status --json` (older
|
|
16
|
+
than PinDeck's tested versions) or with other output starts anyway; the session then reports any problem. */
|
|
17
|
+
let signedIn = null;
|
|
15
18
|
try {
|
|
16
19
|
const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
|
|
17
20
|
{ env: { ...process.env, ...env }, timeout: 15000, maxBuffer: 65536 });
|
|
18
|
-
|
|
19
|
-
} catch (e) {
|
|
21
|
+
signedIn = JSON.parse(stdout).loggedIn;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e.code === 'ENOENT') throw new Error('Claude CLI is not installed (or set CLAUDE_BIN).');
|
|
24
|
+
if (e.killed) throw new Error('Claude did not answer its sign-in check within 15 s. Try again (Refresh models).');
|
|
25
|
+
try { signedIn = JSON.parse(e.stdout).loggedIn; } catch {} // signed out: the CLI answers, and exits 1
|
|
26
|
+
}
|
|
27
|
+
if (signedIn === false) throw new Error('Claude is not signed in. Run claude auth login, then refresh models.');
|
|
20
28
|
if (this.stopped) throw new Error('Agent stopped.');
|
|
21
29
|
const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env,
|
|
22
30
|
args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
|
package/lib/deck-files.mjs
CHANGED
|
@@ -57,17 +57,19 @@ export function revision(dir, files) {
|
|
|
57
57
|
|
|
58
58
|
/* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
|
|
59
59
|
time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
|
|
60
|
-
export/ folders are left out; so are symlinks, which point elsewhere
|
|
60
|
+
export/ folders are left out; so are symlinks, which point elsewhere, and the paths in skip (other decks
|
|
61
|
+
in the folder: their files and folders, as paths relative to dir). */
|
|
61
62
|
const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
|
|
62
63
|
export const KEEP_BYTES = 2 * 1024 * 1024;
|
|
63
|
-
export function scanFiles(dir, maxBytes = KEEP_BYTES) {
|
|
64
|
-
const files = new Map();
|
|
64
|
+
export function scanFiles(dir, { maxBytes = KEEP_BYTES, skip = [] } = {}) {
|
|
65
|
+
const files = new Map(), skipped = new Set(skip);
|
|
65
66
|
const walk = (rel) => {
|
|
66
67
|
let entries = [];
|
|
67
68
|
try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
|
|
68
69
|
for (const e of entries) {
|
|
69
70
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
70
71
|
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
72
|
+
if (skipped.has(r)) continue;
|
|
71
73
|
if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
|
|
72
74
|
if (!e.isFile()) continue;
|
|
73
75
|
try {
|
package/lib/deck-list.mjs
CHANGED
|
@@ -11,18 +11,36 @@ const SKIP = new Set(["node_modules", "export"]); // dependencies, and PinDeck
|
|
|
11
11
|
const MAX_FILES = 300;
|
|
12
12
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
13
13
|
|
|
14
|
+
// symlinks count as what they point to
|
|
15
|
+
const kind = (dir, e) => { if (!e.isSymbolicLink()) return e; try { return fs.statSync(path.join(dir, e.name)); } catch { return e; } };
|
|
14
16
|
const htmlFiles = (dir) => {
|
|
15
17
|
try {
|
|
16
18
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
17
|
-
.filter((e) =>
|
|
19
|
+
.filter((e) => /\.html$/i.test(e.name) && !e.name.startsWith(".") && kind(dir, e).isFile()).map((e) => e.name).sort();
|
|
18
20
|
} catch { return []; }
|
|
19
21
|
};
|
|
20
22
|
const subfolders = (dir) => {
|
|
21
23
|
try {
|
|
22
24
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
23
|
-
.filter((e) =>
|
|
25
|
+
.filter((e) => !e.name.startsWith(".") && !SKIP.has(e.name) && kind(dir, e).isDirectory()).map((e) => e.name).sort();
|
|
24
26
|
} catch { return []; }
|
|
25
27
|
};
|
|
28
|
+
/* A deck file that is a symlink to a file in another folder cannot be opened: its relative paths (theme.css,
|
|
29
|
+
figures/) would be looked up beside the link, where the files are not. Why, or null. */
|
|
30
|
+
export function linkProblem(dir, file) {
|
|
31
|
+
const abs = path.join(dir, file);
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.lstatSync(abs).isSymbolicLink()) return null;
|
|
34
|
+
const real = fs.realpathSync(abs);
|
|
35
|
+
if (path.dirname(real) === fs.realpathSync(dir)) return null;
|
|
36
|
+
return `${file} is a link to a deck in another folder: open that deck itself (${real}).`;
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* What each file's check found, by path, while its size and modification time stay the same: listing is
|
|
41
|
+
asked for on every deck menu and every first request for a deck, and reading every deck each time held up
|
|
42
|
+
the front, which passes on all decks' requests. */
|
|
43
|
+
const checked = new Map();
|
|
26
44
|
|
|
27
45
|
/* [{ key, dir, file, rel, root, problem }]: rel is the path shown in the menu, relative to its root;
|
|
28
46
|
problem is null for a deck PinDeck can open. A file under several roots is listed once, under the first. */
|
|
@@ -36,9 +54,14 @@ export function listDecks(roots) {
|
|
|
36
54
|
seen.add(abs);
|
|
37
55
|
let problem = null;
|
|
38
56
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
const st = fs.statSync(abs), known = checked.get(abs);
|
|
58
|
+
if (known && known.mtimeMs === st.mtimeMs && known.size === st.size) problem = known.problem;
|
|
59
|
+
else {
|
|
60
|
+
problem = st.size > MAX_BYTES ? `${file} is larger than 5 MB, too large for a deck.`
|
|
61
|
+
: unsupportedMessage(fs.readFileSync(abs, "utf8"), file)?.replace(/ Nothing was changed\.$/, "") || null;
|
|
62
|
+
checked.set(abs, { mtimeMs: st.mtimeMs, size: st.size, problem });
|
|
63
|
+
}
|
|
64
|
+
problem = linkProblem(dir, file) || problem;
|
|
42
65
|
} catch (e) { problem = `${file} cannot be read (${e.code || e.message}).`; }
|
|
43
66
|
const t = deckTarget(file === "index.html" ? dir : abs);
|
|
44
67
|
out.push({ key: t.key, dir, file, rel: path.relative(root, abs).split(path.sep).join("/"), root, problem });
|
package/lib/deck-target.mjs
CHANGED
|
@@ -8,15 +8,33 @@ import crypto from "node:crypto";
|
|
|
8
8
|
|
|
9
9
|
export const homeDir = (env = process.env) => env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
|
|
10
10
|
|
|
11
|
-
/*
|
|
11
|
+
/* PinDeck 2 keeps its chats and settings in files of the same names, in other formats: the two versions in one
|
|
12
|
+
folder (one PINDECK_HOME for both) overwrite each other's. PinDeck 3 saves a provider with every chat and
|
|
13
|
+
PinDeck 2 never does, so a chat without one was written by version 2: copied over (which works), or written
|
|
14
|
+
by a PinDeck 2 that uses this folder too (which does not). Returns the warning, or null. */
|
|
15
|
+
export function homeWarning(home) {
|
|
16
|
+
let decks = [];
|
|
17
|
+
try { decks = fs.readdirSync(path.join(home, "decks")).slice(0, 500); } catch { return null; }
|
|
18
|
+
for (const d of decks) {
|
|
19
|
+
let chat;
|
|
20
|
+
try { chat = JSON.parse(fs.readFileSync(path.join(home, "decks", d, "chat.json"), "utf8")); } catch { continue; }
|
|
21
|
+
if (chat && typeof chat === "object" && !("provider" in chat))
|
|
22
|
+
return `${home} holds a chat written by PinDeck 2 (decks/${d}/chat.json). If PinDeck 2 uses this folder too, ` +
|
|
23
|
+
`give PinDeck 3 another one (PINDECK_HOME; by default ~/.pindeck-v3): the two versions overwrite each ` +
|
|
24
|
+
`other's settings and chats. Chats copied over from PinDeck 2 are fine.`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* A file (talk.html), or a folder, which means its index.html. A path ending in .html that is
|
|
12
30
|
not a folder is a file, so a missing file gets a clear message. */
|
|
13
31
|
export function deckTarget(target) {
|
|
14
32
|
const abs = path.resolve(target);
|
|
15
33
|
const isFolder = (() => { try { return fs.statSync(abs).isDirectory(); } catch { return false; } })();
|
|
16
|
-
const dir = isFolder || !/\.html
|
|
34
|
+
const dir = isFolder || !/\.html$/i.test(abs) ? abs : path.dirname(abs);
|
|
17
35
|
const file = dir === abs ? "index.html" : path.basename(abs);
|
|
18
36
|
const named = file !== "index.html"; // a deck file with its own name; several can share a folder
|
|
19
|
-
const name = named ? file.replace(/\.html
|
|
37
|
+
const name = named ? file.replace(/\.html$/i, "") : path.basename(dir); // for exports and titles
|
|
20
38
|
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
21
39
|
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
22
40
|
const key = (named ? `${path.basename(dir)}-${name}` : path.basename(dir)).replace(/[^\w.-]+/g, "_") + "-" +
|
package/lib/eids.mjs
CHANGED
|
@@ -276,7 +276,8 @@ function elementName(node) {
|
|
|
276
276
|
export function assignEids(html) {
|
|
277
277
|
const tree = parse(html);
|
|
278
278
|
const sections = findSlides(tree);
|
|
279
|
-
const
|
|
279
|
+
const sectionSet = new Set(sections);
|
|
280
|
+
const isSlide = slideSelector(tree) ? ((c) => sectionSet.has(c)) : ((c) => c.name === "section");
|
|
280
281
|
const used = new Set();
|
|
281
282
|
const edits = []; // { at, insert } or { start, end, replace }
|
|
282
283
|
const assigned = [], renamed = [];
|
|
@@ -286,9 +287,12 @@ export function assignEids(html) {
|
|
|
286
287
|
const mainCount = {};
|
|
287
288
|
titles.forEach((t) => { if (t.main) mainCount[t.main] = (mainCount[t.main] || 0) + 1; });
|
|
288
289
|
|
|
290
|
+
// each base remembers the next number to try: thousands of "…/li" stay linear, not quadratic
|
|
291
|
+
const next = new Map();
|
|
289
292
|
const unique = (base) => {
|
|
290
|
-
let name = base, k = 2;
|
|
293
|
+
let name = base, k = next.get(base) || 2;
|
|
291
294
|
while (used.has(name)) name = `${base}-${k++}`;
|
|
295
|
+
next.set(base, k);
|
|
292
296
|
used.add(name);
|
|
293
297
|
return name;
|
|
294
298
|
};
|
|
@@ -344,10 +348,13 @@ export function assignEids(html) {
|
|
|
344
348
|
});
|
|
345
349
|
|
|
346
350
|
if (!edits.length) return { html, changed: false, assigned, renamed, slides };
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
+
// in one pass over the file, not one copy of it per edit
|
|
352
|
+
edits.sort((a, b) => a.start - b.start);
|
|
353
|
+
const parts = [];
|
|
354
|
+
let pos = 0;
|
|
355
|
+
for (const e of edits) { parts.push(html.slice(pos, e.start), e.replace); pos = e.end; }
|
|
356
|
+
parts.push(html.slice(pos));
|
|
357
|
+
return { html: parts.join(""), changed: true, assigned, renamed, slides };
|
|
351
358
|
}
|
|
352
359
|
|
|
353
360
|
/* ---- Inline style edits (drag and resize in the editor) ---------------------------------- */
|
|
@@ -406,6 +413,8 @@ export function setStyles(html, edits) {
|
|
|
406
413
|
/* The element with this id, or a descendant reached by element-child indices (path), e.g. a
|
|
407
414
|
table cell [2] under a row that has the id. */
|
|
408
415
|
function nodeAt(html, eid, path = [], { anyKind = false } = {}) {
|
|
416
|
+
// an id and a path of child positions, or nothing (an element without an id must never match a missing one)
|
|
417
|
+
if (typeof eid !== "string" || !eid || !Array.isArray(path) || !path.every((i) => Number.isInteger(i) && i >= 0)) return null;
|
|
409
418
|
let node = null;
|
|
410
419
|
walk(parse(html), (c) => { if (node) return false; if (c.attrs?.["data-eid"]?.value === eid) { node = c; return false; } });
|
|
411
420
|
for (const i of path) node = node?.children[i] || null;
|
package/lib/login.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
share cookies across all ports of a host, so the cookie name carries the server's port. */
|
|
7
7
|
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
9
10
|
|
|
10
11
|
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
11
12
|
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
@@ -50,8 +51,10 @@ export function createLogin({ token, port, origin }) {
|
|
|
50
51
|
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
51
52
|
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
52
53
|
}
|
|
54
|
+
// a path on this server only: browsers drop tabs and line breaks from a URL, so "/\t/site" means "//site"
|
|
53
55
|
let next = url.searchParams.get("next") || "/";
|
|
54
|
-
if (!next.startsWith("/") || next
|
|
56
|
+
if (!next.startsWith("/") || /[\\\x00-\x1f\x7f]/.test(next) || next.startsWith("//") ||
|
|
57
|
+
new URL(next, "http://127.0.0.1").host !== "127.0.0.1") next = "/";
|
|
55
58
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
56
59
|
"set-cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/` });
|
|
57
60
|
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
@@ -59,3 +62,14 @@ export function createLogin({ token, port, origin }) {
|
|
|
59
62
|
}
|
|
60
63
|
return { loginCode, loginUrl, cookieName, authed, login };
|
|
61
64
|
}
|
|
65
|
+
|
|
66
|
+
/* On a Mac, in a terminal: Enter opens a new login link in the default browser (open(1)), so the printed link
|
|
67
|
+
need not be copied. Each Enter asks for a new link: a link works once. Elsewhere (a server reached through an
|
|
68
|
+
SSH tunnel, a test, a pipe) nothing changes. */
|
|
69
|
+
export function openOnEnter(newLink, { platform = process.platform, input = process.stdin, open = (url) => execFile("open", [url], () => {}) } = {}) {
|
|
70
|
+
if (platform !== "darwin" || !input.isTTY) return false;
|
|
71
|
+
console.log(" Press Enter to open PinDeck in your browser.");
|
|
72
|
+
input.setEncoding?.("utf8");
|
|
73
|
+
input.on("data", (d) => { if (/[\r\n]/.test(String(d))) open(newLink()); });
|
|
74
|
+
return true;
|
|
75
|
+
}
|
package/lib/models.mjs
CHANGED
|
@@ -13,10 +13,13 @@ export function normalizeModels(provider, rows = []) {
|
|
|
13
13
|
isDefault: m.isDefault || id === 'default' }];
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
|
+
/* Saved settings as PinDeck uses them. A model only for the provider it was saved with: without one, Claude
|
|
17
|
+
gets Sonnet and Codex the backend's default ('' until the model list is known). */
|
|
16
18
|
export function migrateSettings(raw = {}) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const provider = PROVIDERS.includes(raw?.provider) ? raw.provider : 'claude';
|
|
20
|
+
return { provider,
|
|
21
|
+
model: typeof raw?.model === 'string' && raw.model ? raw.model : provider === 'claude' ? 'sonnet' : '',
|
|
22
|
+
effort: typeof raw?.effort === 'string' ? raw.effort : null };
|
|
20
23
|
}
|
|
21
24
|
export function selectModel(catalog, current, requested, preferences = {}) {
|
|
22
25
|
const provider = requested.provider ?? current.provider;
|
|
@@ -36,7 +39,10 @@ export function selectModel(catalog, current, requested, preferences = {}) {
|
|
|
36
39
|
}
|
|
37
40
|
/* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
|
|
38
41
|
export function handoverSummary(chat) {
|
|
39
|
-
|
|
42
|
+
// the conversation since it last started fresh; nothing to hand over: no summary
|
|
43
|
+
const since = chat.slice(chat.findLastIndex(e => e.fresh) + 1);
|
|
44
|
+
const recent = since.filter(e => ['user', 'assistant'].includes(e.kind) && e.text).slice(-24);
|
|
45
|
+
if (!recent.length) return '';
|
|
40
46
|
return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
|
|
41
47
|
recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
|
|
42
48
|
}
|
package/lib/pdf.mjs
CHANGED
|
@@ -25,8 +25,24 @@ export const PDF_HEAD_TAG = `<script>window.requestAnimationFrame = function (cb
|
|
|
25
25
|
export const PDF_PAGE_TAG = `<style>@media print { * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } }</style>
|
|
26
26
|
</body>`;
|
|
27
27
|
|
|
28
|
+
// Chrome refuses to run as root with its sandbox (containers, some servers); it says so, and needs --no-sandbox.
|
|
29
|
+
// Without --disable-field-trial-config a headless Chrome can wait forever for its field-trial setup (seen
|
|
30
|
+
// behind a proxy); PinDeck needs none of those experiments.
|
|
31
|
+
export const ROOT_ARGS = ["--disable-field-trial-config", ...(process.getuid?.() === 0 ? ["--no-sandbox"] : [])];
|
|
28
32
|
const BASE = ["--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check",
|
|
29
|
-
"--disable-extensions", "--mute-audio", "--hide-scrollbars"];
|
|
33
|
+
"--disable-extensions", "--mute-audio", "--hide-scrollbars", ...ROOT_ARGS];
|
|
34
|
+
|
|
35
|
+
/* Why Chrome stopped, from what it printed: its last error line (such as "Running as root without
|
|
36
|
+
--no-sandbox is not supported"), without the log prefix. "" if it said nothing useful. */
|
|
37
|
+
export function chromeReason(stderr) {
|
|
38
|
+
// Chrome logs much that is harmless (display links, D-Bus, GPU, fonts): a fatal line first, then a plain reason
|
|
39
|
+
const noise = /CVDisplayLink|dbus|gcm|fontconfig|gpu|viz|gles|egl|vaapi|sandbox_linux|ssl_client_socket/i;
|
|
40
|
+
const lines = String(stderr).split("\n").filter((l) => l.trim() && !noise.test(l)).reverse();
|
|
41
|
+
const clean = (l) => l.replace(/^\[[^\]]*\]\s*/, "").trim().slice(0, 300);
|
|
42
|
+
const fatal = lines.find((l) => /:FATAL:/.test(l)), plain = lines.find((l) => /not supported|cannot|denied|no such|error/i.test(l));
|
|
43
|
+
return fatal ? clean(fatal) : plain ? clean(plain) : "";
|
|
44
|
+
}
|
|
45
|
+
const exited = (what, err) => new Error(`${what}${chromeReason(err) ? `: ${chromeReason(err)}` : "."}`);
|
|
30
46
|
|
|
31
47
|
/* Runs Chrome with args, stops it once done(stdout, stderr) is true (Chrome often lingers). */
|
|
32
48
|
function chromeRun(chrome, args, profile, done, timeout) {
|
|
@@ -47,7 +63,7 @@ function chromeRun(chrome, args, profile, done, timeout) {
|
|
|
47
63
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
48
64
|
proc.on("error", (e) => finish(e));
|
|
49
65
|
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
50
|
-
proc.on("close", () => (done(out, err) ? finish(null, out) : finish(
|
|
66
|
+
proc.on("close", () => (done(out, err) ? finish(null, out) : finish(exited("Chrome exited early", err))));
|
|
51
67
|
});
|
|
52
68
|
}
|
|
53
69
|
|
package/lib/servers.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/* The PinDecks running for this user, and which one serves a deck: shared by the front, the deck server,
|
|
2
|
+
`pindeck login` and cli.mjs.
|
|
3
|
+
|
|
4
|
+
Each PinDeck process (a front, a deck server on its own, or one behind a front) keeps a file of its own in
|
|
5
|
+
~/.pindeck-v3/servers/<pid>.json (0600, in a 0700 folder) with its address and token. Several fronts and
|
|
6
|
+
servers can run side by side (another --port, another PINDECK_HOME user, a server started on its own) and
|
|
7
|
+
none overwrites another's file; a file whose process is gone is removed when it is read. */
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const dirOf = (home) => path.join(home, "servers");
|
|
13
|
+
export const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
|
|
14
|
+
|
|
15
|
+
/* info: { url, token, front?, key?, deckDir?, behindFront? }. Returns the function that removes the file again
|
|
16
|
+
(only if it is still ours). */
|
|
17
|
+
export function registerServer(home, info) {
|
|
18
|
+
const dir = dirOf(home);
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
20
|
+
// written whole and private from the start: a reader (pindeck login, cli.mjs) never sees half of it
|
|
21
|
+
const file = path.join(dir, `${process.pid}.json`), tmp = path.join(dir, `.${process.pid}.json.tmp`);
|
|
22
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...info, pid: process.pid }, null, 2), { mode: 0o600 });
|
|
23
|
+
fs.chmodSync(tmp, 0o600);
|
|
24
|
+
fs.renameSync(tmp, file);
|
|
25
|
+
return () => { try { if (JSON.parse(fs.readFileSync(file, "utf8")).token === info.token) fs.rmSync(file, { force: true }); } catch {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* The running ones, the newest first */
|
|
29
|
+
export function runningServers(home) {
|
|
30
|
+
let names = [];
|
|
31
|
+
try { names = fs.readdirSync(dirOf(home)).filter((f) => /^\d+\.json$/.test(f)); } catch {}
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const file = path.join(dirOf(home), name);
|
|
35
|
+
try {
|
|
36
|
+
const info = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
37
|
+
if (alive(info.pid)) { out.push({ ...info, since: fs.statSync(file).mtimeMs }); continue; }
|
|
38
|
+
} catch {}
|
|
39
|
+
try { fs.rmSync(file, { force: true }); } catch {}
|
|
40
|
+
}
|
|
41
|
+
return out.sort((a, b) => b.since - a.since);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ask = async (s, p) => {
|
|
45
|
+
const res = await fetch(`${s.url}${p}`, { headers: { "x-editor-token": s.token }, signal: AbortSignal.timeout(5000) });
|
|
46
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
47
|
+
return res.json();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
51
|
+
|
|
52
|
+
/* Who serves a deck (a deckTarget): { front, key } a front whose deck list has it (open or not), under that
|
|
53
|
+
front's key for it, and/or { own } its deck server if one runs (on its own, or behind a front). Decks are
|
|
54
|
+
compared by real path: the same folder can be named through a symlink (on macOS a terminal in /var/... is
|
|
55
|
+
in /private/var/...). null: no running PinDeck serves it. */
|
|
56
|
+
export async function serverFor(home, deck) {
|
|
57
|
+
const all = runningServers(home), file = real(path.join(deck.dir, deck.file));
|
|
58
|
+
const own = all.find((s) => !s.front && (s.key === deck.key || real(path.join(s.deckDir, s.deckFile || "index.html")) === file)) || null;
|
|
59
|
+
for (const f of all.filter((s) => s.front)) {
|
|
60
|
+
try {
|
|
61
|
+
const { decks } = await ask(f, "/api/decks");
|
|
62
|
+
const d = decks.find((x) => !x.problem && (x.key === deck.key || real(path.join(x.root, x.rel)) === file));
|
|
63
|
+
if (d) return { front: f, key: d.key, own };
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
return own ? { front: null, key: own.key, own } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* pindeck login [deck]: a new one-time login link, from the PinDeck that serves the deck (the link then opens
|
|
70
|
+
it), or from each PinDeck running when no deck is named. Prints and exits. */
|
|
71
|
+
export async function printLogin(home, deck) {
|
|
72
|
+
let targets;
|
|
73
|
+
if (deck) {
|
|
74
|
+
const found = await serverFor(home, deck);
|
|
75
|
+
const shown = path.join(deck.dir, deck.file);
|
|
76
|
+
if (!found || (!found.front && found.own.behindFront)) {
|
|
77
|
+
console.error(`No running PinDeck serves ${shown}.` + (runningServers(home).length ? " Open it from a running PinDeck's deck menu, or start PinDeck for it." : ""));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
targets = [found.front ? { s: found.front, next: `/d/${found.key}/`, label: shown } : { s: found.own, next: "/", label: shown }];
|
|
81
|
+
} else {
|
|
82
|
+
targets = runningServers(home).filter((s) => !s.behindFront).map((s) => ({ s, next: "/", label: s.deckDir }));
|
|
83
|
+
if (!targets.length) { console.error("No PinDeck is running."); process.exit(1); }
|
|
84
|
+
}
|
|
85
|
+
let ok = true;
|
|
86
|
+
for (const { s, next, label } of targets) {
|
|
87
|
+
try { console.log(`PinDeck (${label}): ${(await ask(s, "/api/login-link?" + new URLSearchParams({ next }))).url}`); }
|
|
88
|
+
catch { ok = false; console.error(`The PinDeck at ${s.url} (${label}) does not answer. Is it still running?`); }
|
|
89
|
+
}
|
|
90
|
+
process.exit(ok ? 0 : 1);
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0-beta.1",
|
|
4
4
|
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
package/public/bridge.js
CHANGED
|
@@ -19,7 +19,17 @@
|
|
|
19
19
|
const SLIDE_SEL = deckMeta("slides") || null;
|
|
20
20
|
const SLIDE = SLIDE_SEL ? `:is(${SLIDE_SEL})` : ".reveal .slides section"; // in CSS and closest()
|
|
21
21
|
const SLIDE_OF = SLIDE_SEL || "section"; // the slide an element is on
|
|
22
|
-
|
|
22
|
+
/* The editor, reached by messages: the deck runs sandboxed in an origin of its own (see VIEW in server.mjs),
|
|
23
|
+
so it cannot touch the editor page. Every call answers with a promise of the editor's return value. */
|
|
24
|
+
const PARENT = window.parent !== window ? window.parent : null;
|
|
25
|
+
let callSeq = 0;
|
|
26
|
+
const answers = new Map(); // id -> resolve
|
|
27
|
+
const hostCalls = new Proxy({}, { get: (_, method) => method === "then" ? undefined : (...args) => new Promise((resolve) => {
|
|
28
|
+
const id = ++callSeq;
|
|
29
|
+
answers.set(id, resolve);
|
|
30
|
+
PARENT.postMessage({ pindeck: "host", id, method, args }, "*");
|
|
31
|
+
}) });
|
|
32
|
+
const host = () => (PARENT ? hostCalls : null);
|
|
23
33
|
let W = 1280, H = 720, deck = null; // deck: reveal.js or a selector deck (see "Decks" below)
|
|
24
34
|
|
|
25
35
|
/* ---- Styles for editor-only overlays ---------------------------------------- */
|
|
@@ -128,7 +138,8 @@
|
|
|
128
138
|
hasFooter: !!sec.querySelector(".slide-foot"), hidden };
|
|
129
139
|
}
|
|
130
140
|
function info() {
|
|
131
|
-
|
|
141
|
+
// hash: the slide's address in the deck opened on its own (Present), where hidden slides are left out
|
|
142
|
+
return { slides: slides().map((s, i) => ({ ...slideInfo(s, i), hash: deck.hash(i) })), current: indexOf(deck.current()) };
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
/* ---- Layout measurement ------------------------------------------------------------- */
|
|
@@ -801,12 +812,22 @@
|
|
|
801
812
|
tail.setStart(r.startContainer, r.startOffset);
|
|
802
813
|
const subList = [...el.children].find((c) => c.tagName === "UL" || c.tagName === "OL");
|
|
803
814
|
if (subList) tail.setEndBefore(subList); else tail.setEnd(el, el.childNodes.length);
|
|
815
|
+
// the elements the caret is in (an equation, a <strong>): the split cuts each in two, and extractContents
|
|
816
|
+
// copies them, attributes and all, into the new bullet
|
|
817
|
+
const cut = [];
|
|
818
|
+
for (let n = r.startContainer; n && n !== el; n = n.parentNode) if (n.nodeType === 1) cut.unshift(n);
|
|
804
819
|
const frag = tail.extractContents();
|
|
805
820
|
const li = document.createElement("li");
|
|
806
821
|
const cls = [...el.classList].filter((c) => !c.startsWith("__") && c !== "visible" && c !== "current-fragment").join(" ");
|
|
807
822
|
if (cls) li.className = cls;
|
|
808
823
|
li.appendChild(frag);
|
|
809
824
|
el.after(li);
|
|
825
|
+
// their copies: no id of their own (the server gives new ones), and gone where the split left them
|
|
826
|
+
// empty, on either side (no empty <strong> or equation line left behind)
|
|
827
|
+
const copies = [];
|
|
828
|
+
for (let n = li.firstChild, i = 0; i < cut.length && n?.nodeType === 1 && n.tagName === cut[i].tagName; n = n.firstChild, i++) copies.push(n);
|
|
829
|
+
for (const n of copies) n.removeAttribute("data-eid");
|
|
830
|
+
for (const n of [...cut, ...copies].reverse()) if (n.isConnected && isBlank(n)) n.remove();
|
|
810
831
|
// no stray spaces at the split
|
|
811
832
|
const trim = (node, end) => {
|
|
812
833
|
const w = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
|
@@ -825,8 +846,10 @@
|
|
|
825
846
|
teardownEdit(ed);
|
|
826
847
|
host()?.editingChanged(false);
|
|
827
848
|
chainDirty = true;
|
|
828
|
-
|
|
829
|
-
|
|
849
|
+
// whole: the bullet's text as typed, offered to copy if the save is refused
|
|
850
|
+
host()?.commitSplit({ eid: ed.eid, html, newHtml, newEid, cls, text: textOf(el).slice(0, 200),
|
|
851
|
+
whole: [textOf(el), textOf(li)].filter((t) => t.trim()).join(" ") })
|
|
852
|
+
.then((real) => { if (real && real !== newEid) li.dataset.eid = real; });
|
|
830
853
|
startEdit({ el: li, sec: ed.sec, eid: newEid, path: [], isNew: true }, null, null, { src: newHtml, caret: "start" });
|
|
831
854
|
}
|
|
832
855
|
|
|
@@ -853,7 +876,19 @@
|
|
|
853
876
|
slides: () => Reveal.getSlides(),
|
|
854
877
|
current: () => Reveal.getCurrentSlide(),
|
|
855
878
|
goto(index) { if (Reveal.getSlides()[index]) { const i = ix(index); Reveal.slide(i.h, i.v); } },
|
|
856
|
-
|
|
879
|
+
// counted without hidden slides, which the deck opened on its own leaves out (the editor shows them);
|
|
880
|
+
// a hidden slide opens at the next one shown
|
|
881
|
+
hash(index) {
|
|
882
|
+
const all = Reveal.getSlides(), shown = (s) => s.getAttribute("data-visibility") !== "hidden" &&
|
|
883
|
+
s.parentElement?.closest("section")?.getAttribute("data-visibility") !== "hidden";
|
|
884
|
+
const sec = [...all.slice(index), ...all.slice(0, index).reverse()].find(shown);
|
|
885
|
+
if (!sec) return "";
|
|
886
|
+
const stack = sec.parentElement.closest("section");
|
|
887
|
+
const sections = (el) => [...el.children].filter((c) => c.tagName === "SECTION" && shown(c));
|
|
888
|
+
const h = sections(Reveal.getSlidesElement()).indexOf(stack || sec);
|
|
889
|
+
const v = stack ? sections(stack).indexOf(sec) : 0;
|
|
890
|
+
return `#/${h}${v ? "/" + v : ""}`;
|
|
891
|
+
},
|
|
857
892
|
size: () => ({ width: Reveal.getConfig().width, height: Reveal.getConfig().height }),
|
|
858
893
|
scale: () => Reveal.getScale() || 1,
|
|
859
894
|
on: (ev, fn) => Reveal.on(ev === "slide" ? "slidechanged" : ev, fn),
|
|
@@ -1060,7 +1095,16 @@
|
|
|
1060
1095
|
return measureSection(slides()[index]);
|
|
1061
1096
|
},
|
|
1062
1097
|
};
|
|
1063
|
-
window
|
|
1098
|
+
// the editor's calls: from the parent window only, answered with the result
|
|
1099
|
+
if (PARENT) window.addEventListener("message", async (e) => {
|
|
1100
|
+
const m = e.data;
|
|
1101
|
+
if (e.source !== PARENT || !m || typeof m !== "object") return;
|
|
1102
|
+
if (m.pindeck === "host-answer") { answers.get(m.id)?.(m.value); answers.delete(m.id); return; }
|
|
1103
|
+
if (m.pindeck !== "bridge" || !Object.hasOwn(api, m.method)) return;
|
|
1104
|
+
let value = null;
|
|
1105
|
+
try { value = await api[m.method](...(m.args || [])); } catch (err) { console.error(err); }
|
|
1106
|
+
PARENT.postMessage({ pindeck: "bridge-answer", id: m.id, value: value ?? null }, "*");
|
|
1107
|
+
});
|
|
1064
1108
|
|
|
1065
1109
|
if (MODE === "main") {
|
|
1066
1110
|
document.addEventListener("pointerdown", onPointerDown, true);
|
|
@@ -1129,13 +1173,18 @@
|
|
|
1129
1173
|
if (startAt) goto(startAt);
|
|
1130
1174
|
deck.keys();
|
|
1131
1175
|
}
|
|
1132
|
-
host()?.bridgeReady(MODE,
|
|
1176
|
+
host()?.bridgeReady(MODE, info());
|
|
1133
1177
|
}
|
|
1134
1178
|
if (SLIDE_SEL) {
|
|
1135
1179
|
deck = selectorDeck();
|
|
1136
1180
|
deck.ready(() => (MODE === "pdf" ? deck.print() : start()));
|
|
1137
1181
|
return;
|
|
1138
1182
|
}
|
|
1183
|
+
if (typeof Reveal === "undefined" && (MODE === "main" || MODE === "measure")) {
|
|
1184
|
+
// nothing to edit: the editor says why, instead of a blank stage (and stops waiting for measurements)
|
|
1185
|
+
host()?.deckFailed(MODE, "reveal.js did not load: the deck's script tag for it points to a file or address that cannot be reached.");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1139
1188
|
if (typeof Reveal === "undefined" || MODE === "pdf") return; // reveal.js lays out its own print view
|
|
1140
1189
|
deck = revealDeck();
|
|
1141
1190
|
deck.ready(start);
|
package/public/editor.css
CHANGED
|
@@ -60,12 +60,14 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
|
|
|
60
60
|
.toolbar .group { display: flex; align-items: center; gap: 6px; min-width: 0; flex: none; }
|
|
61
61
|
.toolbar .group:first-child { flex: 1 1 auto; }
|
|
62
62
|
.toolbar .group:first-child > :not(.deck-wrap) { flex: none; }
|
|
63
|
+
/* narrow: the deck's name is shortened, never hidden (it opens the deck menu) */
|
|
63
64
|
@container (max-width: 640px) {
|
|
64
|
-
.toolbar kbd
|
|
65
|
+
.toolbar kbd { display: none; }
|
|
66
|
+
.toolbar .deck-wrap { max-width: 9em; }
|
|
65
67
|
}
|
|
66
68
|
.pos { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 52px; text-align: center; }
|
|
67
69
|
/* the deck's name; behind the front (several decks on one port) it opens the deck menu */
|
|
68
|
-
.deck-wrap { min-width: 0; margin-left: 2px; }
|
|
70
|
+
.deck-wrap { min-width: 0; max-width: 12em; margin-left: 2px; }
|
|
69
71
|
.deck-btn { display: flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0; padding: 2px 6px;
|
|
70
72
|
border: 0; background: none; color: inherit; font: inherit; }
|
|
71
73
|
.deck-btn:hover { background: none; }
|
|
@@ -161,6 +163,10 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
|
|
|
161
163
|
.stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--stage); }
|
|
162
164
|
.stage iframe.loading { visibility: hidden; }
|
|
163
165
|
.stage iframe.measure { inset: auto; left: -20000px; top: 0; width: 1280px; height: 720px; visibility: hidden; }
|
|
166
|
+
/* a deck page that does not start: why, over the empty stage */
|
|
167
|
+
.stage-problem { position: absolute; inset: 0; z-index: 4; display: flex; flex-direction: column; justify-content: center; gap: 8px;
|
|
168
|
+
max-width: 34em; margin: auto; padding: 24px; color: var(--muted, #555); font-size: 14px; line-height: 1.45; text-align: center; }
|
|
169
|
+
.stage-problem b { color: inherit; font-size: 15px; }
|
|
164
170
|
|
|
165
171
|
/* ---- Speaker notes under the slide ---- */
|
|
166
172
|
.notes-panel { position: relative; flex: none; display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--line); }
|