pindeck 3.1.0-beta.1 → 3.2.0

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/front.mjs CHANGED
@@ -16,15 +16,16 @@
16
16
 
17
17
  import http from "node:http";
18
18
  import fs from "node:fs";
19
- import os from "node:os";
20
19
  import path from "node:path";
21
20
  import crypto from "node:crypto";
22
21
  import { spawn } from "node:child_process";
23
22
  import { fileURLToPath } from "node:url";
24
23
  import { isSupportedPlatform, detectCapabilities, publicCapabilities, parsePort } from "./lib/capabilities.mjs";
25
- import { deckTarget, homeDir } from "./lib/deck-target.mjs";
26
- import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
24
+ import { deckTarget, homeDir, homeWarning } from "./lib/deck-target.mjs";
25
+ import { goodHost, LOGIN_PAGE, createLogin, openOnEnter } from "./lib/login.mjs";
27
26
  import { listDecks } from "./lib/deck-list.mjs";
27
+ import { assetRoots } from "./lib/asset-roots.mjs";
28
+ import { registerServer, printLogin } from "./lib/servers.mjs";
28
29
  import { USAGE } from "./lib/usage.mjs";
29
30
 
30
31
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -48,38 +49,24 @@ if (!isSupportedPlatform(process.platform)) {
48
49
  process.exit(0);
49
50
  }
50
51
 
51
- /* pindeck login: a new one-time login link from the running front, asked with the token in its server file
52
- (readable only by this user). With a deck, the link opens that deck. A deck server started on its own
53
- (node server.mjs) for that deck answers instead, as before the front. */
54
- const SERVER_FILE = path.join(HOME_DIR, "server.json");
55
- if (argv[0] === "login") {
56
- const deck = argv[1] ? deckTarget(argv[1]) : null;
57
- let info = null;
58
- for (const f of [...(deck ? [path.join(HOME_DIR, "decks", deck.key, "server.json")] : []), SERVER_FILE]) {
59
- try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {}
60
- }
61
- if (!info) { console.error("No PinDeck is running."); process.exit(1); }
62
- const next = deck && info.front ? `/d/${deck.key}/` : "/";
63
- try {
64
- const res = await fetch(`${info.url}/api/login-link?` + new URLSearchParams({ next }), { headers: { "x-editor-token": info.token } });
65
- if (!res.ok) throw new Error(res.status);
66
- console.log(`PinDeck (${deck ? path.join(deck.dir, deck.file) : info.deckDir}): ${(await res.json()).url}`);
67
- process.exit(0);
68
- } catch {
69
- console.error(`The PinDeck at ${info.url} does not answer. Is it still running?`);
70
- process.exit(1);
71
- }
72
- }
52
+ /* pindeck login: a new one-time login link from the PinDeck that serves the deck (see lib/servers.mjs), asked
53
+ with the token in its server file (readable only by this user). With a deck, the link opens that deck. */
54
+ if (argv[0] === "login") await printLogin(HOME_DIR, argv[1] ? deckTarget(argv[1]) : null);
73
55
 
74
56
  /* The start deck, and the folders whose decks the menu offers. A folder without index.html opens the list
75
57
  of its decks; if it has none, its deck server says why, as for any deck that cannot be opened. */
58
+ const homeNote = homeWarning(HOME_DIR);
59
+ if (homeNote) console.warn(homeNote);
76
60
  const START = deckTarget(argv[0] || process.cwd());
77
- const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
61
+ if (process.env.CLAUDE_MODEL) // PinDeck 2 read it
62
+ console.warn(`CLAUDE_MODEL is not used by PinDeck 3: choose the model in the editor's model menu, or start with --model ${process.env.CLAUDE_MODEL}.`);
78
63
  let fileConfig = {};
79
64
  try { fileConfig = JSON.parse(fs.readFileSync(path.join(HOME_DIR, "config.json"), "utf8")); } catch {}
80
- const ROOTS = [START.dir, ...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])]
81
- .map((r) => path.resolve(expandHome(String(r))))
82
- .filter((r, i, all) => all.indexOf(r) === i && (() => { try { return fs.statSync(r).isDirectory(); } catch { return false; } })());
65
+ if (!fileConfig || typeof fileConfig !== "object" || Array.isArray(fileConfig)) fileConfig = {}; // the deck server says why
66
+ // a missing asset root stops the start, before anything is written; one that goes later lists nothing
67
+ const givenRoots = assetRoots(assetArgs, fileConfig);
68
+ if (givenRoots.missing.length) { console.error(`Asset root ${givenRoots.missing[0]} is not a folder.\n\n${USAGE}`); process.exit(2); }
69
+ const ROOTS = [...new Set([START.dir, ...givenRoots.roots])];
83
70
  const decksNow = () => listDecks(ROOTS);
84
71
  const chooser = !START.named && !fs.existsSync(path.join(START.dir, START.file)) && decksNow().some((d) => !d.problem);
85
72
 
@@ -93,10 +80,10 @@ const decks = new Map(); // key -> { key, dir, file, label, proc, url, token,
93
80
  const labelOf = (d) => (d.file === "index.html" ? path.basename(d.dir) : d.file);
94
81
  let quitting = false;
95
82
 
96
- /* Its output goes to this terminal, each line marked with the deck. hold(): keep the lines back for now */
83
+ /* Its output goes to this terminal, each line marked with the deck. hold(l): keep the line back for now */
97
84
  function forward(stream, label, to, keep, hold = () => false) {
98
85
  let rest = "";
99
- const put = (l) => { keep?.(l + "\n"); if (!hold()) to.write(`[${label}] ${l}\n`); };
86
+ const put = (l) => { keep?.(l + "\n"); if (!hold(l)) to.write(`[${label}] ${l}\n`); };
100
87
  stream.on("data", (chunk) => {
101
88
  const lines = (rest + chunk).split("\n");
102
89
  rest = lines.pop();
@@ -114,15 +101,20 @@ function openDeck(entry, start = false) {
114
101
  decks.set(d.key, d);
115
102
  // its own process group: Ctrl+C reaches the front only, which then ends every deck once
116
103
  d.proc = spawn(process.execPath, [SERVER, entry.file === "index.html" ? entry.dir : path.join(entry.dir, entry.file),
117
- "--port", "0", ...PASS], { env: { ...process.env, PINDECK_FRONT: "1" }, stdio: ["ignore", "pipe", "pipe", "ipc"], detached: true });
104
+ "--port", "0", ...PASS], { env: { ...process.env, PINDECK_FRONT: "1", PINDECK_FRONT_URL: ORIGIN },
105
+ stdio: ["ignore", "pipe", "pipe", "ipc"], detached: true });
106
+ // what it says before it listens: its reason if it stops; the start deck's warnings once it runs
118
107
  let said = "";
108
+ const held = [];
119
109
  forward(d.proc.stdout, d.label, process.stdout);
120
- forward(d.proc.stderr, d.label, process.stderr, (l) => { said += l; }, () => start && !d.url);
110
+ forward(d.proc.stderr, d.label, process.stderr, (l) => { if (!d.url) said = (said + l).slice(-20000); },
111
+ (l) => start && !d.url && held.push(l));
121
112
  d.exited = new Promise((r) => d.proc.once("exit", r));
122
113
  d.ready = new Promise((resolve, reject) => {
123
114
  d.proc.on("message", (m) => {
124
115
  if (!m?.ready) return;
125
116
  Object.assign(d, { url: m.ready.url, token: m.ready.token });
117
+ for (const l of held.splice(0)) console.error(l); // a start that went ahead anyway, as PinDeck said it before the front
126
118
  if (!start) console.log(`[${d.label}] opened: ${path.join(d.dir, d.file)}`);
127
119
  resolve(d);
128
120
  });
@@ -156,14 +148,18 @@ setInterval(async () => {
156
148
 
157
149
  /* The request, without the front's cookie and with the deck's token, to the deck's server; the answer
158
150
  streamed back (event streams, media ranges and large uploads included) */
159
- function proxy(req, res, d, rest) {
151
+ function proxy(req, res, d, rest, anonymous = false) {
160
152
  d.last = Date.now();
161
- const headers = { ...req.headers, host: new URL(d.url).host, "x-editor-token": d.token };
153
+ const headers = { ...req.headers, host: new URL(d.url).host, ...(anonymous ? {} : { "x-editor-token": d.token }) };
154
+ if (anonymous) delete headers["x-editor-token"];
162
155
  delete headers.cookie;
163
156
  delete headers.origin; // checked here against the front's address; the deck server checks its own
164
157
  const up = http.request(d.url + rest, { method: req.method, headers }, (r) => {
165
158
  res.writeHead(r.statusCode, r.headers);
166
159
  r.pipe(res);
160
+ // the deck server stopped mid-answer (an event stream, say): end ours too, or the browser waits forever
161
+ r.on("aborted", () => res.destroy());
162
+ r.on("error", () => res.destroy());
167
163
  });
168
164
  up.on("error", () => { if (!res.headersSent) page(res, 502, "This deck stopped answering", "Reload the page to open it again."); else res.destroy(); });
169
165
  if (rest.split("?")[0] === "/api/events") {
@@ -174,6 +170,83 @@ function proxy(req, res, d, rest) {
174
170
  req.pipe(up);
175
171
  }
176
172
 
173
+ /* ---- One event stream per browser -------------------------------------------------------------------
174
+ Browsers open at most 6 connections to one host and port, and each editor tab held one open for its deck's
175
+ events: a sixth tab never loaded. The tabs of a browser share one stream through a shared worker
176
+ (public/events-worker.js): GET /events?stream=<id> holds it, POST /events/subscribe adds a tab's deck (the
177
+ front opens that deck's events on its server and passes them on, marked with the tab) or removes it. If a
178
+ deck server stops, the deck is opened again and the tab gets its state afresh (its "hello"). */
179
+ const streams = new Map(); // stream id -> { res, tabs: Map(tab -> { stop() }) }
180
+ function eventStream(res, id) {
181
+ streams.get(id)?.res.end(); // the worker reconnected: its old stream is done
182
+ const st = { res, tabs: new Map() };
183
+ streams.set(id, st);
184
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
185
+ res.write("event: ready\ndata: {}\n\n");
186
+ const ping = setInterval(() => res.write(": ping\n\n"), 20000);
187
+ res.on("close", () => {
188
+ clearInterval(ping);
189
+ if (streams.get(id) === st) streams.delete(id);
190
+ for (const t of st.tabs.values()) t.stop();
191
+ });
192
+ }
193
+ function follow(st, tab, key) {
194
+ let stopped = false, up = null, tries = 0;
195
+ const send = (type, raw) => {
196
+ if (!stopped && !st.res.writableEnded) st.res.write(`event: deck\ndata: {"tab":${JSON.stringify(tab)},"type":${JSON.stringify(type)},"data":${raw}}\n\n`);
197
+ };
198
+ const retry = () => {
199
+ if (stopped || quitting) return;
200
+ if (++tries > 20) return send("gone", JSON.stringify({ error: "The deck stopped answering. Reload the page." }));
201
+ setTimeout(connect, Math.min(5000, 200 * tries));
202
+ };
203
+ async function connect() {
204
+ if (stopped || quitting) return;
205
+ let d = decks.get(key);
206
+ try {
207
+ if (!d) {
208
+ const entry = START.key === key && !chooser ? { key, dir: START.dir, file: START.file } : decksNow().find((e) => e.key === key);
209
+ if (!entry || entry.problem) return send("gone", JSON.stringify({ error: entry?.problem || "This deck is not in the deck folder or an asset root (any more)." }));
210
+ d = entry;
211
+ }
212
+ d = await openDeck(d);
213
+ } catch (e) { return send("gone", JSON.stringify({ error: e.message })); }
214
+ if (stopped) return;
215
+ up = http.get(d.url + "/api/events", { headers: { "x-editor-token": d.token } }, (r) => {
216
+ if (r.statusCode !== 200) { r.resume(); return retry(); }
217
+ d.sse++;
218
+ tries = 0;
219
+ let buf = "", ended = false;
220
+ r.setEncoding("utf8");
221
+ r.on("data", (chunk) => {
222
+ buf += chunk;
223
+ for (let i; (i = buf.indexOf("\n\n")) >= 0;) {
224
+ const block = buf.slice(0, i);
225
+ buf = buf.slice(i + 2);
226
+ const type = /^event: (.*)$/m.exec(block)?.[1], data = /^data: (.*)$/m.exec(block)?.[1];
227
+ if (type && data) send(type, data);
228
+ }
229
+ });
230
+ const end = () => {
231
+ if (ended) return;
232
+ ended = true;
233
+ d.sse--;
234
+ d.last = Date.now();
235
+ if (!stopped) { send("gone", "{}"); retry(); }
236
+ };
237
+ r.on("end", end); r.on("aborted", end); r.on("error", end); r.on("close", end);
238
+ });
239
+ up.on("error", retry);
240
+ }
241
+ connect();
242
+ return { stop() { stopped = true; up?.destroy(); } };
243
+ }
244
+ async function readJson(req) {
245
+ let s = "";
246
+ for await (const c of req) { s += c; if (s.length > 10000) throw new Error("body too large"); }
247
+ return s ? JSON.parse(s) : {};
248
+ }
249
+
177
250
  /* ---- HTTP ------------------------------------------------------------------------------------- */
178
251
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
179
252
  const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(s); };
@@ -210,12 +283,21 @@ function serveEditorFile(res, rel) {
210
283
  const server = http.createServer(async (req, res) => {
211
284
  const host = String(req.headers.host || "").toLowerCase();
212
285
  if (!goodHost(host)) return text(res, 403, "Bad host");
213
- const url = new URL(req.url, ORIGIN);
214
- const p = url.pathname;
215
286
  try {
287
+ const url = new URL(req.url, ORIGIN);
288
+ const p = url.pathname;
216
289
  if (req.method === "GET" && p === "/login") return login(res, url);
217
290
  // the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
218
291
  if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
292
+ /* A deck's pages and files under /d/<deck>/v/<token>/: asked for by the deck itself, which runs sandboxed
293
+ without the login cookie (see VIEW in server.mjs). Passed on without the token, so the deck server's own
294
+ check of the path is all they get; only to a deck that is open, so they never start one. */
295
+ const view = /^\/d\/([\w.-]+)(\/v\/.*)$/.exec(p);
296
+ if (view && !authed(req)) {
297
+ const d = decks.get(view[1]);
298
+ if (!d?.url || (req.method !== "GET" && req.method !== "HEAD")) return text(res, 403, "Not logged in");
299
+ return proxy(req, res, d, view[2] + url.search, true);
300
+ }
219
301
  if (!authed(req)) {
220
302
  if (req.method === "GET" && /^\/(d\/[^/]+\/(index\.html)?)?$/.test(p)) {
221
303
  res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
@@ -234,6 +316,20 @@ const server = http.createServer(async (req, res) => {
234
316
  if (p === "/api/ping") return json(res, 200, { ok: true, front: true });
235
317
  if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000), url.searchParams.get("next") || "/") });
236
318
  if (p === "/api/decks") return json(res, 200, { decks: deckList() });
319
+ if (p === "/events" && req.method === "GET") {
320
+ const id = url.searchParams.get("stream") || "";
321
+ return /^[\w-]{8,64}$/.test(id) ? eventStream(res, id) : text(res, 400, "Bad stream");
322
+ }
323
+ if (p === "/events/subscribe" && req.method === "POST") {
324
+ const b = await readJson(req);
325
+ const st = streams.get(String(b.stream));
326
+ const tab = String(b.tab || "");
327
+ if (!st || !/^\d{1,9}$/.test(tab)) return json(res, 404, { error: "No such stream." });
328
+ st.tabs.get(tab)?.stop();
329
+ st.tabs.delete(tab);
330
+ if (b.on && /^[\w.-]+$/.test(String(b.deck))) st.tabs.set(tab, follow(st, tab, String(b.deck)));
331
+ return json(res, 200, {});
332
+ }
237
333
 
238
334
  const m = /^\/d\/([\w.-]+)(\/.*)?$/.exec(p);
239
335
  if (!m) return text(res, 404, "Not found");
@@ -254,20 +350,18 @@ const server = http.createServer(async (req, res) => {
254
350
  catch (e) { return page(res, 502, "PinDeck could not open this deck", `<pre>${esc(e.message)}</pre><p><a href="/">All decks</a></p>`); }
255
351
  return proxy(req, res, d, m[2] + url.search);
256
352
  } catch (e) {
257
- if (!res.headersSent) text(res, 500, String(e.message || e));
353
+ const bad = e instanceof SyntaxError || e instanceof URIError || e.message === "body too large";
354
+ if (!res.headersSent) text(res, bad ? 400 : 500, String(e.message || e));
258
355
  }
259
356
  });
260
357
 
261
358
  /* ---- Start ----------------------------------------------------------------------------------------
262
359
  The port is bound first, then the start deck opens (its server checks it before writing anything). If it
263
360
  cannot, the front says why and stops: as before the front, nothing is written. */
361
+ let removeServerInfo = () => {};
264
362
  function writeServerInfo() {
265
363
  fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
266
- fs.writeFileSync(SERVER_FILE, JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: START.dir, pid: process.pid, front: true }, null, 2), { mode: 0o600 });
267
- fs.chmodSync(SERVER_FILE, 0o600);
268
- }
269
- function removeServerInfo() {
270
- try { if (JSON.parse(fs.readFileSync(SERVER_FILE, "utf8")).token === TOKEN) fs.rmSync(SERVER_FILE, { force: true }); } catch {}
364
+ removeServerInfo = registerServer(HOME_DIR, { url: ORIGIN, token: TOKEN, deckDir: START.dir, front: true });
271
365
  }
272
366
  let listening = false;
273
367
  server.on("error", (e) => {
@@ -291,6 +385,7 @@ server.listen(PORT, "127.0.0.1", async () => {
291
385
  writeServerInfo();
292
386
  console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
293
387
  console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
388
+ openOnEnter(() => loginUrl(loginCode(3600000)));
294
389
  console.log(chooser ? `Decks: ${START.dir}` : `Deck: ${START.dir}`);
295
390
  console.log(`Server: ${CAPS.label}`);
296
391
  for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
@@ -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
- this.proc.on('error', e => this.end(e));
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'); }
@@ -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
- const isLocked = rel => (typeof locked === 'function' ? locked() : locked)
29
- .some(p => p.endsWith('/**') ? rel === p.slice(0, -3) || rel.startsWith(p.slice(0, -2)) : rel === p);
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
+ }
@@ -1,7 +1,7 @@
1
1
  /* What this server can do on this machine, decided once on the backend and used everywhere: the editor
2
2
  (status), the API (refusals before any side effect), the CLI (its error text) and the assistant's system prompt.
3
3
 
4
- macOS: everything, as before. Linux (beta): editing, the chat, live reload and the PowerPoint import;
4
+ macOS: everything, as before. Linux: editing, the chat, live reload and the PowerPoint import;
5
5
  pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
6
6
  waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
7
7
  layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
@@ -12,7 +12,7 @@ import path from "node:path";
12
12
 
13
13
  export const SUPPORTED_PLATFORMS = ["darwin", "linux"];
14
14
  export const isSupportedPlatform = (platform) => SUPPORTED_PLATFORMS.includes(platform);
15
- export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux (beta)" }[platform] || platform);
15
+ export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux" }[platform] || platform);
16
16
 
17
17
  const CHROME_PATHS = {
18
18
  darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
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
- if (!JSON.parse(stdout).loggedIn) throw new Error('Not signed in.');
19
- } catch (e) { throw new Error(e.code === 'ENOENT' ? 'Claude CLI is not installed (or set CLAUDE_BIN).' : 'Claude is not signed in. Run claude auth login, then refresh models.'); }
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',
@@ -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) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith(".")).map((e) => e.name).sort();
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) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => e.name).sort();
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 size = fs.statSync(abs).size;
40
- problem = size > MAX_BYTES ? `${file} is larger than 5 MB, too large for a deck.`
41
- : unsupportedMessage(fs.readFileSync(abs, "utf8"), file)?.replace(/ Nothing was changed\.$/, "") || null;
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 });
@@ -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
- /* A file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm that is
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?$/i.test(abs) ? abs : path.dirname(abs);
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?$/i, "") : path.basename(dir); // for exports and titles
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 isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
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
- edits.sort((a, b) => b.start - a.start);
348
- let out = html;
349
- for (const e of edits) out = out.slice(0, e.start) + e.replace + out.slice(e.end);
350
- return { html: out, changed: true, assigned, renamed, slides };
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.startsWith("//") || next.includes("\\")) 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
- return { provider: PROVIDERS.includes(raw.provider) ? raw.provider : 'claude',
18
- model: typeof raw.model === 'string' && raw.model ? raw.model : 'sonnet',
19
- effort: typeof raw.effort === 'string' ? raw.effort : 'medium' };
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
- const recent = chat.filter(e => ['user', 'assistant', 'notice'].includes(e.kind) && e.text).slice(-24);
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
  }