dsh-claude-data 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/docs/DETAIL.zh.md CHANGED
@@ -1,4 +1,4 @@
1
- # Claude 数据看板
1
+ # Claude 数据
2
2
 
3
3
  给 DeepSeek Harness 加上 **claude-data** 技能。让 Agent 做一个数据看板("把这份销售 CSV 按月份和地区做成看板"),它会在工作区里把看板写成文件:一个文件夹,里面有数据集、数据集要加载的文件和页面。写完它会启动本地看板编辑器,把链接给你。
4
4
 
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-claude-data 2.13.0: built from baoyu-design.
1
+ // dsh-claude-data 2.14.1: built from baoyu-design.
2
2
  var define_PLUGIN_default = { name: "claude-data", provider: "dsh-claude-data", skills: [{ name: "claude-data", description: "Use this for a local, data-backed dashboard, metrics page, KPI tracker, scorecard, data visualization or recurring report that opens in the bundled live editor: a page or scroll story of charts, numbers and tables over datasets (a CSV/TSV/JSON or text file the user has, or data you gather and attach), with a Sources panel that shows where every number comes from. Read it when creating, filling, refreshing or revising one. You write the datasets, then the page as plain HTML, into the dashboard's own store with this skill's script. Not for a static HTML mockup or a picture of a dashboard.", 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/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "Claude 数据看板",
3
+ "title": "Claude 数据",
4
4
  "description": "加入 claude-data 技能:Agent 根据你的数据文件做出数据看板(图表、数字和表格),并在本地看板编辑器里打开,来源面板会显示每个数字是从哪里来的,你也可以把评论发给 Agent 修改。",
5
5
  "detail": "docs/DETAIL.zh.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-claude-data",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
4
4
  "description": "DeepSeek Harness plugin: the claude-data skill from baoyu-design. The agent builds data-backed dashboards as files and opens them in a local dashboard editor.",
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": {
@@ -244,6 +244,7 @@ function sendFile(request, response, file, headers) {
244
244
  var errorText = (error) => error instanceof Error ? error.message : String(error);
245
245
 
246
246
  import { spawn } from "node:child_process";
247
+ import dns from "node:dns/promises";
247
248
  import { EventEmitter } from "node:events";
248
249
  import fs3 from "node:fs";
249
250
  import http from "node:http";
@@ -299,6 +300,7 @@ var Sharing = class _Sharing {
299
300
  if (isRecord(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
300
301
  }
301
302
  }
303
+ if (isRecord(raw.owner) && typeof raw.owner.token === "string" && raw.owner.token.length >= 32) state.owner = { token: raw.owner.token };
302
304
  if (Array.isArray(raw.guests)) {
303
305
  for (const g of raw.guests) {
304
306
  if (!isRecord(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
@@ -329,6 +331,27 @@ var Sharing = class _Sharing {
329
331
  }
330
332
  return null;
331
333
  }
334
+ /** The owner's link token (`state.owner`), made and saved the first time it is asked for. */
335
+ ownerToken() {
336
+ if (!this.state.owner) {
337
+ this.state.owner = { token: token(32) };
338
+ this.save();
339
+ }
340
+ return this.state.owner.token;
341
+ }
342
+ /** Whether `given` is the owner's link token (a cookie or a link's). */
343
+ isOwnerToken(given) {
344
+ const own = this.state.owner?.token;
345
+ if (!given || !own) return false;
346
+ const a = Buffer.from(given);
347
+ const b = Buffer.from(own);
348
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
349
+ }
350
+ /** A new owner's link: the old one, and every browser that came in through it, stop working. */
351
+ resetOwner() {
352
+ this.state.owner = { token: token(32) };
353
+ this.save();
354
+ }
332
355
  /** The guest a cookie names, if they still have access (their link on and not reset). */
333
356
  guest(secret) {
334
357
  if (!secret) return null;
@@ -590,6 +613,8 @@ var Hub = class {
590
613
  hosts = /* @__PURE__ */ new Set();
591
614
  /** One listener per LAN address while any site is open to the network. */
592
615
  lanServers = /* @__PURE__ */ new Map();
616
+ /** Names that resolve to an address listened on, asked for by `allowName` (lower case, no port). */
617
+ lanNames = /* @__PURE__ */ new Set();
593
618
  network;
594
619
  constructor(options) {
595
620
  this.echo = options.echo;
@@ -605,7 +630,8 @@ var Hub = class {
605
630
  },
606
631
  addresses: () => [...this.lanServers.keys()],
607
632
  open: () => this.openLan(),
608
- release: () => this.releaseLan()
633
+ release: () => this.releaseLan(),
634
+ allowName: (name) => this.allowName(name)
609
635
  };
610
636
  }
611
637
  createServer() {
@@ -724,9 +750,9 @@ var Hub = class {
724
750
  }
725
751
  return [...this.lanServers.keys()];
726
752
  }
727
- /** A site stopped sharing: the listeners stay while another is shared. */
753
+ /** A site stopped sharing: the listeners stay while another is shared, or open to its owner on another device. */
728
754
  releaseLan() {
729
- if (![...this.sites.values()].some((site) => site.share.shared)) this.closeLan();
755
+ if (![...this.sites.values()].some((site) => site.share.listening)) this.closeLan();
730
756
  }
731
757
  closeLan() {
732
758
  for (const server of this.lanServers.values()) {
@@ -734,8 +760,30 @@ var Hub = class {
734
760
  server.closeAllConnections?.();
735
761
  }
736
762
  this.lanServers.clear();
763
+ this.lanNames.clear();
764
+ }
765
+ /** The machine's own names, as hostAllowed takes them. */
766
+ isMachineName(name) {
767
+ const machine = os2.hostname().toLowerCase();
768
+ return name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
737
769
  }
738
- /** A Host header this hub answers: its own names, and while something is shared, its LAN addresses and machine name. */
770
+ /**
771
+ * Answer at `name` too while listening: an address listened on, the machine's name, or a name that resolves to an
772
+ * address listened on (looked up once, now: Tailscale's MagicDNS name, a hosts entry).
773
+ */
774
+ async allowName(name) {
775
+ const lower = name.toLowerCase();
776
+ if (!lower || !this.lanServers.size) return false;
777
+ if (this.lanServers.has(lower) || this.isMachineName(lower) || this.lanNames.has(lower)) return true;
778
+ const found = await dns.lookup(lower, { all: true, family: 4 }).catch(() => []);
779
+ if (!found.some((entry) => this.lanServers.has(entry.address))) return false;
780
+ this.lanNames.add(lower);
781
+ return true;
782
+ }
783
+ /**
784
+ * A Host header this hub answers: its own names, and while something is shared (or open to its owner on another
785
+ * device), its LAN addresses, its machine name and the names allowName took.
786
+ */
739
787
  hostAllowed(request) {
740
788
  const host = request.headers.host ?? "";
741
789
  if (this.hosts.has(host)) return true;
@@ -743,8 +791,7 @@ var Hub = class {
743
791
  const suffix = `:${this.port}`;
744
792
  if (!host.endsWith(suffix)) return false;
745
793
  const name = host.slice(0, -suffix.length).toLowerCase();
746
- const machine = os2.hostname().toLowerCase();
747
- return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
794
+ return this.lanServers.has(name) || this.isMachineName(name) || this.lanNames.has(name);
748
795
  }
749
796
  // ---- requests --------------------------------------------------------------------------------------------------
750
797
  /** The site whose address `pathname` is under, with the path below it. */
@@ -1340,6 +1387,12 @@ function commentsViewer(viewer) {
1340
1387
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1341
1388
  var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1342
1389
  var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
1390
+ function hostName(host) {
1391
+ const value = host.trim().toLowerCase();
1392
+ if (value.startsWith("[")) return value.slice(1, value.indexOf("]") > 0 ? value.indexOf("]") : void 0);
1393
+ const colons = value.split(":").length - 1;
1394
+ return colons === 1 ? value.slice(0, value.indexOf(":")) : value;
1395
+ }
1343
1396
  function localOwner() {
1344
1397
  let name = "You";
1345
1398
  try {
@@ -1361,6 +1414,8 @@ var ShareHost = class {
1361
1414
  page;
1362
1415
  /** Open to the local network now (the hub's listeners are up, for this artifact too). */
1363
1416
  on = false;
1417
+ /** Open to its owner on another device (`open --remote`): the listeners are up, guests stay out unless `on`. */
1418
+ remote = false;
1364
1419
  live = /* @__PURE__ */ new Set();
1365
1420
  constructor(options) {
1366
1421
  this.options = options;
@@ -1386,6 +1441,10 @@ var ShareHost = class {
1386
1441
  get shared() {
1387
1442
  return this.on;
1388
1443
  }
1444
+ /** Someone may reach it over the network now (guests, or its owner on another device): the hub keeps listening. */
1445
+ get listening() {
1446
+ return this.on || this.remote;
1447
+ }
1389
1448
  /** The artifact's name, for the join page and the list of what is open at the server's root. */
1390
1449
  title() {
1391
1450
  return this.options.title();
@@ -1408,30 +1467,61 @@ var ShareHost = class {
1408
1467
  this.options.log(`shared on the local network: ${addresses.map((a) => `http://${a}:${this.port}${this.page}`).join(", ") || "(no LAN address)"}`);
1409
1468
  return addresses;
1410
1469
  }
1470
+ /**
1471
+ * Opens the artifact to its owner on another device, or closes that, and returns the owner's link, at `host` when
1472
+ * that is this computer's (the address the owner's browser already reaches it by), else at its first network address.
1473
+ * Guests are not let in by it: that is `setLan`.
1474
+ */
1475
+ async setRemote(on, host) {
1476
+ if (!on) {
1477
+ this.remote = false;
1478
+ for (const live of [...this.live]) if (live.viewer.remote) live.close("unshared");
1479
+ this.network.release();
1480
+ return null;
1481
+ }
1482
+ const addresses = await this.network.open();
1483
+ if (!addresses.length) {
1484
+ this.network.release();
1485
+ throw new ShareError("This computer has no network address to open it at.");
1486
+ }
1487
+ this.remote = true;
1488
+ const name = hostName(host ?? "");
1489
+ const sorted = [...addresses].sort((a, b) => Number(isTailnet(a)) - Number(isTailnet(b)));
1490
+ const address = name && (addresses.includes(name) || await this.network.allowName?.(name)) ? name : sorted[0];
1491
+ const url = `http://${address.includes(":") ? `[${address}]` : address}:${this.port}${this.page}o/${this.sharing.ownerToken()}`;
1492
+ this.options.log(`open to its owner on another device at http://${address}:${this.port}${this.page}`);
1493
+ return { url, address };
1494
+ }
1411
1495
  /** The artifact left the hub: it stays shared in .sharing.json, for the next time it opens. */
1412
1496
  close() {
1413
1497
  this.on = false;
1498
+ this.remote = false;
1414
1499
  this.network.release();
1415
1500
  }
1416
1501
  // ---- who is asking ---------------------------------------------------------------------------------------------
1417
- cookie(request) {
1502
+ cookie(request, name = this.options.cookieName) {
1418
1503
  for (const part of (request.headers.cookie ?? "").split(";")) {
1419
1504
  const eq = part.indexOf("=");
1420
- if (eq > 0 && part.slice(0, eq).trim() === this.options.cookieName) return part.slice(eq + 1).trim();
1505
+ if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
1421
1506
  }
1422
1507
  return void 0;
1423
1508
  }
1509
+ /** The cookie the owner's link leaves in a browser on another device. */
1510
+ get ownerCookie() {
1511
+ return `${this.options.cookieName}_owner`;
1512
+ }
1424
1513
  /**
1425
- * Who a request is: the owner over loopback, a guest by their cookie (while their link is on and the artifact is
1426
- * shared), else nobody. Loopback is always the owner, so trying a link on this computer can't lock the owner out of
1427
- * their own artifact; a guest comes over a LAN address (this computer's own included: that is how the owner sees
1428
- * what a role sees).
1514
+ * Who a request is: the owner over loopback, or by the owner's link's cookie while the artifact is open to them on
1515
+ * another device; a guest by their cookie (while their link is on and the artifact is shared), else nobody. Loopback
1516
+ * is always the owner, so trying a link on this computer can't lock the owner out of their own artifact; a guest
1517
+ * comes over a LAN address (this computer's own included: that is how the owner sees what a role sees).
1429
1518
  */
1430
1519
  isLocal(request) {
1431
1520
  return this.options.local ? this.options.local(request) : isLoopback(request.socket.remoteAddress);
1432
1521
  }
1433
1522
  viewerOf(request) {
1434
1523
  if (this.isLocal(request)) return { role: "owner", user: this.options.owner };
1524
+ if (this.remote && this.sharing.isOwnerToken(this.cookie(request, this.ownerCookie))) return { role: "owner", user: this.options.owner, remote: true };
1435
1525
  const guest = this.on ? this.sharing.guest(this.cookie(request)) : null;
1436
1526
  if (!guest) return null;
1437
1527
  this.sharing.seen(guest);
@@ -1465,7 +1555,8 @@ var ShareHost = class {
1465
1555
  const p = new URL(request.url ?? "/", "http://local").pathname;
1466
1556
  const method = request.method ?? "GET";
1467
1557
  const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
1468
- const ours = link || p === "/__share" || p.startsWith("/__share/");
1558
+ const ownerLink = /^\/o\/([A-Za-z0-9_-]{16,64})$/.exec(p);
1559
+ const ours = link || ownerLink || p === "/__share" || p.startsWith("/__share/");
1469
1560
  if (!ours) return false;
1470
1561
  if (method === "POST" && !sameOrigin(request)) {
1471
1562
  request.resume();
@@ -1477,6 +1568,12 @@ var ShareHost = class {
1477
1568
  else response.writeHead(405, { Allow: "GET, POST" }).end();
1478
1569
  return true;
1479
1570
  }
1571
+ if (ownerLink) {
1572
+ request.resume();
1573
+ if (method === "GET" || method === "HEAD") this.ownerIn(request, response, ownerLink[1]);
1574
+ else response.writeHead(405, { Allow: "GET" }).end();
1575
+ return true;
1576
+ }
1480
1577
  const viewer = this.viewerOf(request);
1481
1578
  if (!viewer) {
1482
1579
  this.denied(request, response);
@@ -1560,6 +1657,24 @@ var ShareHost = class {
1560
1657
  const url = `http://127.0.0.1:${this.port}${this.page}`;
1561
1658
  return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
1562
1659
  }
1660
+ /**
1661
+ * GET /o/<token>: the owner's link. On another device it leaves the owner's cookie (while the artifact is open to them
1662
+ * there) and goes on to the page, its query kept (`?comment=` opens a thread); over loopback it is the page anyway.
1663
+ */
1664
+ ownerIn(request, response, token2) {
1665
+ const local = this.isLocal(request);
1666
+ if (!this.sharing.isOwnerToken(token2) || !local && !this.remote) {
1667
+ const t = this.words(request);
1668
+ return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
1669
+ }
1670
+ const search = new URL(request.url ?? "/", "http://local").search;
1671
+ const headers = { Location: `${this.page}${search}`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
1672
+ if (!local) {
1673
+ headers["Set-Cookie"] = `${this.ownerCookie}=${token2}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
1674
+ this.options.log(`share: the owner came in from another device (${request.socket.remoteAddress})`);
1675
+ }
1676
+ response.writeHead(303, headers).end();
1677
+ }
1563
1678
  /** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
1564
1679
  async join(request, response, method, token2) {
1565
1680
  const t = this.words(request);
@@ -1617,6 +1732,7 @@ var ShareHost = class {
1617
1732
  return {
1618
1733
  title: this.options.title(),
1619
1734
  lan: this.on,
1735
+ remote: this.remote,
1620
1736
  port: this.port,
1621
1737
  page: this.page,
1622
1738
  addresses,
@@ -1628,11 +1744,13 @@ var ShareHost = class {
1628
1744
  }
1629
1745
  /**
1630
1746
  * The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
1631
- * who came through it stop working), `role` {guest, role} (a guest's role, while that role's link is on), `remove`
1632
- * {guest}. Answers the view after.
1747
+ * who came through it stop working; role `owner` the owner's own link), `role` {guest, role} (a guest's role, while
1748
+ * that role's link is on), `remove` {guest}, `remote` {on, host?} (open to the owner on another device: the answer
1749
+ * then has `remoteLink` {url, address}, the owner's link). Answers the view after.
1633
1750
  */
1634
1751
  async share(action) {
1635
1752
  const role = action.role;
1753
+ let remote = null;
1636
1754
  switch (action.action) {
1637
1755
  case "lan": {
1638
1756
  const addresses = await this.setLan(action.on === true);
@@ -1644,9 +1762,17 @@ var ShareHost = class {
1644
1762
  this.sharing.setLink(role, action.on === true);
1645
1763
  break;
1646
1764
  case "reset":
1647
- if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1765
+ if (role === "owner") {
1766
+ this.sharing.resetOwner();
1767
+ for (const live of [...this.live]) if (live.viewer.remote) live.close("revoked");
1768
+ break;
1769
+ }
1770
+ if (!isGuestRole(role)) throw new ShareError("role: owner, editor, commenter or viewer");
1648
1771
  this.sharing.resetLink(role);
1649
1772
  break;
1773
+ case "remote":
1774
+ remote = await this.setRemote(action.on === true, typeof action.host === "string" ? action.host : void 0);
1775
+ break;
1650
1776
  case "role": {
1651
1777
  if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1652
1778
  if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
@@ -1658,11 +1784,11 @@ var ShareHost = class {
1658
1784
  if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
1659
1785
  break;
1660
1786
  default:
1661
- throw new ShareError("action: lan, link, reset, role or remove");
1787
+ throw new ShareError("action: lan, link, reset, role, remove or remote");
1662
1788
  }
1663
1789
  this.options.log(`share: ${JSON.stringify(action)}`);
1664
1790
  this.dropRevoked();
1665
- return this.shareView();
1791
+ return { ...this.shareView(), ...remote ? { remoteLink: remote } : {} };
1666
1792
  }
1667
1793
  async shareApi(request, response, method) {
1668
1794
  if (method === "GET") return sendJson(response, 200, this.shareView());
@@ -2916,11 +3042,11 @@ async function shareCall(url, action, fail2) {
2916
3042
  if (!response.ok) fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
2917
3043
  return body;
2918
3044
  }
2919
- function rolesOf(flags, key, fail2) {
3045
+ function rolesOf(flags, key, fail2, owner = false) {
2920
3046
  const given = flags.get(key);
2921
3047
  if (given === void 0) return [];
2922
3048
  const roles = given === "all" || given === "true" ? [...GUEST_ROLES] : given.split(",").map((r) => r.trim());
2923
- for (const role of roles) if (!isGuestRole(role)) fail2("INVALID_ROLE", `--${key}: editor, commenter, viewer or all (got ${role}).`);
3049
+ for (const role of roles) if (!isGuestRole(role) && !(owner && role === "owner")) fail2("INVALID_ROLE", `--${key}: ${owner ? "owner, " : ""}editor, commenter, viewer or all (got ${role}).`);
2924
3050
  return roles;
2925
3051
  }
2926
3052
  function guestRoles(flags, fail2) {
@@ -2935,7 +3061,7 @@ function guestRoles(flags, fail2) {
2935
3061
  async function shareCommand(o) {
2936
3062
  const off = o.flags.has("off");
2937
3063
  const changes = [
2938
- ...rolesOf(o.flags, "reset", o.fail).map((role) => ({ action: "reset", role })),
3064
+ ...rolesOf(o.flags, "reset", o.fail, true).map((role) => ({ action: "reset", role })),
2939
3065
  ...rolesOf(o.flags, "enable", o.fail).map((role) => ({ action: "link", role, on: true })),
2940
3066
  ...rolesOf(o.flags, "disable", o.fail).map((role) => ({ action: "link", role, on: false })),
2941
3067
  ...guestRoles(o.flags, o.fail)
@@ -2945,7 +3071,8 @@ async function shareCommand(o) {
2945
3071
  if (!server) {
2946
3072
  const sharing = new Sharing(o.root);
2947
3073
  for (const change of changes) {
2948
- if (change.action === "reset") sharing.resetLink(change.role);
3074
+ if (change.action === "reset" && change.role === "owner") sharing.resetOwner();
3075
+ else if (change.action === "reset") sharing.resetLink(change.role);
2949
3076
  else if (change.action === "role") {
2950
3077
  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.`);
2951
3078
  } else sharing.setLink(change.role, change.on === true);
@@ -2991,6 +3118,30 @@ async function shareCommand(o) {
2991
3118
  }
2992
3119
  var NOT_SHARED_HINT = "share <root> with no options shares it.";
2993
3120
  var lanUrls = (view) => (Array.isArray(view.addresses) ? view.addresses : []).map((a) => `http://${String(a)}:${String(view.port)}${String(view.page ?? "/")}`);
3121
+ async function remoteOn(url, flags, fail2, stopCommand) {
3122
+ const given = flags.get("remote");
3123
+ if (given === void 0) return {};
3124
+ const call = async (action) => {
3125
+ const response = await fetch(new URL("__share", url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(action) });
3126
+ const body = await response.json().catch(() => ({}));
3127
+ if (response.ok) return body;
3128
+ if (response.status === 400 && /^action: /.test(String(body.message ?? ""))) {
3129
+ 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.`);
3130
+ }
3131
+ return fail2("SHARE_REFUSED", String(body.message ?? `the server answered ${response.status}`));
3132
+ };
3133
+ if (given === "off") {
3134
+ await call({ action: "remote", on: false });
3135
+ return { remote: false };
3136
+ }
3137
+ const view = await call({ action: "remote", on: true, ...given && given !== "true" ? { host: given } : {} });
3138
+ const link = view.remoteLink;
3139
+ if (typeof link?.url !== "string") return fail2("SHARE_REFUSED", "The server did not give the owner's link.");
3140
+ return {
3141
+ remoteUrl: link.url,
3142
+ 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.`
3143
+ };
3144
+ }
2994
3145
  async function lanOn(url, flags, fail2) {
2995
3146
  if (!flags.has("lan")) return {};
2996
3147
  return { lan: lanUrls(await shareCall(url, { action: "lan", on: true }, fail2)) };
@@ -3852,7 +4003,7 @@ function print(value, compact = false) {
3852
4003
  process.stdout.write(`${compact ? JSON.stringify(value) : JSON.stringify(value, null, 2)}
3853
4004
  `);
3854
4005
  }
3855
- var SWITCHES = /* @__PURE__ */ new Set(["text", "json", "wait", "all", "resolve", "reopen", "lan", "off", "background"]);
4006
+ var SWITCHES = /* @__PURE__ */ new Set(["text", "json", "wait", "all", "resolve", "reopen", "lan", "remote", "off", "background"]);
3856
4007
  function parseArgs(argv) {
3857
4008
  const positional = [];
3858
4009
  const flags = /* @__PURE__ */ new Map();
@@ -5394,12 +5545,12 @@ var lanOn2 = (url, flags) => lanOn(url, flags, fail);
5394
5545
  async function open(root, flags) {
5395
5546
  const existing = await runningServer2(root);
5396
5547
  if (existing) {
5397
- 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);
5548
+ 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);
5398
5549
  return;
5399
5550
  }
5400
5551
  const state = await startBackground(root, flags);
5401
5552
  const empty = !fs10.existsSync(docFile(root, ["files", "index.html"]));
5402
- 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: "files/index.html is not written yet: the page is empty until it is." } : {} }, true);
5553
+ 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: "files/index.html is not written yet: the page is empty until it is." } : {} }, true);
5403
5554
  }
5404
5555
  async function startBackground(root, flags) {
5405
5556
  const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORTS.data;
@@ -5564,6 +5715,8 @@ Usage:
5564
5715
  node scripts/artifact.mjs serve <root> [--port n] [--lan] the same server, in the foreground
5565
5716
  [--agent-hook "<command>"] open/serve: run this for each comment sent to the agent
5566
5717
  (the comment as JSON on stdin); --lan: shared too
5718
+ [--remote[=<host>]] open: also open it to you on another device (<host>: how
5719
+ that device reaches this computer); prints remoteUrl
5567
5720
  node scripts/artifact.mjs stop <root> stop the background editor
5568
5721
  node scripts/artifact.mjs share <root> share on the local network; prints a link per role
5569
5722
  [--off] [--reset R] [--enable R] [--disable R]