@khorsheed/dsh-file-preview 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # 变更记录
2
2
 
3
+ ## 未发布
4
+
5
+ - 从只读宿主包移除 `3d-artifact` 作者 skill;现在由 `@khorsheed/dsh-inline-html-render` 分发并注册。
6
+ - 新增零会话 `capabilities()` Remote 握手(`protocolVersion: 1`),供客户端在宿主缺席时抑制全部 UI。
7
+
8
+ ## 未发布
9
+
10
+ - 从只读宿主包移除 `3d-artifact` 作者 skill;现在由 `@khorsheed/dsh-inline-html-render` 分发并注册。
11
+ - 新增零会话 `capabilities()` Remote 握手(`protocolVersion: 1`),供客户端在宿主缺席时抑制全部 UI。
12
+
13
+ ## 0.3.0(2026-09-11)
14
+
15
+ (以 minor 发布:openExternal 是新增能力;pair 包 ui-file-preview 的 peer 边由 pack-dist 按本包版本重写,两包版本线须一致。)
16
+
17
+ - **BREAKING**:minHost 前移至 `0.1.5-rc.1`;宿主 `0.1.2-rc.1` 的用户请停留在 `0.2.0`。dispatch 事件只匹配 0.1.5 的 `tool/ptc-dispatch`(官方 v2→v3 日志迁移会把持久化的旧名改写过来,0.1.5 宿主不会再发出 `tool/code-dispatch`),0.2.0 的双名探测退役
18
+ - 新增 Remote 方法 `openExternal(agent, path, app, signal)`:在指定宿主应用中打开文件(macOS `open -a`),供浏览器半的「在 IDE 打开」手势使用——官方 open-in-app 路由只收目录。`app` 为官方 catalog id(id → `.app` 名映射镜像官方 catalog 的 darwin 条目);非 macOS / 未知 id 返回 `opened: false`,不写入。
19
+
20
+
3
21
  ## 0.2.0(2026-09-10)
4
22
 
5
23
  适配宿主 0.1.2 线。
package/README.en.md CHANGED
@@ -32,7 +32,7 @@ Host half only — the visible surface (Produced tab, preview drawer, per-turn c
32
32
  dsh plugin --profile web add @khorsheed/dsh-client-ui-file-preview
33
33
  ```
34
34
 
35
- Uninstall; a leftover client half degrades to an empty state rather than an error:
35
+ Uninstall; if the client half is left behind, its zero-session handshake fails and every UI surface is absent (no error card or empty tab remains):
36
36
 
37
37
  ```sh
38
38
  dsh plugin --profile web remove @khorsheed/dsh-file-preview
@@ -53,10 +53,10 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
53
53
 
54
54
  ## Compatibility
55
55
 
56
- - npm release line (`@deepseek-ai/dsh@0.1.2-rc.1`): ✅ full — baseline moved to the 0.1.2-rc.1 API surface (single-arm 0.1.2 API consumption; the 0.1.1-rc.2 runtime arm is retired), full build+test green; minHost moves up to 0.1.2-rc.1 — older hosts stay on the previous release line.
57
- - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.2-rc.1)
56
+ - npm release line (`@deepseek-ai/dsh@0.1.5-rc.1`): ✅ full — adapted to the 0.1.5-rc.1 PTC rename (only `tool/ptc-dispatch` is matched; the official v2→v3 log migration renames persisted rows), full build+test green; minHost moves up to 0.1.5-rc.1 — older hosts stay on the previous release line.
57
+ - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.5-rc.1)
58
58
 
59
- **Version line mapping**: 0.2.0 and up support host `0.1.2-rc.1` and later; hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.1`).
59
+ **Version line mapping**: the first release after 0.2.0 supports host `0.1.5-rc.1` and later; hosts on `0.1.2-rc.1` stay on `0.2.0`, hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.1`).
60
60
 
61
61
  ## Known Limitations
62
62
 
@@ -68,11 +68,14 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
68
68
  <details>
69
69
  <summary>Internals (click to expand)</summary>
70
70
 
71
- `ctx.filePreview` (wire namespace `filePreview`) exposes four generated Remote methods:
71
+ `ctx.filePreview` (wire namespace `filePreview`) exposes six generated Remote methods:
72
72
 
73
- - `list(agent)` — a pure fold over `agent.session.events`: `read`/`write`/`edit` `tool/call`s contribute display paths, as do settled nested Code Mode `tool/code-dispatch`es for those tools (failed dispatches record nothing; entries borrow the root call's turn/step). A `write`/`edit` `tool/result` carrying `diffs` meta appends each change to the entry in event order, `lastDiff` kept as the final one. The response carries the entries, the last scanned seq, and whether `maxFiles` was hit. No filesystem access in the fold; with `captureBashWrites` on, entries merge with the collector's verified bash-written paths. Before returning, each path is resolved against the session cwd and `stat`-ed, keeping only those that still exist as a regular file — the log fold is history, the product list reflects disk state (a temp script a turn wrote then cleaned up is no longer a product); existence is probed fresh per call (not cached with the log fold).
73
+ - `capabilities()` — a zero-session availability handshake returning `{ protocolVersion: 1 }`; the companion client installs UI surfaces only after it succeeds.
74
+
75
+ - `list(agent)` — a pure fold over `agent.session.events`: `read`/`write`/`edit` `tool/call`s contribute display paths, as do settled nested PTC `tool/ptc-dispatch`es for those tools (failed dispatches record nothing; entries borrow the root call's turn/step). A `write`/`edit` `tool/result` carrying `diffs` meta appends each change to the entry in event order, `lastDiff` kept as the final one. The response carries the entries, the last scanned seq, and whether `maxFiles` was hit. No filesystem access in the fold; with `captureBashWrites` on, entries merge with the collector's verified bash-written paths. Before returning, each path is resolved against the session cwd and `stat`-ed, keeping only those that still exist as a regular file — the log fold is history, the product list reflects disk state (a temp script a turn wrote then cleaned up is no longer a product); existence is probed fresh per call (not cached with the log fold).
74
76
  - `read(agent, path, signal)` — resolves `path` against the session cwd and serves `kind: 'text'` (capped at `maxReadBytes`, flagged `truncated`), or `kind: 'image'` with a browser-loadable URL on web hosts — bytes ride a dedicated `/file-preview-image/<sessionId>/<path>` route, registered only when the optional `webServer` and `agents` services are composed; headless hosts answer `binary` — or a classified notice: `binary` (binary extension or NUL bytes; never read), `missing`, `too-large`, or `error` (message included).
75
77
  - `reveal(agent, path, signal)` — opens the file's folder with the file selected, shell-free through `@deepseek-ai/dsh-native-command`: macOS `open -R`, Windows `explorer /select,<path>`, WSL via `wslpath`, desktop Linux tries `nautilus`/`dolphin`/`nemo --select` in order. Answers `{ revealed: true }`, or `false` with `reason: 'missing'` (no such target) or `'select-failed'` (no capable file manager — the caller then opens the parent folder, so the gesture always lands somewhere visible). Writes nothing.
78
+ - `openExternal(agent, path, app, signal)` — open a file in a specific host application (the "open in IDE" gesture): the official open-in-app route accepts directories only, so file-exact opens go here — macOS `open -a <App> <path>`, shell-free as well. `app` is an official open-in-app catalog id (the client probes `/open-in-app/apps`); the id → `.app` name map lives in `open-external.ts` (mirroring the official catalog's darwin entries). Non-macOS hosts and unknown ids answer `{ opened: false, reason }`, and the client hides the gesture. Writes nothing.
76
79
  - `turnFiles(agent)` — every turn's file mutations for the turn-tail card, the same single source of truth as `list` but NOT deduped: a file touched in two turns appears in both groups, so each card lists exactly what that turn mutated. Line deltas are summed from result diffs; the fold is cached per session and invalidated by the log watermark, which the response carries for the client's own cache. Like `list`, it probes existence against the session cwd before returning and keeps only files that still exist (a cleaned-up temp script does not occupy a card).
77
80
 
78
81
  Bash-write collector: watches each session's bash `tool/call`/`tool/result` pairs, extracts high-precision write targets (`cat > path` heredocs, single `>` redirects, `tee` non-append, `sed -i`), expands `$VAR`/`~`/relative paths against the host environment and session cwd, and records only paths `fs.stat` confirms as files (a miss beats a false positive). Captures live in a per-session in-memory registry rebuilt by replaying the session's own history on `session/created`, so a restarted host regains them; the session log itself is never mutated (the official `Session.append` cannot mark a plugin event `ignorable` — see the S2 seam in `docs/upstream-seam-registry.md`). Bash-captured files carry no diff history; previews read current content through `read`.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/file-preview/README.en.md
5
- packages/file-preview/README.en.md: ef4c3a913fe23a474cb4d05114fb9f080ff641d1
6
- packages/file-preview/README.md: a34ba31610e054fd3f82c9b2d5c3635e04a970ed
5
+ packages/file-preview/README.en.md: e454c9f4feefaa3bd879b7e2d277c393f46fb790
6
+ packages/file-preview/README.md: 824c988049eea742c9e0f38078161829b14660ea
package/README.md CHANGED
@@ -32,7 +32,7 @@ dsh plugin --profile web add @khorsheed/dsh-file-preview
32
32
  dsh plugin --profile web add @khorsheed/dsh-client-ui-file-preview
33
33
  ```
34
34
 
35
- 卸载;残留的客户端半降级为空态而非报错:
35
+ 卸载;如果只残留客户端半,它的零会话握手会失败,全部 UI 面均缺席(不留错误卡或空 tab):
36
36
 
37
37
  ```sh
38
38
  dsh plugin --profile web remove @khorsheed/dsh-file-preview
@@ -53,10 +53,10 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
53
53
 
54
54
  ## Compatibility
55
55
 
56
- - npm 发布线(`@deepseek-ai/dsh@0.1.2-rc.1`):✅ 完整——基线迁移至 0.1.2-rc.1 API 面(单臂消费 0.1.2 API,0.1.1-rc.2 运行臂已退役),全量构建测试通过;minHost 前移至 0.1.2-rc.1,旧宿主请停留在旧发布线。
57
- - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.2-rc.1)
56
+ - npm 发布线(`@deepseek-ai/dsh@0.1.5-rc.1`):✅ 完整——适配 0.1.5-rc.1 的 PTC 更名(只匹配 `tool/ptc-dispatch`;旧日志由官方 v2→v3 迁移改写到新名),全量构建测试通过;minHost 前移至 0.1.5-rc.1,旧宿主请停留在旧发布线。
57
+ - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.5-rc.1)
58
58
 
59
- **版本线对照**:0.2.0 起支持宿主 `0.1.2-rc.1` 及以后;宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 的用户请停留在 0.1.x 发布线(末版 `0.1.1`)。
59
+ **版本线对照**:0.2.0 之后的首个发布起支持宿主 `0.1.5-rc.1` 及以后;宿主 `0.1.2-rc.1` 请停留在 `0.2.0`,宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 请停留在 0.1.x 发布线(末版 `0.1.1`)。
60
60
 
61
61
  ## Known Limitations
62
62
 
@@ -68,11 +68,14 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
68
68
  <details>
69
69
  <summary>内部结构(点击展开)</summary>
70
70
 
71
- `ctx.filePreview`(wire 命名空间 `filePreview`)暴露四个生成的 Remote 方法:
71
+ `ctx.filePreview`(wire 命名空间 `filePreview`)暴露六个生成的 Remote 方法:
72
72
 
73
- - `list(agent)`——对 `agent.session.events` 的纯折叠:`read`/`write`/`edit` 的 `tool/call` 贡献展示路径,这些工具的已完结嵌套 Code Mode `tool/code-dispatch` 同样计入(失败的派发不记录;条目借用根调用的 turn/step)。携带 `diffs` 元数据的 `write`/`edit` `tool/result` 把每次改动按事件序追加进条目,`lastDiff` 保留为最后一次。响应携带条目、最后扫描的 seq,以及是否触达 `maxFiles`。折叠本身不访问文件系统;`captureBashWrites` 开启时并入采集器验证过的 bash 写入路径。返回前按会话 cwd 解析每条路径并 `stat`,只保留当前仍存在的常规文件——日志折叠是历史,产物列表只看磁盘现状(某回合写后又清理的临时脚本不再是产物);存在性每次调用都现查(不随日志折叠缓存)。
73
+ - `capabilities()`——零会话可用性握手,返回 `{ protocolVersion: 1 }`;配套客户端只在握手成功后安装 UI 面。
74
+
75
+ - `list(agent)`——对 `agent.session.events` 的纯折叠:`read`/`write`/`edit` 的 `tool/call` 贡献展示路径,这些工具的已完结嵌套 PTC `tool/ptc-dispatch` 同样计入(失败的派发不记录;条目借用根调用的 turn/step)。携带 `diffs` 元数据的 `write`/`edit` `tool/result` 把每次改动按事件序追加进条目,`lastDiff` 保留为最后一次。响应携带条目、最后扫描的 seq,以及是否触达 `maxFiles`。折叠本身不访问文件系统;`captureBashWrites` 开启时并入采集器验证过的 bash 写入路径。返回前按会话 cwd 解析每条路径并 `stat`,只保留当前仍存在的常规文件——日志折叠是历史,产物列表只看磁盘现状(某回合写后又清理的临时脚本不再是产物);存在性每次调用都现查(不随日志折叠缓存)。
74
76
  - `read(agent, path, signal)`——以会话 cwd 为基准解析 `path`,返回 `kind: 'text'`(超过 `maxReadBytes` 截断并标记 `truncated`);web 宿主上图片返回 `kind: 'image'` 及浏览器可加载 URL——字节走专门的 `/file-preview-image/<sessionId>/<path>` 路由,仅当组合了可选的 `webServer` 与 `agents` 服务时注册;无 web 宿主返回 `binary`——否则返回分类提示:`binary`(二进制扩展名或 NUL 字节;绝不读取)、`missing`、`too-large`、`error`(含消息)。
75
77
  - `reveal(agent, path, signal)`——在宿主文件管理器中打开文件所在文件夹并选中它,全程无 shell,走 `@deepseek-ai/dsh-native-command`:macOS `open -R`、Windows `explorer /select,<path>`、WSL 经 `wslpath`、桌面 Linux 依次尝试 `nautilus`/`dolphin`/`nemo --select`。选中返回 `{ revealed: true }`,否则 `false` 及 `reason: 'missing'`(目标不存在)或 `'select-failed'`(无可用文件管理器——调用方改开父文件夹,手势总能落在可见处)。不写入任何东西。
78
+ - `openExternal(agent, path, app, signal)`——在指定宿主应用中打开文件(「在 IDE 打开」手势):官方 open-in-app 路由只收目录,文件级打开走这里——macOS `open -a <App> <path>`,同样无 shell。`app` 是官方 open-in-app catalog id(客户端经 `/open-in-app/apps` 探测);id → `.app` 名映射在 `open-external.ts`(镜像官方 catalog 的 darwin 条目)。非 macOS 或未知 id 返回 `{ opened: false, reason }`,客户端据此隐藏手势。不写入任何东西。
76
79
  - `turnFiles(agent)`——每个回合的文件变更,回合卡片与 `list` 同源但**不**去重:同一文件在两个回合改过,两个分组都有它——每张卡片精确列出该回合改了什么。行数增减由 result diff 求和;折叠按会话缓存、由日志水位线失效,响应携带水位线供客户端自缓存。与 `list` 一样,返回前按会话 cwd 校验存在性,只保留当前仍存在的文件(被清理的临时脚本不占卡片)。
77
80
 
78
81
  bash 写入采集器:监听每个会话的 bash `tool/call`/`tool/result` 配对,提取高精度写入目标(`cat > path` heredoc、单 `>` 重定向、`tee` 非追加、`sed -i`),按宿主环境与会话 cwd 展开 `$VAR`/`~`/相对路径,只记录 `fs.stat` 确认为文件的路径(宁缺毋滥)。采集结果存于按会话的内存登记表,`session/created` 时重放会话自身历史重建——宿主重启后不丢;会话日志本身绝不被改动(官方 `Session.append` 无法给插件事件标 `ignorable`——见 `docs/upstream-seam-registry.md` 的 S2 条目)。bash 采集的文件没有 diff 历史;预览与普通条目一样通过 `read` 读当前内容。
package/lib/index.js CHANGED
@@ -1,9 +1,7 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
1
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
5
2
  import z from "@deepseek-ai/schemastery";
6
3
  import { homedir, release } from "node:os";
4
+ import { resolve } from "node:path";
7
5
  import { runNativeCommand } from "@deepseek-ai/dsh-native-command";
8
6
  //#region lib/types/bash-writes.js
9
7
  /**
@@ -330,14 +328,6 @@ const FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
330
328
  "write",
331
329
  "edit"
332
330
  ]);
333
- /**
334
- * Settled nested-dispatch event names, matched by name (feature detection,
335
- * never a version check): `tool/code-dispatch` is the Code Mode name every
336
- * host through 0.1.2-rc.1 emits; `tool/ptc-dispatch` is the PTC rename later
337
- * 0.1.2 seats move to. Both carry the same settled-dispatch payload, so one
338
- * branch serves either.
339
- */
340
- const DISPATCH_EVENT_NAMES = /* @__PURE__ */ new Set(["tool/code-dispatch", "tool/ptc-dispatch"]);
341
331
  /** The argument key carrying the touched path (shared by all three file tools). */
342
332
  const FILE_PATH_ARGUMENT = "file_path";
343
333
  /**
@@ -389,10 +379,11 @@ function diffsFromResultMeta(meta) {
389
379
  * first-seen order and deduplicated by display path; a later occurrence
390
380
  * refreshes the entry's op and location without moving it, and each write/edit
391
381
  * tool result appends its change's diff to the entry's `diffs` in event order.
392
- * Nested Code Mode file dispatches (`tool/code-dispatch` events, or
393
- * `tool/ptc-dispatch` on seats running the PTC rename) count too —
394
- * they carry no turn/step of their own, so the entry borrows the enclosing
395
- * root call's location.
382
+ * Nested PTC file dispatches (`tool/ptc-dispatch` events — the host's Code
383
+ * Mode name through 0.1.2, renamed with the 0.1.5 format; the v2→v3 log
384
+ * migration renames persisted rows, so only the ptc name is matched) count
385
+ * too — they carry no turn/step of their own, so the entry borrows the
386
+ * enclosing root call's location.
396
387
  * @param events - the session's events in ascending seq order.
397
388
  * @param maxFiles - cap on returned entries; excess files are dropped in
398
389
  * first-seen order after the cap is reached.
@@ -400,7 +391,7 @@ function diffsFromResultMeta(meta) {
400
391
  */
401
392
  function foldFilePreview(events, maxFiles) {
402
393
  const entries = /* @__PURE__ */ new Map();
403
- /** Root tool/call locations, so nested code dispatches borrow their turn/step. */
394
+ /** Root tool/call locations, so nested dispatches borrow their turn/step. */
404
395
  const callSites = /* @__PURE__ */ new Map();
405
396
  let asOfSeq = -1;
406
397
  /** Upsert one touched path, refreshing a repeat in place and keeping its diffs. */
@@ -433,16 +424,15 @@ function foldFilePreview(events, maxFiles) {
433
424
  record(target, event.seq, event.data.turn, event.data.step);
434
425
  continue;
435
426
  }
436
- if (DISPATCH_EVENT_NAMES.has(event.type)) {
437
- const dispatch = event;
438
- if (dispatch.data.isError) continue;
439
- const target = targetFromArguments(dispatch.data.name, dispatch.data.arguments);
427
+ if (event.type === "tool/ptc-dispatch") {
428
+ if (event.data.isError) continue;
429
+ const target = targetFromArguments(event.data.name, event.data.arguments);
440
430
  if (target === void 0) continue;
441
- const site = callSites.get(String(dispatch.data.rootCallId)) ?? {
431
+ const site = callSites.get(String(event.data.rootCallId)) ?? {
442
432
  turn: 0,
443
433
  step: 0
444
434
  };
445
- record(target, dispatch.seq, site.turn, site.step);
435
+ record(target, event.seq, site.turn, site.step);
446
436
  continue;
447
437
  }
448
438
  if (event.type === "tool/result") {
@@ -520,7 +510,7 @@ function resultCallFacts(message) {
520
510
  * turn maps, so each turn's card lists exactly what that turn mutated.
521
511
  * Reads leave no card entry (the card is the mutation vocabulary — write/edit
522
512
  * only), so a pure-read call never registers; write/edit calls register with
523
- * their own turn; nested Code Mode dispatches borrow the enclosing root call's
513
+ * their own turn; nested PTC dispatches borrow the enclosing root call's
524
514
  * turn (dispatch events carry none); a `tool/result` whose presentation meta
525
515
  * carries diffs registers its paths (the render-intent vocabulary) with the
526
516
  * result's turn, and per-turn line deltas are summed from the diffs. A `write` whose result carries no diffs is a
@@ -531,7 +521,7 @@ function resultCallFacts(message) {
531
521
  * Count discipline: a write/edit tool/call only registers the path's location
532
522
  * and never touches counts (its counted result follows); the first count
533
523
  * contribution for a turn+path is adopted verbatim, later ones accumulate per
534
- * field with unknown-wins — an uncounted mutation (a Code Mode dispatch, or a
524
+ * field with unknown-wins — an uncounted mutation (a PTC dispatch, or a
535
525
  * diff whose side reports no prior content) poisons that field to undefined
536
526
  * rather than reporting a misleading partial.
537
527
  * @param events - the session's events in ascending seq order.
@@ -539,7 +529,7 @@ function resultCallFacts(message) {
539
529
  */
540
530
  function foldFilePreviewByTurn(events) {
541
531
  const byTurn = /* @__PURE__ */ new Map();
542
- /** Root tool/call locations, so nested code dispatches borrow their turn/step. */
532
+ /** Root tool/call locations, so nested dispatches borrow their turn/step. */
543
533
  const callSites = /* @__PURE__ */ new Map();
544
534
  /** Write-tool calls keyed by callId (create fallback; see {@link WriteCallFacts}). */
545
535
  const writeCalls = /* @__PURE__ */ new Map();
@@ -577,7 +567,7 @@ function foldFilePreviewByTurn(events) {
577
567
  * a turn+path is adopted verbatim (a count-less placeholder from a
578
568
  * preceding tool/call must not poison it); later contributions accumulate
579
569
  * per field with unknown-wins. `unknown` marks an uncountable mutation (a
580
- * Code Mode dispatch): it poisons both fields. */
570
+ * PTC dispatch): it poisons both fields. */
581
571
  const recordContribution = (path, turn, seq, step, added, removed, unknown) => {
582
572
  const map = turnMap(turn);
583
573
  const key = countKey(turn, path);
@@ -636,18 +626,17 @@ function foldFilePreviewByTurn(events) {
636
626
  recordLocation(target.path, event.data.turn, event.seq, event.data.step);
637
627
  continue;
638
628
  }
639
- if (DISPATCH_EVENT_NAMES.has(event.type)) {
640
- const dispatch = event;
641
- if (dispatch.data.isError) continue;
642
- const target = targetFromArguments(dispatch.data.name, dispatch.data.arguments);
629
+ if (event.type === "tool/ptc-dispatch") {
630
+ if (event.data.isError) continue;
631
+ const target = targetFromArguments(event.data.name, event.data.arguments);
643
632
  if (target === void 0) continue;
644
633
  if (target.op === "read") continue;
645
- const site = callSites.get(String(dispatch.data.rootCallId)) ?? {
634
+ const site = callSites.get(String(event.data.rootCallId)) ?? {
646
635
  turn: 0,
647
636
  step: 0
648
637
  };
649
638
  sessionSeen.add(target.path);
650
- recordContribution(target.path, site.turn, dispatch.seq, site.step, void 0, void 0, true);
639
+ recordContribution(target.path, site.turn, event.seq, site.step, void 0, void 0, true);
651
640
  continue;
652
641
  }
653
642
  if (event.type === "tool/result") {
@@ -668,6 +657,68 @@ function foldFilePreviewByTurn(events) {
668
657
  return byTurn;
669
658
  }
670
659
  //#endregion
660
+ //#region lib/types/open-external.js
661
+ /**
662
+ * Native "open this file in a specific application" dispatch used by the
663
+ * file-preview Remote's `openExternal` method (the "open in IDE" gesture).
664
+ *
665
+ * The official open-in-app route (`POST /open-in-app/open`) only accepts
666
+ * directories — a file path 404s — so file-exact opens carry their own
667
+ * dispatch here. macOS only: `open -a <App> <path>` through the official
668
+ * `@deepseek-ai/dsh-native-command` runner (no shell, abort propagation),
669
+ * mirroring `reveal.ts`'s discipline. The browser picks the application by
670
+ * official open-in-app catalog id (probed from `/open-in-app/apps`); the
671
+ * id → `.app` name map below mirrors the official catalog's darwin entries
672
+ * (packages/host/open-in-app/src/catalog.ts at 0.1.5-rc.1 — re-check on every
673
+ * host upgrade). Other platforms and unknown ids answer unsupported, and the
674
+ * client hides the gesture.
675
+ * @module @khorsheed/dsh-file-preview
676
+ */
677
+ /** Official catalog id → macOS application name (darwin entries of the official catalog). */
678
+ const MAC_APP_NAMES = {
679
+ cursor: "Cursor",
680
+ vscode: "Visual Studio Code",
681
+ vscodeinsiders: "Visual Studio Code - Insiders",
682
+ windsurf: "Windsurf",
683
+ zed: "Zed",
684
+ sublimetext: "Sublime Text",
685
+ xcode: "Xcode",
686
+ androidstudio: "Android Studio",
687
+ intellij: "IntelliJ IDEA",
688
+ pycharm: "PyCharm",
689
+ webstorm: "WebStorm",
690
+ phpstorm: "PhpStorm",
691
+ goland: "GoLand",
692
+ rider: "Rider",
693
+ rustrover: "RustRover"
694
+ };
695
+ /** The application name one catalog id maps to, or undefined when unknown. */
696
+ function macAppName(appId) {
697
+ return MAC_APP_NAMES[appId];
698
+ }
699
+ /**
700
+ * Open one file in the named application. Throws when the platform is not
701
+ * macOS or the catalog id is unknown — the caller maps both to an
702
+ * `unsupported`/`unknown-app` answer and the client hides the gesture. A
703
+ * launch failure propagates as the runner's rejection.
704
+ * @param path - absolute path in the host's execution world (caller owns
705
+ * resolution, typically `FileSystem.processPath`).
706
+ * @param appId - official open-in-app catalog id (e.g. `cursor`).
707
+ * @param signal - caller/connection lifetime; abort terminates the native command.
708
+ * @param internals - platform seam for deterministic tests.
709
+ */
710
+ async function openExternalNative(path, appId, signal, internals = {}) {
711
+ const platform = internals.platform ?? process.platform;
712
+ if (platform !== "darwin") throw new Error(`open-in-app is unsupported on ${platform}`);
713
+ const app = macAppName(appId);
714
+ if (app === void 0) throw new Error(`unknown application id "${appId}"`);
715
+ await (internals.run ?? runNativeCommand)("open", [
716
+ "-a",
717
+ app,
718
+ path
719
+ ], signal);
720
+ }
721
+ //#endregion
671
722
  //#region lib/types/reveal.js
672
723
  /**
673
724
  * Cross-platform native "show in folder, selecting the file" dispatch used by
@@ -785,47 +836,6 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
785
836
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
786
837
  done = true;
787
838
  };
788
- /**
789
- * Register the 3d-artifact skill — the generation-side contract for
790
- * sandbox-runnable interactive 3D / digital-twin single-file HTML (self-
791
- * contained, zero runtime network, GLB-inline zero-fetch models). Pull-based
792
- * discovery: an agent whose task involves generating such an HTML page finds
793
- * the contract through the skill catalog — no per-session push notice.
794
- * Optional: compositions without the skill capability skip the registration.
795
- * A missing/malformed shipped SKILL.md degrades to a warning — a discovery
796
- * aid must never take a boot down; the pack-smoke test owns the file's
797
- * presence in the tarball.
798
- * @param ctx - plugin context.
799
- */
800
- function registerArtifactSkill(ctx) {
801
- const skills = ctx.get("skills");
802
- if (skills === void 0) {
803
- ctx.logger.warn("file-preview: skills capability absent — the 3d-artifact skill is not registered");
804
- return;
805
- }
806
- try {
807
- const skillDir = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "3d-artifact");
808
- const skillFile = join(skillDir, "SKILL.md");
809
- const raw = readFileSync(skillFile, "utf8");
810
- const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
811
- const name = /^name: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
812
- const description = /^description: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
813
- const content = match?.[2];
814
- if (match === null || name === void 0 || description === void 0 || content === void 0) {
815
- ctx.logger.warn("file-preview: shipped SKILL.md is malformed — the 3d-artifact skill is not registered");
816
- return;
817
- }
818
- ctx.effect(() => skills.register({
819
- name,
820
- description,
821
- content,
822
- source: "runtime",
823
- provider: "file-preview"
824
- }));
825
- } catch (error) {
826
- ctx.logger.warn(`file-preview: shipped SKILL.md unreadable (${String(error)}) — the 3d-artifact skill is not registered`);
827
- }
828
- }
829
839
  /** Extensions treated as binary without reading (their text decode is meaningless). */
830
840
  const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
831
841
  ".pdf",
@@ -926,17 +936,32 @@ function readErrorMessage(error) {
926
936
  let FilePreviewService = (() => {
927
937
  let _classSuper = TypertRemoteService;
928
938
  let _instanceExtraInitializers = [];
939
+ let _capabilities_decorators;
929
940
  let _list_decorators;
930
941
  let _turnFiles_decorators;
931
942
  let _read_decorators;
932
943
  let _reveal_decorators;
944
+ let _openExternal_decorators;
933
945
  return class FilePreviewService extends _classSuper {
934
946
  static {
935
947
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
948
+ _capabilities_decorators = [Remote("capabilities")];
936
949
  _list_decorators = [Remote("list")];
937
950
  _turnFiles_decorators = [Remote("turnFiles")];
938
951
  _read_decorators = [Remote("read")];
939
952
  _reveal_decorators = [Remote("reveal")];
953
+ _openExternal_decorators = [Remote("openExternal")];
954
+ __esDecorate(this, null, _capabilities_decorators, {
955
+ kind: "method",
956
+ name: "capabilities",
957
+ static: false,
958
+ private: false,
959
+ access: {
960
+ has: (obj) => "capabilities" in obj,
961
+ get: (obj) => obj.capabilities
962
+ },
963
+ metadata: _metadata
964
+ }, null, _instanceExtraInitializers);
940
965
  __esDecorate(this, null, _list_decorators, {
941
966
  kind: "method",
942
967
  name: "list",
@@ -981,6 +1006,17 @@ let FilePreviewService = (() => {
981
1006
  },
982
1007
  metadata: _metadata
983
1008
  }, null, _instanceExtraInitializers);
1009
+ __esDecorate(this, null, _openExternal_decorators, {
1010
+ kind: "method",
1011
+ name: "openExternal",
1012
+ static: false,
1013
+ private: false,
1014
+ access: {
1015
+ has: (obj) => "openExternal" in obj,
1016
+ get: (obj) => obj.openExternal
1017
+ },
1018
+ metadata: _metadata
1019
+ }, null, _instanceExtraInitializers);
984
1020
  if (_metadata) Object.defineProperty(this, Symbol.metadata, {
985
1021
  enumerable: true,
986
1022
  configurable: true,
@@ -1005,6 +1041,8 @@ let FilePreviewService = (() => {
1005
1041
  agents;
1006
1042
  /** Native reveal runner; a constructor seam for deterministic tests. */
1007
1043
  revealNative;
1044
+ /** Native open-in-app runner; a constructor seam for deterministic tests. */
1045
+ openExternalNative;
1008
1046
  /** Bash-write collector (S2 seam), or undefined when disabled in config. */
1009
1047
  collector;
1010
1048
  /** Per-session by-turn fold cache, invalidated by the log watermark. */
@@ -1025,6 +1063,7 @@ let FilePreviewService = (() => {
1025
1063
  captureBashWrites: config.captureBashWrites ?? true
1026
1064
  };
1027
1065
  this.revealNative = deps.revealNative ?? revealNativePath;
1066
+ this.openExternalNative = deps.openExternalNative ?? openExternalNative;
1028
1067
  if (config.captureBashWrites !== false) {
1029
1068
  this.collector = new BashWriteCollector({
1030
1069
  fs: this.fs,
@@ -1045,7 +1084,10 @@ let FilePreviewService = (() => {
1045
1084
  this.serveImage(req, res);
1046
1085
  }
1047
1086
  }), "file-preview: image route");
1048
- registerArtifactSkill(ctx);
1087
+ }
1088
+ /** Zero-session availability handshake for browser companions. */
1089
+ capabilities() {
1090
+ return { protocolVersion: 1 };
1049
1091
  }
1050
1092
  get fs() {
1051
1093
  return this.ctx.fs;
@@ -1184,7 +1226,7 @@ let FilePreviewService = (() => {
1184
1226
  /**
1185
1227
  * Every turn's file mutations for the turn-tail card — the single source of
1186
1228
  * truth the card renders (the client's old write/edit-only fold retired).
1187
- * Built by folding the session log per turn (write/edit calls, Code Mode
1229
+ * Built by folding the session log per turn (write/edit calls, PTC
1188
1230
  * dispatches borrowing the root call's turn, and render-intent paths from
1189
1231
  * result diff meta) and merging the collector's bash-written captures by
1190
1232
  * their own turn. The fold is cached per session and invalidated by the log
@@ -1386,6 +1428,63 @@ let FilePreviewService = (() => {
1386
1428
  };
1387
1429
  }
1388
1430
  }
1431
+ /**
1432
+ * Open one recorded file in a specific host application (the "open in IDE"
1433
+ * gesture). The official open-in-app route only accepts directories — a
1434
+ * file path 404s — so file-exact opens go through this method: macOS
1435
+ * `open -a <App> <path>` via the same native-command runner reveal uses.
1436
+ * The browser names the application by official open-in-app catalog id
1437
+ * (probed from `/open-in-app/apps`); the id → app-name map lives in
1438
+ * open-external.ts. Non-macOS hosts and unknown ids answer without
1439
+ * launching, and the client hides the gesture.
1440
+ * @param agent - owning live agent; its session cwd anchors relative paths.
1441
+ * @param path - the display path recorded by the read/write/edit tool call.
1442
+ * @param app - official open-in-app catalog id (e.g. `cursor`).
1443
+ * @param signal - cooperative cancellation from the calling UI request.
1444
+ * @returns whether the application launched, or why not.
1445
+ */
1446
+ async openExternal(agent, path, app, signal) {
1447
+ if (typeof path !== "string" || path.length === 0 || typeof app !== "string" || app.length === 0) return {
1448
+ opened: false,
1449
+ reason: "missing"
1450
+ };
1451
+ if (macAppName(app) === void 0) return {
1452
+ opened: false,
1453
+ reason: "unknown-app"
1454
+ };
1455
+ const cwd = agent.session.header.cwd;
1456
+ let target;
1457
+ try {
1458
+ target = await this.fs.resolve(path, cwd === void 0 ? { signal } : {
1459
+ cwd,
1460
+ signal
1461
+ });
1462
+ } catch {
1463
+ return {
1464
+ opened: false,
1465
+ reason: "missing"
1466
+ };
1467
+ }
1468
+ let info;
1469
+ try {
1470
+ info = await this.fs.stat(target, signal);
1471
+ } catch {
1472
+ info = void 0;
1473
+ }
1474
+ if (info === void 0) return {
1475
+ opened: false,
1476
+ reason: "missing"
1477
+ };
1478
+ try {
1479
+ await this.openExternalNative(this.fs.processPath(target), app, signal);
1480
+ return { opened: true };
1481
+ } catch (error) {
1482
+ return {
1483
+ opened: false,
1484
+ reason: (error instanceof Error ? error.message : String(error)).startsWith("open-in-app is unsupported on ") ? "unsupported-platform" : "launch-failed"
1485
+ };
1486
+ }
1487
+ }
1389
1488
  };
1390
1489
  })();
1391
1490
  //#endregion