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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # PinDeck
2
2
 
3
- > **macOS, and Linux as a beta · Version 3 beta preview** (`3.1.0-beta.1`). On Linux, PinDeck runs as a
3
+ > **macOS, and Linux as a beta · Version 3 beta preview** (`3.2.0-beta.1`). On Linux, PinDeck runs as a
4
4
  > headless server that you use from your Mac's browser through an SSH tunnel (see
5
5
  > [Running on a Linux server](#running-on-a-linux-server-beta)). Windows is not supported. The beta is on
6
6
  > npm’s `beta` channel and the [`v3-beta`](https://github.com/ehabets/PinDeck/tree/v3-beta) branch; stable
@@ -42,7 +42,7 @@ Install the beta from npm:
42
42
  ```bash
43
43
  npx pindeck@beta /path/to/deck
44
44
  # Or select this exact beta:
45
- npx pindeck@3.1.0-beta.1 /path/to/deck
45
+ npx pindeck@3.2.0-beta.1 /path/to/deck
46
46
  ```
47
47
 
48
48
  Or run it from the `v3-beta` branch, which includes the example deck:
@@ -57,14 +57,15 @@ Use `npx pindeck@latest /path/to/deck` for the stable release; the beta channel
57
57
  Releases, with notes for each beta, are on [GitHub](https://github.com/ehabets/PinDeck/releases).
58
58
 
59
59
  Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
60
- once and logs this browser in; for a new one, run `pindeck login` (or `node front.mjs login`).
60
+ once and logs this browser in; for a new one, run `pindeck login` (or `node front.mjs login`). On a Mac,
61
+ pressing Enter in the terminal where PinDeck runs opens a new link in your default browser.
61
62
 
62
- The deck is a file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`; without
63
- one, the current folder. Several decks can share a folder, each with its own chat and undo history;
63
+ The deck is an `.html` file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`;
64
+ without one, the current folder. Several decks can share a folder, each with its own chat and undo history;
64
65
  they share the folder's `theme.css` and `brand.css`, and the assistant edits only the deck that is open.
65
66
  PinDeck adds element ids to the deck file when it opens it (see below), so try it on a copy of a deck
66
67
  first. Options: `--port 5180`, `--provider claude|codex`, `--model <name>` (for this run only; otherwise the
67
- model in `~/.pindeck-v3/config.json`), `--asset-root <folder>` (see [Files outside the deck folder](#files-outside-the-deck-folder)),
68
+ deck's own choice, see [Models, providers and configuration](#models-providers-and-configuration)), `--asset-root <folder>` (see [Files outside the deck folder](#files-outside-the-deck-folder)),
68
69
  `--help`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck-v3/` (change with
69
70
  `PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
70
71
 
@@ -75,9 +76,16 @@ the asset roots, and in their folders one level down (`lecture/index.html`). A d
75
76
  tab, or with ⌘-click in a new one, on the same port and login, so one SSH tunnel serves them all. Each keeps
76
77
  its own chat and undo history while it is open, and the assistant finishes a turn in a deck you leave. A
77
78
  file PinDeck cannot open is greyed out with the reason; listing a deck changes nothing, and only opening it
78
- adds its element ids. Started with a folder that has no `index.html`, PinDeck shows the list of its decks.
79
- A deck with no editor open for 30 minutes is closed (its undo history goes, as on a restart) and opens
80
- again when you choose it.
79
+ adds its element ids. Symlinked decks and folders are listed too; a deck file that links to a deck in
80
+ another folder is greyed out (open that deck itself). Started with a folder that has no `index.html`,
81
+ PinDeck shows the list of its decks. A deck with no editor open for 30 minutes is closed (its undo history
82
+ goes, as on a restart) and opens again when you choose it. Open as many decks in as many tabs as you like:
83
+ the tabs of a browser share one connection for their live updates.
84
+
85
+ A deck is open in one PinDeck at a time: started again for a deck that another PinDeck (another port, or
86
+ `server.mjs` on its own) has open, PinDeck says where it is open and stops, so their chats and undo
87
+ histories never overwrite each other. `pindeck login <deck>` and `cli.mjs` find the PinDeck that serves
88
+ the deck, whichever port it runs on.
81
89
 
82
90
  ## Compatibility and requirements
83
91
 
@@ -220,7 +228,9 @@ and Haiku model in each family. Claude versions are compared numerically from di
220
228
  a rolling family alias wins ties, avoiding duplicate entries. **Show all available models** reveals
221
229
  the full discovered catalog; this display preference is remembered in the browser. A currently selected
222
230
  older model remains visible and is never switched automatically by the filter.
223
- **Refresh models** retries discovery. A missing CLI, missing sign-in, or discovery failure is shown in its provider group.
231
+ **Refresh models** asks the backends again; otherwise the lists are shared by the decks you open for 10 minutes
232
+ (`models.json` in the state folder), so opening a deck starts no backend just to list models. A missing CLI,
233
+ missing sign-in, or discovery failure is shown in its provider group.
224
234
  These are backend-reported choices, not a guarantee of current quota or entitlement; a later rejected
225
235
  request reports its error. Older CLIs may expose fewer models than a provider's website.
226
236
 
@@ -234,7 +244,9 @@ additional model-generated summary. It excludes tool output, needs no extra infe
234
244
  sent with the first message to the new session. Earlier messages may be omitted. **Start fresh** sends
235
245
  no conversation digest; the assistant still receives PinDeck instructions and the current deck context.
236
246
 
237
- Settings are saved when changed in `~/.pindeck-v3/config.json` (or `$PINDECK_HOME/config.json`):
247
+ Each deck keeps its own provider, model and effort with its conversation (in its `chat.json`), so a choice in
248
+ one deck never moves another deck's conversation to another backend. The last choice is also saved in
249
+ `~/.pindeck-v3/config.json` (or `$PINDECK_HOME/config.json`), as the start for decks that have none yet:
238
250
 
239
251
  ```json
240
252
  {
@@ -244,13 +256,22 @@ Settings are saved when changed in `~/.pindeck-v3/config.json` (or `$PINDECK_HOM
244
256
  }
245
257
  ```
246
258
 
247
- Legacy configuration without `provider` is interpreted as Claude. Unsupported effort choices are
248
- adjusted to the discovered model's default when starting. Provider-tagged chat/session records prevent
249
- one backend from resuming the other's IDs. `--provider` and `--model` override startup settings for
250
- that run; a later menu selection is saved. `CLAUDE_BIN` and `CODEX_BIN` override executable paths.
259
+ Legacy configuration without `provider` is interpreted as Claude; a provider without a model gets that
260
+ provider's default model. Once the backend has listed its models, a saved model it no longer offers gives way
261
+ to its default model (the chat says so), and an effort the model does not offer to its saved or default one,
262
+ before the first message. config.json is read again and merged whenever a choice is saved, so decks open at
263
+ the same time keep each other's effort preferences. Provider-tagged chat/session records prevent one backend
264
+ from resuming the other's IDs. `--provider` and `--model` override the deck's settings for that run; a
265
+ `--provider` other than the deck's starts a new conversation (the chat says so), and the next run without it
266
+ is back on the deck's own choice. A later menu selection is saved. `CLAUDE_BIN` and `CODEX_BIN` override
267
+ executable paths. `CLAUDE_MODEL`, which PinDeck 2 read, is not used: PinDeck warns and names `--model`.
251
268
 
252
269
  The v3 preview defaults to **`~/.pindeck-v3`**, keeping v2 settings and conversations separate. It does
253
- not copy the v2 directory automatically. To run both versions at once, use another port, for example:
270
+ not copy the v2 directory automatically; `config.json` and `decks/*/chat.json` can be copied over by hand
271
+ (asset roots too: they are in config.json). Never give both versions the same `PINDECK_HOME`: they keep files
272
+ of the same names in different formats and overwrite each other's settings and chats. PinDeck 3 warns at
273
+ start when its folder holds a chat that PinDeck 2 wrote. To run both versions at once, use another port, for
274
+ example:
254
275
 
255
276
  ```bash
256
277
  node front.mjs /path/to/a/deck-copy --port 5183 --provider codex
@@ -261,16 +282,17 @@ node front.mjs /path/to/a/deck-copy --port 5183 --provider codex
261
282
  Claude uses its native Read/Edit tools with PinDeck's narrow allowlist, safe mode, and no inherited
262
283
  MCP configuration. Codex runs with a read-only sandbox, command execution disabled, and no approval
263
284
  grants. Inherited MCP servers and plugins are disabled in the thread configuration. Codex receives
264
- three host-controlled tools: `pindeck_read`, `pindeck_edit`, and `pindeck_command`.
265
- Only `index.html` and `theme.css` can be edited. Linked writable files and paths outside the allowed
266
- roots are rejected. Commands are restricted to PinDeck's layout, shot, slides, pdf and pdf-figure
267
- helpers, executed with argument arrays rather than a shell. `brand.css` also retains the server's
268
- post-turn restoration safeguard.
285
+ four host-controlled tools: `pindeck_read`, `pindeck_edit`, `pindeck_write` and `pindeck_command`.
286
+ Both assistants may edit and create the files of the deck folder: the deck file, `theme.css` and its other
287
+ files. Never `brand.css`, `vendor/`, `.git/`, or the other decks in the folder (its other `.html` files, and
288
+ subfolders that hold a deck of their own). Linked writable files and paths outside the allowed roots are
289
+ rejected. Commands are restricted to PinDeck's layout, shot, slides, pdf and pdf-figure helpers, executed
290
+ with argument arrays rather than a shell. `brand.css` also retains the server's post-turn restoration safeguard.
269
291
 
270
292
  Codex reads text, PNG/JPEG/WebP/GIF images and screenshots through the bridge. SVG is read as text.
271
- Attached PDFs are handled page by page using PDFKit on macOS: extracted text plus a rendered page image.
272
- PDF reading/rendering through this bridge is currently macOS-only. Audio is embedded by path and is
273
- not transcribed. Read operations have a 12 MB limit; long text can be read in chunks.
293
+ Attached PDFs are handled page by page: a rendered page image (PDFKit on macOS, Poppler on Linux), plus the
294
+ page's text on macOS. Audio is embedded by path and is not transcribed. Read operations have a 12 MB limit;
295
+ long text can be read in chunks.
274
296
 
275
297
  ### Files outside the deck folder
276
298
 
@@ -283,6 +305,8 @@ exports. Claude may read those files but does not edit them.
283
305
  Symlinks: PinDeck serves a file only if, with symlinks followed, it lies inside the deck folder or an
284
306
  asset root. A symlink in the deck that leads anywhere else is refused (403). To use a symlinked
285
307
  library folder, add the folder it points to as an asset root; it does not have to contain the deck.
308
+ PinDeck never writes or deletes through a symlink out of the deck folder: with a `figures/` or `export/`
309
+ that links elsewhere, uploads, exports and deleting a picture's file are refused with the reason.
286
310
 
287
311
  ## Running on a Linux server (beta)
288
312
 
@@ -312,7 +336,7 @@ message twice. If PinDeck restarts, the editor asks you to log in again: run `no
312
336
  path/to/deck` on the server for a new link.
313
337
 
314
338
  Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
315
- protects it is only in your browser's cookie and in PinDeck's own files in `~/.pindeck` (readable only by
339
+ protects it is only in your browser's cookie and in PinDeck's own files in `~/.pindeck-v3` (readable only by
316
340
  you), never in a page or on a command line.
317
341
 
318
342
  What works on Linux in this beta: editing by hand, speaker notes, slides, pins, the chat with Claude or Codex,
@@ -331,9 +355,12 @@ Without them the editor says what is missing (hover over "Server: Linux (beta)")
331
355
  which commands are not available. LibreOffice may draw some slides a little differently from PowerPoint;
332
356
  the pictures are references and figures, the slides themselves are rebuilt from the file. Chromium from snap may not be
333
357
  allowed to use hidden folders in your home folder such as `~/.pindeck-v3`; if screenshots fail with it, set
334
- `PINDECK_HOME` to a folder that is not hidden, or install Chrome or Chromium from a package.
358
+ `PINDECK_HOME` to a folder that is not hidden (not the one PinDeck 2 uses), or install Chrome or Chromium from
359
+ a package. Run as root (in a container, say), PinDeck starts Chrome with `--no-sandbox`, which Chrome needs
360
+ there; a screenshot or PDF that fails gives Chrome's own reason.
335
361
 
336
- Run one PinDeck per deck. While PinDeck or the assistant is working on a deck, do not edit it with other tools
362
+ One PinDeck serves all your decks on one port, and a deck can be open in only one PinDeck at a time (a
363
+ second one says where it is open). While PinDeck or the assistant is working on a deck, do not edit it with other tools
337
364
  or switch git branches at the same time. Stop first, or wait until the assistant has finished. PinDeck notices
338
365
  changes made by other programs and reloads, and it refuses Undo, Redo or an edit that would overwrite
339
366
  them. Its checks cannot rule out every race with a program that writes at the same moment.
@@ -361,10 +388,10 @@ show and commit as usual. Clones without the filter configured simply see the fi
361
388
  | Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and the assistant's changes. |
362
389
  | Pin elements | Press `P` (or the Pin button), click elements; each click gets a number. Click a marker to remove it, `Esc` to stop. |
363
390
  | Ask the assistant | Write in the sidebar and refer to pins by number: "1: align with 2, make 3 smaller". The selected element is sent too, so "make this smaller" works. `Enter` sends, `Shift+Enter` adds a new line. |
364
- | Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. |
365
- | Review | Each answer ends with the changed files, a **Diff** button and an **Undo** button (most recent change). |
366
- | Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts over. |
367
- | New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides, and its title opens for typing. |
391
+ | Visual questions | **+** in the chat, then **Screenshot**: the current slide with the pins of that message is attached when you send. If Chrome cannot take it, the assistant is told. **Stop** during the screenshot stops at once. |
392
+ | Review | Each answer ends with the changed files and a **Diff** button; the most recent change still in effect also has an **Undo** button. After a restart, earlier changes keep their *undone* mark but have no Diff or Undo (the undo history ends with the run). |
393
+ | Stop | **Stop** interrupts the assistant; the conversation continues. **New chat** starts a new conversation; your slides, Undo and Redo stay as they are. |
394
+ | New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides; a new content slide or divider opens its title for typing. |
368
395
  | Import from PowerPoint | **+ New slide** → **Import from PowerPoint…**, then choose a `.pptx` file and tick the slides you want (hidden PowerPoint slides start unticked). They are added after the current slide in this deck's own layouts, and the assistant can improve the complex ones; see [PowerPoint import](#powerpoint-import). `⌘Z` removes them again, with their figures. |
369
396
  | Speaker notes | The **Notes** field under the slide shows the current slide's notes (the speaker view, `S`, shows them when presenting). Type to change them; they are saved when you click elsewhere or press `⌘Enter`, and `Esc` puts back what was there. A slide without notes gets them; emptying the field removes them. Notes with formatting other than line breaks are edited as HTML. Drag the line above the notes to give them more or less room (double-click it for the default size); collapse them with their header. |
370
397
  | Move a slide | Drag it up or down in the slide list; an orange line shows where it will go. It moves with its notes and the comment above it, and `⌘Z` puts it back. Slides inside vertical stacks cannot be moved yet. |
@@ -373,10 +400,12 @@ show and commit as usual. Clones without the filter configured simply see the fi
373
400
  | Attach figures and audio | **+** in the chat, then **File…**, drag and drop onto the chat or a slide, or paste an image. Files are saved in `<deck>/figures/` straight away; audio also gets a waveform SVG. Then ask the assistant, e.g. "put this figure on a new slide after 4". Removing an attachment before sending deletes the file again. |
374
401
  | Attach a PDF | The same way: a figure or table to take over, or a paper or report with more details. PDFs stay outside the deck (in `~/.pindeck-v3`); the assistant reads them, rebuilds tables as HTML, and renders a figure (a page or a cropped part of one) into `<deck>/figures/` as PNG with `cli.mjs pdf-figure` (macOS, or Linux with Poppler). E.g. "put Fig. 3 of this paper on slide 7" or "add the results of Table 2 to slide 9". |
375
402
  | Export PDF | **Export ▾** in the toolbar. **Handout PDF**: one page per slide, build steps shown complete. **Presentation PDF**: one page per click, so a slide with two build steps gives three pages. Files go to `<deck>/export/`. Needs Chrome or Chromium. |
376
- | Navigate | Slide list, `←` `→`, or the toolbar. **Present** opens the deck in a new tab. |
403
+ | Navigate | Slide list, `←` `→`, or the toolbar. **Present** opens the deck in a new tab, at the current slide. |
404
+ | Check from a terminal | `node cli.mjs layout 3 --issues`, `slides`, `shot 3` or `pdf handout`, in the deck's folder (or with `--deck <folder or file.html>`): asks the PinDeck that serves the deck. |
377
405
 
378
406
  You can also edit `index.html` or `theme.css` in any editor; the slide reloads, and the assistant is told
379
- the files changed. CSS changes are swapped in without a reload.
407
+ the files changed. CSS changes are swapped in without a reload. Typing in the slide is not lost meanwhile:
408
+ when you finish, PinDeck refuses to save it over the other change and offers the text to copy.
380
409
 
381
410
  ## PowerPoint import
382
411
 
@@ -425,7 +454,17 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
425
454
  * **One port, a server per deck.** `pindeck` starts the front (`front.mjs`): it holds the port, the login
426
455
  and the deck list, and serves each open deck at `/d/<deck>/`. Every deck runs its own deck server
427
456
  (`server.mjs`) on a private port of 127.0.0.1 with a token of its own; the front passes each request on
428
- with that token. A deck's server, its assistant and its undo history are those of that deck alone.
457
+ with that token. A deck's server, its assistant and its undo history are those of that deck alone. The
458
+ editor tabs of a browser share one event stream from the front (a shared worker), since browsers open
459
+ only six connections to one port; if a deck server stops, the front opens the deck again and the tab
460
+ catches up. Each PinDeck process notes its address in `~/.pindeck-v3/servers/` for `pindeck login` and
461
+ `cli.mjs`, and a deck server holds its deck's `open.json`, so a deck opens in one PinDeck only.
462
+ * **Deck pages are sandboxed.** The deck page (in the editor's frames, and in Present) runs with a sandbox
463
+ policy in an origin of its own, at a per-run address that serves the deck's files and nothing else. A
464
+ script in a deck, a template, or written by the assistant cannot reach the login cookie, the API, the
465
+ chat or other decks. The editor and its bridge in the slide frame talk by `postMessage`. Headless Chrome
466
+ (screenshots, layout, PDFs) does not log in at all; the page it asks for is served without the sandbox,
467
+ which its screenshot and print modes cannot handle, only with a key made for that one run.
429
468
  * **Element ids.** On start, the server gives every slide and every visible element a readable
430
469
  `data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
431
470
  inserted; the rest of the file stays as it was. A backup of the file before the first change is
@@ -461,13 +500,15 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
461
500
  so no allow rule in your own, the deck's or a parent folder's settings widens this. Codex has no shell
462
501
  and no file access of its own: it reads and edits through PinDeck's own tools, which apply the same
463
502
  rules and refuse symlinks out of the deck. If `brand.css` changes during a turn anyway, the server
464
- restores it and says so in the chat.
503
+ restores it and says so in the chat. Subfolders that hold a deck of their own count as other decks.
465
504
  * **Undo.** The server keeps a copy of every file a change touched, from before and after it (an AI
466
505
  turn, which may change, create or delete several files of the deck, or a hand edit). Files over 2 MB
467
506
  are not copied: the chat says when a turn changed one, and Undo leaves it as it is. Hidden files,
468
507
  `vendor/` and `export/` are not part of undo. Undo and redo walk that history and tell the assistant on
469
508
  the next message. They first check that the files are still as that change left them; if something
470
509
  else changed them since, they do nothing and say which file differs, rather than overwrite that work.
510
+ Decks that share a folder share its files: while another deck's assistant works there too, a turn keeps
511
+ only the files its own assistant said it wrote, and says which it left out.
471
512
  * **Saving.** Every file PinDeck writes into the deck is written to a temporary file next to it and then
472
513
  renamed into place, so other programs never see half a file. Each change from the editor carries the
473
514
  revision of the files it was made on; if the files have changed since, the change is refused, the
@@ -480,8 +521,9 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
480
521
  | `front.mjs` | What `pindeck` starts (127.0.0.1 only, random token per run, one-time login links): the login, the deck list and one deck server per open deck at `/d/<deck>/`; `login`. |
481
522
  | `server.mjs` | The server for one deck: UI, deck files, events, backend session, undo, CLI endpoints. Behind the front, or on its own (`node server.mjs <deck>`). |
482
523
  | `lib/login.mjs`, `lib/deck-target.mjs`, `lib/deck-list.mjs` | Login links and cookies, which deck a path names (and its state folder), and the decks the menu lists. |
524
+ | `lib/servers.mjs` | The PinDecks running for this user, and which one serves a deck (`pindeck login`, `cli.mjs`). |
483
525
  | `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
484
- | `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
526
+ | `public/` | The editor page; `bridge.js`, which is injected into the deck in edit mode (never saved to it); `events-worker.js`, the event stream the tabs of a browser share. |
485
527
  | `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
486
528
  | `lib/pptx-import.mjs` | Turns PowerPoint slides into slides of the deck: figures, tables, build steps, audio, layout, HTML in the deck's style. |
487
529
  | `lib/waveform.mjs` | Waveform SVGs for imported audio. |
@@ -511,11 +553,38 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
511
553
  * The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
512
554
  30 minutes without an open editor.
513
555
  * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
556
+ * A deck's own scripts run sandboxed in the editor and in Present: they cannot use `localStorage`, cookies
557
+ or the editor page. A deck that needs them for its slides to work should catch the error.
558
+ * Decks are `.html` files; `.htm` files are not listed or opened as decks.
514
559
  * Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
515
560
  Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
516
561
 
517
562
  ## Versions
518
563
 
564
+ * **3.2.0-beta.1**: fixes from a code review and a round of user tests.
565
+ - Security: deck pages run sandboxed, so a deck's script can no longer use the API, read other decks or
566
+ send messages; the login link's `next` stays on PinDeck. A deck's own script cannot use `localStorage`
567
+ there either (it throws).
568
+ - Typing in a bullet that cannot be saved because the deck changed meanwhile can be copied, as other text
569
+ can; a deck that never chose a model keeps the one its conversation started with.
570
+ - Several decks in one folder: a turn no longer records or undoes another deck's files, and deleting a
571
+ picture keeps a file another deck uses. Subfolder decks are locked for the assistant.
572
+ - Each deck keeps its provider, model and effort; config.json is merged, not overwritten; unusable saved
573
+ settings are made usable before the first message.
574
+ - Many tabs on one port; a deck server that stops reconnects; `pindeck login` and `cli.mjs` find the right
575
+ PinDeck; a deck opens in one PinDeck only.
576
+ - Screenshots show the pins of their own message; Stop during a screenshot; failures reach the assistant.
577
+ - New chat keeps Undo; undone marks survive a restart; only the latest change offers Undo.
578
+ - A deck page that cannot start says why, and Send no longer hangs; typing survives an outside save; the
579
+ deck menu at every width; keyboard use of the model menus; Enter in a bullet leaves no copied ids.
580
+ - Large decks get their ids in linear time; symlinked decks; a deck that is gone; Chrome's own errors,
581
+ and `--no-sandbox` as root; a warning when PinDeck 2 writes to the same state folder.
582
+ - On a Mac, Enter in the terminal opens PinDeck in the browser.
583
+ - From the code review: locked paths regardless of case (macOS) and `.git/config` restored after each
584
+ turn; Codex reads PDFs of a deck reached through a symlink; "not signed in" only when Claude says so;
585
+ betas tested before they are staged; model lists shared between decks; a faster deck list; smaller
586
+ status updates; asset roots read alike by the front and the deck servers.
587
+
519
588
  * **3.1.0-beta.1**: several decks on one port, and screenshots you can open from the chat.
520
589
  - A deck menu: click the deck's name above the slide to open another deck from its folder or the
521
590
  asset roots (and one folder down). Each open deck keeps its own chat, assistant session and Undo;
package/cli.mjs CHANGED
@@ -15,14 +15,20 @@
15
15
 
16
16
  node cli.mjs git-setup [deck] keep PinDeck's data-eid ids out of git (no server needed; a folder or deck file)
17
17
 
18
- <slide> is a slide id (data-eid), a slide number (1-based) or "current". */
18
+ <slide> is a slide id (data-eid), a slide number (1-based) or "current". The assistant's commands reach its
19
+ own deck server (DECK_EDITOR_URL); run from a terminal, they reach the PinDeck that serves the deck in the
20
+ current folder (its index.html), or the deck named with --deck <folder | file.html>. */
19
21
 
20
22
  import fs from "node:fs";
21
- import os from "node:os";
22
23
  import path from "node:path";
23
24
  import { execFileSync } from "node:child_process";
25
+ import { deckTarget, homeDir } from "./lib/deck-target.mjs";
26
+ import { serverFor } from "./lib/servers.mjs";
24
27
 
25
- const [cmd, ...rest] = process.argv.slice(2);
28
+ const args = process.argv.slice(2);
29
+ const deckAt = args.indexOf("--deck");
30
+ const deckArg = deckAt >= 0 ? args.splice(deckAt, 2)[1] : null;
31
+ const [cmd, ...rest] = args;
26
32
  const arg = rest[0];
27
33
 
28
34
  /* git-setup: a git "clean" filter strips PinDeck's data-eid attributes whenever git reads the deck's
@@ -68,13 +74,17 @@ function figureQuery(args) {
68
74
 
69
75
  let url = process.env.DECK_EDITOR_URL, token = process.env.DECK_EDITOR_TOKEN;
70
76
  if (!url || !token) {
71
- try {
72
- const home = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
73
- const s = JSON.parse(fs.readFileSync(path.join(home, "server.json"), "utf8"));
74
- url = s.url; token = s.token;
75
- } catch {}
77
+ // from a terminal: the deck's own server if it is open, else a front that lists it (which opens it)
78
+ const deck = deckTarget(deckArg || process.cwd());
79
+ if (!fs.existsSync(path.join(deck.dir, deck.file))) {
80
+ console.error(`No ${deck.file} in ${deck.dir}: run this in a deck's folder, or name the deck with --deck <folder | file.html>.`);
81
+ process.exit(2);
82
+ }
83
+ const found = await serverFor(homeDir(), deck);
84
+ if (!found) { console.error(`No running PinDeck serves ${path.join(deck.dir, deck.file)} (start it with: pindeck ${deckArg || "."}).`); process.exit(1); }
85
+ ({ url, token } = found.own || found.front);
86
+ if (!found.own) url = `${url}/d/${found.key}`;
76
87
  }
77
- if (!url) { console.error("The PinDeck server is not running (start it with: pindeck <deck-folder>)."); process.exit(1); }
78
88
 
79
89
  /* layout and shot take several slides, so the assistant needs no shell loop or pipe (which PinDeck refuses) */
80
90
  const queries = cmd === "pdf-figure" ? [figureQuery([...rest])]
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)))