twombly 0.2.0 → 0.3.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/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { spawn as spawn2 } from "node:child_process";
5
5
  import { randomBytes as randomBytes2 } from "node:crypto";
6
- import { copyFile, mkdir as mkdir7, mkdtemp, readFile as readFile7, rm as rm5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
6
+ import { copyFile as copyFile2, mkdtemp, readFile as readFile7, rm as rm5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { basename as basename2, extname as extname3, join as join9, resolve as resolve2 } from "node:path";
9
9
  import { fileURLToPath as fileURLToPath2 } from "node:url";
@@ -13,7 +13,7 @@ var LIMITS = {
13
13
  fileBytes: 25 * 1024 * 1024,
14
14
  frameBytes: 100 * 1024 * 1024,
15
15
  frameFiles: 5e3,
16
- canvasFrames: 200,
16
+ canvasFrames: 500,
17
17
  scanBytes: 8 * 1024 * 1024,
18
18
  titleChars: 120,
19
19
  nameChars: 120,
@@ -244,6 +244,12 @@ function cleanStroke(input) {
244
244
  }
245
245
  var WORKING_MS = 3 * 6e4;
246
246
  var WORKING_TAIL_MS = 3e4;
247
+ var COLORS = ["#F24822", "#FF7A00", "#14AE5C", "#0D99FF", "#9747FF", "#E5277B", "#00B5B8", "#7B61FF"];
248
+ function colorOf(id) {
249
+ let h = 0;
250
+ for (const c of String(id)) h = h * 31 + c.charCodeAt(0) >>> 0;
251
+ return COLORS[h % COLORS.length];
252
+ }
247
253
  var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
248
254
  var FRAME_GAP = 80;
249
255
  function nextPosition(frames, newRow = false) {
@@ -255,7 +261,7 @@ function nextPosition(frames, newRow = false) {
255
261
  }
256
262
 
257
263
  // ../../skills/twombly/SKILL.md
258
- var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 200 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. The canvas background moves so people can see you're at work: `init` and the slow commands keep it going for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
264
+ var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 500 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address. If `created` is false, the canvas already existed: see [Other agents and more canvases](#other-agents-and-more-canvases).\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<task>/<version>` so the person's source stays clean. Pick a `<task>` no other agent would, because a shared folder gets overwritten. Use the first approach below that works. The canvas tells people an agent is working: `init` and the slow commands keep that showing for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it in your own folder under `.twombly/work`; here, `composer`.\n\n```js\n// .twombly/work/composer/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/composer/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/composer/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/composer/vite.config.mjs` and `twombly add .twombly/work/composer/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## What the person is looking at\n\nWhen the person says \"this\" or \"these\" without naming a frame, run `twombly context`. It returns the frames they have open fullscreen, are using, or have selected, in that order, plus any comment they have open. `looking: false` means no canvas is open in a browser. If nothing is selected, ask which frame they mean.\n\n`twombly comments` returns every comment, with the frame it's on, where on the frame (in the frame's pixels), whether it's resolved, and `mine: true` for the person's own comments.\n\nEach frame in `context` and `list` has a `source`: the folder or file it was last added or updated from. Edit what built it, rebuild, then `update` the frame. Frames added before this existed show `null`.\n\n## Other agents and more canvases\n\nA folder has one default canvas, and every agent in that folder or below it lands on it. `init` on an existing canvas joins it and keeps its name. A folder can also hold any number of named canvases, each with its own address, frames and link.\n\n**Use a named canvas** when your work is a separate exploration, or the person asks for its own canvas: `twombly init --canvas \"Pricing\" --no-open`. Then pass `--canvas \"Pricing\"` on every command, because nothing remembers it between commands. `init --canvas` with a name that exists joins it, so agents can share one by agreeing on the name. `twombly canvases` lists what's here.\n\n**On a canvas with other agents:**\n\n- Only change your own frames, unless the person asks. Any agent's `update`, `move` and `remove` work on any frame, and `arrange` moves all of them.\n- Give frames titles no one else would use, such as \"Pricing: V1\", since commands find frames by title. A repeated title gets a warning, and then has to be addressed by id.\n- Add each set in one `--frames` command, with `--new-row` on its first frame, so it stays together.\n- If `update` says someone else changed a frame, run `twombly list` and don't retry without checking whose work you'd replace.\n- Run `twombly done` when you finish. The canvas keeps showing work until every agent that ran `init` has finished, or three minutes after the last activity.\n\n**Worktrees:** a canvas lives in the folder it was started in. A worktree outside the repo gets its own canvas, which is deleted with the worktree, so tell the person that before removing it. A worktree inside the repo joins the repo's default canvas if there is one.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and keeps the canvas on this machine, comments included.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
259
265
 
260
266
  // src/capture.ts
261
267
  import { createHash } from "node:crypto";
@@ -410,9 +416,9 @@ async function capture(url, out, options) {
410
416
  // src/daemon.ts
411
417
  import { createHash as createHash3, randomBytes } from "node:crypto";
412
418
  import { createReadStream as createReadStream2 } from "node:fs";
413
- import { readFile as readFile5, stat as stat3 } from "node:fs/promises";
419
+ import { copyFile, mkdir as mkdir6, readFile as readFile5, stat as stat3 } from "node:fs/promises";
414
420
  import { createServer as createServer2 } from "node:http";
415
- import { extname as extname2, join as join8 } from "node:path";
421
+ import { dirname as dirname3, extname as extname2, join as join8 } from "node:path";
416
422
  import { WebSocketServer, WebSocket } from "ws";
417
423
 
418
424
  // src/credentials.ts
@@ -429,6 +435,7 @@ var PORT = Number(process.env.TWOMBLY_PORT) || 4747;
429
435
  var ENDPOINT = (process.env.TWOMBLY_ENDPOINT || "https://twombly.app").replace(/\/+$/, "");
430
436
  var DAEMON_ORIGIN = `http://localhost:${PORT}`;
431
437
  var NO_OPEN = process.env.TWOMBLY_NO_OPEN === "1";
438
+ var TOOL_API = 3;
432
439
 
433
440
  // src/credentials.ts
434
441
  var run = promisify(execFile);
@@ -586,7 +593,7 @@ var checkedAt = 0;
586
593
  function interfaceFolders() {
587
594
  if (OVERRIDE) return [OVERRIDE];
588
595
  const folder = (released) => join5(STORE, String(released));
589
- return [...saved.filter((released) => released > 1793896544071).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793896544071).map(folder)];
596
+ return [...saved.filter((released) => released > 1793910384715).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793910384715).map(folder)];
590
597
  }
591
598
  function unpack(body) {
592
599
  const { payload, signature } = JSON.parse(body);
@@ -620,7 +627,7 @@ async function check() {
620
627
  const release = unpack(body);
621
628
  if (!release) return;
622
629
  etag = response.headers.get("etag") || "";
623
- if (release.released > Math.max(saved[0] ?? 0, 1793896544071)) await install(release.released, release.files);
630
+ if (release.released > Math.max(saved[0] ?? 0, 1793910384715)) await install(release.released, release.files);
624
631
  }
625
632
  function startCheck() {
626
633
  if (checking) return;
@@ -660,6 +667,44 @@ async function findProject(from) {
660
667
  dir = up;
661
668
  }
662
669
  }
670
+ function inOrder() {
671
+ let last = Promise.resolve();
672
+ return (work) => {
673
+ const next = last.then(work, work);
674
+ last = next.catch(() => {
675
+ });
676
+ return next;
677
+ };
678
+ }
679
+ var NAMED = "canvases";
680
+ function canvasSlug(name) {
681
+ const slug = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
682
+ return slug || createHash2("sha256").update(name).digest("hex").slice(0, 12);
683
+ }
684
+ var namedProject = (root, name) => join6(root, DIR, NAMED, canvasSlug(name));
685
+ async function canvasesAbove(from) {
686
+ const out = [];
687
+ const read = async (project, slug) => {
688
+ const canvas = await readCanvas(project).catch(() => null);
689
+ if (canvas) out.push({ project, slug, id: canvas.id, name: canvas.name, frames: canvas.frames.length, link: canvas.remote?.url || null });
690
+ };
691
+ for (let dir = resolve(from); ; dir = dirname2(dir)) {
692
+ await read(dir, null);
693
+ const named = await readdir2(join6(dir, DIR, NAMED)).catch(() => []);
694
+ for (const slug of named.sort()) await read(join6(dir, DIR, NAMED, slug), slug);
695
+ if (dirname2(dir) === dir) return out;
696
+ }
697
+ }
698
+ async function findNamed(from, ref) {
699
+ const slug = canvasSlug(ref), all = await canvasesAbove(from);
700
+ return all.find((entry) => entry.slug === slug || entry.id === ref) ?? all.find((entry) => canvasSlug(entry.name) === slug) ?? null;
701
+ }
702
+ async function findRoot(from) {
703
+ for (let dir = resolve(from); ; dir = dirname2(dir)) {
704
+ if (await exists(join6(dir, DIR, "canvas.json")) || await exists(join6(dir, DIR, NAMED))) return dir;
705
+ if (dirname2(dir) === dir) return null;
706
+ }
707
+ }
663
708
  var canvasFile = (project) => join6(project, DIR, "canvas.json");
664
709
  var frameDir = (project, id) => join6(project, DIR, "frames", id);
665
710
  var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
@@ -675,6 +720,7 @@ async function ensureProjectDir(project) {
675
720
  await mkdir4(join6(project, DIR, "frames"), { recursive: true });
676
721
  await mkdir4(join6(project, DIR, "posters"), { recursive: true });
677
722
  await writeFile4(join6(project, DIR, ".gitignore"), "*\n");
723
+ if (basename(dirname2(project)) === NAMED && basename(dirname2(dirname2(project))) === DIR) await writeFile4(join6(dirname2(dirname2(project)), ".gitignore"), "*\n");
678
724
  }
679
725
  function defaultName(project) {
680
726
  return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
@@ -700,12 +746,16 @@ async function folderContent(folder) {
700
746
  return { files, content: await contentHash(files) };
701
747
  }
702
748
  async function replaceFolder(source, target) {
703
- const temp = `${target}.${process.pid}.tmp`;
749
+ const temp = `${target}.${process.pid}.tmp`, old = `${target}.${process.pid}.old`;
704
750
  await rm4(temp, { recursive: true, force: true });
751
+ await rm4(old, { recursive: true, force: true });
705
752
  await mkdir4(dirname2(target), { recursive: true });
706
753
  await cp(source, temp, { recursive: true, verbatimSymlinks: false, filter: (path) => basename(path) !== ".DS_Store" });
707
- await rm4(target, { recursive: true, force: true });
754
+ await rename2(target, old).catch((error) => {
755
+ if (error.code !== "ENOENT") throw error;
756
+ });
708
757
  await rename2(temp, target);
758
+ await rm4(old, { recursive: true, force: true });
709
759
  }
710
760
  var registryFile = join6(HOME, "registry.json");
711
761
  async function readRegistry() {
@@ -715,12 +765,17 @@ async function readRegistry() {
715
765
  return {};
716
766
  }
717
767
  }
718
- async function register(id, project) {
719
- const registry = await readRegistry();
720
- if (registry[id] === project) return;
721
- registry[id] = project;
722
- await mkdir4(HOME, { recursive: true });
723
- await writeFile4(registryFile, JSON.stringify(registry, null, 2));
768
+ var registryTurn = inOrder();
769
+ function register(id, project) {
770
+ return registryTurn(async () => {
771
+ const registry = await readRegistry();
772
+ if (registry[id] === project) return;
773
+ registry[id] = project;
774
+ await mkdir4(HOME, { recursive: true });
775
+ const temp = `${registryFile}.${process.pid}.tmp`;
776
+ await writeFile4(temp, JSON.stringify(registry, null, 2));
777
+ await rename2(temp, registryFile);
778
+ });
724
779
  }
725
780
 
726
781
  // src/settings.ts
@@ -782,6 +837,10 @@ async function edge(path, init = {}) {
782
837
  return data;
783
838
  }
784
839
  var STROKE_UPLOAD = 40;
840
+ var THREAD_UPLOAD_CHARS = 5e5;
841
+ var THREAD_ID = /^[a-z0-9]{8,32}$/;
842
+ var finite = (value) => typeof value === "number" && Number.isFinite(value);
843
+ var FRAME_SERVERS_IDLE_MS = Number(process.env.TWOMBLY_FRAME_IDLE_MS) || 5 * 6e4;
785
844
  var Canvas = class {
786
845
  id;
787
846
  project;
@@ -791,7 +850,13 @@ var Canvas = class {
791
850
  servers = /* @__PURE__ */ new Map();
792
851
  queue = Promise.resolve();
793
852
  saveTimer = null;
853
+ serversTimer = null;
854
+ writeTurn = inOrder();
794
855
  workingUntil = 0;
856
+ agents = 0;
857
+ contexts = /* @__PURE__ */ new Map();
858
+ /** So undoing a delete in the page keeps where the frame came from. */
859
+ removedSources = /* @__PURE__ */ new Map();
795
860
  constructor(project, data) {
796
861
  this.project = project;
797
862
  this.data = data;
@@ -807,13 +872,26 @@ var Canvas = class {
807
872
  save() {
808
873
  if (this.saveTimer) clearTimeout(this.saveTimer);
809
874
  this.saveTimer = setTimeout(() => {
810
- writeCanvas(this.project, this.data).catch(() => {
875
+ this.writeTurn(() => writeCanvas(this.project, this.data)).catch(() => {
811
876
  });
812
877
  }, 150);
813
878
  }
814
879
  async saveNow() {
815
880
  if (this.saveTimer) clearTimeout(this.saveTimer);
816
- await writeCanvas(this.project, this.data);
881
+ await this.writeTurn(() => writeCanvas(this.project, this.data));
882
+ }
883
+ /** Frame servers are only for people looking. They come back, on new ports, the next time someone does. */
884
+ closeServersWhenUnwatched() {
885
+ if (this.serversTimer) clearTimeout(this.serversTimer);
886
+ this.serversTimer = setTimeout(() => {
887
+ if (this.sockets.size) return;
888
+ for (const running of this.servers.values()) running.then((server) => server.close());
889
+ this.servers.clear();
890
+ }, FRAME_SERVERS_IDLE_MS);
891
+ }
892
+ /** Tells anyone looking about a frame, without starting its server for nobody. */
893
+ async announce(frame) {
894
+ if (this.sockets.size) this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
817
895
  }
818
896
  serverFor(frame) {
819
897
  const key = `${frame.id}:${frame.content}`;
@@ -848,9 +926,18 @@ var Canvas = class {
848
926
  workingLeft() {
849
927
  return Math.max(0, this.workingUntil - Date.now());
850
928
  }
851
- /** An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it. */
852
- working(ms) {
853
- this.workingUntil = ms > 0 ? Math.max(this.workingUntil, Date.now() + ms) : 0;
929
+ /**
930
+ * An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it.
931
+ * Agents have no identity, so each `init` counts as one more and it only stops once each has finished, or time runs out.
932
+ */
933
+ working(ms, joining = false) {
934
+ if (!this.workingLeft()) this.agents = 0;
935
+ if (joining) this.agents++;
936
+ if (ms > 0) this.workingUntil = Math.max(this.workingUntil, Date.now() + ms);
937
+ else if (--this.agents <= 0) {
938
+ this.agents = 0;
939
+ this.workingUntil = 0;
940
+ }
854
941
  const left = this.workingLeft();
855
942
  this.broadcast({ t: "working", ms: left });
856
943
  if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
@@ -865,7 +952,10 @@ var Canvas = class {
865
952
  this.sockets.add(socket);
866
953
  touch();
867
954
  const self = randomId(10);
868
- socket.on("close", () => this.sockets.delete(socket));
955
+ socket.on("close", () => {
956
+ this.sockets.delete(socket);
957
+ this.closeServersWhenUnwatched();
958
+ });
869
959
  socket.on("message", (raw) => {
870
960
  let message;
871
961
  try {
@@ -883,22 +973,104 @@ var Canvas = class {
883
973
  self,
884
974
  role: "owner",
885
975
  doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
886
- threads: [],
976
+ threads: this.data.threads ?? [],
887
977
  peers: [this.peer(self)],
888
- capabilities: { comments: false, huddle: false },
978
+ // Shared but signed out, this is a copy the room can't hear about.
979
+ capabilities: { comments: !this.data.remote, huddle: false },
889
980
  working: this.workingLeft()
890
981
  }));
891
982
  }
892
983
  attachEvents(socket) {
893
984
  this.events.add(socket);
894
- socket.on("close", () => this.events.delete(socket));
985
+ socket.on("close", () => {
986
+ this.events.delete(socket);
987
+ this.contexts.delete(socket);
988
+ });
989
+ socket.on("message", (raw) => {
990
+ let message;
991
+ try {
992
+ message = JSON.parse(String(raw));
993
+ } catch {
994
+ return;
995
+ }
996
+ const context = message?.t === "context" ? cleanContext(message.context) : null;
997
+ if (context) this.contexts.set(socket, { context, at: Date.now() });
998
+ });
999
+ }
1000
+ /** What the person means by "this", from whichever of their windows changed it last. Null when none is open. */
1001
+ context() {
1002
+ let latest = null;
1003
+ for (const entry of this.contexts.values()) if (!latest || entry.at > latest.at) latest = entry;
1004
+ return latest?.context ?? null;
1005
+ }
1006
+ // ---------- comments, while local ----------
1007
+ author() {
1008
+ const me = credential?.me, id = me?.id || "local";
1009
+ return { id, name: me?.name || "You", picture: me?.picture || null, color: colorOf(id) };
1010
+ }
1011
+ thread(id) {
1012
+ return (this.data.threads ?? []).find((thread) => thread.id === id);
1013
+ }
1014
+ putThread(thread) {
1015
+ const threads = this.data.threads ??= [], index = threads.findIndex((item) => item.id === thread.id);
1016
+ if (index < 0) threads.push(thread);
1017
+ else threads[index] = thread;
1018
+ this.broadcast({ t: "thread", thread });
1019
+ this.save();
1020
+ }
1021
+ dropThread(id) {
1022
+ this.data.threads = (this.data.threads ?? []).filter((thread) => thread.id !== id);
1023
+ this.broadcast({ t: "thread.remove", id });
1024
+ this.save();
1025
+ }
1026
+ frameIdOf(id) {
1027
+ return typeof id === "string" && this.data.frames.some((frame) => frame.id === id) ? id : null;
1028
+ }
1029
+ handleComment(m) {
1030
+ const text2 = (value) => typeof value === "string" ? value.trim().slice(0, LIMITS.commentChars) : "";
1031
+ const comment = (value) => ({ id: randomId(12), author: this.author(), text: value, createdAt: Date.now() });
1032
+ switch (m.t) {
1033
+ case "thread.create": {
1034
+ const value = text2(m.text);
1035
+ if (!value || !finite(m.x) || !finite(m.y) || typeof m.id !== "string" || !THREAD_ID.test(m.id) || this.thread(m.id)) return;
1036
+ const first = comment(value);
1037
+ return this.putThread({ id: m.id, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y, resolved: false, createdAt: first.createdAt, comments: [first] });
1038
+ }
1039
+ case "thread.move": {
1040
+ const thread = this.thread(m.id);
1041
+ if (thread && finite(m.x) && finite(m.y)) this.putThread({ ...thread, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y });
1042
+ return;
1043
+ }
1044
+ case "thread.resolve": {
1045
+ const thread = this.thread(m.id);
1046
+ if (thread) this.putThread({ ...thread, resolved: !!m.resolved });
1047
+ return;
1048
+ }
1049
+ case "thread.delete":
1050
+ if (this.thread(m.id)) this.dropThread(m.id);
1051
+ return;
1052
+ case "comment.add": {
1053
+ const thread = this.thread(m.threadId), value = text2(m.text);
1054
+ if (thread && value) this.putThread({ ...thread, comments: [...thread.comments, comment(value)] });
1055
+ return;
1056
+ }
1057
+ case "comment.delete": {
1058
+ const thread = this.thread(m.threadId);
1059
+ if (!thread || !thread.comments.some((item) => item.id === m.id)) return;
1060
+ if (thread.comments[0].id === m.id) return this.dropThread(thread.id);
1061
+ this.putThread({ ...thread, comments: thread.comments.filter((item) => item.id !== m.id) });
1062
+ }
1063
+ }
895
1064
  }
896
1065
  async handle(socket, m) {
897
1066
  if (m.t === "ping") return socket.send('{"t":"pong"}');
898
1067
  if (this.data.remote) return;
899
1068
  if (m.t === "frame.patch") return this.applyPatch(m.id, m.patch);
1069
+ if (m.t.startsWith("thread.") || m.t.startsWith("comment.")) return this.handleComment(m);
900
1070
  if (m.t === "frame.remove") {
901
1071
  const before = this.data.frames.length;
1072
+ const removed = this.data.frames.find((frame) => frame.id === m.id);
1073
+ if (removed) this.removedSources.set(removed.id, removed.source);
902
1074
  this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
903
1075
  if (this.data.frames.length !== before) {
904
1076
  this.broadcast({ t: "frame.remove", id: m.id });
@@ -909,9 +1081,9 @@ var Canvas = class {
909
1081
  if (m.t === "frame.restore") {
910
1082
  const f = m.frame;
911
1083
  if (this.data.frames.some((frame) => frame.id === f.id)) return;
912
- const restored = { id: f.id, title: f.title, x: f.x, y: f.y, w: f.w, h: f.h, z: f.z, entry: f.entry, content: f.content, level: f.level, hasPoster: !!f.posterUrl, updatedAt: Date.now() };
1084
+ const restored = { id: f.id, title: f.title, x: f.x, y: f.y, w: f.w, h: f.h, z: f.z, entry: f.entry, content: f.content, level: f.level, hasPoster: !!f.posterUrl, updatedAt: Date.now(), source: this.removedSources.get(f.id) };
913
1085
  this.data.frames.push(restored);
914
- this.broadcast({ t: "frame.upsert", frame: await this.toFrame(restored) });
1086
+ await this.announce(restored);
915
1087
  this.save();
916
1088
  return;
917
1089
  }
@@ -948,16 +1120,16 @@ var Canvas = class {
948
1120
  frame.w = Math.max(40, Math.min(8e3, frame.w));
949
1121
  frame.h = Math.max(40, Math.min(8e3, frame.h));
950
1122
  frame.updatedAt = Date.now();
951
- this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
1123
+ await this.announce(frame);
952
1124
  this.save();
953
1125
  return frame;
954
1126
  }
955
1127
  /** Once shared, the room is the truth. Bring the local list up to date with it. */
956
1128
  async pull() {
957
1129
  if (!this.data.remote) return;
958
- let doc;
1130
+ let doc, threads;
959
1131
  try {
960
- ({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
1132
+ ({ doc, threads } = await edge(`/api/canvases/${this.data.remote.id}`));
961
1133
  } catch (error) {
962
1134
  if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
963
1135
  this.data.remote = null;
@@ -969,6 +1141,7 @@ var Canvas = class {
969
1141
  }
970
1142
  this.data.name = doc.name;
971
1143
  if (doc.strokes) this.data.strokes = doc.strokes;
1144
+ if (threads) this.data.threads = threads;
972
1145
  const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
973
1146
  this.data.frames = doc.frames.map((frame) => ({
974
1147
  id: frame.id,
@@ -982,7 +1155,8 @@ var Canvas = class {
982
1155
  content: frame.content,
983
1156
  level: frame.level,
984
1157
  hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
985
- updatedAt: frame.updatedAt
1158
+ updatedAt: frame.updatedAt,
1159
+ source: known.get(frame.id)?.source
986
1160
  }));
987
1161
  await this.saveNow();
988
1162
  }
@@ -1031,6 +1205,19 @@ var Canvas = class {
1031
1205
  for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
1032
1206
  await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
1033
1207
  }
1208
+ let batch = [], chars = 0;
1209
+ const send = async () => {
1210
+ if (batch.length) await edge(`/api/canvases/${created.id}/threads`, { method: "POST", body: JSON.stringify({ threads: batch }) });
1211
+ batch = [];
1212
+ chars = 0;
1213
+ };
1214
+ for (const thread of this.data.threads ?? []) {
1215
+ const size2 = JSON.stringify(thread).length;
1216
+ if (chars + size2 > THREAD_UPLOAD_CHARS) await send();
1217
+ batch.push(thread);
1218
+ chars += size2;
1219
+ }
1220
+ await send();
1034
1221
  } catch (error) {
1035
1222
  await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
1036
1223
  });
@@ -1043,8 +1230,8 @@ var Canvas = class {
1043
1230
  }
1044
1231
  async unshare() {
1045
1232
  if (!this.data.remote) return;
1046
- await this.pull().catch(() => {
1047
- });
1233
+ await this.pull();
1234
+ if (!this.data.remote) return;
1048
1235
  await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
1049
1236
  if (error.status !== 404) throw error;
1050
1237
  });
@@ -1054,16 +1241,24 @@ var Canvas = class {
1054
1241
  }
1055
1242
  };
1056
1243
  var canvases = /* @__PURE__ */ new Map();
1057
- async function canvasById(id) {
1244
+ var opening = /* @__PURE__ */ new Map();
1245
+ function canvasById(id) {
1058
1246
  const open = canvases.get(id);
1059
- if (open) return open;
1060
- const project = (await readRegistry())[id];
1061
- if (!project) throw new Failure(404, "That canvas is not on this machine");
1062
- const data = await readCanvas(project).catch(() => null);
1063
- if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
1064
- const canvas = new Canvas(project, data);
1065
- canvases.set(id, canvas);
1066
- return canvas;
1247
+ if (open) return Promise.resolve(open);
1248
+ let pending = opening.get(id);
1249
+ if (!pending) {
1250
+ pending = (async () => {
1251
+ const project = (await readRegistry())[id];
1252
+ if (!project) throw new Failure(404, "That canvas is not on this machine");
1253
+ const data = await readCanvas(project).catch(() => null);
1254
+ if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
1255
+ const canvas = new Canvas(project, data);
1256
+ canvases.set(id, canvas);
1257
+ return canvas;
1258
+ })().finally(() => opening.delete(id));
1259
+ opening.set(id, pending);
1260
+ }
1261
+ return pending;
1067
1262
  }
1068
1263
  async function canvasForProject(project) {
1069
1264
  const found = await findProject(project);
@@ -1085,8 +1280,36 @@ var summary = (canvas) => ({
1085
1280
  name: canvas.data.name,
1086
1281
  local: `${DAEMON_ORIGIN}/${canvas.id}`,
1087
1282
  link: canvas.data.remote?.url || null,
1088
- frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2 }) => ({ id, title, x, y, w, h, entry, level: level2 }))
1283
+ frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2, content, source }) => ({ id, title, x, y, w, h, entry, level: level2, content, source: source ?? null }))
1089
1284
  });
1285
+ function cleanContext(input) {
1286
+ const c = input;
1287
+ if (!c || typeof c !== "object" || !Array.isArray(c.frames)) return null;
1288
+ const via = c.via === "fullscreen" || c.via === "interacting" || c.via === "selected" ? c.via : null;
1289
+ const frames = c.frames.filter((id) => typeof id === "string").slice(0, LIMITS.canvasFrames);
1290
+ return { via, frames, thread: typeof c.thread === "string" ? c.thread : null };
1291
+ }
1292
+ function commentsOf(canvas, thread) {
1293
+ const me = credential?.me?.id;
1294
+ const frame = thread.frameId ? canvas.data.frames.find((item) => item.id === thread.frameId) : void 0;
1295
+ return {
1296
+ id: thread.id,
1297
+ frame: frame ? { id: frame.id, title: frame.title } : null,
1298
+ // Where the pin is: in the frame's own pixels, or on the open canvas.
1299
+ at: frame ? { x: Math.round(thread.x * frame.w), y: Math.round(thread.y * frame.h) } : { x: Math.round(thread.x), y: Math.round(thread.y) },
1300
+ resolved: thread.resolved,
1301
+ comments: thread.comments.map((comment) => ({ by: comment.author.name, mine: comment.author.id === "local" || comment.author.id === me, text: comment.text, at: new Date(comment.createdAt).toISOString() }))
1302
+ };
1303
+ }
1304
+ var visibleThreads = (canvas) => (canvas.data.threads ?? []).filter((thread) => !thread.frameId || canvas.data.frames.some((frame) => frame.id === thread.frameId));
1305
+ function contextOf(canvas) {
1306
+ const context = canvas.context();
1307
+ if (!context) return { looking: false, via: null, frames: [], comment: null };
1308
+ const frames = context.frames.flatMap((id) => canvas.data.frames.filter((frame) => frame.id === id)).map(({ id, title, w, h, entry, source }) => ({ id, title, w, h, entry, source: source ?? null }));
1309
+ const thread = visibleThreads(canvas).find((item) => item.id === context.thread);
1310
+ return { looking: true, via: frames.length ? context.via : null, frames, comment: thread ? commentsOf(canvas, thread) : null };
1311
+ }
1312
+ var initTurn = inOrder();
1090
1313
  function findFrame(canvas, ref) {
1091
1314
  const byId = canvas.data.frames.find((frame) => frame.id === ref);
1092
1315
  if (byId) return byId;
@@ -1097,6 +1320,7 @@ function findFrame(canvas, ref) {
1097
1320
  async function command(name, input) {
1098
1321
  touch();
1099
1322
  if (name === "stop") {
1323
+ if (input.ifIdle && inFlight > 1) return { stopped: false };
1100
1324
  setTimeout(() => process.exit(0), 50);
1101
1325
  return { stopped: true };
1102
1326
  }
@@ -1119,18 +1343,21 @@ async function command(name, input) {
1119
1343
  return { signedIn: false };
1120
1344
  }
1121
1345
  if (name === "init") {
1122
- const project = input.project;
1123
- let found = await findProject(project);
1124
- if (!found) {
1125
- await ensureProjectDir(project);
1126
- await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
1127
- found = project;
1128
- }
1129
- const canvas2 = await canvasForProject(found);
1130
- if (input.rename && input.name && input.name !== canvas2.data.name) await rename4(canvas2, input.name);
1131
- if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
1132
- canvas2.working(WORKING_MS);
1133
- return summary(canvas2);
1346
+ return initTurn(async () => {
1347
+ const project = input.project;
1348
+ const nearest = await findProject(project);
1349
+ let found = input.exact && nearest !== project ? null : nearest;
1350
+ const created = !found;
1351
+ if (!found) {
1352
+ await ensureProjectDir(project);
1353
+ await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
1354
+ found = project;
1355
+ }
1356
+ const canvas2 = await canvasForProject(found);
1357
+ if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
1358
+ canvas2.working(WORKING_MS, true);
1359
+ return { ...summary(canvas2), created };
1360
+ });
1134
1361
  }
1135
1362
  const canvas = await canvasForProject(input.project);
1136
1363
  if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
@@ -1139,6 +1366,10 @@ async function command(name, input) {
1139
1366
  switch (name) {
1140
1367
  case "list":
1141
1368
  return summary(canvas);
1369
+ case "context":
1370
+ return contextOf(canvas);
1371
+ case "comments":
1372
+ return { comments: visibleThreads(canvas).map((thread) => commentsOf(canvas, thread)) };
1142
1373
  case "open":
1143
1374
  openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
1144
1375
  return summary(canvas);
@@ -1207,45 +1438,76 @@ async function patchFrame(canvas, id, patch) {
1207
1438
  await canvas.applyPatch(id, patch);
1208
1439
  await canvas.saveNow();
1209
1440
  }
1441
+ async function install2(canvas, id, folder, screenshot) {
1442
+ await replaceFolder(folder, frameDir(canvas.project, id));
1443
+ if (screenshot) {
1444
+ const poster = posterFile(canvas.project, id);
1445
+ await mkdir6(dirname3(poster), { recursive: true });
1446
+ await copyFile(screenshot, poster);
1447
+ }
1448
+ return (await folderContent(frameDir(canvas.project, id))).content;
1449
+ }
1450
+ var placedFrame = ({ id, title, x, y, w, h }) => ({ id, title, x, y, w, h });
1451
+ var canvasResult = (canvas) => ({ local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length });
1210
1452
  async function place(canvas, input) {
1211
- const position = nextPosition(canvas.data.frames, !!input.newRow);
1212
- const frame = {
1213
- id: input.id,
1214
- title: String(input.title).slice(0, LIMITS.titleChars),
1215
- w: input.w,
1216
- h: input.h,
1217
- x: typeof input.x === "number" ? input.x : position.x,
1218
- y: typeof input.y === "number" ? input.y : position.y,
1219
- z: canvas.data.frames.length,
1220
- entry: input.entry,
1221
- content: input.content,
1222
- level: input.level,
1223
- hasPoster: !!input.hasPoster,
1224
- updatedAt: Date.now()
1225
- };
1226
- if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
1227
- canvas.data.frames.push(frame);
1228
- await canvas.saveNow();
1229
- canvas.broadcast({ t: "frame.upsert", frame: await canvas.toFrame(frame) });
1453
+ const count = canvas.data.frames.length;
1454
+ if (count + input.items.length > LIMITS.canvasFrames) throw new Failure(422, `A canvas can hold ${LIMITS.canvasFrames} frames, and this one has ${count}. Remove some, or start another: twombly init --canvas "Name"`);
1455
+ const frames = [];
1456
+ for (const item of input.items) {
1457
+ const title = String(item.title).slice(0, LIMITS.titleChars);
1458
+ const warnings = canvas.data.frames.some((other) => other.title === title) ? [`Another frame is already titled "${title}". Give each a distinct title, or use update to replace it.`] : [];
1459
+ const content = await install2(canvas, item.id, item.folder, item.screenshot);
1460
+ const position = nextPosition(canvas.data.frames, !!item.newRow);
1461
+ const frame = {
1462
+ id: item.id,
1463
+ title,
1464
+ w: item.w,
1465
+ h: item.h,
1466
+ x: typeof item.x === "number" ? item.x : position.x,
1467
+ y: typeof item.y === "number" ? item.y : position.y,
1468
+ z: canvas.data.frames.length,
1469
+ entry: item.entry,
1470
+ content,
1471
+ level: item.level,
1472
+ hasPoster: !!item.screenshot,
1473
+ updatedAt: Date.now(),
1474
+ source: typeof item.source === "string" ? item.source : void 0
1475
+ };
1476
+ if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
1477
+ canvas.data.frames.push(frame);
1478
+ await canvas.saveNow();
1479
+ await canvas.announce(frame);
1480
+ frames.push({ ...placedFrame(frame), warnings });
1481
+ }
1230
1482
  canvas.working(WORKING_TAIL_MS);
1231
- return { frame: { id: frame.id, title: frame.title, x: frame.x, y: frame.y, w: frame.w, h: frame.h }, canvas: { local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length } };
1483
+ return { frames, canvas: canvasResult(canvas) };
1232
1484
  }
1233
1485
  async function revise(canvas, input) {
1234
- const frame = canvas.data.frames.find((item) => item.id === input.id);
1235
- if (!frame) throw new Failure(404, "That frame is gone");
1236
- const next = { ...frame, updatedAt: Date.now() };
1237
- if (input.title) next.title = String(input.title).slice(0, LIMITS.titleChars);
1238
- for (const key of ["w", "h"]) if (typeof input[key] === "number") next[key] = input[key];
1239
- if (input.entry) next.entry = input.entry;
1240
- if (input.level) next.level = input.level;
1241
- if (input.content) next.content = input.content;
1242
- if (typeof input.hasPoster === "boolean") next.hasPoster = input.hasPoster;
1243
- if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
1244
- Object.assign(frame, next);
1245
- await canvas.saveNow();
1246
- canvas.broadcast({ t: "frame.upsert", frame: await canvas.toFrame(frame) });
1486
+ const targets = input.items.map((item) => {
1487
+ const frame = canvas.data.frames.find((other) => other.id === item.id);
1488
+ if (!frame) throw new Failure(404, `"${item.label}" is gone. Run: twombly list`);
1489
+ if (frame.content !== item.base) throw new Failure(409, `"${frame.title}" was updated by someone else while this was being checked, so nothing changed. Run twombly list, and update again only if you mean to replace their work.`);
1490
+ return frame;
1491
+ });
1492
+ const frames = [];
1493
+ for (const [index, item] of input.items.entries()) {
1494
+ const frame = targets[index];
1495
+ const next = { ...frame, updatedAt: Date.now() };
1496
+ if (item.title) next.title = String(item.title).slice(0, LIMITS.titleChars);
1497
+ for (const key of ["w", "h"]) if (typeof item[key] === "number") next[key] = item[key];
1498
+ if (item.entry) next.entry = item.entry;
1499
+ if (item.level) next.level = item.level;
1500
+ if (typeof item.source === "string") next.source = item.source;
1501
+ next.content = await install2(canvas, item.id, item.folder, item.screenshot);
1502
+ if (item.screenshot) next.hasPoster = true;
1503
+ if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
1504
+ Object.assign(frame, next);
1505
+ await canvas.saveNow();
1506
+ await canvas.announce(frame);
1507
+ frames.push({ ...placedFrame(frame), warnings: [] });
1508
+ }
1247
1509
  canvas.working(WORKING_TAIL_MS);
1248
- return { frame: { id: frame.id, title: frame.title, x: frame.x, y: frame.y, w: frame.w, h: frame.h }, canvas: { local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length } };
1510
+ return { frames, canvas: canvasResult(canvas) };
1249
1511
  }
1250
1512
  var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
1251
1513
  var reply = (response, status, value) => {
@@ -1331,7 +1593,7 @@ async function servePreview(response, canvasId, frameId) {
1331
1593
  async function route(request, response) {
1332
1594
  const url = new URL(request.url || "/", DAEMON_ORIGIN);
1333
1595
  const path = url.pathname, method = request.method || "GET";
1334
- if (path === "/health") return reply(response, 200, { ok: true, build: "0.2.0+1793896544071", pid: process.pid });
1596
+ if (path === "/health") return reply(response, 200, { ok: true, build: "0.3.0+1793910384715", api: TOOL_API, pid: process.pid });
1335
1597
  if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
1336
1598
  if (path === "/auth/callback" && method === "GET") {
1337
1599
  const code = url.searchParams.get("code"), state = url.searchParams.get("state");
@@ -1386,6 +1648,7 @@ async function route(request, response) {
1386
1648
  if (preview) return servePreview(response, preview[1], preview[2]);
1387
1649
  return serveUi(response, path);
1388
1650
  }
1651
+ var inFlight = 0;
1389
1652
  var idleTimer = null;
1390
1653
  function touch() {
1391
1654
  if (idleTimer) clearTimeout(idleTimer);
@@ -1403,6 +1666,8 @@ async function runDaemon() {
1403
1666
  response.end('{"error":"Forbidden"}');
1404
1667
  return;
1405
1668
  }
1669
+ const counted = /^\/(cli|api\/canvases)\//.test(request.url || "");
1670
+ if (counted) inFlight++;
1406
1671
  try {
1407
1672
  await route(request, response);
1408
1673
  } catch (error) {
@@ -1413,6 +1678,8 @@ async function runDaemon() {
1413
1678
  if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
1414
1679
  console.error(error);
1415
1680
  reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
1681
+ } finally {
1682
+ if (counted) inFlight--;
1416
1683
  }
1417
1684
  });
1418
1685
  const sockets = new WebSocketServer({ noServer: true });
@@ -1443,8 +1710,8 @@ async function runDaemon() {
1443
1710
 
1444
1711
  // src/gates.ts
1445
1712
  import { createServer as createServer3 } from "node:http";
1446
- import { mkdir as mkdir6, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
1447
- import { dirname as dirname3 } from "node:path";
1713
+ import { mkdir as mkdir7, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
1714
+ import { dirname as dirname4 } from "node:path";
1448
1715
  var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
1449
1716
  async function scanFolder(folder) {
1450
1717
  const problems = [];
@@ -1571,7 +1838,7 @@ async function browserGates(folder, options, launch) {
1571
1838
  const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
1572
1839
  if (shot) {
1573
1840
  if (options.poster) {
1574
- await mkdir6(dirname3(options.poster), { recursive: true });
1841
+ await mkdir7(dirname4(options.poster), { recursive: true });
1575
1842
  await writeFile6(options.poster, shot);
1576
1843
  }
1577
1844
  const colors = await page.evaluate(async (base64) => {
@@ -1635,9 +1902,12 @@ async function runGates(folder, options) {
1635
1902
  }
1636
1903
 
1637
1904
  // src/cli.ts
1638
- var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
1905
+ var HELP = `twombly ${"0.3.0"}: put design explorations on a shared canvas.
1639
1906
 
1640
- init [--name "Name"] [--no-open] create or reopen this project's canvas
1907
+ init [--name "Name"] [--no-open] create or join this folder's canvas
1908
+ init --canvas "Name" [--no-open] create or join a separate named canvas in this folder
1909
+ canvases list the canvases this folder can reach
1910
+ --canvas "Name" works on every command below, and picks that canvas
1641
1911
  add <folder|file> --title "T" run the gates, then place a frame
1642
1912
  [--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
1643
1913
  [--new-row] [--x N --y N]
@@ -1650,6 +1920,8 @@ var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
1650
1920
  [--width 1280] [--height 800] [--image] [--selector css]
1651
1921
  preview <frame> a page with just that frame, for testing interactions
1652
1922
  working | done show on the canvas that you're building, or that you've finished
1923
+ context what the person has selected, open fullscreen or open as a comment
1924
+ comments every comment, and which are the person's own
1653
1925
  list | move <frame> --x N --y N | resize <frame> --width N --height N
1654
1926
  retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
1655
1927
  open | status | login | logout | stop
@@ -1688,7 +1960,7 @@ function parse(argv) {
1688
1960
  return { positional, flags };
1689
1961
  }
1690
1962
  function checkFlags(name, flags) {
1691
- const known = FLAGS[name] || [];
1963
+ const known = name === "canvases" ? [] : [...FLAGS[name] || [], "canvas"];
1692
1964
  for (const flag of Object.keys(flags)) {
1693
1965
  if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
1694
1966
  }
@@ -1728,18 +2000,31 @@ async function health() {
1728
2000
  return null;
1729
2001
  }
1730
2002
  }
2003
+ var builtAt = (build) => Number(build.split("+")[1]) || 0;
2004
+ var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
2005
+ var IDLE_WAIT_MS = 6e4;
2006
+ var usable = (running) => !!running && (running.build === "0.3.0+1793910384715" || running.api === TOOL_API && builtAt(running.build) > builtAt("0.3.0+1793910384715"));
1731
2007
  async function ensureDaemon() {
1732
2008
  const running = await health();
1733
- if (running?.build === "0.2.0+1793896544071") return;
2009
+ if (usable(running)) return;
1734
2010
  if (running) {
1735
- await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
1736
- });
1737
- await new Promise((done) => setTimeout(done, 200));
2011
+ if (builtAt(running.build) > builtAt("0.3.0+1793910384715")) fail("A newer twombly is running on this machine. Run this with twombly@latest.");
2012
+ let stopped = false;
2013
+ for (const end = Date.now() + IDLE_WAIT_MS; !stopped && Date.now() < end; ) {
2014
+ const answer = await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: '{"ifIdle":true}' }).then((response) => response.json()).catch(() => ({ stopped: true }));
2015
+ stopped = !!answer.stopped;
2016
+ if (!stopped) await sleep(500);
2017
+ }
2018
+ if (!stopped) {
2019
+ if (running.api === TOOL_API) return;
2020
+ fail("The background server is busy with another agent's work. Try again in a minute.");
2021
+ }
2022
+ await sleep(200);
1738
2023
  }
1739
2024
  spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
1740
2025
  for (let attempt = 0; attempt < 60; attempt++) {
1741
- await new Promise((done) => setTimeout(done, 100));
1742
- if ((await health())?.build === "0.2.0+1793896544071") return;
2026
+ await sleep(100);
2027
+ if (usable(await health())) return;
1743
2028
  }
1744
2029
  fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
1745
2030
  }
@@ -1761,20 +2046,27 @@ async function prepare(source) {
1761
2046
  const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
1762
2047
  if (isImage) {
1763
2048
  const name = `image${extension}`;
1764
- await copyFile(source, join9(temporary, name));
2049
+ await copyFile2(source, join9(temporary, name));
1765
2050
  await writeFile7(join9(temporary, "index.html"), `<!doctype html><meta charset="utf-8"><title>${basename2(source)}</title><style>html,body{margin:0;background:#fff}img{display:block;width:100%;height:auto}</style><img src="${name}" alt="">`);
1766
2051
  } else {
1767
- await copyFile(source, join9(temporary, "index.html"));
2052
+ await copyFile2(source, join9(temporary, "index.html"));
1768
2053
  }
1769
2054
  return { folder: temporary, temporary, image: isImage };
1770
2055
  }
2056
+ var chosenCanvas;
2057
+ async function locate() {
2058
+ if (chosenCanvas === void 0) return findProject(process.cwd());
2059
+ return (await findNamed(process.cwd(), chosenCanvas))?.project ?? null;
2060
+ }
1771
2061
  async function currentProject() {
1772
- const project = await findProject(process.cwd());
1773
- if (!project) fail("No canvas here yet. Run: twombly init");
1774
- return project;
2062
+ const project = await locate();
2063
+ if (project) return project;
2064
+ if (chosenCanvas !== void 0) fail(`No canvas "${chosenCanvas}" here. Run: twombly init --canvas "${chosenCanvas}"`);
2065
+ const others = await canvasesAbove(process.cwd());
2066
+ fail(others.length ? `This folder has no default canvas. Use --canvas with one of: ${others.map((entry) => `"${entry.name}"`).join(", ")}.` : "No canvas here yet. Run: twombly init");
1775
2067
  }
1776
2068
  async function markWorking() {
1777
- const project = await findProject(process.cwd());
2069
+ const project = await locate();
1778
2070
  if (project) await call("working", { project, ms: WORKING_MS });
1779
2071
  }
1780
2072
  function gateFailure(result) {
@@ -1808,7 +2100,7 @@ async function frameItems(flags, known) {
1808
2100
  return merged;
1809
2101
  });
1810
2102
  }
1811
- async function placeFrames(project, many, jobs, place2) {
2103
+ async function placeFrames(project, many, jobs, command2, item) {
1812
2104
  await markWorking();
1813
2105
  const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
1814
2106
  const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
@@ -1836,19 +2128,17 @@ async function placeFrames(project, many, jobs, place2) {
1836
2128
  warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
1837
2129
  });
1838
2130
  }
1839
- const placed = [];
1840
- for (const [index, job] of jobs.entries()) {
1841
- const result = results[index], { folder, image } = prepared.get(job.source);
1842
- const poster = result.screenshot ? posterFile(project, job.id) : void 0;
1843
- await replaceFolder(folder, frameDir(project, job.id));
1844
- if (poster) {
1845
- await mkdir7(join9(project, ".twombly/posters"), { recursive: true });
1846
- await copyFile(result.screenshot, poster);
1847
- }
1848
- placed.push({ ...await place2(job, { result, folder, image, poster }), warnings: result.warnings, screenshot: poster, contentSize: result.content });
1849
- }
1850
- if (!many) return print({ ok: true, placed: true, ...placed[0], next: NEXT });
1851
- print({ ok: true, placed: true, frames: placed.map(({ frame, canvas: _, ...rest }) => ({ ...frame, ...rest })), canvas: placed[placed.length - 1].canvas, next: NEXT });
2131
+ const items = jobs.map((job, index) => {
2132
+ const { folder, image } = prepared.get(job.source);
2133
+ return { id: job.id, folder, source: job.source, screenshot: results[index].screenshot, ...item(job, { result: results[index], folder, image, screenshot: results[index].screenshot }) };
2134
+ });
2135
+ const { frames, canvas } = await call(command2, { project, items });
2136
+ const placed = frames.map(({ warnings, ...frame }, index) => {
2137
+ const result = results[index];
2138
+ return { frame, warnings: [...result.warnings, ...warnings], screenshot: result.screenshot ? posterFile(project, jobs[index].id) : void 0, contentSize: result.content };
2139
+ });
2140
+ if (!many) return print({ ok: true, placed: true, ...placed[0], canvas, next: NEXT });
2141
+ print({ ok: true, placed: true, frames: placed.map(({ frame, ...rest }) => ({ ...frame, ...rest })), canvas, next: NEXT });
1852
2142
  } finally {
1853
2143
  await gates.close();
1854
2144
  for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
@@ -1872,30 +2162,20 @@ async function addFrames(project, source, many, items) {
1872
2162
  id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
1873
2163
  };
1874
2164
  });
1875
- const titles = (await call("list", { project })).frames.map((frame) => frame.title);
1876
- await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
1877
- const { content } = await folderContent(frameDir(project, job.id));
1878
- if (titles.includes(job.label)) result.warnings.push(`Another frame is already titled "${job.label}". Give each a distinct title, or use update to replace it.`);
1879
- titles.push(job.label);
1880
- return call("place", {
1881
- project,
1882
- id: job.id,
1883
- title: job.label,
1884
- w: job.width,
1885
- h: result.height,
1886
- entry: job.entry,
1887
- content,
1888
- level: level(job.flags, image ? "image" : "build"),
1889
- hasPoster: !!poster,
1890
- x: number(job.flags, "x"),
1891
- y: number(job.flags, "y"),
1892
- newRow: job.flags["new-row"] === true
1893
- });
1894
- });
2165
+ await placeFrames(project, many, jobs, "place", (job, { result, image }) => ({
2166
+ title: job.label,
2167
+ w: job.width,
2168
+ h: result.height,
2169
+ entry: job.entry,
2170
+ level: level(job.flags, image ? "image" : "build"),
2171
+ x: number(job.flags, "x"),
2172
+ y: number(job.flags, "y"),
2173
+ newRow: job.flags["new-row"] === true
2174
+ }));
1895
2175
  }
1896
2176
  async function updateFrames(project, source, many, items) {
1897
2177
  const { frames } = await call("list", { project });
1898
- const seen = /* @__PURE__ */ new Set();
2178
+ const seen = /* @__PURE__ */ new Set(), bases = /* @__PURE__ */ new Map();
1899
2179
  const jobs = items.map((flags) => {
1900
2180
  const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
1901
2181
  if (!ref || !folder) fail("twombly update <frame> <folder|file>");
@@ -1904,6 +2184,7 @@ async function updateFrames(project, source, many, items) {
1904
2184
  const frame = matches[0];
1905
2185
  if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
1906
2186
  seen.add(frame.id);
2187
+ bases.set(frame.id, frame.content);
1907
2188
  level(flags, "build");
1908
2189
  return {
1909
2190
  flags,
@@ -1915,20 +2196,15 @@ async function updateFrames(project, source, many, items) {
1915
2196
  height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
1916
2197
  };
1917
2198
  });
1918
- await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
1919
- const { content } = await folderContent(frameDir(project, job.id));
1920
- return call("revise", {
1921
- project,
1922
- id: job.id,
1923
- title: text(job.flags, "title"),
1924
- w: job.width,
1925
- h: result.height,
1926
- entry: job.entry,
1927
- content,
1928
- hasPoster: poster ? true : void 0,
1929
- level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
1930
- });
1931
- });
2199
+ await placeFrames(project, many, jobs, "revise", (job, { result, image }) => ({
2200
+ label: job.label,
2201
+ base: bases.get(job.id),
2202
+ title: text(job.flags, "title"),
2203
+ w: job.flags.width !== void 0 ? job.width : void 0,
2204
+ h: job.flags.height !== void 0 ? result.height : void 0,
2205
+ entry: text(job.flags, "entry"),
2206
+ level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
2207
+ }));
1932
2208
  }
1933
2209
  async function main() {
1934
2210
  const [name, ...rest] = process.argv.slice(2);
@@ -1938,6 +2214,10 @@ async function main() {
1938
2214
  return;
1939
2215
  }
1940
2216
  if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
2217
+ if (flags.canvas !== void 0) {
2218
+ if (typeof flags.canvas !== "string" || !flags.canvas.trim()) fail("--canvas needs a name.");
2219
+ chosenCanvas = flags.canvas.trim();
2220
+ }
1941
2221
  switch (name) {
1942
2222
  case "__daemon":
1943
2223
  return runDaemon();
@@ -1949,15 +2229,30 @@ async function main() {
1949
2229
  return;
1950
2230
  case "--version":
1951
2231
  case "version":
1952
- process.stdout.write("0.2.0\n");
2232
+ process.stdout.write("0.3.0\n");
1953
2233
  return;
1954
2234
  case "guide":
1955
2235
  process.stdout.write(SKILL_default);
1956
2236
  return;
1957
2237
  case "init": {
2238
+ const next = "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide";
2239
+ const open = flags["no-open"] === void 0, given = text(flags, "name");
2240
+ if (chosenCanvas !== void 0) {
2241
+ if (given) fail("Use --canvas or --name, not both. --canvas is the name.");
2242
+ const found = await findNamed(process.cwd(), chosenCanvas);
2243
+ const project2 = found?.project || namedProject(await findRoot(process.cwd()) || process.cwd(), chosenCanvas);
2244
+ print({ ok: true, ...await call("init", { project: project2, exact: true, name: chosenCanvas, open }), next });
2245
+ return;
2246
+ }
1958
2247
  const project = await findProject(process.cwd()) || process.cwd();
1959
- const given = text(flags, "name");
1960
- print({ ok: true, ...await call("init", { project, name: given || defaultName(project), rename: !!given, open: flags["no-open"] === void 0 }), next: "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide" });
2248
+ const result = await call("init", { project, name: given || defaultName(project), open });
2249
+ const note = !result.created && given && given !== result.name ? `This folder's canvas is "${result.name}", and you've joined it. For a separate one: twombly init --canvas "${given}"` : void 0;
2250
+ print({ ok: true, ...result, note, next });
2251
+ return;
2252
+ }
2253
+ case "canvases": {
2254
+ const found = await canvasesAbove(process.cwd());
2255
+ print({ ok: true, canvases: found.map(({ name: name2, slug, frames, id, link }) => ({ name: name2, canvas: slug, frames, local: `${DAEMON_ORIGIN}/${id}`, link })) });
1961
2256
  return;
1962
2257
  }
1963
2258
  case "check": {
@@ -2002,6 +2297,12 @@ async function main() {
2002
2297
  case "list":
2003
2298
  print({ ok: true, ...await call("list", { project: await currentProject() }) });
2004
2299
  return;
2300
+ case "context":
2301
+ print({ ok: true, ...await call("context", { project: await currentProject() }) });
2302
+ return;
2303
+ case "comments":
2304
+ print({ ok: true, ...await call("comments", { project: await currentProject() }) });
2305
+ return;
2005
2306
  case "open":
2006
2307
  print({ ok: true, ...await call("open", { project: await currentProject() }) });
2007
2308
  return;
@@ -2011,7 +2312,7 @@ async function main() {
2011
2312
  return;
2012
2313
  }
2013
2314
  case "status":
2014
- print({ ok: true, ...await call("status", { project: await findProject(process.cwd()) }) });
2315
+ print({ ok: true, ...await call("status", { project: await locate() }) });
2015
2316
  return;
2016
2317
  case "remove": {
2017
2318
  if (!positional[0]) fail("twombly remove <frame>");