dsh-claude-whiteboard 2.13.0 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE.md CHANGED
@@ -24,6 +24,7 @@ baoyu-design brings claude.ai's Artifact types and Claude Design to local agents
24
24
  - **Reference docs.** `skills/*/artifact-type/reference/`, and the copies under `scripts/editor/artifact-type/`: the craft, format and layout guides, font lists and other references of the Artifact types.
25
25
  - **The `dataviz` skill.** `skills/dataviz/` is Anthropic's dataviz skill. Its palette validators include code from Color.js (MIT, Lea Verou and Chris Lilley), credited in the files.
26
26
  - **The editors.** `skills/claude-*/scripts/editor/` holds editors reconstructed from the claude.ai Artifact-type runtimes: their interface, wording and behaviour are Anthropic's. The built files mix that reconstruction with the maintainer's local additions (section 1) and with open-source packages (section 3).
27
+ - **Compiled engines.** Where an editor ships a WebAssembly engine as upstream built it (a `*.wasm` file in its `scripts/editor/artifact-type/`), the engine is Anthropic's too. The open-source code compiled into it is listed in the notices shipped beside it (`THIRD_PARTY_NOTICES.md`, `RUST_STANDARD_LIBRARY_NOTICES.html`).
27
28
  - **Icon fonts.** The Anthropicons icon fonts: the `anthropicons-*.woff2` files in the editors' `assets/` and `artifact-type/` folders, and the Anthropicons font faces embedded as data URIs in the editors' CSS.
28
29
  - **The archived `baoyu-design` skill.** In `archive/baoyu-design/`: `system-prompt.md`, `references/upstream-system-prompt.md` and `references/upstream-sync/` (the Claude Design system prompt, a snapshot of it and its sync records), `built-in-skills/` (Claude Design's built-in skills) and `starter-components/` (Claude Design's starter components; the local patches are recorded in the `*-patch.md` files there).
29
30
 
@@ -33,7 +34,7 @@ baoyu-design brings claude.ai's Artifact types and Claude Design to local agents
33
34
 
34
35
  ## 3. Third-party open-source code
35
36
 
36
- The editors bundle open-source packages, such as React, Base UI, d3 and pptxgenjs. Each keeps its own license, which this project's license does not replace. The licenses are in each editor's `scripts/editor/third-party-licenses.txt`, and for the slides compiler in `scripts/editor/node/third-party-licenses.txt`.
37
+ The editors bundle open-source packages, such as React, Base UI, d3 and pptxgenjs. Each keeps its own license, which this project's license does not replace. The licenses are in each editor's `scripts/editor/third-party-licenses.txt`, and for the slides compiler in `scripts/editor/node/third-party-licenses.txt`. The open-source crates and the Rust standard library compiled into a WebAssembly engine are listed in the notices beside it.
37
38
 
38
39
  The archived `baoyu-design` skill vendors React, ReactDOM, Babel and fflate (MIT) in `archive/baoyu-design/agents/vendor/`, and pptxgenjs (MIT) in `archive/baoyu-design/agents/gen-pptx/vendor/pptxgenjs/`, with its `LICENSE`.
39
40
 
@@ -51,6 +52,7 @@ If you hold rights in anything here and want it credited differently, changed or
51
52
  - `artifact-type/reference/` 里的参考文档;
52
53
  - `skills/dataviz/`(Anthropic 的 dataviz 技能);
53
54
  - `scripts/editor/` 里从 claude.ai 运行时还原的编辑器(界面、文案和行为);
55
+ - 编辑器原样带上的 WebAssembly 引擎(`scripts/editor/artifact-type/` 里的 `*.wasm`),编进去的开源代码列在它旁边的 `THIRD_PARTY_NOTICES.md`、`RUST_STANDARD_LIBRARY_NOTICES.html` 里;
54
56
  - Anthropicons 图标字体(`anthropicons-*.woff2` 文件,以及编辑器 CSS 里内嵌的字体);
55
57
  - `archive/baoyu-design/` 里的 Claude Design 系统提示词、内置技能和起步组件。
56
58
 
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-claude-whiteboard 2.13.0: built from baoyu-design.
1
+ // dsh-claude-whiteboard 2.14.1: built from baoyu-design.
2
2
  var define_PLUGIN_default = { name: "claude-whiteboard", provider: "dsh-claude-whiteboard", skills: [{ name: "claude-whiteboard", description: "Use this for a local Whiteboard: a shared space for laying out thinking in whatever form it needs (a diagram, a flow, a map of ideas, a plan, a comparison, a drawn explanation, a small working embed) that opens in the bundled board editor, where people move, draw, comment and send comments to the agent. Its content lives in the board's own store, written with this skill's script, never in files you edit by hand. Read it before you create a board or change one. Not for a slide deck, a document, a film or a static HTML page.", scriptAlias: "A", comments: true }] };
3
3
 
4
4
  import { readFile } from "node:fs/promises";
@@ -110,13 +110,14 @@ function runtimeSection(skill, skillDir, node, platform = process.platform, watc
110
110
  skill.commentsBy === "mention" ? "(read the block, change the doc with `update`, then `comment-reply`)." : "(change the files, then `comment-reply`).",
111
111
  "So do not start `comments --wait` yourself, where Comments says to: run `open` in the foreground (not",
112
112
  "`run_in_background`), and the wait starts with it. It stops when the editor stops; after `stop`, the next `open`",
113
- "starts it again.",
113
+ "starts it again. Harness starts the next wait when your turn ends, which is where this skill says to wait again:",
114
+ "finish what a message asks for in the turn that takes it.",
114
115
  ...skill.commentsBy === "mention" ? ["", "When you first give the link, tell the user that a comment with `@agent` in it reaches you (type `@` and pick agent)."] : [],
115
116
  ...skill.requests ? [
116
117
  "",
117
118
  "The page's buttons reach you the same way: a request one of them sends arrives as such a message, under",
118
- "`requests` (its words in `text`, the button in `label`). Those are the user's words, as if typed here: handle",
119
- "them as this skill says for those words."
119
+ "`requests` (the button in `label`, what it asks in `text`), as `comments <root> --wait` printed it for that",
120
+ "page. It is a request from the page wherever this skill speaks of one: handle it as this skill says for those."
120
121
  ] : []
121
122
  );
122
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-claude-whiteboard",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
4
4
  "description": "DeepSeek Harness plugin: the claude-whiteboard skill from baoyu-design. The agent lays out diagrams, flows, maps of ideas and plans on a whiteboard and opens it in a local board editor where you move, draw and comment.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -24,6 +24,18 @@
24
24
  "icon": "./icon.svg",
25
25
  "baoyuDesign": {
26
26
  "releaseNotes": [
27
+ {
28
+ "version": "2.14.1",
29
+ "date": "2026-11-11",
30
+ "en": "### Features\n- **DeepSeek Harness: open your work from another device.** When Harness runs on one computer and you use it from another (`dsh web` on a server, opened in a browser on your laptop or phone over the same network or a tailnet), opening an item from **Artifacts**, or **View thread** on a comment card, gave a `http://127.0.0.1:…` link that only that computer can open. The plugin now gives the device you are on a link of its own, at the address your browser reaches Harness by, and opens it in a new tab. Through it you are the editor's owner, as on that computer: you edit, and **Send to Agent** is there. Behind a proxy or a domain of your own, set `BAOYU_DESIGN_REMOTE_HOST` to the address other devices reach that computer at; `off` turns this off. Every editor's `open` takes `--remote[=<host>]` to print that owner's link (`--remote=off` closes it), and `share <folder> --reset owner` replaces its key. See [Use it from another device](https://github.com/JimLiu/baoyu-design/blob/main/dsh-plugins/README.md#use-it-from-another-device) (requested by @akaDeyve, [#40](https://github.com/JimLiu/baoyu-design/issues/40)).\n- **DeepSeek Harness: `dsh-claude-dashboard` updates to `dsh-claude-data`.** The Artifacts page names the type **Data**, and offers an installed `dsh-claude-dashboard` as an update, `claude-dashboard → claude-data`: **Update** installs `dsh-claude-data`, switched on or off as the old one was, then uninstalls `dsh-claude-dashboard`. A preset that names `dsh-claude-dashboard` keeps it, and the page says what to change. 2.14.0 said `dsh-claude-dashboard` would get no more updates; instead it keeps getting releases with no skill in them. An Artifacts page from 2.13.0 or earlier offers that release as an update, and it brings the page that offers the move, so click **Update** once more when the move shows.\n\n### Fixes\n- `claude-design-system`: a guest on a **Share** link could not add an asset or a font. The page stayed at **Adding …**, because a page served over plain http on the network lacks the browser's hashing that the upload used. It now hashes the file itself.\n- DeepSeek Harness: uninstalling the plugin that was running the Artifacts page left the page at a 404 until the app restarted. Another installed plugin now takes it over at once.",
31
+ "zh": "### 新功能\n- **DeepSeek Harness:在另一台设备上打开你的作品。** Harness 跑在一台电脑上、你在另一台设备上用时(比如服务器上跑 `dsh web`,在笔记本或手机的浏览器里通过同一个局域网或 tailnet 打开),从 **Artifacts**(「作品」)打开,或者点评论卡片上的 **View thread**,拿到的是 `http://127.0.0.1:…`,只有那台电脑打得开。现在插件会给你手上这台设备一个它自己的链接,地址就是浏览器访问 Harness 用的那个,在新标签页里打开。通过它打开,你就是编辑器的主人,和在那台电脑上一样:能编辑,也有 **Send to Agent**。经过代理或用自己的域名访问时,把 `BAOYU_DESIGN_REMOTE_HOST` 设成其他设备访问那台电脑用的地址;设成 `off` 就关掉这个功能。每个编辑器的 `open` 都可以加 `--remote[=<host>]`,打出这个主人链接(`--remote=off` 关掉),`share <文件夹> --reset owner` 换一把钥匙。见[在另一台设备上用](https://github.com/JimLiu/baoyu-design/blob/main/dsh-plugins/README.zh-CN.md#在另一台设备上用)(@akaDeyve 提出,[#40](https://github.com/JimLiu/baoyu-design/issues/40))。\n- **DeepSeek Harness:`dsh-claude-dashboard` 可以更新成 `dsh-claude-data`。** 「作品」页上这个类型叫 **数据**(Data),装着 `dsh-claude-dashboard` 的,会把换到新插件当作一项更新来提示,写作 `claude-dashboard → claude-data`:点 **Update**(更新)会先装上 `dsh-claude-data`(开关和旧插件一样),再卸载 `dsh-claude-dashboard`。预设里写了 `dsh-claude-dashboard` 的,旧插件会留着,页面上会说要改哪里。2.14.0 说 `dsh-claude-dashboard` 不再更新;现在改成它继续发不带技能的版本。2.13.0 或更早的「作品」页会把这个版本当作更新来提示,装上它就有了会提示换插件的新「作品」页,提示出来后再点一次 **Update**。\n\n### 修复\n- `claude-design-system`:通过 **Share**(分享)链接进来的访客加不了资源和字体,页面一直停在 **Adding …**。局域网上用明文 http 打开的页面没有浏览器自带的哈希功能,上传要用它。现在由页面自己算。\n- DeepSeek Harness:卸载正在提供「作品」页的那个插件后,这个页面一直是 404,要重启应用才好。现在另一个装着的插件会马上接手。"
32
+ },
33
+ {
34
+ "version": "2.14.0",
35
+ "date": "2026-11-11",
36
+ "en": "### Features\n- **`claude-dashboard` is now `claude-data`.** claude.ai renamed the type to Data, and the skill, its DeepSeek Harness plugin (`dsh-claude-data`) and its ChatGPT and Codex plugin (**Baoyu Data**) follow. Call it as `/claude-data` (`$claude-data` in Codex). A dashboard made with `claude-dashboard` opens in `claude-data` as it is, with the same folder; its address is now `/data/<id>/` instead of `/dashboard/<id>/`. `dsh-claude-dashboard` and **Baoyu Dashboard** get no more updates: install `dsh-claude-data` (the **Artifacts** page offers it as a **Not installed** tile) or **Baoyu Data**, then remove the old one. On the Artifacts page, dashboards opened before the rename are listed with the new kind and still open, and comments sent from a dashboard reach the agent through `dsh-claude-data` (the copy of `dsh-claude-data` put on npm on its own before this release lacked both, because the Host plugin it came with did not know the new name).\n- **`claude-data` follows claude.ai's 2026-11-10 update.** A dashboard's toolbar now has a **Design** button. It shows the look in use and a new **Style** section: chart colors, page color, font, corners and text size. A change shows on the page at once and is kept with **Save**; **Reset all** goes back to the defaults. A dataset can be a text file (`format: \"text\"`), which the page turns into rows with a loader. `dash/meta` takes the page's language (`lang`). `check` also reports what the open page found when it checked itself (`pageCheck`: passed, failed with what went wrong, or still loading) beside the page's `runs`.\n- **`claude-design` follows claude.ai's 2026-11-10 update.** Pen, Line and Arrow have a **Drawing options** bar: four stroke weights, and solid, dashed or dotted lines. An artboard's export menu has **All artboards (.png)**, a zip with one PNG per artboard on the page. When the canvas's saved layout is damaged, the canvas turns read-only with a banner and an **Ask the agent to fix** button, and comes back on its own once the file is fixed. A new reference, `shaders.md`, is how the agent makes a shader artboard (WebGL, or anything that moves like light, water, smoke or liquid).\n\n### Fixes\n- `claude-design`: artboards ran React's development build (1.1 MB), which printed warnings such as `Invalid DOM property vector-effect` for drawn lines. They now run the production build (354 KB).\n- `claude-design`: when two tabs changed the same artboard and you chose **Keep both**, the copy was put 80 px right of the original, where it could cover another artboard. It now goes in the first free spot right of that row.\n- In every editor, Escape pressed while the keyboard is in the comments layer (on the comments button, say) now ends comment mode, as it does on the page.\n\n### Documentation\n- `claude-data`'s `SKILL.md` and reference follow the same update: what runs where (each reader's browser loads the datasets, and Sources shows where every number came from), what a page's code may write and when, and how `check` and `runs` report a page that went wrong.",
37
+ "zh": "### 新功能\n- **`claude-dashboard` 改名为 `claude-data`。** claude.ai 把这个类型改名为 Data,技能、它的 DeepSeek Harness 插件(`dsh-claude-data`)和 ChatGPT、Codex 插件(**Baoyu Data**)都跟着改名。调用名是 `/claude-data`(Codex 里是 `$claude-data`)。用 `claude-dashboard` 做的仪表盘,原样用 `claude-data` 打开,文件夹不变;地址从 `/dashboard/<id>/` 变成 `/data/<id>/`。`dsh-claude-dashboard` 和 **Baoyu Dashboard** 不再更新:装上 `dsh-claude-data`(**Artifacts** 页面上它是一个 **Not installed** 的卡片,点开就能装)或 **Baoyu Data**,再把旧的删掉。改名前打开过的仪表盘,在 Artifacts 页面上按新的类型列出,照样能打开;仪表盘上发出的评论经 `dsh-claude-data` 交给 Agent(这一版之前单独发到 npm 的 `dsh-claude-data` 这两样都不行,因为和它配套的 Host 插件还不认识新名字)。\n- **`claude-data` 跟上了 claude.ai 2026-11-10 的更新。** 仪表盘的工具栏多了一个 **Design** 按钮,里面是当前的外观和新的 **Style** 一段:图表配色、页面底色、字体、圆角和字号。改了页面马上显示,点 **Save** 保存;**Reset all** 回到默认。数据集可以是文本文件(`format: \"text\"`),由页面用 loader 拆成行。`dash/meta` 可以写页面的语言(`lang`)。`check` 还会报告打开着的页面自查的结果(`pageCheck`:通过、没通过和出了什么问题、还在加载),和页面的 `runs` 放在一起。\n- **`claude-design` 跟上了 claude.ai 2026-11-10 的更新。** 画笔、直线和箭头有了 **Drawing options** 条:四档粗细,实线、虚线或点线。画板的导出菜单里多了 **All artboards (.png)**,把这一页每块画板各导出一张 PNG,打成一个 zip。画布保存的布局文件坏了时,画布变成只读,顶上有横幅和 **Ask the agent to fix** 按钮,文件修好后自己恢复。新增 reference `shaders.md`,Agent 照它做着色器画板(WebGL,或者像光、水、烟、液体那样流动的效果)。\n\n### 修复\n- `claude-design`:画板里跑的是 React 的开发版(1.1 MB),画的线会报 `Invalid DOM property vector-effect` 这类警告。现在是生产版(354 KB)。\n- `claude-design`:两个标签页改了同一块画板、选 **Keep both** 时,副本放在原画板右边 80 px,可能压住那里的另一块画板。现在放在这一行右边第一个空位。\n- 所有编辑器:键盘在评论层里时(比如停在评论按钮上)按 Escape,现在会退出评论模式,和在页面上按一样。\n\n### 文档\n- `claude-data` 的 `SKILL.md` 和 reference 也跟着这次更新:什么在哪里运行(每个读者的浏览器加载数据集,Sources 说明每个数字从哪里来),页面的代码什么时候可以写入、能写什么,以及 `check` 和 `runs` 怎样报告出错的页面。"
38
+ },
27
39
  {
28
40
  "version": "2.13.0",
29
41
  "date": "2026-11-10",
@@ -59,18 +71,6 @@
59
71
  "date": "2026-11-05",
60
72
  "en": "### Features\n- **DeepSeek Harness: the Artifacts page offers the plugins you haven't installed.** Beside the tiles for the skills you have, each other baoyu-design plugin gets a dimmed tile marked **Not installed**. Click it to see what the plugin makes and install it from there, at the same version as the ones you have, usually without a restart. Once it's in, **Start** opens a session with the skill. In an app that can't install from the page, the tile points you to the Plugins page. A plugin you installed and switched off still gets no tile.\n\n### Fixes\n- With `--agent-hook`, a comment could reach the agent twice: a second send could start the hook again after the first run had finished but before the comment was marked sent.",
61
73
  "zh": "### 新功能\n- **DeepSeek Harness:「作品」页会列出你还没装的插件。** 已装技能的入口旁边,其他 baoyu-design 插件也各有一个入口,颜色淡一些,标着 **未安装**。点开会说明这个插件做什么,可以直接在这里安装,版本和你已装的一致,多数情况不用重启。装好后点 **新建** 就用这个技能开一个会话。应用不支持在页面里安装时,会引导你去插件页。装了但被你关掉的插件,仍然没有入口。\n\n### 修复\n- 用 `--agent-hook` 时,一条评论可能发给 agent 两次:第一次运行已经结束、评论还没标成已发送时,第二次发送会再启动一次 hook。"
62
- },
63
- {
64
- "version": "2.11.0",
65
- "date": "2026-11-05",
66
- "en": "### Features\n- **DeepSeek Harness: a new version no longer pops up over the app.** The **Artifacts** row in the left sidebar gets an **Update** label instead (a dot on the icon, where a collapsed sidebar keeps its icons), and a panel at the top of the Artifacts page shows the versions, each plugin and what the release changes. **Update all** updates them together; **Update** on a plugin's line takes just that one. **Later** folds the panel to one line and removes the label until the next version.\n- While an update runs you can leave the Artifacts page: the label says **Updating**, **Failed** or **Restart**. **Try again** picks up where it stopped: the rest after **Update all**, that plugin after its own **Update**.\n- The **Update** button on a plugin's page now updates only that plugin, and a page that was open when its plugin was replaced comes back by itself.",
67
- "zh": "### 新功能\n- **DeepSeek Harness:新版本不再弹出来打断你。** 左侧栏的 **Artifacts**(「作品」)这一行会多一个 **更新** 标签(侧栏收起后还留着图标的话,是图标上的小圆点),「作品」页顶部的面板写着版本、每个插件和这次改了什么。**全部更新** 一起更新;每个插件那一行的 **更新** 只更新它自己。**稍后** 把面板收成一行,标签也消失,等下一个版本再提示。\n- 更新时可以离开「作品」页:标签会显示 **更新中**、**更新失败** 或 **待重启**。**重试** 从停下的地方接着来:**全部更新** 失败就接着更新剩下的,单独更新失败就再试这一个。\n- 插件详情页的 **更新** 按钮现在只更新这一个插件;更新时开着的页面被替换后,会自己回来。"
68
- },
69
- {
70
- "version": "2.10.1",
71
- "date": "2026-11-05",
72
- "en": "### Fixes\n- DeepSeek Harness 0.2.2 has its own plugin updates, so a plugin's page showed two update buttons and two tags, the app's and the plugin's. It now shows one: the app's for a plugin the card hasn't updated, the plugin's own for one it has.\n- The one-click update looks again at what there is to update before it starts, so a plugin the app's button just updated isn't tried again. When the reply to an update is lost, the card no longer says the plugin stayed on its version: it asks you to check on the Plugins page.\n- The [how-to](dsh-plugins/README.md#if-something-doesnt-work) says what to do when the app's own **Update to x** fails on the second of two plugins and switches it off, and that 2.9.2 and earlier can use that button on DeepSeek Harness 0.2.2 instead of updating by hand.",
73
- "zh": "### 修复\n- DeepSeek Harness 0.2.2 自带了插件更新,插件详情页因此出现两个更新按钮和两个标签,应用的和插件的各一套。现在只有一个:没用卡片更新过的插件显示应用的按钮,更新过的显示插件自己的。\n- 一键更新开始前会重新确认要更新什么,刚用应用自带按钮更新过的插件不会再更新一次。更新的回复丢失时,卡片不再说插件还是原来的版本,而是请你到插件页确认。\n- [使用说明](dsh-plugins/README.zh-CN.md#遇到问题)写明了应用自带的 **更新到 x** 更新第二个插件失败、插件被关掉时怎么办;也说明了在 DeepSeek Harness 0.2.2 上,2.9.2 和更早的版本可以直接用这个按钮更新,不用手动。"
74
74
  }
75
75
  ]
76
76
  },
@@ -96,7 +96,7 @@
96
96
  "dsh": ">=0.2.0-0"
97
97
  },
98
98
  "dependencies": {
99
- "dsh-claude-artifacts": "^2.13.0"
99
+ "dsh-claude-artifacts": "^2.14.1"
100
100
  },
101
101
  "dsh": {
102
102
  "bundle": {
@@ -242,6 +242,7 @@ function sendFile(request, response, file, headers) {
242
242
  var errorText = (error) => error instanceof Error ? error.message : String(error);
243
243
 
244
244
  import { spawn } from "node:child_process";
245
+ import dns from "node:dns/promises";
245
246
  import { EventEmitter } from "node:events";
246
247
  import fs3 from "node:fs";
247
248
  import http from "node:http";
@@ -297,6 +298,7 @@ var Sharing = class _Sharing {
297
298
  if (isRecord(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
298
299
  }
299
300
  }
301
+ if (isRecord(raw.owner) && typeof raw.owner.token === "string" && raw.owner.token.length >= 32) state.owner = { token: raw.owner.token };
300
302
  if (Array.isArray(raw.guests)) {
301
303
  for (const g of raw.guests) {
302
304
  if (!isRecord(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
@@ -327,6 +329,27 @@ var Sharing = class _Sharing {
327
329
  }
328
330
  return null;
329
331
  }
332
+ /** The owner's link token (`state.owner`), made and saved the first time it is asked for. */
333
+ ownerToken() {
334
+ if (!this.state.owner) {
335
+ this.state.owner = { token: token(32) };
336
+ this.save();
337
+ }
338
+ return this.state.owner.token;
339
+ }
340
+ /** Whether `given` is the owner's link token (a cookie or a link's). */
341
+ isOwnerToken(given) {
342
+ const own = this.state.owner?.token;
343
+ if (!given || !own) return false;
344
+ const a = Buffer.from(given);
345
+ const b = Buffer.from(own);
346
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
347
+ }
348
+ /** A new owner's link: the old one, and every browser that came in through it, stop working. */
349
+ resetOwner() {
350
+ this.state.owner = { token: token(32) };
351
+ this.save();
352
+ }
330
353
  /** The guest a cookie names, if they still have access (their link on and not reset). */
331
354
  guest(secret) {
332
355
  if (!secret) return null;
@@ -588,6 +611,8 @@ var Hub = class {
588
611
  hosts = /* @__PURE__ */ new Set();
589
612
  /** One listener per LAN address while any site is open to the network. */
590
613
  lanServers = /* @__PURE__ */ new Map();
614
+ /** Names that resolve to an address listened on, asked for by `allowName` (lower case, no port). */
615
+ lanNames = /* @__PURE__ */ new Set();
591
616
  network;
592
617
  constructor(options) {
593
618
  this.echo = options.echo;
@@ -603,7 +628,8 @@ var Hub = class {
603
628
  },
604
629
  addresses: () => [...this.lanServers.keys()],
605
630
  open: () => this.openLan(),
606
- release: () => this.releaseLan()
631
+ release: () => this.releaseLan(),
632
+ allowName: (name) => this.allowName(name)
607
633
  };
608
634
  }
609
635
  createServer() {
@@ -722,9 +748,9 @@ var Hub = class {
722
748
  }
723
749
  return [...this.lanServers.keys()];
724
750
  }
725
- /** A site stopped sharing: the listeners stay while another is shared. */
751
+ /** A site stopped sharing: the listeners stay while another is shared, or open to its owner on another device. */
726
752
  releaseLan() {
727
- if (![...this.sites.values()].some((site) => site.share.shared)) this.closeLan();
753
+ if (![...this.sites.values()].some((site) => site.share.listening)) this.closeLan();
728
754
  }
729
755
  closeLan() {
730
756
  for (const server of this.lanServers.values()) {
@@ -732,8 +758,30 @@ var Hub = class {
732
758
  server.closeAllConnections?.();
733
759
  }
734
760
  this.lanServers.clear();
761
+ this.lanNames.clear();
762
+ }
763
+ /** The machine's own names, as hostAllowed takes them. */
764
+ isMachineName(name) {
765
+ const machine = os2.hostname().toLowerCase();
766
+ return name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
735
767
  }
736
- /** A Host header this hub answers: its own names, and while something is shared, its LAN addresses and machine name. */
768
+ /**
769
+ * Answer at `name` too while listening: an address listened on, the machine's name, or a name that resolves to an
770
+ * address listened on (looked up once, now: Tailscale's MagicDNS name, a hosts entry).
771
+ */
772
+ async allowName(name) {
773
+ const lower = name.toLowerCase();
774
+ if (!lower || !this.lanServers.size) return false;
775
+ if (this.lanServers.has(lower) || this.isMachineName(lower) || this.lanNames.has(lower)) return true;
776
+ const found = await dns.lookup(lower, { all: true, family: 4 }).catch(() => []);
777
+ if (!found.some((entry) => this.lanServers.has(entry.address))) return false;
778
+ this.lanNames.add(lower);
779
+ return true;
780
+ }
781
+ /**
782
+ * A Host header this hub answers: its own names, and while something is shared (or open to its owner on another
783
+ * device), its LAN addresses, its machine name and the names allowName took.
784
+ */
737
785
  hostAllowed(request) {
738
786
  const host = request.headers.host ?? "";
739
787
  if (this.hosts.has(host)) return true;
@@ -741,8 +789,7 @@ var Hub = class {
741
789
  const suffix = `:${this.port}`;
742
790
  if (!host.endsWith(suffix)) return false;
743
791
  const name = host.slice(0, -suffix.length).toLowerCase();
744
- const machine = os2.hostname().toLowerCase();
745
- return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
792
+ return this.lanServers.has(name) || this.isMachineName(name) || this.lanNames.has(name);
746
793
  }
747
794
  // ---- requests --------------------------------------------------------------------------------------------------
748
795
  /** The site whose address `pathname` is under, with the path below it. */
@@ -1330,6 +1377,12 @@ function commentsViewer(viewer) {
1330
1377
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1331
1378
  var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1332
1379
  var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
1380
+ function hostName(host) {
1381
+ const value = host.trim().toLowerCase();
1382
+ if (value.startsWith("[")) return value.slice(1, value.indexOf("]") > 0 ? value.indexOf("]") : void 0);
1383
+ const colons = value.split(":").length - 1;
1384
+ return colons === 1 ? value.slice(0, value.indexOf(":")) : value;
1385
+ }
1333
1386
  function localOwner() {
1334
1387
  let name = "You";
1335
1388
  try {
@@ -1351,6 +1404,8 @@ var ShareHost = class {
1351
1404
  page;
1352
1405
  /** Open to the local network now (the hub's listeners are up, for this artifact too). */
1353
1406
  on = false;
1407
+ /** Open to its owner on another device (`open --remote`): the listeners are up, guests stay out unless `on`. */
1408
+ remote = false;
1354
1409
  live = /* @__PURE__ */ new Set();
1355
1410
  constructor(options) {
1356
1411
  this.options = options;
@@ -1376,6 +1431,10 @@ var ShareHost = class {
1376
1431
  get shared() {
1377
1432
  return this.on;
1378
1433
  }
1434
+ /** Someone may reach it over the network now (guests, or its owner on another device): the hub keeps listening. */
1435
+ get listening() {
1436
+ return this.on || this.remote;
1437
+ }
1379
1438
  /** The artifact's name, for the join page and the list of what is open at the server's root. */
1380
1439
  title() {
1381
1440
  return this.options.title();
@@ -1398,30 +1457,61 @@ var ShareHost = class {
1398
1457
  this.options.log(`shared on the local network: ${addresses.map((a) => `http://${a}:${this.port}${this.page}`).join(", ") || "(no LAN address)"}`);
1399
1458
  return addresses;
1400
1459
  }
1460
+ /**
1461
+ * Opens the artifact to its owner on another device, or closes that, and returns the owner's link, at `host` when
1462
+ * that is this computer's (the address the owner's browser already reaches it by), else at its first network address.
1463
+ * Guests are not let in by it: that is `setLan`.
1464
+ */
1465
+ async setRemote(on, host) {
1466
+ if (!on) {
1467
+ this.remote = false;
1468
+ for (const live of [...this.live]) if (live.viewer.remote) live.close("unshared");
1469
+ this.network.release();
1470
+ return null;
1471
+ }
1472
+ const addresses = await this.network.open();
1473
+ if (!addresses.length) {
1474
+ this.network.release();
1475
+ throw new ShareError("This computer has no network address to open it at.");
1476
+ }
1477
+ this.remote = true;
1478
+ const name = hostName(host ?? "");
1479
+ const sorted = [...addresses].sort((a, b) => Number(isTailnet(a)) - Number(isTailnet(b)));
1480
+ const address = name && (addresses.includes(name) || await this.network.allowName?.(name)) ? name : sorted[0];
1481
+ const url = `http://${address.includes(":") ? `[${address}]` : address}:${this.port}${this.page}o/${this.sharing.ownerToken()}`;
1482
+ this.options.log(`open to its owner on another device at http://${address}:${this.port}${this.page}`);
1483
+ return { url, address };
1484
+ }
1401
1485
  /** The artifact left the hub: it stays shared in .sharing.json, for the next time it opens. */
1402
1486
  close() {
1403
1487
  this.on = false;
1488
+ this.remote = false;
1404
1489
  this.network.release();
1405
1490
  }
1406
1491
  // ---- who is asking ---------------------------------------------------------------------------------------------
1407
- cookie(request) {
1492
+ cookie(request, name = this.options.cookieName) {
1408
1493
  for (const part of (request.headers.cookie ?? "").split(";")) {
1409
1494
  const eq = part.indexOf("=");
1410
- if (eq > 0 && part.slice(0, eq).trim() === this.options.cookieName) return part.slice(eq + 1).trim();
1495
+ if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
1411
1496
  }
1412
1497
  return void 0;
1413
1498
  }
1499
+ /** The cookie the owner's link leaves in a browser on another device. */
1500
+ get ownerCookie() {
1501
+ return `${this.options.cookieName}_owner`;
1502
+ }
1414
1503
  /**
1415
- * Who a request is: the owner over loopback, a guest by their cookie (while their link is on and the artifact is
1416
- * shared), else nobody. Loopback is always the owner, so trying a link on this computer can't lock the owner out of
1417
- * their own artifact; a guest comes over a LAN address (this computer's own included: that is how the owner sees
1418
- * what a role sees).
1504
+ * Who a request is: the owner over loopback, or by the owner's link's cookie while the artifact is open to them on
1505
+ * another device; a guest by their cookie (while their link is on and the artifact is shared), else nobody. Loopback
1506
+ * is always the owner, so trying a link on this computer can't lock the owner out of their own artifact; a guest
1507
+ * comes over a LAN address (this computer's own included: that is how the owner sees what a role sees).
1419
1508
  */
1420
1509
  isLocal(request) {
1421
1510
  return this.options.local ? this.options.local(request) : isLoopback(request.socket.remoteAddress);
1422
1511
  }
1423
1512
  viewerOf(request) {
1424
1513
  if (this.isLocal(request)) return { role: "owner", user: this.options.owner };
1514
+ if (this.remote && this.sharing.isOwnerToken(this.cookie(request, this.ownerCookie))) return { role: "owner", user: this.options.owner, remote: true };
1425
1515
  const guest = this.on ? this.sharing.guest(this.cookie(request)) : null;
1426
1516
  if (!guest) return null;
1427
1517
  this.sharing.seen(guest);
@@ -1455,7 +1545,8 @@ var ShareHost = class {
1455
1545
  const p = new URL(request.url ?? "/", "http://local").pathname;
1456
1546
  const method = request.method ?? "GET";
1457
1547
  const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
1458
- const ours = link || p === "/__share" || p.startsWith("/__share/");
1548
+ const ownerLink = /^\/o\/([A-Za-z0-9_-]{16,64})$/.exec(p);
1549
+ const ours = link || ownerLink || p === "/__share" || p.startsWith("/__share/");
1459
1550
  if (!ours) return false;
1460
1551
  if (method === "POST" && !sameOrigin(request)) {
1461
1552
  request.resume();
@@ -1467,6 +1558,12 @@ var ShareHost = class {
1467
1558
  else response.writeHead(405, { Allow: "GET, POST" }).end();
1468
1559
  return true;
1469
1560
  }
1561
+ if (ownerLink) {
1562
+ request.resume();
1563
+ if (method === "GET" || method === "HEAD") this.ownerIn(request, response, ownerLink[1]);
1564
+ else response.writeHead(405, { Allow: "GET" }).end();
1565
+ return true;
1566
+ }
1470
1567
  const viewer = this.viewerOf(request);
1471
1568
  if (!viewer) {
1472
1569
  this.denied(request, response);
@@ -1550,6 +1647,24 @@ var ShareHost = class {
1550
1647
  const url = `http://127.0.0.1:${this.port}${this.page}`;
1551
1648
  return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
1552
1649
  }
1650
+ /**
1651
+ * GET /o/<token>: the owner's link. On another device it leaves the owner's cookie (while the artifact is open to them
1652
+ * there) and goes on to the page, its query kept (`?comment=` opens a thread); over loopback it is the page anyway.
1653
+ */
1654
+ ownerIn(request, response, token2) {
1655
+ const local = this.isLocal(request);
1656
+ if (!this.sharing.isOwnerToken(token2) || !local && !this.remote) {
1657
+ const t = this.words(request);
1658
+ return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
1659
+ }
1660
+ const search = new URL(request.url ?? "/", "http://local").search;
1661
+ const headers = { Location: `${this.page}${search}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
1662
+ if (!local) {
1663
+ headers["Set-Cookie"] = `${this.ownerCookie}=${token2}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
1664
+ this.options.log(`share: the owner came in from another device (${request.socket.remoteAddress})`);
1665
+ }
1666
+ response.writeHead(303, headers).end();
1667
+ }
1553
1668
  /** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
1554
1669
  async join(request, response, method, token2) {
1555
1670
  const t = this.words(request);
@@ -1607,6 +1722,7 @@ var ShareHost = class {
1607
1722
  return {
1608
1723
  title: this.options.title(),
1609
1724
  lan: this.on,
1725
+ remote: this.remote,
1610
1726
  port: this.port,
1611
1727
  page: this.page,
1612
1728
  addresses,
@@ -1618,11 +1734,13 @@ var ShareHost = class {
1618
1734
  }
1619
1735
  /**
1620
1736
  * The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
1621
- * who came through it stop working), `role` {guest, role} (a guest's role, while that role's link is on), `remove`
1622
- * {guest}. Answers the view after.
1737
+ * who came through it stop working; role `owner` the owner's own link), `role` {guest, role} (a guest's role, while
1738
+ * that role's link is on), `remove` {guest}, `remote` {on, host?} (open to the owner on another device: the answer
1739
+ * then has `remoteLink` {url, address}, the owner's link). Answers the view after.
1623
1740
  */
1624
1741
  async share(action) {
1625
1742
  const role = action.role;
1743
+ let remote = null;
1626
1744
  switch (action.action) {
1627
1745
  case "lan": {
1628
1746
  const addresses = await this.setLan(action.on === true);
@@ -1634,9 +1752,17 @@ var ShareHost = class {
1634
1752
  this.sharing.setLink(role, action.on === true);
1635
1753
  break;
1636
1754
  case "reset":
1637
- if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1755
+ if (role === "owner") {
1756
+ this.sharing.resetOwner();
1757
+ for (const live of [...this.live]) if (live.viewer.remote) live.close("revoked");
1758
+ break;
1759
+ }
1760
+ if (!isGuestRole(role)) throw new ShareError("role: owner, editor, commenter or viewer");
1638
1761
  this.sharing.resetLink(role);
1639
1762
  break;
1763
+ case "remote":
1764
+ remote = await this.setRemote(action.on === true, typeof action.host === "string" ? action.host : void 0);
1765
+ break;
1640
1766
  case "role": {
1641
1767
  if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1642
1768
  if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
@@ -1648,11 +1774,11 @@ var ShareHost = class {
1648
1774
  if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
1649
1775
  break;
1650
1776
  default:
1651
- throw new ShareError("action: lan, link, reset, role or remove");
1777
+ throw new ShareError("action: lan, link, reset, role, remove or remote");
1652
1778
  }
1653
1779
  this.options.log(`share: ${JSON.stringify(action)}`);
1654
1780
  this.dropRevoked();
1655
- return this.shareView();
1781
+ return { ...this.shareView(), ...remote ? { remoteLink: remote } : {} };
1656
1782
  }
1657
1783
  async shareApi(request, response, method) {
1658
1784
  if (method === "GET") return sendJson(response, 200, this.shareView());
@@ -2906,11 +3032,11 @@ async function shareCall(url, action, fail2) {
2906
3032
  if (!response.ok) fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
2907
3033
  return body;
2908
3034
  }
2909
- function rolesOf(flags, key, fail2) {
3035
+ function rolesOf(flags, key, fail2, owner = false) {
2910
3036
  const given = flags.get(key);
2911
3037
  if (given === void 0) return [];
2912
3038
  const roles = given === "all" || given === "true" ? [...GUEST_ROLES] : given.split(",").map((r) => r.trim());
2913
- for (const role of roles) if (!isGuestRole(role)) fail2("INVALID_ROLE", `--${key}: editor, commenter, viewer or all (got ${role}).`);
3039
+ for (const role of roles) if (!isGuestRole(role) && !(owner && role === "owner")) fail2("INVALID_ROLE", `--${key}: ${owner ? "owner, " : ""}editor, commenter, viewer or all (got ${role}).`);
2914
3040
  return roles;
2915
3041
  }
2916
3042
  function guestRoles(flags, fail2) {
@@ -2925,7 +3051,7 @@ function guestRoles(flags, fail2) {
2925
3051
  async function shareCommand(o) {
2926
3052
  const off = o.flags.has("off");
2927
3053
  const changes = [
2928
- ...rolesOf(o.flags, "reset", o.fail).map((role) => ({ action: "reset", role })),
3054
+ ...rolesOf(o.flags, "reset", o.fail, true).map((role) => ({ action: "reset", role })),
2929
3055
  ...rolesOf(o.flags, "enable", o.fail).map((role) => ({ action: "link", role, on: true })),
2930
3056
  ...rolesOf(o.flags, "disable", o.fail).map((role) => ({ action: "link", role, on: false })),
2931
3057
  ...guestRoles(o.flags, o.fail)
@@ -2935,7 +3061,8 @@ async function shareCommand(o) {
2935
3061
  if (!server) {
2936
3062
  const sharing = new Sharing(o.root);
2937
3063
  for (const change of changes) {
2938
- if (change.action === "reset") sharing.resetLink(change.role);
3064
+ if (change.action === "reset" && change.role === "owner") sharing.resetOwner();
3065
+ else if (change.action === "reset") sharing.resetLink(change.role);
2939
3066
  else if (change.action === "role") {
2940
3067
  if (!sharing.setRole(String(change.guest), change.role)) o.fail("SHARE_REFUSED", `No guest ${String(change.guest)}, or the ${String(change.role)} link is off.`);
2941
3068
  } else sharing.setLink(change.role, change.on === true);
@@ -2981,6 +3108,30 @@ async function shareCommand(o) {
2981
3108
  }
2982
3109
  var NOT_SHARED_HINT = "share <root> with no options shares it.";
2983
3110
  var lanUrls = (view) => (Array.isArray(view.addresses) ? view.addresses : []).map((a) => `http://${String(a)}:${String(view.port)}${String(view.page ?? "/")}`);
3111
+ async function remoteOn(url, flags, fail2, stopCommand) {
3112
+ const given = flags.get("remote");
3113
+ if (given === void 0) return {};
3114
+ const call = async (action) => {
3115
+ const response = await fetch(new URL("__share", url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(action) });
3116
+ const body = await response.json().catch(() => ({}));
3117
+ if (response.ok) return body;
3118
+ if (response.status === 400 && /^action: /.test(String(body.message ?? ""))) {
3119
+ fail2("REMOTE_UNSUPPORTED", `The editor server running for this folder is of an earlier release and cannot open it to another device. Run ${stopCommand}, then open again.`);
3120
+ }
3121
+ return fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
3122
+ };
3123
+ if (given === "off") {
3124
+ await call({ action: "remote", on: false });
3125
+ return { remote: false };
3126
+ }
3127
+ const view = await call({ action: "remote", on: true, ...given && given !== "true" ? { host: given } : {} });
3128
+ const link = view.remoteLink;
3129
+ if (typeof link?.url !== "string") return fail2("SHARE_REFUSED", "The server did not give the owner's link.");
3130
+ return {
3131
+ remoteUrl: link.url,
3132
+ remoteNote: `remoteUrl opens the editor as its owner from another device that reaches this computer at ${String(link.address)} (an app reached remotely, a phone, a laptop on the same network or tailnet). It is the owner's own key, over plain http: open it only on your own devices and give it to nobody (to let others in, \`share\`). \`--remote=off\` closes it; \`share <root> --reset owner\` replaces it.`
3133
+ };
3134
+ }
2984
3135
  async function lanOn(url, flags, fail2) {
2985
3136
  if (!flags.has("lan")) return {};
2986
3137
  return { lan: lanUrls(await shareCall(url, { action: "lan", on: true }, fail2)) };
@@ -3902,7 +4053,7 @@ function print(value, compact = false) {
3902
4053
  process.stdout.write(`${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}
3903
4054
  `);
3904
4055
  }
3905
- var BARE_FLAGS = ["text", "json", "wait", "all", "resolve", "reopen", "lan", "off", "force"];
4056
+ var BARE_FLAGS = ["text", "json", "wait", "all", "resolve", "reopen", "lan", "remote", "off", "force"];
3906
4057
  function parseArgs(argv) {
3907
4058
  const positional = [];
3908
4059
  const flags = /* @__PURE__ */ new Map();
@@ -4822,12 +4973,12 @@ var lanOn2 = (url, flags) => lanOn(url, flags, fail);
4822
4973
  async function open(root, flags) {
4823
4974
  const existing = await runningServer2(root);
4824
4975
  if (existing) {
4825
- print({ url: existing.url, root, pid: existing.pid, reused: true, ...await lanOn2(existing.url, flags), log: path9.join(root, LOG_FILE), stop: cmd("stop", root) }, true);
4976
+ print({ url: existing.url, root, pid: existing.pid, reused: true, ...await lanOn2(existing.url, flags), ...await remoteOn(existing.url, flags, fail, cmd("stop", root)), log: path9.join(root, LOG_FILE), stop: cmd("stop", root) }, true);
4826
4977
  return;
4827
4978
  }
4828
4979
  const state = await startBackground(root, flags);
4829
4980
  const empty = !fs10.existsSync(docFile(root, ["board", "meta"]));
4830
- print({ url: state.url, root, pid: state.pid, ...state.lan?.length ? { lan: state.lan } : {}, log: path9.join(root, LOG_FILE), stop: cmd("stop", root), ...empty ? { note: "board/meta is not written yet: the page shows an empty board until it is." } : {} }, true);
4981
+ print({ url: state.url, root, pid: state.pid, ...state.lan?.length ? { lan: state.lan } : {}, ...await remoteOn(state.url, flags, fail, cmd("stop", root)), log: path9.join(root, LOG_FILE), stop: cmd("stop", root), ...empty ? { note: "board/meta is not written yet: the page shows an empty board until it is." } : {} }, true);
4831
4982
  }
4832
4983
  async function startBackground(root, flags) {
4833
4984
  const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORT;
@@ -5022,6 +5173,8 @@ Usage:
5022
5173
  node scripts/artifact.mjs serve <root> [--port n] [--lan] the same server, in the foreground
5023
5174
  [--agent-hook "<command>"] open/serve: run this for each comment sent to the agent
5024
5175
  (the comment as JSON on stdin); --lan: shared too
5176
+ [--remote[=<host>]] open: also open it to you on another device (<host>: how
5177
+ that device reaches this computer); prints remoteUrl
5025
5178
  node scripts/artifact.mjs stop <root> stop the background board server
5026
5179
  node scripts/artifact.mjs share <root> share on the local network; prints a link per role
5027
5180
  [--off] [--reset R] [--enable R] [--disable R]