dsh-workflow-map 0.3.2 → 0.3.5

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.
@@ -0,0 +1,320 @@
1
+ # 宿主兼容性核验(DSH `0.2.1-rc.1` – `0.2.1-rc.5`)
2
+
3
+ 本文件回答一个问题:本插件声明的宿主范围,在 `0.2.1` 全系列(`rc.1` 至 `rc.5`)里是否真的有依据。
4
+
5
+ 结论以 `package.json` 的 `engines.dsh` 为唯一声明口径;本文件记录该口径的判据、核验方法与逐条证据,供使用者自行复核。
6
+
7
+ ---
8
+
9
+ ## 一、声明
10
+
11
+ ```json
12
+ "engines": {
13
+ "node": ">=20",
14
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
15
+ }
16
+ ```
17
+
18
+ 区间含义:
19
+
20
+ - 起点 `>=0.2.1-rc.1`:`0.2.1` 元组上的第一个预发布版本,带显式预发布标签。
21
+ - 上界 `<0.3.0-0`:排除 `0.3.0` 及其预发布(`0.3.0-0` 是 `0.3.0` 预发布的最小值),即不声称兼容下一 minor。
22
+
23
+ 本插件不使用 `dsh.host` 之类的自定义元数据字段:宿主包由 `peerDependencies` 声明(均为可选 peer),版本闸门由 `engines.dsh` 单独决定。
24
+
25
+ ---
26
+
27
+ ## 二、`engines.dsh` 由谁判定:两种判据
28
+
29
+ 同一个区间字符串会被两套判据读取,结论不完全相同,声明必须同时满足两者。
30
+
31
+ ### 2.1 DSH 自身的兼容性判定
32
+
33
+ `@deepseek-ai/dsh-app-boot` 的 `evaluatePluginCompatibility()` 是安装与启用期的判定入口:
34
+
35
+ ```js
36
+ // @deepseek-ai/dsh-app-boot lib/index.js:252-284(节选,省略参数校验与回落分支的具体实现)
37
+ function satisfies(runtimeVersion, range) {
38
+ return range.trim() !== "" && semver.satisfies(runtimeVersion, range, { includePrerelease: true });
39
+ }
40
+ function unmetRequirements(fields, runtimeVersion) {
41
+ const engines = Object.hasOwn(fields, "engines") ? objectOf$1(fields.engines, "Plugin manifest engines") : {};
42
+ if (Object.hasOwn(engines, "dsh")) {
43
+ const range = engines.dsh;
44
+ if (typeof range !== "string") throw new Error("Plugin manifest engines.dsh must be a string");
45
+ return satisfies(runtimeVersion, range) ? {} : { "engines.dsh": range };
46
+ }
47
+ // 没有 engines.dsh 时,回落到逐个 @deepseek-ai/dsh / @deepseek-ai/dsh-* peer 的 range 判定
48
+ }
49
+ ```
50
+
51
+ `@deepseek-ai/dsh-plugin-manager` 在三个位置调用它,未满足即抛 `incompatible-version`:
52
+
53
+ | 位置 | 场景 |
54
+ |---|---|
55
+ | `lib/types/index.js:368` | 列出 bundle 时逐个核对 |
56
+ | `lib/types/index.js:660` | 安装(`add`)前核对 |
57
+ | `lib/types/index.js:857` | 启用(`enable`)前核对 |
58
+
59
+ 同一包对外的 `requirements` 字段说明也把键名写死为 `engines.dsh`(`lib/typert.host.js:59`):
60
+
61
+ > "Only the requirements the running version does not satisfy, keyed `engines.dsh` or by DSH peer package name."
62
+
63
+ ### 2.2 npm / pnpm 自身的 engines 检查
64
+
65
+ npm 与 pnpm 用不带 `includePrerelease` 的默认 semver 语义检查 `engines`。
66
+
67
+ ### 2.3 两者对同一区间的判定(semver 7.8.5 实测)
68
+
69
+ | 运行时版本 | `>=0.2.1-rc.1 <0.3.0-0`(npm 默认) | 同区间(DSH 闸门,`includePrerelease`) | `>=0.1.0-rc.1 <0.3.0-0`(npm 默认) |
70
+ |---|---|---|---|
71
+ | `0.2.1-alpha.5` | 不通过 | 不通过 | 不通过 |
72
+ | `0.2.1-rc.1` | 通过 | 通过 | **不通过** |
73
+ | `0.2.1-rc.2` | 通过 | 通过 | **不通过** |
74
+ | `0.2.1-rc.3` | 通过 | 通过 | **不通过** |
75
+ | `0.2.1-rc.4` | 通过 | 通过 | **不通过** |
76
+ | `0.2.1-rc.5` | 通过 | 通过 | **不通过** |
77
+ | `0.2.1` | 通过 | 通过 | 通过 |
78
+ | `0.2.2-rc.1` | 不通过 | 通过 | 不通过 |
79
+ | `0.3.0-0` | 不通过 | 不通过 | 不通过 |
80
+ | `0.3.0` | 不通过 | 不通过 | 不通过 |
81
+
82
+ 由此得到两个写法上的硬要求:
83
+
84
+ 1. **起点必须落在 `0.2.1` 元组上。** 默认 semver 只允许「与区间内某个比较符同元组」的预发布版本参与匹配;写成 `>=0.1.0-rc.1`(元组 `0.1.0`)时,`0.2.1-rc.x` 全部被判为不匹配。所以在 npm/pnpm 的默认判据下,`>=0.1.0-rc.1 <0.3.0-0` 会静默排除全部 `0.2.1` 预发布版本。
85
+ 2. **上界取 `<0.3.0-0`。** 写成 `<0.3.0` 会在 DSH 闸门(`includePrerelease`)下把 `0.3.0-rc.x` 也算作通过,与「不声称兼容下一 minor」冲突。
86
+
87
+ DSH 闸门带 `includePrerelease`,因此 `0.2.2-rc.x` 这类后续 `0.2.x` 预发布也会通过。这是有意接受的结果:声明覆盖整个 `0.2.x` 预发布链,而不是逐版本列举。
88
+
89
+ ---
90
+
91
+ ## 三、逐版本核验表
92
+
93
+ | 版本 | 取样方式 | 结论 |
94
+ |---|---|---|
95
+ | `0.2.1-rc.1` | npm 闭包元数据(206 包)+ 9 个契约点的逐文件 SHA / 锚点窗口 | 契约面与 `rc.5` 一致;闭包比 `rc.3` 少 3 个包(见 §4.3) |
96
+ | `0.2.1-rc.2` | 同上 | 同上 |
97
+ | `0.2.1-rc.3` | 同上 + 核验机实机运行(插件加载、面板注入、视图路由) | 基线版本 |
98
+ | `0.2.1-rc.4` | npm 闭包元数据 + 5 个变动包逐文件 SHA + 契约锚点窗口 | 契约面无变化 |
99
+ | `0.2.1-rc.5` | npm 闭包元数据 + 逐文件比对 + **隔离 `DSH_HOME` 实机运行**(`--dump-config` 解析、web 宿主启动、`/dsh-workflow/*` 三种端点实测,见 §3.1) | 契约面无变化,且宿主半在 `rc.5` 上实机应答 |
100
+
101
+ `rc.4` 的结论来自与 `rc.3` 的逐文件比对,不是实机运行结论(见 §8)。`rc.5` 已补做隔离实机运行,记录见下节。
102
+
103
+ ### 3.1 `rc.5` 隔离实机运行记录(2026-10-30)
104
+
105
+ 核验机内核是 `rc.3`,因此不升级本机环境:在 `%TEMP%\wfmap-rc5-run\` 另起一套 `npm i @deepseek-ai/dsh@0.2.1-rc.5`,`DSH_HOME=%TEMP%\wfmap-rc5-run\home`(隔离),新 profile `wfmap5` 的 `dsh.profile.bundles` = `dsh-base` + `dsh-web-app` + `dsh-workflow-map`(后者以 `link:` 指向本仓库的工作副本,未改动源文件)。全程不写本机 `~/.dsh`、不动 `desktop` profile。
106
+
107
+ **A 级 —— `rc.5` 的 loader 解析本插件 manifest 与 patch:通过**
108
+
109
+ ```text
110
+ node <run>\node_modules\@deepseek-ai\dsh\lib\bin.js --profile wfmap5 --dump-config
111
+ # 退出码 0;stdout 1234 行;stderr 0 行
112
+ # 末尾三行即本插件层:
113
+ # # == dsh-workflow-map
114
+ # - id: workflow-map
115
+ # name: dsh-workflow-map
116
+ # stdout 全文无 unknown / invalid / unresolved / warn / error 类告警
117
+ ```
118
+
119
+ **B 级 —— `rc.5` 宿主上路由实机应答:通过**
120
+
121
+ 同一套 `rc.5` 起 web 宿主(`--profile wfmap5 --port 55999 --no-open --host 127.0.0.1`),逐端点实测(响应头均为 `content-type: application/json; charset=utf-8`,与 `src/route.js` 的契约一致):
122
+
123
+ | 请求 | 状态 | 响应体(原文) | 说明 |
124
+ |---|---|---|---|
125
+ | `GET /dsh-workflow/state?sessionId=none` | `404` | `{"ok":false,"error":"unknown session"}` | 前缀路由已挂上;`sessions` 服务在 `rc.5` 上可用(否则会是 503) |
126
+ | `GET /dsh-workflow/state`(无 `sessionId`) | `400` | `{"ok":false,"error":"sessionId is required"}` | 参数校验分支 |
127
+ | `POST /dsh-workflow/steer` | `404` | `{"ok":false,"error":"unknown session"}` | 热注入通道已挂上 |
128
+ | `POST /dsh-workflow/unsteer` | `404` | `{"ok":false,"error":"unknown session"}` | 撤回通道已挂上 |
129
+ | `GET /dsh-workflow/nope` | `404` | `{"ok":false,"error":"not found","path":"/dsh-workflow/state \| /dsh-workflow/steer \| /dsh-workflow/unsteer"}` | 前缀内部路由表,证明命中的是本插件的 handler,而非宿主兜底 404 |
130
+
131
+ 对照:同一宿主上 `GET /` 返回 `401 dsh web authentication required; reopen the URL printed by dsh web.`(`dsh-client-connection` 的页面鉴权闸门,`lib/index.js:576`),属宿主行为,不影响插件路由。
132
+
133
+ **两个会把人带偏的坑(实测)**
134
+
135
+ 1. **系统代理会把 `127.0.0.1` 也拦走。** 本机代理 `127.0.0.1:26561` 的 `ProxyOverride` 不含 `localhost`/`127.0.0.1`,于是 `Invoke-WebRequest http://127.0.0.1:55999/...` 全部返回 **404 且空体**——看着像「插件没挂」。改用裸 socket 或 `-NoProxy` 后立刻看到 `401`(页面)与本插件的 JSON(路由)。**结论:HTTP 404 不能单独作为「路由未注册」的证据。**
136
+ 2. **`--profile <name>` 后面不能再跟 `web`。** `dsh --profile wfmap5 web` 报 `too many arguments. Expected 0 arguments but got 1: web`(`web` 是 profile 名,不是子命令);正确写法是 `dsh --profile wfmap5 --port 55999 --no-open --host 127.0.0.1`。
137
+
138
+ **复跑命令**(升级到 `rc.5` 后任选一个临时目录执行,不动本机环境):
139
+
140
+ ```powershell
141
+ $run="$env:TEMP\wfmap-rc5-run"; $env:DSH_HOME="$run\home"
142
+ node "$run\node_modules\@deepseek-ai\dsh\lib\bin.js" --profile wfmap5 --port 55999 --no-open --host 127.0.0.1
143
+ # 另开窗口(-NoProxy 是关键,否则会被系统代理拦成假 404):
144
+ Invoke-WebRequest 'http://127.0.0.1:55999/dsh-workflow/state?sessionId=none' -SkipHttpErrorCheck -NoProxy
145
+ # 期望:404 + {"ok":false,"error":"unknown session"}
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 四、闭包级别比对
151
+
152
+ ### 4.1 方法
153
+
154
+ 以 `@deepseek-ai/dsh-base` 与 `@deepseek-ai/dsh-web-app` 的依赖并集为闭包,共 **206 个包名**。对每个包从 npm registry 取 `dist.unpackedSize` 与 `dist.fileCount`,逐版本比较;任一版本组合不同即进入人工取证。
155
+
156
+ 用 registry 元数据做初筛、再用逐文件 SHA-256 取证,是因为元数据能覆盖全部 206 个包,而逐文件下载只对筛出的少数包做。
157
+
158
+ ### 4.2 差异分布
159
+
160
+ 206 个包中有 **24 个**在 `rc.1`–`rc.5` 之间存在差异,全部集中在三段边界上:
161
+
162
+ | 变化边界 | 包数 | 包 |
163
+ |---|---|---|
164
+ | `rc.1 → rc.2` | 5 | `dsh-web`、`dsh-web-search-deepseek`、`dsh-web-deepseek-api`、`dsh-client-ui-deepseek-model-label-1017`、`dsh-client-ui-settings-web-search` |
165
+ | `rc.1 → rc.2` 且 `rc.2 → rc.3` | 1 | `dsh-agent-preset` |
166
+ | `rc.2 → rc.3` | 13 | `dsh-llm-deepseek-api-key`、`dsh-api-session-controller`、`dsh-api-remotes`、`dsh-client-ui-entry-customization`、`dsh-client-ui-plugin-manager`、`dsh-client-ui-settings-general`、`dsh-client-ui-agent-preset`、`dsh-client-ui-sidebar-right`、`dsh-client-ui-workspace`、`dsh-client-ui-sidebar`、`dsh-tool-cordis`、`dsh-web-frontend`、`dsh-client-product-analytics` |
167
+ | `rc.2 → rc.3` 且 `rc.3 → rc.4` | 2 | `dsh-llm-deepseek-account`、`dsh-client-ui-conversation` |
168
+ | `rc.3 → rc.4` | 1 | `dsh-mcp-resources` |
169
+ | `rc.4 → rc.5` | 1 | `dsh-client-ui-chat` |
170
+ | 三个边界都有 | 1 | `dsh-cordis-client-runner` |
171
+
172
+ 其中 `dsh-client-ui-sidebar` 与 `dsh-client-ui-conversation` 属于本插件的契约面,其锚点级比对见 §5。
173
+
174
+ ### 4.3 版本缺席
175
+
176
+ | 包 | 缺席版本 | 说明 |
177
+ |---|---|---|
178
+ | `dsh-web-deepseek-api` | `rc.1` | 首次发布于 `rc.2` |
179
+ | `dsh-client-ui-deepseek-model-label-1017` | `rc.1` | 首次发布于 `rc.2` |
180
+ | `dsh-client-ui-entry-customization` | `rc.1`、`rc.2` | 首次发布于 `rc.3` |
181
+ | `cordis-plugin-timer`、`schemastery` | 全部 | 第三方依赖,不随 DSH 的 `rc` 编号发布 |
182
+
183
+ `rc.1` 的 profile 因此比 `rc.3` 少 3 个客户端包。这 3 个包都与本插件的契约面无关(模型标签角标、入口定制、API 通道包),缺失不影响本插件加载。
184
+
185
+ ### 4.4 `rc.3 → rc.5` 之间真正改动的 5 个包
186
+
187
+ | 包 | `rc.3` | `rc.4` | `rc.5` | 改动文件 | 是否触及本插件契约 |
188
+ |---|---|---|---|---|---|
189
+ | `dsh-llm-deepseek-account` | 35661 B / 10 | 35735 B / 10 | 35735 B / 10 | `lib/index.js`、三个 README、`package.json` | 否:`lib/index.js` 只改 1 行,日志级别 `console.info` → `console.error` |
190
+ | `dsh-mcp-resources` | 30771 B / 11 | 30844 B / 11 | 30844 B / 11 | 三个 README、`package.json` | 否:无代码改动 |
191
+ | `dsh-client-ui-chat` | 857882 B / 106 | 同 `rc.3` | 859933 B / 107 | `lib/client.js`、3 个 `*.d.ts`、新增 `reasoning-text.d.ts`、README、`package.json` | 否:改动集中在推理正文渲染(新增 `conversation.chat.reasoning.content` 槽、`ReasoningBodyOwnerProps.text` 拆成 `rawText` + 显示文本) |
192
+ | `dsh-client-ui-conversation` | 1130507 B / 89 | 1131117 B / 89 | 同 `rc.4` | `lib/client.js`、`locales.d.ts`、README、`package.json` | 否:92 个 bundle 分区中仅 `locales.js` 与 `skeleton/ConversationSession.js` 变化 |
193
+ | `dsh-cordis-client-runner` | 483933 B / 20 | 同 `rc.3` | 484021 B / 20 | `lib/client.js`、`package.json` | 否:12 行变化全部是槽文档文本与 `sources` 源行号(`slots.ts:441` → `:447`) |
194
+
195
+ `dsh-client-ui-conversation` 的改动按 bundle 内 `//#region` 分区逐一比对:`lib/types/client/locales.js`(词条增删)与 `lib/types/client/skeleton/ConversationSession.js`(输入框手势与文件选择绑定重构)。本插件依赖的槽注册、DOM 属性都不在这两个分区内。
196
+
197
+ ---
198
+
199
+ ## 五、契约面逐项核对
200
+
201
+ 本插件只依赖九个契约点。下表每个点都给出提供方、用法与 `rc.1` ↔ `rc.5` 的比对证据。
202
+
203
+ | 契约点 | 提供方 | 本插件用法 | `rc.1` ↔ `rc.5` 证据 | 结论 |
204
+ |---|---|---|---|---|
205
+ | `sidebar.panellist` 槽 | `dsh-client-ui-sidebar` | 注册左栏面板项(`kind: 'list'`) | 槽声明在 `rc.1` 与 `rc.3` 逐字节相同:`kind: 'list'; scope: 'root'; owner: SidebarPanelIconOwnerProps` | 未变 |
206
+ | `main` 槽 | `dsh-client-ui-layout` | 注册主内容面板(`kind: 'keyed'`) | 整包除 `package.json` 外逐文件 SHA-256 相同(`lib/client.js` = `686fb83c91c8ff8d…`) | 未变 |
207
+ | `conversation.view` 槽 | `dsh-client-ui-conversation` | 注册会话标签栏视图 | 4 处锚点窗口(`DefaultConversationViews` 的 `renderSlot`、`apply.js` 的 `entries` / `subscribe` / `children`)在 `rc.1`/`rc.3`/`rc.4`/`rc.5` 逐字节相同 | 未变 |
208
+ | 输入框座位 DOM | `dsh-client-ui-conversation` | 选中本插件视图时隐藏输入框 | `data-composer-seat`(1 处)、`data-conversation-content`(1 处)、`data-conversation-scroll`(3 处)的上下文窗口逐字节相同 | 未变 |
209
+ | `sessionProjections` 服务 | `dsh-session-projection` | 注册会话投影 | `lib/index.js` SHA-256 `7dc2b212819baf4f…` 相同 | 未变 |
210
+ | `sessions` 服务 | `dsh-session` | 按会话号取会话 | `lib/index.js` SHA-256 `2f5dd07cb5c744a2…` 相同 | 未变 |
211
+ | `webServer` 服务 | `dsh-host-webserver` | 注册面板数据路由 | `lib/index.js` SHA-256 `08907285802f0edd…` 相同 | 未变 |
212
+ | `agents` 服务 | `dsh-agent-loop` | 读运行中的 agent 状态 | `lib/index.js` SHA-256 `cf57e3ecd487ac5c…` 相同 | 未变 |
213
+ | 会话事件 / JSONL 格式 | `dsh-session-persistence-jsonl` | 折叠会话事件为步骤序列 | `lib/worker.cjs` SHA-256 `4b0f235e772792d7…` 相同 | 未变 |
214
+
215
+ 输入框座位的实测嵌套(`dsh-client-ui-conversation` `rc.5` 的 `lib/client.js`):
216
+
217
+ ```text
218
+ div[data-conversation-content]
219
+ └─ div[data-conversation-scroll]
220
+ ├─ Views ← 选中的 conversation.view 占据位
221
+ └─ div[data-composer-seat]
222
+ ```
223
+
224
+ 本插件依赖的 DOM 只有这一条父子链与两个 `data-*` 属性,不依赖 class 名或内部组件。
225
+
226
+ §4.2 表里其余 22 个变动包与本插件的九个契约点没有交集:不注册 `conversation.chat.*` 系列槽、不读设置页组件、不调用搜索工具、不依赖 API 网关包。
227
+
228
+ ---
229
+
230
+ ## 六、安装闸门生效性
231
+
232
+ 结论:`engines.dsh` 不是装饰性字段,安装与启用期都会被读取。
233
+
234
+ | 环节 | 证据 |
235
+ |---|---|
236
+ | 判定实现 | `dsh-app-boot` 的 `evaluatePluginCompatibility()` → `unmetRequirements()`(§2.1 引文) |
237
+ | 判定实现版本一致 | `dsh-app-boot` `rc.1` 与 `rc.5` 除 `package.json` 外逐文件 SHA-256 相同(`lib/index.js` = `5135179ac810e4f6…`) |
238
+ | 安装期调用 | `dsh-plugin-manager` 三处调用(§2.1 表),未满足抛 `incompatible-version`,并在 `requirements` 里给出 `engines.dsh` 键 |
239
+ | 调用方版本一致 | `dsh-plugin-manager` `rc.3` 与 `rc.5` 除 `package.json` 外逐文件 SHA-256 相同 |
240
+
241
+ 由此可推出两条可验证的后果:
242
+
243
+ 1. 在 `0.2.1-rc.1`–`0.2.1-rc.5` 任一内核上安装或启用,本插件的 `engines.dsh` 判定通过,不会有 `incompatible-version`。
244
+ 2. 在 `0.3.0` 及以后的内核上,`engines.dsh` 判定不通过(§2.3),安装或启用会被上述任一调用点拦下并给出明确的 `engines.dsh: ">=0.2.1-rc.1 <0.3.0-0"` 说明。这属于预期行为:不兼容时应显式失败,而不是勉强加载。
245
+
246
+ ---
247
+
248
+ ## 七、可复跑命令
249
+ 以下命令只需 Node.js 20 以上。前两条只需 npm registry 访问;第三条需要环境里能解析到 `semver`(`npm i semver`,或使用 DSH 安装目录自带的副本)。
250
+
251
+ **1) 闭包元数据对比(206 包 × 5 个版本)**
252
+
253
+ ```bash
254
+ node -e '
255
+ const { readFileSync } = require("node:fs");
256
+ const names = readFileSync("closure-names.txt", "utf8").split(/\r?\n/).filter(Boolean);
257
+ const V = ["0.2.1-rc.1","0.2.1-rc.2","0.2.1-rc.3","0.2.1-rc.4","0.2.1-rc.5"];
258
+ let i = 0; const rows = [];
259
+ (async () => {
260
+ await Promise.all(Array.from({ length: 8 }, async () => {
261
+ while (i < names.length) {
262
+ const name = names[i++];
263
+ const p = await (await fetch("https://registry.npmjs.org/" + name.replace("/", "%2f"))).json();
264
+ const pick = (v) => { const d = p.versions?.[v]?.dist; return d ? [d.unpackedSize, d.fileCount] : null; };
265
+ const row = V.map((v) => JSON.stringify(pick(v)));
266
+ if (new Set(row).size > 1) rows.push({ name, row });
267
+ }
268
+ }));
269
+ console.log(rows.length, JSON.stringify(rows, null, 1));
270
+ })();
271
+ '
272
+ ```
273
+
274
+ `closure-names.txt` 是 `dsh-base` 与 `dsh-web-app` 依赖并集的包名清单(每行一个)。换用别的清单会得到别的闭包,比较结果随之变化。
275
+
276
+ **2) 单包逐文件 SHA-256 比对**
277
+
278
+ ```bash
279
+ # 取两个版本的 tarball,解包后逐文件比对
280
+ npm pack @deepseek-ai/dsh-session-projection@0.2.1-rc.1
281
+ npm pack @deepseek-ai/dsh-session-projection@0.2.1-rc.5
282
+ tar -xzf deepseek-ai-dsh-session-projection-0.2.1-rc.1.tgz -C rc1
283
+ tar -xzf deepseek-ai-dsh-session-projection-0.2.1-rc.5.tgz -C rc5
284
+ diff -r rc1/package rc5/package # 期望:仅 package.json 不同
285
+ ```
286
+
287
+ **3) 区间判定矩阵**
288
+
289
+ ```bash
290
+ node -e '
291
+ const semver = require("semver");
292
+ const ranges = [">=0.2.1-rc.1 <0.3.0-0", ">=0.1.0-rc.1 <0.3.0-0"];
293
+ const versions = ["0.2.1-alpha.5","0.2.1-rc.1","0.2.1-rc.5","0.2.1","0.2.2-rc.1","0.3.0-0","0.3.0"];
294
+ for (const r of ranges) console.log(r, versions.map((v) => [v, semver.satisfies(v, r), semver.satisfies(v, r, { includePrerelease: true })]));
295
+ '
296
+ ```
297
+
298
+ ---
299
+
300
+ ## 八、限制声明
301
+
302
+ - **动态核验范围**:截至 2026-10-30,`0.2.1-rc.3` 是本机实机运行版本;`0.2.1-rc.5` 已在隔离 `DSH_HOME` 的临时区实机启动并实测路由应答(§3.1),但不覆盖「真实会话内的投影内容」——`rc.5` 上没有跑过带会话的完整回合,`/state` 只验到「按契约返回 `unknown session`」。`rc.4` 仍只做了静态比对(闭包元数据、逐文件 SHA-256、契约锚点窗口),未实机启动。
303
+ - **比对对象**:只比对 npm 上已发布的产物,不覆盖未发布的仓库提交。
304
+ - **闭包口径**:206 个包名按 `dsh-base` 与 `dsh-web-app` 的依赖并集确定;更换清单会改变闭包边界。
305
+ - **第三方依赖**:闭包内含 `cordis`、`schemastery`、`zod` 等第三方包,它们不随 DSH 的 `rc` 编号发布,本文件不对其版本承诺做任何声明。
306
+ - **上界外版本**:`0.3.0` 及以后未做任何核验,声明上也不覆盖。
307
+ - **判定语义**:§2.3 的矩阵来自 semver 7.8.5;换用其他实现时 `includePrerelease` 的细节可能不同。
308
+
309
+ ---
310
+
311
+ ## 九、复核记录
312
+
313
+ | 项 | 取值 |
314
+ |---|---|
315
+ | 复核日期 | 2026-10-30 |
316
+ | 数据来源 | `registry.npmjs.org` 发布产物 |
317
+ | semver 实现 | 7.8.5(`satisfies`,分别带与不带 `includePrerelease`) |
318
+ | 闭包规模 | 206 个包名,24 个存在版本间差异 |
319
+ | 逐文件比对包 | `dsh-app-boot`、`dsh-session`、`dsh-session-projection`、`dsh-host-webserver`、`dsh-agent-loop`、`dsh-session-persistence-jsonl`、`dsh-plugin-manager`、`dsh-client-ui-layout`、`dsh-client-ui-sidebar`、`dsh-client-ui-conversation`、`dsh-client-ui-chat`、`dsh-cordis-client-runner`、`dsh-llm-deepseek-account`、`dsh-mcp-resources` |
320
+ | 实机运行内核 | `0.2.1-rc.3`(本机内核);`0.2.1-rc.5`(隔离 `DSH_HOME` 临时区,仅路由与 loader 层) |
Binary file
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "dsh-workflow-map",
3
- "version": "0.3.2",
4
- "description": "工作流地图插件(DeepSeek Harness bundle):在会话标签栏、左栏菜单与主内容区以同一份面板展示当前会话的分步总览、每步的工具/文件类型明细、唯一彩色流光进度条,并支持热注入(steer)与「需确认」标注。宿主半折叠会话事件并读取 PAE 计划,经本地 HTTP 视图供数据;浏览器半渲染面板。",
3
+ "version": "0.3.5",
4
+ "description": "把当前会话渲染成一张可交互的分步工作流地图:会话标签栏、左栏菜单与主内容区共用同一份面板,逐步显示状态、工具与文件类型明细、进度条,并支持热注入(steer)与「需确认」标注。宿主半折叠会话事件、读取按步执行(PAE)计划,经本地 HTTP 视图供数据;浏览器半渲染面板。",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "deepseek-harness",
10
+ "deepseek-harness-plugin",
10
11
  "dsh",
12
+ "dsh-0.2.1",
11
13
  "cordis",
12
14
  "plugin",
13
15
  "bundle",
@@ -24,7 +26,20 @@
24
26
  "url": "https://github.com/Joz739/dsh-workflow-map/issues"
25
27
  },
26
28
  "engines": {
27
- "node": ">=20"
29
+ "node": ">=20",
30
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
31
+ },
32
+ "peerDependencies": {
33
+ "@deepseek-ai/dsh-agent-loop": ">=0.2.1-rc.1 <0.3.0-0",
34
+ "@deepseek-ai/dsh-llm": ">=0.2.1-rc.1 <0.3.0-0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@deepseek-ai/dsh-agent-loop": {
38
+ "optional": true
39
+ },
40
+ "@deepseek-ai/dsh-llm": {
41
+ "optional": true
42
+ }
28
43
  },
29
44
  "exports": {
30
45
  ".": "./index.js",
@@ -45,8 +60,10 @@
45
60
  "CHANGELOG.md",
46
61
  "LICENSE",
47
62
  "docs/OPEN-SOURCE-GUIDE.md",
63
+ "docs/host-compatibility.md",
48
64
  "docs/screenshot.png",
49
65
  "docs/screenshot-detail.png",
66
+ "docs/screenshots",
50
67
  "package.json"
51
68
  ],
52
69
  "scripts": {
@@ -24,7 +24,7 @@
24
24
  * "0 seconds ago" would be a fabricated number, which is exactly what this plugin
25
25
  * refuses to print.
26
26
  *
27
- * (2026-10-26) · the projected REMAINING half is GONE by user request. It used to be shipped
27
+ * The projected REMAINING half is no longer part of this rail. It used to be shipped
28
28
  * by the host as a derived millisecond field (median of the finished steps, hard-bounded) and
29
29
  * rendered as "remaining ~N minutes"; the host no longer computes it and this rail no longer renders it.
30
30
  * One line, one fact, no consumer-less field — and a payload that still carries that field (an
@@ -155,8 +155,11 @@ function wfHasKey(object, key) {
155
155
  */
156
156
  function wfDeriveStepStatus(row, plan, todoIndex, currentPlanIndex) {
157
157
  var planIndex = row && wfFinite(row.planIndex) !== null ? Math.round(row.planIndex) : null;
158
+ // A finished plan (completed / aborted) is history: its statuses and anchor
159
+ // cursor describe a turn that is over, so the todo table and the host decide.
160
+ var live = plan && plan.terminal !== true;
158
161
 
159
- if (plan && planIndex !== null) {
162
+ if (live && planIndex !== null) {
160
163
  // 1. PAE's own statuses are the published truth.
161
164
  var label = wfHasKey(plan.statuses, String(planIndex)) ? plan.statuses[String(planIndex)] : null;
162
165
  var fromPae = wfCanonicalStatus(label);
@@ -34,6 +34,9 @@ var DICT_ZH = {
34
34
  "panel.planning.note": "计划尚未提交,提交后会显示分步。",
35
35
  "panel.collapse": "收起",
36
36
  "panel.expand": "展开",
37
+ "panel.refresh": "刷新",
38
+ "panel.refresh.hint": "立即重新读取这一步的状态(自动刷新每 0.8 秒一次,这里是不等它的手动兜底)",
39
+ "panel.refresh.done": "已刷新",
37
40
  "panel.settings": "设置",
38
41
  "panel.steps": "总任务分步",
39
42
  "panel.flow": "分步流程",
@@ -244,6 +247,9 @@ var DICT_EN = {
244
247
  "panel.planning.note": "The plan has not been submitted yet; steps appear after it is.",
245
248
  "panel.collapse": "Collapse",
246
249
  "panel.expand": "Expand",
250
+ "panel.refresh": "Refresh",
251
+ "panel.refresh.hint": "Read this step's state again now (auto-refresh runs every 0.8 s; this is the manual fallback)",
252
+ "panel.refresh.done": "Refreshed",
247
253
  "panel.settings": "Settings",
248
254
  "panel.steps": "Task steps",
249
255
  "panel.flow": "Step flow",
@@ -523,6 +529,10 @@ function wfUseSnap(sessionId) {
523
529
  });
524
530
  var snap = pair[0];
525
531
  var setSnap = pair[1];
532
+ // The running loop's own tick, so the header's refresh button can pull once on
533
+ // demand without waiting for the interval. A ref, not state: swapping the function
534
+ // must not re-render, and a click must never restart the effect.
535
+ var tickRef = React.useRef(null);
526
536
 
527
537
  React.useEffect(
528
538
  function () {
@@ -535,6 +545,9 @@ function wfUseSnap(sessionId) {
535
545
  var timer = 0;
536
546
  var controller = null;
537
547
  var etag = "";
548
+ // How many requests are in the air. A replaced request resolves as -1 and must
549
+ // not re-arm while the newer one is still going, or two loops would run.
550
+ var inflight = 0;
538
551
 
539
552
  setSnap({ status: "loading", view: null, rev: null, error: "" });
540
553
 
@@ -559,11 +572,20 @@ function wfUseSnap(sessionId) {
559
572
  }
560
573
  controller = typeof AbortController === "function" ? new AbortController() : null;
561
574
  var signal = controller ? controller.signal : undefined;
575
+ inflight += 1;
562
576
  wfPollOnce(window.fetch, wfPollUrl(sessionId), etag, signal).then(function (result) {
577
+ inflight -= 1;
563
578
  if (stopped) return;
564
- // -1 = this request was aborted by a newer one (visibility change): the
565
- // newer tick already owns the loop, so stay silent and do not re-arm.
566
- if (result.status === -1) return;
579
+ // -1 = this request was aborted. A tick aborts the request it replaces, so this
580
+ // is the ORDINARY path on a slow link, not an exception: returning here without
581
+ // re-arming stopped the loop for good the first time a poll outlived its tick —
582
+ // the map then froze while the session kept working, which is the reported
583
+ // "the map does not update by itself". Only the deliberate stop (stopped) ends it.
584
+ if (result.status === -1) {
585
+ // A replaced request: re-arm only when no replacement is still in the air.
586
+ if (wfPollContinues(result, inflight)) schedule(delay);
587
+ return;
588
+ }
567
589
  if (result.status === 304) {
568
590
  setSnap(function (prev) {
569
591
  return prev.view ? { status: "live", view: prev.view, rev: prev.rev, error: "" } : prev;
@@ -596,10 +618,12 @@ function wfUseSnap(sessionId) {
596
618
  tick();
597
619
  }
598
620
 
621
+ tickRef.current = tick;
599
622
  tick();
600
623
  document.addEventListener("visibilitychange", onVisibilityChange);
601
624
  return function () {
602
625
  stopped = true;
626
+ tickRef.current = null;
603
627
  window.clearTimeout(timer);
604
628
  if (controller && typeof controller.abort === "function") {
605
629
  try {
@@ -614,7 +638,18 @@ function wfUseSnap(sessionId) {
614
638
  [sessionId]
615
639
  );
616
640
 
617
- return snap;
641
+ // One object, not two returns: every caller already reads the snapshot field by field,
642
+ // so the refresh hook rides along without changing the shape they destructure.
643
+ return {
644
+ status: snap.status,
645
+ view: snap.view,
646
+ rev: snap.rev,
647
+ error: snap.error,
648
+ refresh: function () {
649
+ var pull = tickRef.current;
650
+ if (typeof pull === "function") pull();
651
+ },
652
+ };
618
653
  }
619
654
 
620
655
  /** The stylesheet lives exactly as long as the mount. */
@@ -634,6 +669,25 @@ function wfUseStyle() {
634
669
  }, []);
635
670
  }
636
671
 
672
+ /**
673
+ * Clear a "just happened" stamp after a moment, so the button returns to its own
674
+ * glyph. One timer, cleared on unmount and on a second click (the stamp changes).
675
+ */
676
+ function wfUseFlashClear(stamp, clear) {
677
+ React.useEffect(
678
+ function () {
679
+ if (!(stamp > 0)) return undefined;
680
+ var timer = window.setTimeout(function () {
681
+ clear();
682
+ }, 1200);
683
+ return function () {
684
+ window.clearTimeout(timer);
685
+ };
686
+ },
687
+ [stamp]
688
+ );
689
+ }
690
+
637
691
  /** Alt+W toggles the collapsed state; the listener is removed on unmount. */
638
692
  function wfUseAltW(updatePrefs) {
639
693
  React.useEffect(function () {
@@ -1026,6 +1080,15 @@ function WorkflowMapPanel(props) {
1026
1080
  var prefs = prefsHandle.prefs;
1027
1081
  var updatePrefs = prefsHandle.update;
1028
1082
  var snap = wfUseSnap(sessionId);
1083
+ // When the header's refresh was last clicked; drives the one-word acknowledgement
1084
+ // that clears itself. Deliberately NOT the poll's own state: a click must not look
1085
+ // like a data change.
1086
+ var refreshedPair = React.useState(0);
1087
+ var refreshed = refreshedPair[0];
1088
+ var setRefreshed = refreshedPair[1];
1089
+ wfUseFlashClear(refreshed, function () {
1090
+ setRefreshed(0);
1091
+ });
1029
1092
  var settingsPair = React.useState(false);
1030
1093
  var settingsOpen = settingsPair[0];
1031
1094
  var setSettingsOpen = settingsPair[1];
@@ -1124,6 +1187,13 @@ function WorkflowMapPanel(props) {
1124
1187
  onToggleSettings: function () {
1125
1188
  setSettingsOpen(!settingsOpen);
1126
1189
  },
1190
+ // The manual fallback: one pull, now, without waiting for the interval and without
1191
+ // touching the loop. It reads the same endpoint with the same validator, so a click
1192
+ // is a normal poll and never a second data path.
1193
+ onRefresh: function () {
1194
+ setRefreshed(Date.now());
1195
+ if (typeof snap.refresh === "function") snap.refresh();
1196
+ },
1127
1197
  // Clicking a main-flow unit: it becomes the SELECTED step (store.js builds its detail) and the
1128
1198
  // right page opens on it. It deliberately does not expand anything in place.
1129
1199
  // right page opens on it. It deliberately does not expand anything in place.
@@ -1202,6 +1272,7 @@ function WorkflowMapPanel(props) {
1202
1272
 
1203
1273
  return wfRenderPanel(model, prefs, say, actions, {
1204
1274
  settingsOpen: settingsOpen,
1275
+ refreshed: refreshed,
1205
1276
  status: snap.status,
1206
1277
  error: snap.error,
1207
1278
  rev: snap.rev,
@@ -1242,6 +1313,9 @@ function wfRenderPanel(model, prefs, say, actions, ui) {
1242
1313
  var pendingCount = wfPendingInjectCount(model.injects);
1243
1314
  var extras = ui || {};
1244
1315
  var fire = actions || {};
1316
+ // The refresh button's one-second acknowledgement, handed in by the component: the
1317
+ // renderer stays pure, so a self-test can drive it with a fixed stamp.
1318
+ var refreshed = wfFinite(extras.refreshed) === null ? 0 : extras.refreshed;
1245
1319
  // the two attributes the living layer needs. `motion` comes from the system's
1246
1320
  // prefers-reduced-motion preference (pulse.js), `idle` from the model's running flag.
1247
1321
  // Both live on .wf-root so the capsule is covered as well as the panel.
@@ -1341,6 +1415,24 @@ function wfRenderPanel(model, prefs, say, actions, ui) {
1341
1415
  h(
1342
1416
  "div",
1343
1417
  { className: "wf-btns", key: "btns" },
1418
+ // First in the row: it is the escape hatch when the automatic poll is not
1419
+ // delivering, so it must not be the hard one to find.
1420
+ h(
1421
+ "button",
1422
+ {
1423
+ type: "button",
1424
+ key: "refresh",
1425
+ className: refreshed > 0 ? "wf-btn wf-btn-ok" : "wf-btn",
1426
+ "data-dsh-workflow-map": "refresh",
1427
+ "data-refreshed": refreshed > 0 ? "true" : undefined,
1428
+ title: say("panel.refresh.hint"),
1429
+ "aria-label": say("panel.refresh"),
1430
+ onClick: function () {
1431
+ wfFire(fire.onRefresh);
1432
+ },
1433
+ },
1434
+ refreshed > 0 ? say("panel.refresh.done") : "↻"
1435
+ ),
1344
1436
  h(
1345
1437
  "button",
1346
1438
  {
@@ -1811,6 +1903,7 @@ var __test = {
1811
1903
  sessionIdFromSnapshot: wfSessionIdFromSnapshot,
1812
1904
  pollUrl: wfPollUrl,
1813
1905
  pollOnce: wfPollOnce,
1906
+ pollContinues: wfPollContinues,
1814
1907
  parsePrefs: wfParsePrefs,
1815
1908
  sanitizePrefs: wfSanitizePrefs,
1816
1909
  applyPrefsPatch: wfApplyPrefsPatch,
@@ -53,7 +53,17 @@ function wfMotionMode(matchMediaFn) {
53
53
  * working session as idle (which would make the curve and the frame look broken).
54
54
  */
55
55
  function wfSessionRunning(view, activeNodeId, nowMs) {
56
- if (view && typeof view.running === "boolean") return view.running;
56
+ // The clock is the second witness. view.running is the turn flag, and a turn that never
57
+ // received its turn/end event (interrupt, crash, a compacted session) leaves it true forever.
58
+ // Once the last folded event is older than the idle window the session is not working,
59
+ // whatever the flag says.
60
+ if (view && typeof view.running === "boolean") {
61
+ if (view.running !== true) return false;
62
+ var flagAsOf = wfFinite(view.asOf);
63
+ var flagNow = wfFinite(nowMs);
64
+ if (flagAsOf !== null && flagAsOf > 0 && flagNow !== null && flagNow - flagAsOf >= WF_IDLE_AFTER_MS) return false;
65
+ return true;
66
+ }
57
67
  if (typeof activeNodeId === "string" && activeNodeId !== "") return true;
58
68
  var asOf = wfFinite(view && view.asOf);
59
69
  if (asOf === null || asOf <= 0) return false;