twombly 0.2.0 → 0.4.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
@@ -2,10 +2,10 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { spawn as spawn2 } from "node:child_process";
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";
5
+ import { randomBytes as randomBytes3 } from "node:crypto";
6
+ import { copyFile as copyFile2, mkdtemp, readFile as readFile8, rm as rm6, stat as stat5, writeFile as writeFile8 } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { basename as basename2, extname as extname3, join as join9, resolve as resolve2 } from "node:path";
8
+ import { basename as basename2, extname as extname3, join as join10, resolve as resolve2 } from "node:path";
9
9
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10
10
 
11
11
  // ../protocol/src/gates.ts
@@ -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. 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";
@@ -297,7 +303,7 @@ async function capture(url, out, options) {
297
303
  return { folder: out, assets: 1, failed: [] };
298
304
  }
299
305
  const saved2 = /* @__PURE__ */ new Map(), failed = [];
300
- const save = async (assetUrl) => {
306
+ const save2 = async (assetUrl) => {
301
307
  if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
302
308
  if (saved2.has(assetUrl)) return saved2.get(assetUrl);
303
309
  try {
@@ -327,7 +333,7 @@ async function capture(url, out, options) {
327
333
  } catch {
328
334
  continue;
329
335
  }
330
- const name = await save(absolute);
336
+ const name = await save2(absolute);
331
337
  if (!name) continue;
332
338
  output = output.split(match[0]).join(`url("${from === "css" ? name.replace("assets/", "") : name}")`);
333
339
  }
@@ -385,7 +391,7 @@ async function capture(url, out, options) {
385
391
  });
386
392
  const replacements = [];
387
393
  for (const attribute of found.attributes) {
388
- const name = await save(attribute.value);
394
+ const name = await save2(attribute.value);
389
395
  if (name) replacements.push({ tag: attribute.tag, name: attribute.name, value: name });
390
396
  }
391
397
  const styles = await Promise.all(found.styles.map((style) => rewriteCss(style, found.base, "html")));
@@ -408,11 +414,11 @@ async function capture(url, out, options) {
408
414
  }
409
415
 
410
416
  // src/daemon.ts
411
- import { createHash as createHash3, randomBytes } from "node:crypto";
417
+ import { createHash as createHash3, randomBytes as randomBytes2 } 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 mkdir7, readFile as readFile6, 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 join9 } 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 > 1793918977725).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793918977725).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, 1793918977725)) 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
@@ -729,7 +784,7 @@ import { join as join7 } from "node:path";
729
784
  var FILE = join7(HOME, "settings.json");
730
785
  async function readSettings() {
731
786
  const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
732
- return { invertScroll: saved2?.invertScroll === true };
787
+ return { invertScroll: saved2?.invertScroll === true, theme: saved2?.theme === "light" || saved2?.theme === "dark" ? saved2.theme : void 0 };
733
788
  }
734
789
  async function writeSettings(settings2) {
735
790
  await mkdir5(HOME, { recursive: true });
@@ -738,11 +793,89 @@ async function writeSettings(settings2) {
738
793
  await rename3(temporary, FILE);
739
794
  }
740
795
 
796
+ // src/usage.ts
797
+ import { randomBytes } from "node:crypto";
798
+ import { existsSync } from "node:fs";
799
+ import { mkdir as mkdir6, readFile as readFile5, rename as rename4, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
800
+ import { homedir as homedir2 } from "node:os";
801
+ import { join as join8 } from "node:path";
802
+ var FILE2 = join8(HOME, "usage.json");
803
+ var OFF = join8(HOME, "usage-off");
804
+ var CHECK_MS2 = 36e5;
805
+ var ERRORS_PER_RUN = 20;
806
+ var state = null;
807
+ var saving = null;
808
+ var errorsSent = 0;
809
+ var usageOn = () => process.env.TWOMBLY_NO_USAGE !== "1" && !existsSync(OFF);
810
+ var scrub = (text2, max) => text2.split(homedir2()).join("~").slice(0, max);
811
+ var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
812
+ var post = (path, body) => fetch(ENDPOINT + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(1e4) });
813
+ async function setUsage(on) {
814
+ await mkdir6(HOME, { recursive: true });
815
+ if (on) await rm5(OFF, { force: true });
816
+ else await writeFile6(OFF, "");
817
+ }
818
+ async function load() {
819
+ const saved2 = await readFile5(FILE2, "utf8").then(JSON.parse).catch(() => null);
820
+ const install3 = typeof saved2?.install === "string" && /^[a-z0-9]{16,40}$/.test(saved2.install) ? saved2.install : randomBytes(12).toString("hex");
821
+ return { install: install3, day: typeof saved2?.day === "string" ? saved2.day : today(), counts: saved2?.counts && typeof saved2.counts === "object" ? saved2.counts : {} };
822
+ }
823
+ function save() {
824
+ if (saving) return;
825
+ saving = setTimeout(async () => {
826
+ saving = null;
827
+ if (!state) return;
828
+ await mkdir6(HOME, { recursive: true }).catch(() => {
829
+ });
830
+ const temporary = `${FILE2}.${process.pid}`;
831
+ await writeFile6(temporary, JSON.stringify(state)).then(() => rename4(temporary, FILE2)).catch(() => {
832
+ });
833
+ }, 1e3);
834
+ }
835
+ function tally(key, by = 1) {
836
+ if (!state || !usageOn()) return;
837
+ state.counts[key] = (state.counts[key] || 0) + by;
838
+ save();
839
+ }
840
+ async function flush() {
841
+ if (!state || state.day === today()) return;
842
+ const { day, counts } = state;
843
+ state.day = today();
844
+ state.counts = {};
845
+ save();
846
+ if (!usageOn() || !Object.values(counts).some(Boolean)) return;
847
+ const canvases2 = Object.keys(await readRegistry()).length;
848
+ await post("/api/usage", { install: state.install, day, version: "0.4.0", platform: process.platform, counts: { ...counts, canvases: canvases2 } }).catch(() => {
849
+ });
850
+ }
851
+ async function startUsage() {
852
+ state = await load();
853
+ await flush();
854
+ tally("starts");
855
+ setInterval(() => {
856
+ flush().catch(() => {
857
+ });
858
+ }, CHECK_MS2).unref();
859
+ }
860
+ function reportError(error, source = "tool") {
861
+ if (!usageOn() || ++errorsSent > ERRORS_PER_RUN) return;
862
+ post("/api/errors", {
863
+ source,
864
+ mode: "local",
865
+ install: state?.install,
866
+ version: "0.4.0",
867
+ type: String(error.type || "Error").slice(0, 80),
868
+ message: scrub(String(error.message), 500),
869
+ stack: scrub(String(error.stack || ""), 4e3)
870
+ }).catch(() => {
871
+ });
872
+ }
873
+
741
874
  // src/daemon.ts
742
875
  var IDLE_MS = 6 * 36e5;
743
876
  var LOGIN_MS = 10 * 6e4;
744
877
  var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
745
- var randomId = (length) => [...randomBytes(length)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
878
+ var randomId = (length) => [...randomBytes2(length)].map((b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
746
879
  var Failure = class extends Error {
747
880
  status;
748
881
  extra;
@@ -757,7 +890,7 @@ var credential = null;
757
890
  var pendingLogin = null;
758
891
  function loginUrl() {
759
892
  if (!pendingLogin || Date.now() - pendingLogin.at > LOGIN_MS) {
760
- pendingLogin = { state: randomBytes(24).toString("hex"), verifier: randomBytes(32).toString("base64url"), at: Date.now() };
893
+ pendingLogin = { state: randomBytes2(24).toString("hex"), verifier: randomBytes2(32).toString("base64url"), at: Date.now() };
761
894
  }
762
895
  const challenge = createHash3("sha256").update(pendingLogin.verifier).digest("base64url");
763
896
  return `${ENDPOINT}/auth/machine/start?` + new URLSearchParams({ port: String(PORT), state: pendingLogin.state, challenge });
@@ -782,6 +915,10 @@ async function edge(path, init = {}) {
782
915
  return data;
783
916
  }
784
917
  var STROKE_UPLOAD = 40;
918
+ var THREAD_UPLOAD_CHARS = 5e5;
919
+ var THREAD_ID = /^[a-z0-9]{8,32}$/;
920
+ var finite = (value) => typeof value === "number" && Number.isFinite(value);
921
+ var FRAME_SERVERS_IDLE_MS = Number(process.env.TWOMBLY_FRAME_IDLE_MS) || 5 * 6e4;
785
922
  var Canvas = class {
786
923
  id;
787
924
  project;
@@ -791,7 +928,13 @@ var Canvas = class {
791
928
  servers = /* @__PURE__ */ new Map();
792
929
  queue = Promise.resolve();
793
930
  saveTimer = null;
931
+ serversTimer = null;
932
+ writeTurn = inOrder();
794
933
  workingUntil = 0;
934
+ agents = 0;
935
+ contexts = /* @__PURE__ */ new Map();
936
+ /** So undoing a delete in the page keeps where the frame came from. */
937
+ removedSources = /* @__PURE__ */ new Map();
795
938
  constructor(project, data) {
796
939
  this.project = project;
797
940
  this.data = data;
@@ -807,13 +950,26 @@ var Canvas = class {
807
950
  save() {
808
951
  if (this.saveTimer) clearTimeout(this.saveTimer);
809
952
  this.saveTimer = setTimeout(() => {
810
- writeCanvas(this.project, this.data).catch(() => {
953
+ this.writeTurn(() => writeCanvas(this.project, this.data)).catch(() => {
811
954
  });
812
955
  }, 150);
813
956
  }
814
957
  async saveNow() {
815
958
  if (this.saveTimer) clearTimeout(this.saveTimer);
816
- await writeCanvas(this.project, this.data);
959
+ await this.writeTurn(() => writeCanvas(this.project, this.data));
960
+ }
961
+ /** Frame servers are only for people looking. They come back, on new ports, the next time someone does. */
962
+ closeServersWhenUnwatched() {
963
+ if (this.serversTimer) clearTimeout(this.serversTimer);
964
+ this.serversTimer = setTimeout(() => {
965
+ if (this.sockets.size) return;
966
+ for (const running of this.servers.values()) running.then((server) => server.close());
967
+ this.servers.clear();
968
+ }, FRAME_SERVERS_IDLE_MS);
969
+ }
970
+ /** Tells anyone looking about a frame, without starting its server for nobody. */
971
+ async announce(frame) {
972
+ if (this.sockets.size) this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
817
973
  }
818
974
  serverFor(frame) {
819
975
  const key = `${frame.id}:${frame.content}`;
@@ -848,9 +1004,18 @@ var Canvas = class {
848
1004
  workingLeft() {
849
1005
  return Math.max(0, this.workingUntil - Date.now());
850
1006
  }
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;
1007
+ /**
1008
+ * An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it.
1009
+ * Agents have no identity, so each `init` counts as one more and it only stops once each has finished, or time runs out.
1010
+ */
1011
+ working(ms, joining = false) {
1012
+ if (!this.workingLeft()) this.agents = 0;
1013
+ if (joining) this.agents++;
1014
+ if (ms > 0) this.workingUntil = Math.max(this.workingUntil, Date.now() + ms);
1015
+ else if (--this.agents <= 0) {
1016
+ this.agents = 0;
1017
+ this.workingUntil = 0;
1018
+ }
854
1019
  const left = this.workingLeft();
855
1020
  this.broadcast({ t: "working", ms: left });
856
1021
  if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
@@ -865,7 +1030,10 @@ var Canvas = class {
865
1030
  this.sockets.add(socket);
866
1031
  touch();
867
1032
  const self = randomId(10);
868
- socket.on("close", () => this.sockets.delete(socket));
1033
+ socket.on("close", () => {
1034
+ this.sockets.delete(socket);
1035
+ this.closeServersWhenUnwatched();
1036
+ });
869
1037
  socket.on("message", (raw) => {
870
1038
  let message;
871
1039
  try {
@@ -883,22 +1051,104 @@ var Canvas = class {
883
1051
  self,
884
1052
  role: "owner",
885
1053
  doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
886
- threads: [],
1054
+ threads: this.data.threads ?? [],
887
1055
  peers: [this.peer(self)],
888
- capabilities: { comments: false, huddle: false },
1056
+ // Shared but signed out, this is a copy the room can't hear about.
1057
+ capabilities: { comments: !this.data.remote, huddle: false },
889
1058
  working: this.workingLeft()
890
1059
  }));
891
1060
  }
892
1061
  attachEvents(socket) {
893
1062
  this.events.add(socket);
894
- socket.on("close", () => this.events.delete(socket));
1063
+ socket.on("close", () => {
1064
+ this.events.delete(socket);
1065
+ this.contexts.delete(socket);
1066
+ });
1067
+ socket.on("message", (raw) => {
1068
+ let message;
1069
+ try {
1070
+ message = JSON.parse(String(raw));
1071
+ } catch {
1072
+ return;
1073
+ }
1074
+ const context = message?.t === "context" ? cleanContext(message.context) : null;
1075
+ if (context) this.contexts.set(socket, { context, at: Date.now() });
1076
+ });
1077
+ }
1078
+ /** What the person means by "this", from whichever of their windows changed it last. Null when none is open. */
1079
+ context() {
1080
+ let latest = null;
1081
+ for (const entry of this.contexts.values()) if (!latest || entry.at > latest.at) latest = entry;
1082
+ return latest?.context ?? null;
1083
+ }
1084
+ // ---------- comments, while local ----------
1085
+ author() {
1086
+ const me = credential?.me, id = me?.id || "local";
1087
+ return { id, name: me?.name || "You", picture: me?.picture || null, color: colorOf(id) };
1088
+ }
1089
+ thread(id) {
1090
+ return (this.data.threads ?? []).find((thread) => thread.id === id);
1091
+ }
1092
+ putThread(thread) {
1093
+ const threads = this.data.threads ??= [], index = threads.findIndex((item) => item.id === thread.id);
1094
+ if (index < 0) threads.push(thread);
1095
+ else threads[index] = thread;
1096
+ this.broadcast({ t: "thread", thread });
1097
+ this.save();
1098
+ }
1099
+ dropThread(id) {
1100
+ this.data.threads = (this.data.threads ?? []).filter((thread) => thread.id !== id);
1101
+ this.broadcast({ t: "thread.remove", id });
1102
+ this.save();
1103
+ }
1104
+ frameIdOf(id) {
1105
+ return typeof id === "string" && this.data.frames.some((frame) => frame.id === id) ? id : null;
1106
+ }
1107
+ handleComment(m) {
1108
+ const text2 = (value) => typeof value === "string" ? value.trim().slice(0, LIMITS.commentChars) : "";
1109
+ const comment = (value) => ({ id: randomId(12), author: this.author(), text: value, createdAt: Date.now() });
1110
+ switch (m.t) {
1111
+ case "thread.create": {
1112
+ const value = text2(m.text);
1113
+ if (!value || !finite(m.x) || !finite(m.y) || typeof m.id !== "string" || !THREAD_ID.test(m.id) || this.thread(m.id)) return;
1114
+ const first = comment(value);
1115
+ return this.putThread({ id: m.id, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y, resolved: false, createdAt: first.createdAt, comments: [first] });
1116
+ }
1117
+ case "thread.move": {
1118
+ const thread = this.thread(m.id);
1119
+ if (thread && finite(m.x) && finite(m.y)) this.putThread({ ...thread, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y });
1120
+ return;
1121
+ }
1122
+ case "thread.resolve": {
1123
+ const thread = this.thread(m.id);
1124
+ if (thread) this.putThread({ ...thread, resolved: !!m.resolved });
1125
+ return;
1126
+ }
1127
+ case "thread.delete":
1128
+ if (this.thread(m.id)) this.dropThread(m.id);
1129
+ return;
1130
+ case "comment.add": {
1131
+ const thread = this.thread(m.threadId), value = text2(m.text);
1132
+ if (thread && value) this.putThread({ ...thread, comments: [...thread.comments, comment(value)] });
1133
+ return;
1134
+ }
1135
+ case "comment.delete": {
1136
+ const thread = this.thread(m.threadId);
1137
+ if (!thread || !thread.comments.some((item) => item.id === m.id)) return;
1138
+ if (thread.comments[0].id === m.id) return this.dropThread(thread.id);
1139
+ this.putThread({ ...thread, comments: thread.comments.filter((item) => item.id !== m.id) });
1140
+ }
1141
+ }
895
1142
  }
896
1143
  async handle(socket, m) {
897
1144
  if (m.t === "ping") return socket.send('{"t":"pong"}');
898
1145
  if (this.data.remote) return;
899
1146
  if (m.t === "frame.patch") return this.applyPatch(m.id, m.patch);
1147
+ if (m.t.startsWith("thread.") || m.t.startsWith("comment.")) return this.handleComment(m);
900
1148
  if (m.t === "frame.remove") {
901
1149
  const before = this.data.frames.length;
1150
+ const removed = this.data.frames.find((frame) => frame.id === m.id);
1151
+ if (removed) this.removedSources.set(removed.id, removed.source);
902
1152
  this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
903
1153
  if (this.data.frames.length !== before) {
904
1154
  this.broadcast({ t: "frame.remove", id: m.id });
@@ -909,9 +1159,9 @@ var Canvas = class {
909
1159
  if (m.t === "frame.restore") {
910
1160
  const f = m.frame;
911
1161
  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() };
1162
+ 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
1163
  this.data.frames.push(restored);
914
- this.broadcast({ t: "frame.upsert", frame: await this.toFrame(restored) });
1164
+ await this.announce(restored);
915
1165
  this.save();
916
1166
  return;
917
1167
  }
@@ -948,16 +1198,16 @@ var Canvas = class {
948
1198
  frame.w = Math.max(40, Math.min(8e3, frame.w));
949
1199
  frame.h = Math.max(40, Math.min(8e3, frame.h));
950
1200
  frame.updatedAt = Date.now();
951
- this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
1201
+ await this.announce(frame);
952
1202
  this.save();
953
1203
  return frame;
954
1204
  }
955
1205
  /** Once shared, the room is the truth. Bring the local list up to date with it. */
956
1206
  async pull() {
957
1207
  if (!this.data.remote) return;
958
- let doc;
1208
+ let doc, threads;
959
1209
  try {
960
- ({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
1210
+ ({ doc, threads } = await edge(`/api/canvases/${this.data.remote.id}`));
961
1211
  } catch (error) {
962
1212
  if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
963
1213
  this.data.remote = null;
@@ -969,6 +1219,7 @@ var Canvas = class {
969
1219
  }
970
1220
  this.data.name = doc.name;
971
1221
  if (doc.strokes) this.data.strokes = doc.strokes;
1222
+ if (threads) this.data.threads = threads;
972
1223
  const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
973
1224
  this.data.frames = doc.frames.map((frame) => ({
974
1225
  id: frame.id,
@@ -982,7 +1233,8 @@ var Canvas = class {
982
1233
  content: frame.content,
983
1234
  level: frame.level,
984
1235
  hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
985
- updatedAt: frame.updatedAt
1236
+ updatedAt: frame.updatedAt,
1237
+ source: known.get(frame.id)?.source
986
1238
  }));
987
1239
  await this.saveNow();
988
1240
  }
@@ -991,7 +1243,7 @@ var Canvas = class {
991
1243
  const { files } = await folderContent(frameDir(this.project, frame.id));
992
1244
  let poster = null, posterBytes = null;
993
1245
  if (frame.hasPoster) {
994
- posterBytes = await readFile5(posterFile(this.project, frame.id)).catch(() => null);
1246
+ posterBytes = await readFile6(posterFile(this.project, frame.id)).catch(() => null);
995
1247
  if (posterBytes) poster = createHash3("sha256").update(posterBytes).digest("hex");
996
1248
  }
997
1249
  const manifest = files.map(({ path, hash, size: size2 }) => ({ path, hash, size: size2 }));
@@ -1005,7 +1257,7 @@ var Canvas = class {
1005
1257
  await Promise.all(Array.from({ length: 6 }, async () => {
1006
1258
  while (next < queue.length) {
1007
1259
  const file2 = queue[next++];
1008
- await put(file2.hash, await readFile5(file2.absolute));
1260
+ await put(file2.hash, await readFile6(file2.absolute));
1009
1261
  }
1010
1262
  }));
1011
1263
  if (poster && posterBytes && wanted.has(poster)) await put(poster, posterBytes);
@@ -1031,6 +1283,19 @@ var Canvas = class {
1031
1283
  for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
1032
1284
  await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
1033
1285
  }
1286
+ let batch = [], chars = 0;
1287
+ const send = async () => {
1288
+ if (batch.length) await edge(`/api/canvases/${created.id}/threads`, { method: "POST", body: JSON.stringify({ threads: batch }) });
1289
+ batch = [];
1290
+ chars = 0;
1291
+ };
1292
+ for (const thread of this.data.threads ?? []) {
1293
+ const size2 = JSON.stringify(thread).length;
1294
+ if (chars + size2 > THREAD_UPLOAD_CHARS) await send();
1295
+ batch.push(thread);
1296
+ chars += size2;
1297
+ }
1298
+ await send();
1034
1299
  } catch (error) {
1035
1300
  await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
1036
1301
  });
@@ -1043,8 +1308,8 @@ var Canvas = class {
1043
1308
  }
1044
1309
  async unshare() {
1045
1310
  if (!this.data.remote) return;
1046
- await this.pull().catch(() => {
1047
- });
1311
+ await this.pull();
1312
+ if (!this.data.remote) return;
1048
1313
  await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
1049
1314
  if (error.status !== 404) throw error;
1050
1315
  });
@@ -1054,16 +1319,24 @@ var Canvas = class {
1054
1319
  }
1055
1320
  };
1056
1321
  var canvases = /* @__PURE__ */ new Map();
1057
- async function canvasById(id) {
1322
+ var opening = /* @__PURE__ */ new Map();
1323
+ function canvasById(id) {
1058
1324
  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;
1325
+ if (open) return Promise.resolve(open);
1326
+ let pending = opening.get(id);
1327
+ if (!pending) {
1328
+ pending = (async () => {
1329
+ const project = (await readRegistry())[id];
1330
+ if (!project) throw new Failure(404, "That canvas is not on this machine");
1331
+ const data = await readCanvas(project).catch(() => null);
1332
+ if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
1333
+ const canvas = new Canvas(project, data);
1334
+ canvases.set(id, canvas);
1335
+ return canvas;
1336
+ })().finally(() => opening.delete(id));
1337
+ opening.set(id, pending);
1338
+ }
1339
+ return pending;
1067
1340
  }
1068
1341
  async function canvasForProject(project) {
1069
1342
  const found = await findProject(project);
@@ -1085,8 +1358,36 @@ var summary = (canvas) => ({
1085
1358
  name: canvas.data.name,
1086
1359
  local: `${DAEMON_ORIGIN}/${canvas.id}`,
1087
1360
  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 }))
1361
+ 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
1362
  });
1363
+ function cleanContext(input) {
1364
+ const c = input;
1365
+ if (!c || typeof c !== "object" || !Array.isArray(c.frames)) return null;
1366
+ const via = c.via === "fullscreen" || c.via === "interacting" || c.via === "selected" ? c.via : null;
1367
+ const frames = c.frames.filter((id) => typeof id === "string").slice(0, LIMITS.canvasFrames);
1368
+ return { via, frames, thread: typeof c.thread === "string" ? c.thread : null };
1369
+ }
1370
+ function commentsOf(canvas, thread) {
1371
+ const me = credential?.me?.id;
1372
+ const frame = thread.frameId ? canvas.data.frames.find((item) => item.id === thread.frameId) : void 0;
1373
+ return {
1374
+ id: thread.id,
1375
+ frame: frame ? { id: frame.id, title: frame.title } : null,
1376
+ // Where the pin is: in the frame's own pixels, or on the open canvas.
1377
+ 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) },
1378
+ resolved: thread.resolved,
1379
+ 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() }))
1380
+ };
1381
+ }
1382
+ var visibleThreads = (canvas) => (canvas.data.threads ?? []).filter((thread) => !thread.frameId || canvas.data.frames.some((frame) => frame.id === thread.frameId));
1383
+ function contextOf(canvas) {
1384
+ const context = canvas.context();
1385
+ if (!context) return { looking: false, via: null, frames: [], comment: null };
1386
+ 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 }));
1387
+ const thread = visibleThreads(canvas).find((item) => item.id === context.thread);
1388
+ return { looking: true, via: frames.length ? context.via : null, frames, comment: thread ? commentsOf(canvas, thread) : null };
1389
+ }
1390
+ var initTurn = inOrder();
1090
1391
  function findFrame(canvas, ref) {
1091
1392
  const byId = canvas.data.frames.find((frame) => frame.id === ref);
1092
1393
  if (byId) return byId;
@@ -1097,6 +1398,7 @@ function findFrame(canvas, ref) {
1097
1398
  async function command(name, input) {
1098
1399
  touch();
1099
1400
  if (name === "stop") {
1401
+ if (input.ifIdle && inFlight > 1) return { stopped: false };
1100
1402
  setTimeout(() => process.exit(0), 50);
1101
1403
  return { stopped: true };
1102
1404
  }
@@ -1119,18 +1421,22 @@ async function command(name, input) {
1119
1421
  return { signedIn: false };
1120
1422
  }
1121
1423
  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);
1424
+ return initTurn(async () => {
1425
+ const project = input.project;
1426
+ const nearest = await findProject(project);
1427
+ let found = input.exact && nearest !== project ? null : nearest;
1428
+ const created = !found;
1429
+ if (!found) {
1430
+ await ensureProjectDir(project);
1431
+ await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
1432
+ found = project;
1433
+ }
1434
+ const canvas2 = await canvasForProject(found);
1435
+ if (created) tally("canvasesCreated");
1436
+ if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
1437
+ canvas2.working(WORKING_MS, true);
1438
+ return { ...summary(canvas2), created };
1439
+ });
1134
1440
  }
1135
1441
  const canvas = await canvasForProject(input.project);
1136
1442
  if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
@@ -1139,6 +1445,10 @@ async function command(name, input) {
1139
1445
  switch (name) {
1140
1446
  case "list":
1141
1447
  return summary(canvas);
1448
+ case "context":
1449
+ return contextOf(canvas);
1450
+ case "comments":
1451
+ return { comments: visibleThreads(canvas).map((thread) => commentsOf(canvas, thread)) };
1142
1452
  case "open":
1143
1453
  openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
1144
1454
  return summary(canvas);
@@ -1179,7 +1489,7 @@ async function command(name, input) {
1179
1489
  return summary(canvas);
1180
1490
  }
1181
1491
  case "rename":
1182
- await rename4(canvas, input.name);
1492
+ await rename5(canvas, input.name);
1183
1493
  return summary(canvas);
1184
1494
  case "share": {
1185
1495
  if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
@@ -1194,7 +1504,7 @@ async function command(name, input) {
1194
1504
  }
1195
1505
  });
1196
1506
  }
1197
- async function rename4(canvas, name) {
1507
+ async function rename5(canvas, name) {
1198
1508
  const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
1199
1509
  if (!clean) throw new Failure(400, "Expected a name");
1200
1510
  if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
@@ -1207,45 +1517,78 @@ async function patchFrame(canvas, id, patch) {
1207
1517
  await canvas.applyPatch(id, patch);
1208
1518
  await canvas.saveNow();
1209
1519
  }
1520
+ async function install2(canvas, id, folder, screenshot) {
1521
+ await replaceFolder(folder, frameDir(canvas.project, id));
1522
+ if (screenshot) {
1523
+ const poster = posterFile(canvas.project, id);
1524
+ await mkdir7(dirname3(poster), { recursive: true });
1525
+ await copyFile(screenshot, poster);
1526
+ }
1527
+ return (await folderContent(frameDir(canvas.project, id))).content;
1528
+ }
1529
+ var placedFrame = ({ id, title, x, y, w, h }) => ({ id, title, x, y, w, h });
1530
+ var canvasResult = (canvas) => ({ local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length });
1210
1531
  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) });
1532
+ const count = canvas.data.frames.length;
1533
+ 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"`);
1534
+ const frames = [];
1535
+ for (const item of input.items) {
1536
+ const title = String(item.title).slice(0, LIMITS.titleChars);
1537
+ 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.`] : [];
1538
+ const content = await install2(canvas, item.id, item.folder, item.screenshot);
1539
+ const position = nextPosition(canvas.data.frames, !!item.newRow);
1540
+ const frame = {
1541
+ id: item.id,
1542
+ title,
1543
+ w: item.w,
1544
+ h: item.h,
1545
+ x: typeof item.x === "number" ? item.x : position.x,
1546
+ y: typeof item.y === "number" ? item.y : position.y,
1547
+ z: canvas.data.frames.length,
1548
+ entry: item.entry,
1549
+ content,
1550
+ level: item.level,
1551
+ hasPoster: !!item.screenshot,
1552
+ updatedAt: Date.now(),
1553
+ source: typeof item.source === "string" ? item.source : void 0
1554
+ };
1555
+ if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
1556
+ canvas.data.frames.push(frame);
1557
+ await canvas.saveNow();
1558
+ await canvas.announce(frame);
1559
+ frames.push({ ...placedFrame(frame), warnings });
1560
+ }
1230
1561
  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 } };
1562
+ tally("framesAdded", frames.length);
1563
+ return { frames, canvas: canvasResult(canvas) };
1232
1564
  }
1233
1565
  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) });
1566
+ const targets = input.items.map((item) => {
1567
+ const frame = canvas.data.frames.find((other) => other.id === item.id);
1568
+ if (!frame) throw new Failure(404, `"${item.label}" is gone. Run: twombly list`);
1569
+ 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.`);
1570
+ return frame;
1571
+ });
1572
+ const frames = [];
1573
+ for (const [index, item] of input.items.entries()) {
1574
+ const frame = targets[index];
1575
+ const next = { ...frame, updatedAt: Date.now() };
1576
+ if (item.title) next.title = String(item.title).slice(0, LIMITS.titleChars);
1577
+ for (const key of ["w", "h"]) if (typeof item[key] === "number") next[key] = item[key];
1578
+ if (item.entry) next.entry = item.entry;
1579
+ if (item.level) next.level = item.level;
1580
+ if (typeof item.source === "string") next.source = item.source;
1581
+ next.content = await install2(canvas, item.id, item.folder, item.screenshot);
1582
+ if (item.screenshot) next.hasPoster = true;
1583
+ if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
1584
+ Object.assign(frame, next);
1585
+ await canvas.saveNow();
1586
+ await canvas.announce(frame);
1587
+ frames.push({ ...placedFrame(frame), warnings: [] });
1588
+ }
1247
1589
  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 } };
1590
+ tally("framesUpdated", frames.length);
1591
+ return { frames, canvas: canvasResult(canvas) };
1249
1592
  }
1250
1593
  var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
1251
1594
  var reply = (response, status, value) => {
@@ -1295,7 +1638,7 @@ async function serveUi(response, pathname) {
1295
1638
  let file2 = null;
1296
1639
  if (clean && !clean.includes("..")) {
1297
1640
  for (const folder of interfaceFolders()) {
1298
- const candidate = join8(folder, clean);
1641
+ const candidate = join9(folder, clean);
1299
1642
  if ((await stat3(candidate).catch(() => null))?.isFile()) {
1300
1643
  file2 = candidate;
1301
1644
  break;
@@ -1304,7 +1647,7 @@ async function serveUi(response, pathname) {
1304
1647
  }
1305
1648
  if (!file2) {
1306
1649
  await freshInterface();
1307
- file2 = join8(interfaceFolders()[0], "index.html");
1650
+ file2 = join9(interfaceFolders()[0], "index.html");
1308
1651
  }
1309
1652
  const type = contentType(file2);
1310
1653
  const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
@@ -1331,15 +1674,15 @@ async function servePreview(response, canvasId, frameId) {
1331
1674
  async function route(request, response) {
1332
1675
  const url = new URL(request.url || "/", DAEMON_ORIGIN);
1333
1676
  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 });
1677
+ if (path === "/health") return reply(response, 200, { ok: true, build: "0.4.0+1793918977725", api: TOOL_API, pid: process.pid });
1335
1678
  if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
1336
1679
  if (path === "/auth/callback" && method === "GET") {
1337
- const code = url.searchParams.get("code"), state = url.searchParams.get("state");
1680
+ const code = url.searchParams.get("code"), state2 = url.searchParams.get("state");
1338
1681
  const page = (text2, status = 200) => {
1339
1682
  response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" });
1340
1683
  response.end(`<!doctype html><meta charset="utf-8"><title>Twombly</title><style>body{font:13px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;height:100vh;margin:0}@media(prefers-color-scheme:dark){body{background:#1e1e1e;color:#fff}}</style><p>${text2}</p><script>setTimeout(()=>window.close(),600)</script>`);
1341
1684
  };
1342
- if (!code || !pendingLogin || state !== pendingLogin.state) return page("That sign-in expired. Try again.", 400);
1685
+ if (!code || !pendingLogin || state2 !== pendingLogin.state) return page("That sign-in expired. Try again.", 400);
1343
1686
  const exchange = await fetch(`${ENDPOINT}/auth/machine/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code, verifier: pendingLogin.verifier }) });
1344
1687
  const data = await exchange.json().catch(() => ({}));
1345
1688
  pendingLogin = null;
@@ -1363,8 +1706,9 @@ async function route(request, response) {
1363
1706
  }
1364
1707
  if (path === "/api/login" && method === "POST") return reply(response, 200, await command("login", {}));
1365
1708
  if (path === "/api/settings" && method === "POST") {
1366
- const { invertScroll } = await readBody(request);
1709
+ const { invertScroll, theme } = await readBody(request);
1367
1710
  if (typeof invertScroll === "boolean") settings = { ...settings, invertScroll };
1711
+ if (theme === "light" || theme === "dark") settings = { ...settings, theme };
1368
1712
  await writeSettings(settings);
1369
1713
  for (const canvas of canvases.values()) canvas.event({ t: "settings", settings });
1370
1714
  return reply(response, 200, settings);
@@ -1376,8 +1720,18 @@ async function route(request, response) {
1376
1720
  if (!canvas.data.remote || !credential) return reply(response, 200, { url: `ws://localhost:${PORT}/local/${canvas.id}` });
1377
1721
  return reply(response, 200, await edge(`/api/canvases/${canvas.data.remote.id}/ticket`, { method: "POST", body: "{}" }));
1378
1722
  }
1379
- if (match[2] === "share") return reply(response, 200, { url: await canvas.run(() => canvas.share()) });
1723
+ if (match[2] === "share") {
1724
+ const url2 = await canvas.run(() => canvas.share());
1725
+ tally("shares");
1726
+ return reply(response, 200, { url: url2 });
1727
+ }
1380
1728
  await canvas.run(() => canvas.unshare());
1729
+ tally("unshares");
1730
+ return reply(response, 200, { ok: true });
1731
+ }
1732
+ if (path === "/api/errors" && method === "POST") {
1733
+ const body = await readBody(request);
1734
+ if (typeof body.message === "string") reportError(body, "canvas");
1381
1735
  return reply(response, 200, { ok: true });
1382
1736
  }
1383
1737
  if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
@@ -1386,6 +1740,7 @@ async function route(request, response) {
1386
1740
  if (preview) return servePreview(response, preview[1], preview[2]);
1387
1741
  return serveUi(response, path);
1388
1742
  }
1743
+ var inFlight = 0;
1389
1744
  var idleTimer = null;
1390
1745
  function touch() {
1391
1746
  if (idleTimer) clearTimeout(idleTimer);
@@ -1403,6 +1758,8 @@ async function runDaemon() {
1403
1758
  response.end('{"error":"Forbidden"}');
1404
1759
  return;
1405
1760
  }
1761
+ const counted = /^\/(cli|api\/canvases)\//.test(request.url || "");
1762
+ if (counted) inFlight++;
1406
1763
  try {
1407
1764
  await route(request, response);
1408
1765
  } catch (error) {
@@ -1412,7 +1769,10 @@ async function runDaemon() {
1412
1769
  }
1413
1770
  if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
1414
1771
  console.error(error);
1772
+ reportError(error instanceof Error ? { type: error.name, message: error.message, stack: error.stack } : { message: String(error) });
1415
1773
  reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
1774
+ } finally {
1775
+ if (counted) inFlight--;
1416
1776
  }
1417
1777
  });
1418
1778
  const sockets = new WebSocketServer({ noServer: true });
@@ -1429,7 +1789,10 @@ async function runDaemon() {
1429
1789
  }
1430
1790
  sockets.handleUpgrade(request, socket, head, (client) => {
1431
1791
  if (match[2]) canvas.attachEvents(client);
1432
- else canvas.attach(client).catch(() => client.close());
1792
+ else {
1793
+ tally("canvasViews");
1794
+ canvas.attach(client).catch(() => client.close());
1795
+ }
1433
1796
  });
1434
1797
  });
1435
1798
  await new Promise((resolve3, reject) => {
@@ -1439,12 +1802,14 @@ async function runDaemon() {
1439
1802
  touch();
1440
1803
  startInterfaceUpdates().catch(() => {
1441
1804
  });
1805
+ startUsage().catch(() => {
1806
+ });
1442
1807
  }
1443
1808
 
1444
1809
  // src/gates.ts
1445
1810
  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";
1811
+ import { mkdir as mkdir8, readFile as readFile7, stat as stat4, writeFile as writeFile7 } from "node:fs/promises";
1812
+ import { dirname as dirname4 } from "node:path";
1448
1813
  var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
1449
1814
  async function scanFolder(folder) {
1450
1815
  const problems = [];
@@ -1460,7 +1825,7 @@ async function scanFolder(folder) {
1460
1825
  continue;
1461
1826
  }
1462
1827
  if (file2.size > LIMITS.fileBytes) problems.push({ code: "FILE_TOO_LARGE", message: `"${file2.path}" is ${(file2.size / 1048576).toFixed(1)} MB; the limit is ${LIMITS.fileBytes / 1048576} MB.`, fix: "Compress it, or swap in a smaller asset.", file: file2.path });
1463
- problems.push(...scanBytes(file2.path, await readFile6(file2.absolute)));
1828
+ problems.push(...scanBytes(file2.path, await readFile7(file2.absolute)));
1464
1829
  }
1465
1830
  if (files.length > LIMITS.frameFiles) problems.push({ code: "TOO_MANY_FILES", message: `${files.length} files; the limit is ${LIMITS.frameFiles}.`, fix: "This looks like more than one built page. Add only what the frame needs." });
1466
1831
  if (bytes > LIMITS.frameBytes) {
@@ -1478,7 +1843,7 @@ function entryProblems(files, entry) {
1478
1843
  async function findReferences(folder, needle) {
1479
1844
  const found = [];
1480
1845
  for (const file2 of await hashFolder(folder)) {
1481
- const bytes = await readFile6(file2.absolute);
1846
+ const bytes = await readFile7(file2.absolute);
1482
1847
  if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
1483
1848
  if (found.length === 3) break;
1484
1849
  }
@@ -1571,8 +1936,8 @@ async function browserGates(folder, options, launch) {
1571
1936
  const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
1572
1937
  if (shot) {
1573
1938
  if (options.poster) {
1574
- await mkdir6(dirname3(options.poster), { recursive: true });
1575
- await writeFile6(options.poster, shot);
1939
+ await mkdir8(dirname4(options.poster), { recursive: true });
1940
+ await writeFile7(options.poster, shot);
1576
1941
  }
1577
1942
  const colors = await page.evaluate(async (base64) => {
1578
1943
  const image = new Image();
@@ -1635,9 +2000,12 @@ async function runGates(folder, options) {
1635
2000
  }
1636
2001
 
1637
2002
  // src/cli.ts
1638
- var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
2003
+ var HELP = `twombly ${"0.4.0"}: put design explorations on a shared canvas.
1639
2004
 
1640
- init [--name "Name"] [--no-open] create or reopen this project's canvas
2005
+ init [--name "Name"] [--no-open] create or join this folder's canvas
2006
+ init --canvas "Name" [--no-open] create or join a separate named canvas in this folder
2007
+ canvases list the canvases this folder can reach
2008
+ --canvas "Name" works on every command below, and picks that canvas
1641
2009
  add <folder|file> --title "T" run the gates, then place a frame
1642
2010
  [--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
1643
2011
  [--new-row] [--x N --y N]
@@ -1650,9 +2018,12 @@ var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
1650
2018
  [--width 1280] [--height 800] [--image] [--selector css]
1651
2019
  preview <frame> a page with just that frame, for testing interactions
1652
2020
  working | done show on the canvas that you're building, or that you've finished
2021
+ context what the person has selected, open fullscreen or open as a comment
2022
+ comments every comment, and which are the person's own
1653
2023
  list | move <frame> --x N --y N | resize <frame> --width N --height N
1654
2024
  retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
1655
2025
  open | status | login | logout | stop
2026
+ usage on | off the daily counts and error reports this machine sends to Twombly
1656
2027
  share | unshare only when the person asks: this publishes a public link
1657
2028
  guide how to adapt a project into frames. Read this first.`;
1658
2029
  var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
@@ -1688,7 +2059,7 @@ function parse(argv) {
1688
2059
  return { positional, flags };
1689
2060
  }
1690
2061
  function checkFlags(name, flags) {
1691
- const known = FLAGS[name] || [];
2062
+ const known = name === "canvases" ? [] : [...FLAGS[name] || [], "canvas"];
1692
2063
  for (const flag of Object.keys(flags)) {
1693
2064
  if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
1694
2065
  }
@@ -1728,18 +2099,31 @@ async function health() {
1728
2099
  return null;
1729
2100
  }
1730
2101
  }
2102
+ var builtAt = (build) => Number(build.split("+")[1]) || 0;
2103
+ var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
2104
+ var IDLE_WAIT_MS = 6e4;
2105
+ var usable = (running) => !!running && (running.build === "0.4.0+1793918977725" || running.api === TOOL_API && builtAt(running.build) > builtAt("0.4.0+1793918977725"));
1731
2106
  async function ensureDaemon() {
1732
2107
  const running = await health();
1733
- if (running?.build === "0.2.0+1793896544071") return;
2108
+ if (usable(running)) return;
1734
2109
  if (running) {
1735
- await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
1736
- });
1737
- await new Promise((done) => setTimeout(done, 200));
2110
+ if (builtAt(running.build) > builtAt("0.4.0+1793918977725")) fail("A newer twombly is running on this machine. Run this with twombly@latest.");
2111
+ let stopped = false;
2112
+ for (const end = Date.now() + IDLE_WAIT_MS; !stopped && Date.now() < end; ) {
2113
+ const answer = await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: '{"ifIdle":true}' }).then((response) => response.json()).catch(() => ({ stopped: true }));
2114
+ stopped = !!answer.stopped;
2115
+ if (!stopped) await sleep(500);
2116
+ }
2117
+ if (!stopped) {
2118
+ if (running.api === TOOL_API) return;
2119
+ fail("The background server is busy with another agent's work. Try again in a minute.");
2120
+ }
2121
+ await sleep(200);
1738
2122
  }
1739
2123
  spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
1740
2124
  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;
2125
+ await sleep(100);
2126
+ if (usable(await health())) return;
1743
2127
  }
1744
2128
  fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
1745
2129
  }
@@ -1758,23 +2142,30 @@ async function prepare(source) {
1758
2142
  const extension = extname3(source).toLowerCase();
1759
2143
  const isImage = IMAGE.has(extension);
1760
2144
  if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
1761
- const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
2145
+ const temporary = await mkdtemp(join10(tmpdir(), "twombly-"));
1762
2146
  if (isImage) {
1763
2147
  const name = `image${extension}`;
1764
- await copyFile(source, join9(temporary, name));
1765
- 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="">`);
2148
+ await copyFile2(source, join10(temporary, name));
2149
+ await writeFile8(join10(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
2150
  } else {
1767
- await copyFile(source, join9(temporary, "index.html"));
2151
+ await copyFile2(source, join10(temporary, "index.html"));
1768
2152
  }
1769
2153
  return { folder: temporary, temporary, image: isImage };
1770
2154
  }
2155
+ var chosenCanvas;
2156
+ async function locate() {
2157
+ if (chosenCanvas === void 0) return findProject(process.cwd());
2158
+ return (await findNamed(process.cwd(), chosenCanvas))?.project ?? null;
2159
+ }
1771
2160
  async function currentProject() {
1772
- const project = await findProject(process.cwd());
1773
- if (!project) fail("No canvas here yet. Run: twombly init");
1774
- return project;
2161
+ const project = await locate();
2162
+ if (project) return project;
2163
+ if (chosenCanvas !== void 0) fail(`No canvas "${chosenCanvas}" here. Run: twombly init --canvas "${chosenCanvas}"`);
2164
+ const others = await canvasesAbove(process.cwd());
2165
+ 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
2166
  }
1776
2167
  async function markWorking() {
1777
- const project = await findProject(process.cwd());
2168
+ const project = await locate();
1778
2169
  if (project) await call("working", { project, ms: WORKING_MS });
1779
2170
  }
1780
2171
  function gateFailure(result) {
@@ -1790,7 +2181,7 @@ async function frameItems(flags, known) {
1790
2181
  const { frames: _, ...shared } = flags;
1791
2182
  let items;
1792
2183
  try {
1793
- items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile7(value, "utf8"));
2184
+ items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile8(value, "utf8"));
1794
2185
  } catch (error) {
1795
2186
  fail(`--frames: ${error instanceof Error ? error.message : error}`);
1796
2187
  }
@@ -1808,9 +2199,9 @@ async function frameItems(flags, known) {
1808
2199
  return merged;
1809
2200
  });
1810
2201
  }
1811
- async function placeFrames(project, many, jobs, place2) {
2202
+ async function placeFrames(project, many, jobs, command2, item) {
1812
2203
  await markWorking();
1813
- const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
2204
+ const scratch = await mkdtemp(join10(tmpdir(), "twombly-gates-"));
1814
2205
  const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
1815
2206
  let keepScratch = false;
1816
2207
  try {
@@ -1825,7 +2216,7 @@ async function placeFrames(project, many, jobs, place2) {
1825
2216
  await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
1826
2217
  while (next < jobs.length) {
1827
2218
  const index = next++, job = jobs[index];
1828
- results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster: join9(scratch, `${job.id}.png`) });
2219
+ results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster: join10(scratch, `${job.id}.png`) });
1829
2220
  }
1830
2221
  }));
1831
2222
  if (results.some((result) => !result.ok)) {
@@ -1836,23 +2227,21 @@ async function placeFrames(project, many, jobs, place2) {
1836
2227
  warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
1837
2228
  });
1838
2229
  }
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 });
2230
+ const items = jobs.map((job, index) => {
2231
+ const { folder, image } = prepared.get(job.source);
2232
+ return { id: job.id, folder, source: job.source, screenshot: results[index].screenshot, ...item(job, { result: results[index], folder, image, screenshot: results[index].screenshot }) };
2233
+ });
2234
+ const { frames, canvas } = await call(command2, { project, items });
2235
+ const placed = frames.map(({ warnings, ...frame }, index) => {
2236
+ const result = results[index];
2237
+ return { frame, warnings: [...result.warnings, ...warnings], screenshot: result.screenshot ? posterFile(project, jobs[index].id) : void 0, contentSize: result.content };
2238
+ });
2239
+ if (!many) return print({ ok: true, placed: true, ...placed[0], canvas, next: NEXT });
2240
+ print({ ok: true, placed: true, frames: placed.map(({ frame, ...rest }) => ({ ...frame, ...rest })), canvas, next: NEXT });
1852
2241
  } finally {
1853
2242
  await gates.close();
1854
- for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
1855
- if (!keepScratch) await rm5(scratch, { recursive: true, force: true });
2243
+ for (const folder of temporary) await rm6(folder, { recursive: true, force: true });
2244
+ if (!keepScratch) await rm6(scratch, { recursive: true, force: true });
1856
2245
  }
1857
2246
  }
1858
2247
  async function addFrames(project, source, many, items) {
@@ -1869,33 +2258,23 @@ async function addFrames(project, source, many, items) {
1869
2258
  entry: text(flags, "entry") || "index.html",
1870
2259
  width: size(flags, "width", 1280),
1871
2260
  height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
1872
- id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
2261
+ id: randomBytes3(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
1873
2262
  };
1874
2263
  });
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
- });
2264
+ await placeFrames(project, many, jobs, "place", (job, { result, image }) => ({
2265
+ title: job.label,
2266
+ w: job.width,
2267
+ h: result.height,
2268
+ entry: job.entry,
2269
+ level: level(job.flags, image ? "image" : "build"),
2270
+ x: number(job.flags, "x"),
2271
+ y: number(job.flags, "y"),
2272
+ newRow: job.flags["new-row"] === true
2273
+ }));
1895
2274
  }
1896
2275
  async function updateFrames(project, source, many, items) {
1897
2276
  const { frames } = await call("list", { project });
1898
- const seen = /* @__PURE__ */ new Set();
2277
+ const seen = /* @__PURE__ */ new Set(), bases = /* @__PURE__ */ new Map();
1899
2278
  const jobs = items.map((flags) => {
1900
2279
  const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
1901
2280
  if (!ref || !folder) fail("twombly update <frame> <folder|file>");
@@ -1904,6 +2283,7 @@ async function updateFrames(project, source, many, items) {
1904
2283
  const frame = matches[0];
1905
2284
  if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
1906
2285
  seen.add(frame.id);
2286
+ bases.set(frame.id, frame.content);
1907
2287
  level(flags, "build");
1908
2288
  return {
1909
2289
  flags,
@@ -1915,20 +2295,15 @@ async function updateFrames(project, source, many, items) {
1915
2295
  height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
1916
2296
  };
1917
2297
  });
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
- });
2298
+ await placeFrames(project, many, jobs, "revise", (job, { result, image }) => ({
2299
+ label: job.label,
2300
+ base: bases.get(job.id),
2301
+ title: text(job.flags, "title"),
2302
+ w: job.flags.width !== void 0 ? job.width : void 0,
2303
+ h: job.flags.height !== void 0 ? result.height : void 0,
2304
+ entry: text(job.flags, "entry"),
2305
+ level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
2306
+ }));
1932
2307
  }
1933
2308
  async function main() {
1934
2309
  const [name, ...rest] = process.argv.slice(2);
@@ -1938,6 +2313,10 @@ async function main() {
1938
2313
  return;
1939
2314
  }
1940
2315
  if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
2316
+ if (flags.canvas !== void 0) {
2317
+ if (typeof flags.canvas !== "string" || !flags.canvas.trim()) fail("--canvas needs a name.");
2318
+ chosenCanvas = flags.canvas.trim();
2319
+ }
1941
2320
  switch (name) {
1942
2321
  case "__daemon":
1943
2322
  return runDaemon();
@@ -1949,15 +2328,30 @@ async function main() {
1949
2328
  return;
1950
2329
  case "--version":
1951
2330
  case "version":
1952
- process.stdout.write("0.2.0\n");
2331
+ process.stdout.write("0.4.0\n");
1953
2332
  return;
1954
2333
  case "guide":
1955
2334
  process.stdout.write(SKILL_default);
1956
2335
  return;
1957
2336
  case "init": {
2337
+ const next = "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide";
2338
+ const open = flags["no-open"] === void 0, given = text(flags, "name");
2339
+ if (chosenCanvas !== void 0) {
2340
+ if (given) fail("Use --canvas or --name, not both. --canvas is the name.");
2341
+ const found = await findNamed(process.cwd(), chosenCanvas);
2342
+ const project2 = found?.project || namedProject(await findRoot(process.cwd()) || process.cwd(), chosenCanvas);
2343
+ print({ ok: true, ...await call("init", { project: project2, exact: true, name: chosenCanvas, open }), next });
2344
+ return;
2345
+ }
1958
2346
  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" });
2347
+ const result = await call("init", { project, name: given || defaultName(project), open });
2348
+ 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;
2349
+ print({ ok: true, ...result, note, next });
2350
+ return;
2351
+ }
2352
+ case "canvases": {
2353
+ const found = await canvasesAbove(process.cwd());
2354
+ print({ ok: true, canvases: found.map(({ name: name2, slug, frames, id, link }) => ({ name: name2, canvas: slug, frames, local: `${DAEMON_ORIGIN}/${id}`, link })) });
1961
2355
  return;
1962
2356
  }
1963
2357
  case "check": {
@@ -1965,15 +2359,15 @@ async function main() {
1965
2359
  if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
1966
2360
  await markWorking();
1967
2361
  const { folder, temporary } = await prepare(resolve2(source));
1968
- const scratch = await mkdtemp(join9(tmpdir(), "twombly-check-"));
1969
- const shot = join9(scratch, "screenshot.png");
2362
+ const scratch = await mkdtemp(join10(tmpdir(), "twombly-check-"));
2363
+ const shot = join10(scratch, "screenshot.png");
1970
2364
  try {
1971
2365
  const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
1972
2366
  const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
1973
2367
  print(result);
1974
2368
  if (!result.ok) process.exitCode = 1;
1975
2369
  } finally {
1976
- if (temporary) await rm5(temporary, { recursive: true, force: true });
2370
+ if (temporary) await rm6(temporary, { recursive: true, force: true });
1977
2371
  }
1978
2372
  return;
1979
2373
  }
@@ -2002,6 +2396,12 @@ async function main() {
2002
2396
  case "list":
2003
2397
  print({ ok: true, ...await call("list", { project: await currentProject() }) });
2004
2398
  return;
2399
+ case "context":
2400
+ print({ ok: true, ...await call("context", { project: await currentProject() }) });
2401
+ return;
2402
+ case "comments":
2403
+ print({ ok: true, ...await call("comments", { project: await currentProject() }) });
2404
+ return;
2005
2405
  case "open":
2006
2406
  print({ ok: true, ...await call("open", { project: await currentProject() }) });
2007
2407
  return;
@@ -2011,7 +2411,7 @@ async function main() {
2011
2411
  return;
2012
2412
  }
2013
2413
  case "status":
2014
- print({ ok: true, ...await call("status", { project: await findProject(process.cwd()) }) });
2414
+ print({ ok: true, ...await call("status", { project: await locate() }) });
2015
2415
  return;
2016
2416
  case "remove": {
2017
2417
  if (!positional[0]) fail("twombly remove <frame>");
@@ -2069,6 +2469,13 @@ async function main() {
2069
2469
  case "logout":
2070
2470
  print({ ok: true, ...await call("logout") });
2071
2471
  return;
2472
+ case "usage": {
2473
+ const choice = positional[0];
2474
+ if (choice !== void 0 && choice !== "on" && choice !== "off") fail("Run: twombly usage on, or twombly usage off");
2475
+ if (choice) await setUsage(choice === "on");
2476
+ print({ ok: true, usage: usageOn() ? "on" : "off" });
2477
+ return;
2478
+ }
2072
2479
  case "stop": {
2073
2480
  if (await health()) await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
2074
2481
  });