@khorsheed/dsh-file-preview 0.1.0 → 0.2.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,17 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.2.0(2026-09-10)
4
+
5
+ 适配宿主 0.1.2 线。
6
+
7
+ - **BREAKING**:minHost 前移至 `0.1.2-rc.1`;宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 的用户请停留在 0.1.x 线(末版 `0.1.1`)
8
+ - 会话事件读取走 `Session.snapshotEvents()`(`session.events` 已随宿主移除)
9
+ - 嵌套 dispatch 事件按名探测(feature detection,非版本判断):同时识别 Code Mode 的 `tool/code-dispatch` 与 PTC 更名后的 `tool/ptc-dispatch`
10
+
11
+ ## 0.1.1(2026-08-23)
12
+
13
+ - 修复:随包 skill 在目录里可见、调用即炸——宿主在 load 时才校验注册的 `source` 字段,之前没传;已补 `source: 'runtime'`,并加了真实 SkillRegistry 往返测试(list + load)防回归
14
+
3
15
  ## 0.1.0(2026-08-22)
4
16
 
5
17
  首个公开发布。
package/README.en.md CHANGED
@@ -2,29 +2,31 @@
2
2
 
3
3
  English | [中文](README.md)
4
4
 
5
- Host service for dsh's file-preview surface: lists every file a session's tool calls touched — each write/edit change's diff included — and serves any file's current content for preview. Pair it with `@khorsheed/dsh-client-ui-file-preview` and the web GUI gains a Produced-files tab with inline previews and reveal-in-folder.
5
+ Every file the agent touched, on one page: what changed, and what it looks like now.
6
6
 
7
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview1.png" width="480" alt="the companion client's file-preview pane: file list and inline markdown preview">
7
+ This package is the host half of file preview. Install it together with the companion client, and the web GUI gains a Produced-files tab: every file the session read, wrote, or edited is listed there, each write/edit change comes with its diff, and clicking any file shows its current content. Files the agent happened to write through bash (heredocs, redirects, and the like) are collected too. The whole service is read-only — it looks at files, never touches them.
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview2.png" width="480" alt="per-artifact change history: pageable per-turn diffs">
10
-
11
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview3.png" width="480" alt="the Produced tab: every file the session wrote, at a glance">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview1.png" width="640" alt="the Produced tab's file preview: file list and inline markdown preview">
12
10
 
13
11
  ## Features
14
12
 
15
13
  - **Session file list** — every file the session's `read`/`write`/`edit` calls touched, with each write/edit change's diff attached.
16
- - **Content reads** — current text, capped and flagged when truncated; images as browser-loadable URLs on web hosts; binary, missing, and oversized files answer classified notices.
14
+ - **Content reads** — current text, capped and flagged when truncated; images as browser-loadable URLs on web hosts; binary, missing, and oversized files answer classified notices instead of errors.
17
15
  - **Bash-write capture** — files written through `bash` (heredocs, redirects, `tee`, `sed -i`) merged into the list, stat-verified and rebuilt after a host restart.
18
16
  - **Reveal in folder** — opens the file's folder with the file selected, on macOS, Windows, WSL, and desktop Linux.
19
17
  - **Read-only by design** — no session state, no writes; a restarted host loses nothing.
20
18
 
19
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview2.png" width="640" alt="per-artifact change history: pageable per-turn diffs">
20
+
21
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview3.png" width="640" alt="the Produced tab: every file the session wrote, at a glance">
22
+
21
23
  ## Install
22
24
 
23
25
  ```sh
24
26
  dsh plugin --profile web add @khorsheed/dsh-file-preview
25
27
  ```
26
28
 
27
- Host half only — install the companion client for the visible surface (Produced tab, preview drawer, per-turn change card):
29
+ Host half only — the visible surface (Produced tab, preview drawer, per-turn change card) lives in the companion client:
28
30
 
29
31
  ```sh
30
32
  dsh plugin --profile web add @khorsheed/dsh-client-ui-file-preview
@@ -51,8 +53,10 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
51
53
 
52
54
  ## Compatibility
53
55
 
54
- - npm release line (`@deepseek-ai/dsh@0.1.1-rc.2`): ✅ full — the rc.8→0.1.1-rc.1 API audit (2026-08-21) confirms every surface this plugin consumes is unchanged or additive (the ProjectionDefinition restructure, cacheHitPercent return-type change, and the credentials/updated event rename do not touch this package); no source change was needed; re-audited for rc.2 (2026-08-22): consumed surface unchanged, full build+test green.
55
- - source line (deepseek-harness master): ✅
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)
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`).
56
60
 
57
61
  ## Known Limitations
58
62
 
@@ -66,10 +70,10 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
66
70
 
67
71
  `ctx.filePreview` (wire namespace `filePreview`) exposes four generated Remote methods:
68
72
 
69
- - `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.
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).
70
74
  - `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).
71
75
  - `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.
72
- - `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.
76
+ - `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).
73
77
 
74
78
  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`.
75
79
 
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: 244347bb89f3796d687220bfdb44c3daf7d68a5a
6
- packages/file-preview/README.md: 3131fafedb08f02e90323172cfd4d087e3ce77a6
5
+ packages/file-preview/README.en.md: ef4c3a913fe23a474cb4d05114fb9f080ff641d1
6
+ packages/file-preview/README.md: a34ba31610e054fd3f82c9b2d5c3635e04a970ed
package/README.md CHANGED
@@ -2,21 +2,23 @@
2
2
 
3
3
  [English](README.en.md) | 中文
4
4
 
5
- dsh 文件预览界面的宿主服务:列出会话工具调用触碰过的每个文件——附每次 write/edit 改动的 diff——并提供任一文件的当前内容用于预览。与 `@khorsheed/dsh-client-ui-file-preview` 成对安装,web GUI 即获得「产物」tab:内联预览与"在文件夹中打开"。
5
+ agent 碰过的文件一页看全:改了什么、现在长什么样。
6
6
 
7
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview1.png" width="480" alt="配套客户端的文件预览:文件列表与内联 markdown 预览">
7
+ 这是文件预览的宿主半。装上它和配套的界面包,web GUI 会多一个「产物」tab:会话里 agent 读过、写过、改过的每个文件都列在里面,每次 write/edit 改动附 diff,点开任一文件就能看到当前内容。agent 用 bash 顺手写的文件(heredoc、重定向之类)也一样收进来。整个服务只读——它只看文件,从不动文件。
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview2.png" width="480" alt="每个产物的改动记录:逐轮 diff 可翻页回看">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview1.png" width="640" alt="「产物」tab 的文件预览:文件列表与内联 markdown 预览">
10
10
 
11
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview3.png" width="480" alt="「产物」tab:会话写过的全部文件一览">
11
+ ## 功能
12
12
 
13
- ## 特性
13
+ - **会话文件清单**——会话 `read`/`write`/`edit` 调用触碰过的每个文件,每次 write/edit 改动附 diff。
14
+ - **内容读取**——文件当前文本,超限截断并标记;web 宿主上图片返回浏览器可加载的 URL;二进制、不存在、超限的文件返回分类提示而不是报错。
15
+ - **bash 写入采集**——经 `bash` 写入的文件(heredoc、重定向、`tee`、`sed -i`)并入清单,经 stat 验证,宿主重启后自动重建。
16
+ - **在文件夹中打开**——在宿主文件管理器中打开所在文件夹并选中文件,支持 macOS、Windows、WSL、桌面 Linux。
17
+ - **只读设计**——无会话状态、无写入;宿主重启不丢任何东西。
14
18
 
15
- - **会话文件清单**——会话 `read`/`write`/`edit` 调用触碰过的每个文件,每次 write/edit 改动附 diff。
16
- - **内容读取**——文件当前文本,超限截断并标记;web 宿主上图片返回浏览器可加载的 URL;二进制、不存在、超限文件返回分类提示。
17
- - **bash 写入采集**——经 `bash` 写入的文件(heredoc、重定向、`tee`、`sed -i`)并入清单,经 stat 验证,宿主重启后重建。
18
- - **在文件夹中打开**——在宿主文件管理器中打开所在文件夹并选中文件,支持 macOS、Windows、WSL、桌面 Linux。
19
- - **只读设计**——无会话状态、无写入;宿主重启不丢任何东西。
19
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview2.png" width="640" alt="每个产物的改动记录:逐轮 diff 可翻页回看">
20
+
21
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/file-preview3.png" width="640" alt="「产物」tab:会话写过的全部文件一览">
20
22
 
21
23
  ## 安装
22
24
 
@@ -24,13 +26,13 @@ dsh 文件预览界面的宿主服务:列出会话工具调用触碰过的每个
24
26
  dsh plugin --profile web add @khorsheed/dsh-file-preview
25
27
  ```
26
28
 
27
- 仅宿主半——可见界面(「产物」tab、预览抽屉、回合变更卡片)在配套客户端里:
29
+ 仅宿主半——可见界面(「产物」tab、预览抽屉、回合变更卡片)在配套客户端里:
28
30
 
29
31
  ```sh
30
32
  dsh plugin --profile web add @khorsheed/dsh-client-ui-file-preview
31
33
  ```
32
34
 
33
- 卸载;残留的客户端半降级为空态而非报错:
35
+ 卸载;残留的客户端半降级为空态而非报错:
34
36
 
35
37
  ```sh
36
38
  dsh plugin --profile web remove @khorsheed/dsh-file-preview
@@ -47,41 +49,43 @@ dsh plugin --profile web remove @khorsheed/dsh-file-preview
47
49
  captureBashWrites: true
48
50
  ```
49
51
 
50
- `maxReadBytes` 限制单次 `read`(超限返回 `too-large`),`maxFiles` 限制 `list` 折叠结果——均为正整数,默认 512 KiB 与 500——`captureBashWrites`(默认 `true`)开关 bash 写入采集器。
52
+ `maxReadBytes` 限制单次 `read`(超限返回 `too-large`),`maxFiles` 限制 `list` 折叠结果——均为正整数,默认 512 KiB 与 500——`captureBashWrites`(默认 `true`)开关 bash 写入采集器。
51
53
 
52
54
  ## Compatibility
53
55
 
54
- - npm 发布线(`@deepseek-ai/dsh@0.1.1-rc.2`):✅ 完整——rc.8→0.1.1-rc.1 API 审计(2026-08-21)确认本插件消费的所有面无变化或纯增量(ProjectionDefinition 重构、cacheHitPercent 返回值变更、credentials/updated 事件改名均不涉及本包),无需改动源码;rc.1→rc.2 复核(2026-08-22):消费面无变化,全量构建测试通过。
55
- - 源码线(deepseek-harness master):✅
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)
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`)。
56
60
 
57
- ## 已知限制
61
+ ## Known Limitations
58
62
 
59
- - **列表是时间点折叠**——没有推送通道;客户端通过重新调用 `list` 刷新。
63
+ - **列表是时间点折叠**——没有推送通道;客户端通过重新调用 `list` 刷新。
60
64
  - **不提供二进制预览内容**——非图片二进制文件只返回 `kind: 'binary'` 及大小。
61
65
 
62
66
  ## 实现原理
63
67
 
64
68
  <details>
65
- <summary>内部结构(点击展开)</summary>
69
+ <summary>内部结构(点击展开)</summary>
66
70
 
67
- `ctx.filePreview`(wire 命名空间 `filePreview`)暴露四个生成的 Remote 方法:
71
+ `ctx.filePreview`(wire 命名空间 `filePreview`)暴露四个生成的 Remote 方法:
68
72
 
69
- - `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 写入路径。
70
- - `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`(含消息)。
71
- - `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'`(无可用文件管理器——调用方改开父文件夹,手势总能落在可见处)。不写入任何东西。
72
- - `turnFiles(agent)`——每个回合的文件变更,回合卡片与 `list` 同源但**不**去重:同一文件在两个回合改过,两个分组都有它——每张卡片精确列出该回合改了什么。行数增减由 result diff 求和;折叠按会话缓存、由日志水位线失效,响应携带水位线供客户端自缓存。
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`,只保留当前仍存在的常规文件——日志折叠是历史,产物列表只看磁盘现状(某回合写后又清理的临时脚本不再是产物);存在性每次调用都现查(不随日志折叠缓存)。
74
+ - `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
+ - `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'`(无可用文件管理器——调用方改开父文件夹,手势总能落在可见处)。不写入任何东西。
76
+ - `turnFiles(agent)`——每个回合的文件变更,回合卡片与 `list` 同源但**不**去重:同一文件在两个回合改过,两个分组都有它——每张卡片精确列出该回合改了什么。行数增减由 result diff 求和;折叠按会话缓存、由日志水位线失效,响应携带水位线供客户端自缓存。与 `list` 一样,返回前按会话 cwd 校验存在性,只保留当前仍存在的文件(被清理的临时脚本不占卡片)。
73
77
 
74
- 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` 读当前内容。
78
+ 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` 读当前内容。
75
79
 
76
- 信任与状态:受信任的只读能力——能读取 `ctx.fs` 允许会话访问的一切,组合它即授予对会话文件系统视图的预览权限;它不是安全边界。不产生会话事件;仅有的按会话状态是回合折叠缓存与 bash 采集器的已验证写入登记表。
80
+ 信任与状态:受信任的只读能力——能读取 `ctx.fs` 允许会话访问的一切,组合它即授予对会话文件系统视图的预览权限;它不是安全边界。不产生会话事件;仅有的按会话状态是回合折叠缓存与 bash 采集器的已验证写入登记表。
77
81
 
78
- 分享:纯宿主侧增量——一个 Remote 服务,由浏览器半经官方的 `ctx.remote.$mount` 通道挂载。浏览器半(`@khorsheed/dsh-client-ui-file-preview`)同样纯增量;两个包独立分发,零改动组合进原版 dsh 核心。
82
+ 分享:纯宿主侧增量——一个 Remote 服务,由浏览器半经官方的 `ctx.remote.$mount` 通道挂载。浏览器半(`@khorsheed/dsh-client-ui-file-preview`)同样纯增量;两个包独立分发,零改动组合进原版 dsh 核心。
79
83
 
80
84
  </details>
81
85
 
82
86
  ## 开发
83
87
 
84
- 隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo(`packages/file-preview`)。问题与贡献请移步该仓库。
88
+ 隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo(`packages/file-preview`)。问题与贡献请移步该仓库。
85
89
 
86
90
  ## 变更记录
87
91
 
package/lib/index.js CHANGED
@@ -271,7 +271,7 @@ var BashWriteCollector = class {
271
271
  #replay(session) {
272
272
  this.#pending.set(session.id, /* @__PURE__ */ new Map());
273
273
  this.#captured.set(session.id, /* @__PURE__ */ new Map());
274
- for (const event of session.events) this.#onEvent(session, event);
274
+ for (const event of session.snapshotEvents()) this.#onEvent(session, event);
275
275
  }
276
276
  #onEvent(session, event) {
277
277
  if (event.type === "tool/call") {
@@ -330,6 +330,14 @@ const FILE_TOOL_NAMES = /* @__PURE__ */ new Set([
330
330
  "write",
331
331
  "edit"
332
332
  ]);
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"]);
333
341
  /** The argument key carrying the touched path (shared by all three file tools). */
334
342
  const FILE_PATH_ARGUMENT = "file_path";
335
343
  /**
@@ -381,7 +389,8 @@ function diffsFromResultMeta(meta) {
381
389
  * first-seen order and deduplicated by display path; a later occurrence
382
390
  * refreshes the entry's op and location without moving it, and each write/edit
383
391
  * tool result appends its change's diff to the entry's `diffs` in event order.
384
- * Nested Code Mode file dispatches (`tool/code-dispatch` events) count too —
392
+ * Nested Code Mode file dispatches (`tool/code-dispatch` events, or
393
+ * `tool/ptc-dispatch` on seats running the PTC rename) count too —
385
394
  * they carry no turn/step of their own, so the entry borrows the enclosing
386
395
  * root call's location.
387
396
  * @param events - the session's events in ascending seq order.
@@ -424,15 +433,16 @@ function foldFilePreview(events, maxFiles) {
424
433
  record(target, event.seq, event.data.turn, event.data.step);
425
434
  continue;
426
435
  }
427
- if (event.type === "tool/code-dispatch") {
428
- if (event.data.isError) continue;
429
- const target = targetFromArguments(event.data.name, event.data.arguments);
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);
430
440
  if (target === void 0) continue;
431
- const site = callSites.get(String(event.data.rootCallId)) ?? {
441
+ const site = callSites.get(String(dispatch.data.rootCallId)) ?? {
432
442
  turn: 0,
433
443
  step: 0
434
444
  };
435
- record(target, event.seq, site.turn, site.step);
445
+ record(target, dispatch.seq, site.turn, site.step);
436
446
  continue;
437
447
  }
438
448
  if (event.type === "tool/result") {
@@ -508,11 +518,12 @@ function resultCallFacts(message) {
508
518
  * single source of truth. Unlike {@link foldFilePreview}, this does NOT dedupe
509
519
  * a path to its last occurrence: a file touched in two turns appears in BOTH
510
520
  * turn maps, so each turn's card lists exactly what that turn mutated.
511
- * write/edit calls register with their own turn; nested Code Mode dispatches
512
- * borrow the enclosing root call's turn (dispatch events carry none); a
513
- * `tool/result` whose presentation meta carries diffs registers its paths (the
514
- * render-intent vocabulary) with the result's turn, and per-turn line deltas
515
- * are summed from the diffs. A `write` whose result carries no diffs is a
521
+ * Reads leave no card entry (the card is the mutation vocabulary — write/edit
522
+ * 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
524
+ * turn (dispatch events carry none); a `tool/result` whose presentation meta
525
+ * carries diffs registers its paths (the render-intent vocabulary) with the
526
+ * result's turn, and per-turn line deltas are summed from the diffs. A `write` whose result carries no diffs is a
516
527
  * create (the write tool records no diffs when the file had no prior content):
517
528
  * its full written content — read from the call arguments, which the log keeps
518
529
  * — is the added-line count, once per file per turn.
@@ -609,6 +620,7 @@ function foldFilePreviewByTurn(events) {
609
620
  });
610
621
  const target = pathFromToolCall(event.data.name, event.data.arguments);
611
622
  if (target === void 0) continue;
623
+ if (target.op === "read") continue;
612
624
  if (target.op === "write") {
613
625
  const content = contentFromWriteCall(event.data.arguments);
614
626
  if (content !== void 0) {
@@ -624,16 +636,18 @@ function foldFilePreviewByTurn(events) {
624
636
  recordLocation(target.path, event.data.turn, event.seq, event.data.step);
625
637
  continue;
626
638
  }
627
- if (event.type === "tool/code-dispatch") {
628
- if (event.data.isError) continue;
629
- const target = targetFromArguments(event.data.name, event.data.arguments);
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);
630
643
  if (target === void 0) continue;
631
- const site = callSites.get(String(event.data.rootCallId)) ?? {
644
+ if (target.op === "read") continue;
645
+ const site = callSites.get(String(dispatch.data.rootCallId)) ?? {
632
646
  turn: 0,
633
647
  step: 0
634
648
  };
635
649
  sessionSeen.add(target.path);
636
- recordContribution(target.path, site.turn, event.seq, site.step, void 0, void 0, true);
650
+ recordContribution(target.path, site.turn, dispatch.seq, site.step, void 0, void 0, true);
637
651
  continue;
638
652
  }
639
653
  if (event.type === "tool/result") {
@@ -790,7 +804,8 @@ function registerArtifactSkill(ctx) {
790
804
  return;
791
805
  }
792
806
  try {
793
- const skillFile = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "3d-artifact", "SKILL.md");
807
+ const skillDir = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "3d-artifact");
808
+ const skillFile = join(skillDir, "SKILL.md");
794
809
  const raw = readFileSync(skillFile, "utf8");
795
810
  const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
796
811
  const name = /^name: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
@@ -803,7 +818,9 @@ function registerArtifactSkill(ctx) {
803
818
  ctx.effect(() => skills.register({
804
819
  name,
805
820
  description,
806
- content
821
+ content,
822
+ source: "runtime",
823
+ provider: "file-preview"
807
824
  }));
808
825
  } catch (error) {
809
826
  ctx.logger.warn(`file-preview: shipped SKILL.md unreadable (${String(error)}) — the 3d-artifact skill is not registered`);
@@ -899,8 +916,9 @@ function readErrorMessage(error) {
899
916
  }
900
917
  /**
901
918
  * The `filePreview` Remote service: session-scoped read-only file inspection
902
- * for web surfaces. `list` is a pure fold over the session log (no filesystem
903
- * access); `read` resolves the path against the session cwd and serves current
919
+ * for web surfaces. `list` is a fold over the session log, then filtered to the
920
+ * paths that still exist on disk (resolved against the session cwd and stat-ed
921
+ * per call); `read` resolves the path against the session cwd and serves current
904
922
  * text content through `ctx.fs` (or a browser URL for images), capped by
905
923
  * config. Image bytes ride a dedicated host route so the browser loads them
906
924
  * natively without bloating the RPC channel.
@@ -1103,35 +1121,64 @@ let FilePreviewService = (() => {
1103
1121
  });
1104
1122
  res.end(Buffer.from(bytes));
1105
1123
  }
1124
+ /** Resolve the session's recorded display paths against its cwd and keep only
1125
+ * those that currently exist as a regular file. The products surfaces fold
1126
+ * the session log (which is history, not disk state): a path the log wrote
1127
+ * may have been cleaned up since (a temp script the turn deleted), and such
1128
+ * a path is not a product to list. Existence is checked fresh on every call
1129
+ * (never cached), so a deletion that lands after a fold is visible on the
1130
+ * next fetch. Unresolvable/unstat-able paths are treated as absent.
1131
+ * @param agent - owning live agent; its session cwd anchors relative paths.
1132
+ * @param paths - the display paths to probe.
1133
+ * @returns the subset that resolves to an existing regular file.
1134
+ */
1135
+ async retainExisting(agent, paths) {
1136
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
1137
+ const cwd = agent.session.header.cwd;
1138
+ const existing = /* @__PURE__ */ new Set();
1139
+ for (const path of paths) {
1140
+ if (existing.has(path)) continue;
1141
+ try {
1142
+ const target = await this.fs.resolve(path, cwd === void 0 ? {} : { cwd });
1143
+ if ((await this.fs.stat(target))?.type === "file") existing.add(path);
1144
+ } catch {}
1145
+ }
1146
+ return existing;
1147
+ }
1106
1148
  /**
1107
1149
  * List the files one session wrote or edited: the log fold plus any
1108
1150
  * bash-written files the collector verified (the S2 seam), merged so a
1109
- * captured path the fold already knows keeps its log-derived entry.
1151
+ * captured path the fold already knows keeps its log-derived entry. Only
1152
+ * paths that currently exist on disk are returned — a file the session
1153
+ * wrote and later deleted is history, not a product.
1110
1154
  * @param agent - owning live agent; its session log is the data source.
1111
- * @returns first-seen files capped by `maxFiles`, with the log watermark.
1155
+ * @returns existing first-seen files capped by `maxFiles`, with the log watermark.
1112
1156
  */
1113
- list(agent) {
1114
- const folded = foldFilePreview(agent.session.events, this.resolved.maxFiles);
1157
+ async list(agent) {
1158
+ const folded = foldFilePreview(agent.session.snapshotEvents(), this.resolved.maxFiles);
1115
1159
  const captured = this.collector?.captured(agent.session.id);
1116
- if (captured === void 0 || captured.size === 0) return folded;
1117
- const seen = new Set(folded.entries.map((entry) => entry.path));
1118
- const extra = [];
1119
- for (const [path, write] of captured) {
1120
- if (seen.has(path) || folded.entries.length + extra.length >= this.resolved.maxFiles) continue;
1121
- extra.push({
1122
- path,
1123
- op: "write",
1124
- seq: write.seq,
1125
- turn: write.turn,
1126
- step: write.step,
1127
- diffs: []
1128
- });
1160
+ let entries = folded.entries;
1161
+ if (captured !== void 0 && captured.size > 0) {
1162
+ const seen = new Set(entries.map((entry) => entry.path));
1163
+ const extra = [];
1164
+ for (const [path, write] of captured) {
1165
+ if (seen.has(path) || entries.length + extra.length >= this.resolved.maxFiles) continue;
1166
+ extra.push({
1167
+ path,
1168
+ op: "write",
1169
+ seq: write.seq,
1170
+ turn: write.turn,
1171
+ step: write.step,
1172
+ diffs: []
1173
+ });
1174
+ }
1175
+ entries = [...entries, ...extra];
1129
1176
  }
1130
- if (extra.length === 0) return folded;
1177
+ const existing = await this.retainExisting(agent, entries.map((entry) => entry.path));
1131
1178
  return {
1132
- entries: [...folded.entries, ...extra],
1179
+ entries: entries.filter((entry) => existing.has(entry.path)),
1133
1180
  asOfSeq: folded.asOfSeq,
1134
- truncated: folded.truncated || folded.entries.length + extra.length >= this.resolved.maxFiles
1181
+ truncated: folded.truncated || entries.length >= this.resolved.maxFiles
1135
1182
  };
1136
1183
  }
1137
1184
  /**
@@ -1142,13 +1189,16 @@ let FilePreviewService = (() => {
1142
1189
  * result diff meta) and merging the collector's bash-written captures by
1143
1190
  * their own turn. The fold is cached per session and invalidated by the log
1144
1191
  * watermark, so repeated card fetches do not refold. A path touched in two
1145
- * turns appears in BOTH turn groups — per-turn attribution is exact.
1192
+ * turns appears in BOTH turn groups — per-turn attribution is exact. Only
1193
+ * paths that currently exist on disk are returned (a deleted temp script is
1194
+ * history, not a product); existence is probed fresh on every call, so the
1195
+ * cached fold is filtered per request rather than cached.
1146
1196
  * @param agent - owning live agent; its session log is the data source.
1147
- * @returns per-turn file groups plus the scanned watermark.
1197
+ * @returns existing per-turn file groups plus the scanned watermark.
1148
1198
  */
1149
- turnFiles(agent) {
1199
+ async turnFiles(agent) {
1150
1200
  const session = agent.session;
1151
- const events = session.events;
1201
+ const events = session.snapshotEvents();
1152
1202
  const asOfSeq = events.length === 0 ? -1 : events[events.length - 1].seq;
1153
1203
  const cached = this.turnCache.get(session.id);
1154
1204
  let byTurn;
@@ -1181,8 +1231,12 @@ let FilePreviewService = (() => {
1181
1231
  files: files.sort((a, b) => a.seq - b.seq)
1182
1232
  });
1183
1233
  }
1234
+ const existing = await this.retainExisting(agent, turns.flatMap((group) => group.files.map((file) => file.path)));
1184
1235
  return {
1185
- turns,
1236
+ turns: turns.map((group) => ({
1237
+ turn: group.turn,
1238
+ files: group.files.filter((file) => existing.has(file.path))
1239
+ })).filter((group) => group.files.length > 0),
1186
1240
  asOfSeq
1187
1241
  };
1188
1242
  }