dsh-composer-ux 0.6.2 → 0.10.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
@@ -2,6 +2,495 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.10.0] — 2026-09-29(节假日 / 峰谷提醒 / 余额 / 官方价同步 / 非 DeepSeek 定价 / 价格历史档)
6
+
7
+ > 起因:用户要求"联网搜一下最新峰谷价格,解析 `dsh-context` 与 `dsh-cost-meter` 这两个插件
8
+ > 怎么算钱,与我们的对比,并补上我们没有的功能"。一问一答定下范围:**A 节假日、B 峰谷提醒、
9
+ > C 余额、D 官方价同步、E 非 DeepSeek 定价**;随后他又选了 **F 价格历史档**("旧账要按当时的价
10
+ > 固定",最大那一档)。顺手修掉三处**会算错钱**的既有错误。
11
+
12
+ ### 修正(先看这五条)
13
+
14
+ - **浮层里那行"未定价"说明以前写着 markdown 的 `**未定价**`**(第 7 轮的真渲染测试抓到):`note()`
15
+ 是纯文本渲染,用户会在明细浮层里**看到字面的星号**。已改成「未定价」。同一处还有一条**旧断言
16
+ 把 bug 焊死**(`test/client-registration.mjs` 里断言的正是 `未定价**:那是非 DeepSeek 模型`),
17
+ 一并改成断言修好后的文本 + 禁止 markdown 记号。
18
+ - **`deepseek-reasoner` 一度被映到 Pro(第 5 轮核查时发现并修掉)**。官方更新日志(2026-04-24,中英
19
+ 两版都写了):`deepseek-chat` 与 `deepseek-reasoner` **都指向 `deepseek-v4-flash`** —— 前者是
20
+ 非思考模式、后者是思考模式,与 `deepseek-v4-pro` 没有对应关系(这两个名字已于 2026-07-24 停用)。
21
+ 0.10.0 第一版把 reasoner 映到了 Pro ⇒ history 里叫 `deepseek-reasoner` 的用量会**按 Pro 价算**
22
+ (flash 的 3 倍)。现在两个名字都映到 `deepseek-flash`,并有按官方规则写的行为断言
23
+ (`test/pricing.mjs`)+ 变异条目 `CB`。
24
+ - **flash 的人民币列不再由美元折算**。旧表 `2.05 / 8.18 / 1.02 / 4.09 / 0.041 / 0.020` 是拿官方
25
+ 美元列 ×6.82 凑出来的,而官方人民币页直接给 `2 / 8 / 1 / 4 / 0.04 / 0.02` —— **高报约 2.3%**。
26
+ 现在两列都是官方原值(Flash 两列之比约 6.667、Pro 约 6.82,本来就不是同一个汇率)。
27
+ - **`deepseek-v4-flash` 不再单列一行**。官方脚注明说旧名仍可调用、但由 V4.1-Flash 服务
28
+ **并按 Flash 价计费**,而它还占着一行 8 月旧价(谷 `1.5 / 0.05 / 4.5`)→ **未命中高报 50%**。
29
+ 现在它是别名(含 `deepseek-v4-flash-vision-exp` / `deepseek-v4.1-flash` / `deepseek-chat`),
30
+ 内置价表只剩**官方现役的两个模型**。
31
+ - **`cacheWriteTokens` 不单独计价(核对,不是改动)**:官方价表只有"缓存命中/未命中"两行输入价,
32
+ 而实测本机投影缓存里 200 处样本全是 0,所以它只进显示口径。这条写进了 `pricing.ts` 的注释,
33
+ 免得下次有人"顺手补上"。
34
+
35
+ ### 新增
36
+
37
+ - **价格历史档(`PRICE_ERAS`)**:官方 2026-09-10 12:00(北京)调过 Flash 的价
38
+ (未缓存输入 0.22→0.15、输出 0.66→0.60、命中 0.007→0.003,美元/1M),历史金额不该跟着现在变。
39
+ 于是价目表按**生效时刻**分档(`legacy` 峰谷制之前 / `peak-2026-08` / `flash-2026-09-10`),
40
+ 折叠时给每条用量打上那一刻的档位 id,route key 因此多一维 —— 同一模型在调价前后是**两条**
41
+ 分列,各按当时的价结算,永不互相污染。`test/usage-fold.mjs` 第 10 节与 `test/pricing.mjs`
42
+ 第 10 节各钉一遍。
43
+ - **中国法定节假日全天谷价**(`DEFAULT_PEAK_HOLIDAYS`):2026 中秋 09-25~27、国庆 10-01~07
44
+ (国务院办公厅 2026 年安排)。设置页「金额」里有一个文本框可整份覆盖(一行一个 `YYYY-MM-DD`,
45
+ 坏行逐条点名报错,不静默丢)。另外补上**周末全谷价的生效点** `2026-08-22T16:00Z`
46
+ —— 在那之前的周六(2026-08-22)**是有高峰的**,历史账要按那时的规则算。
47
+ - **峰谷提醒**(`src/client/peak-alert.ts`):明细页常显"现在哪一档、还有多久切换"
48
+ (`空闲档 · 2 小时 15 分钟后转高峰`,节假日/周末会写明原因),胶囊的 `title` 也带上;
49
+ 可选浏览器系统通知(需授权),去重放**模块级**按切换点记 —— 放组件里会因重挂而连发。
50
+ - **账号余额**(`src/balance.ts` + 宿主路由):查官方 `GET /user/balance`,
51
+ **API Key 只在宿主半**从 `llm-deepseek` 的 `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`)经
52
+ `credentials.resolve()` 取;**端点白名单只放行 `api.deepseek.com`** —— 你的 baseURL 被指向
53
+ 第三方时**一个请求都不发**,绝不把 Key 交出去。余额用分项相加(不用平台自己的 `total_balance`),
54
+ 拿不准就不显示(不显示 0、也不显示旧值)。
55
+ - **一键同步价目**(`src/price-sync.ts` + `src/official-pricing.ts`):
56
+ 「同步官方价」抓官方中英文两页(各约 24 KB)解析成一个新档,**有变化才新增档**
57
+ (原地改档 = 把历史账重算);「同步第三方价目」抓 models.dev(5.2 MB → 压缩后约 450 KB,
58
+ 落 `$DSH_HOME/storages/composer-ux/prices.json`)。两条都守同一条纪律:
59
+ **抓失败绝不覆盖本地价**(`test/price-sync.mjs` 用抛错 / 非 2xx / 正文过短 / 只有一页成功
60
+ 四种情形钉住)。
61
+ - **内置第三方价目快照**(`src/provider-prices.ts`,由 `scripts/gen-provider-prices.mjs` 从 models.dev 生成):11 个 provider / 346 个模型,快照日期写在文件头。**没点过同步时**它就是非 DeepSeek 模型的兜底;点过一次「同步第三方价目」后,那份最新数据会盖住同名条目(界面会分别标明"内置快照价"还是已同步)。快照里**故意不收 `deepseek`** —— models.dev 那边只有平坦谷价,留着迟早被误用成 DeepSeek 单价、把峰价静默算成谷价。
62
+ - **自动同步官方价(可选,默认关)**:设置页「金额」里一个复选框。打开后宿主半每天最多自动抓一次官方价格页(进程启动先查一次,之后每 30 分钟查一次"距上次成功同步是否够 24 小时"),失败只写日志、界面仍显示上次成功的时间。"该不该出网"由纯函数 `autoSyncDue` 判定(单测 + 变异各钉一条)—— 会自动出网的开关不该默认开,更不该看漏用户的开关。
63
+ - **联网复核脚本**(`scripts/live-smoke.mjs`,手动跑、不进 `npm test`):单测里的出网路径全部用假 fetcher(测试不该依赖网络),所以"能抓到价"在单测里只是没被证伪。这个脚本用**构建用的同一份源码**打真实网络,把三件事变成实测:① 官方价格页解析出的**人民币六格**与写死的现役档**逐格对比**(等于每天给价目表对账);② models.dev 压缩后的表与**内置快照逐条对比**,并确认 `deepseek` 整块被丢掉;③ 余额端点的白名单与真实状态码(用假 Key,只打印状态码,不打印 Key),且全程**不写**任何文件。2026-09-29 实测:官方两模型六格全一致、内置快照 **346/346 条**与实时一致、余额端点按预期返回 401。
64
+ - **非 DeepSeek 模型定价**:按 `(provider, model)` 查价(精确 → `PROVIDER_ALIASES` 别名 →
65
+ 按模型 id 全局唯一匹配,**多个 provider 同名就拒绝猜**);覆盖价键支持 `provider:model`;
66
+ 第三方路由没有峰谷两档,设置页给它们渲染**平坦价**一档。**认不出价就说"未定价"**
67
+ (单价全 0 + 界面上写明怎么补价),**不再把 DeepSeek 的 flash 价静默套到别人头上**。
68
+ - **安全**:新增的三条宿主路由(用量分列 / 价目同步 / 余额)都补上了官方那道
69
+ `connection.requestRejection(request)` 关卡(Host/Origin 围栏 + 浏览器令牌)——
70
+ 此前只有重启路由挂了它,而 `webServer` 是可以绑 `0.0.0.0` 的。
71
+
72
+ ### 内部
73
+
74
+ - **`test/settings-render.mjs` 补一节"长期用户那套非默认设置"**(第 13 轮,69 → 78 条):前面几节要么用默认值、
75
+ 要么只改一两个字段,而真实用户是一堆字段都被改过 —— 那正是最容易画错的地方(某个分支把别人的文案顶掉、
76
+ 或者开关状态没跟设置走)。这一节同时钉住:覆盖价(含 `provider:model` 键)、自定义节假日(3 天)、
77
+ **自动同步开着时复选框要勾上**(此前只验过"默认关"那一半)、余额关掉(复选框不勾 + 「刷新」按钮禁用)、
78
+ 提前量跟着设置走(15 分钟而非默认 5)、第三方那行渲染成「平坦价」一档、内置行仍是峰 / 谷两档,
79
+ 以及渲染文本里**不许出现 markdown 记号**(沿用浮层那条纪律)。
80
+ - **修好 `test/check-sections.mjs` 在 0.1.7 上的 ENOENT**(第 9 轮):0.1.7 把 `$DSH_HOME/settings.yaml`
81
+ 改名成了 `settings.yaml.imported`,而这个"升级前先看一眼"的只读工具只试老名字,于是一直报 ENOENT
82
+ (交接文档里记成"已知红")。现在两个名字都试,找不到就把试过的路径都打出来。本机实测已能打印
83
+ 七栏迁移结果(键位 / 右键菜单 / 快捷指令 / 设置面板 / 默认终端 / OpenCode / 统计行)。
84
+ - **`test/cost-panel-render.mjs`:把「金额浮层」也真渲染出来测**(19 条,已进 `npm test`)。
85
+ 浮层是 0.10.0 改动最大、却只有源码字符串断言的一处,而它正是"诚实性"的落点:未定价要不要写
86
+ `¥0.00`、内置快照价有没有标明、价格历史档有没有说出来、分项加起来是不是恰好等于合计。
87
+ 手法与 `settings-render` 相同(借 profile 的 react + `react-dom/server`),另加三处**内存里**的替换:
88
+ `createPortal` 换恒等函数 + 假 `document`/`window`(SSR 没有 DOM,而浮层在渲染期就读
89
+ `window.innerWidth` 算位置)、`open`/`hover`/`anchor` 的初值换成"已展开"(浮层条件是
90
+ `open && anchor !== null`,而 `anchor` 只在点击时才设上)、`useSessionCost` 的返回值换成夹具
91
+ (钩子本身由 `client-registration` 覆盖)。**锚点找不到就报错退出**,不静默放行。
92
+ 第一版有 12 条红:两条是夹具/断言写错(未定价那条 route 我给了 0 token,被组件按
93
+ `tokenTotal > 0` 过滤掉了)、一条是我的断言太严(分项里的 `¥0.00` 是"没定价所以乘出来是 0",
94
+ 关键是**头号数字**不能是 `¥0.00`),其余全是那个 markdown 真缺陷。
95
+ - **`scripts/dsh-shape-check.mjs`:升级 DSH 后的形状核对**。本插件依赖一批宿主内部形状,它们
96
+ **名字或形状一变不会报错、只会静默降级**:`sessions` 服务(`ctx.inject([...])` 里缺了它整条用量
97
+ 路由根本不注册)、`sessions.list()`、`Session.id` / **`Session.seq`**(拿不到 `seq` 时 `liveSeq`
98
+ 是 `undefined`,增量重读悄悄失效、面板停在旧数字上)、`sessionQuery.readSession()` 与其返回体里的
99
+ `events`、**事件信封上的 `seq`/`time`**、设置服务的 `describe/update/replace/mutate` 与
100
+ `settings/document-updated`(以及"这个版本没有 `get(ns)`")、`webServer.register(route: WebRoute)`
101
+ 与 `kind: 'exact'`、`connection.requestRejection()`、以及服务器**能绑 `0.0.0.0`**(这正是每条路由
102
+ 都必须过信任关卡的原因)。脚本**读 DSH 源码**逐条核对并打印 `文件:行号`,2026-09-29 实测 20 条全过。
103
+ 写这个脚本本身抓到我两处路径/正则记错(`webServer` 在 `packages/host/webserver`、
104
+ `requestRejection` 在 `packages/client/connection/src/rpc-host.ts`、写入口签名带 `async`)——
105
+ 正是它要防的那类"凭记忆写死名字"。
106
+ - **`alive` ref 必须在 effect 体里重置**(复查时发现,3 处同病):客户端三处"卸载后丢弃响应"的写法是
107
+ `useEffect(() => () => { alive.current = false }, [])` —— StrictMode(以及某些 HMR 重挂)会走
108
+ "挂载 → 清理 → 再挂载",而 ref 在这一轮里是**同一个对象**,于是 `alive` 永远是 `false`,
109
+ 之后所有响应被静默丢弃(表现为"同步永远停在同步中…""余额一直空白""胶囊永远停在本地估算那个数")。
110
+ 本机 DSH 客户端当前**没有**开 StrictMode(已查 `packages/client/ui-renderer`),所以还没暴露,
111
+ 但这是一颗定时炸弹。现在三处都改成在 effect 体里 `alive.current = true` 再返回 cleanup;
112
+ 护栏见 `test/client-registration.mjs` 第 14 节,变异条目 `CA`。
113
+ - **写完设置的人自己通知失效,不靠设置变更事件**(复查时发现的静默错处):0.1.7 的
114
+ `settings/document-updated` 只在 `describe()` 里比对 raw 变化后发出
115
+ (`packages/settings/settings/src/index.ts`),而我们自己用 `mutate` 写设置时**并不调
116
+ `describe()`** —— 设置页开着时会被界面刷新顺带触发,但**设置页没开着**(典型场景:后台的
117
+ 「自动同步官方价」跑完)那条事件根本不会发;而折叠缓存按 `seq` 去重,改规则不会让旧事件重折,
118
+ 于是金额会**静静地停在旧价格档**上、界面上完全看不出来。现在:模块级
119
+ `moneyInvalidators` 注册表 + `invalidateMoney()`,用量路由登记回调(回收时摘掉),
120
+ `syncOfficial()` 的两条收尾与 `syncModelsDev()` 写盘后都直接通知失效;事件只作补充。
121
+ 测试见 `test/quick-commands.mjs` 第 13 节(**源码级**护栏:那条路由会真出网、测试不该打网络),
122
+ 变异条目 `BZ` 保证它咬得住。
123
+ - `pricing.ts` 的 `resolvePrice(model, options)` 增加 `provider` / `era` / `providers` / `holidays`,
124
+ 返回体增加 `era` / `source` / `unpriced` / `provider`;新增 `eraAt` / `eraIdAt` / `eraById`、
125
+ `isPeakAt` 的节假日与周末边界、`peakPhaseAt`(切换点 + 倒计时)、`formatCountdown`、
126
+ `parseHolidays`、`isDayKey`、`isDeepSeekRoute`、`providerRateOf`、`parseProviderPrices`、
127
+ `parsePriceEras`。
128
+ - `usage-fold.ts` 的 `PeakAt` 换成 `TierAt`(多收 `provider` / `model`,回 `{peak, era}`),
129
+ `RouteUsage` 多一个 `era`;`UsageCache` 多一个 `clear()` —— 判定规则(节假日/价档)一变,
130
+ 宿主半必须整份作废缓存,否则水位去重会让金额一直停在旧规则上(第 11 节钉住这条)。
131
+ - 新增测试:`test/official-pricing.mjs`(45 条,真页面夹具)、`test/balance.mjs`(80 条)、
132
+ `test/price-sync.mjs`(48 条);`test/settings-render.mjs` 也进了 `npm test`。
133
+
134
+ ## [0.9.1] — 2026-09-28(新增:设置页「金额」栏可改峰谷单价;峰谷改成按每笔用量发生的时间判定)
135
+
136
+ > 起因:用户要求"在插件的设置页面添加一个金额胶囊的设置条目,让我能调峰谷价格之类的"。
137
+ > 一问一答定下范围:**只做按模型的峰/谷单价**(内置三个模型常显 + 可自加任意模型名),
138
+ > 不做整体系数、不改币种、不动时段规则、不加这一栏的总开关。做的时候又发现一个既有缺陷、
139
+ > 按用户选择一并修掉:**峰谷原本按"你看面板的那一刻"判定**(昨晚跑的会话今天上午看会整份
140
+ > 按高峰价显示,差 2 倍),改成按**每条用量事件自己的时间戳**判定 —— 连折叠态胶囊那个数字
141
+ > 也一并准时。
142
+
143
+ ### 新增
144
+
145
+ - **设置页第 8 张卡「金额」**(`src/client/CostCard.tsx`):按模型改**高峰 / 空闲两档**的
146
+ 三项单价(未缓存输入 / 缓存命中 / 输出,元每 1M tokens)。内置 `deepseek-flash`、
147
+ `deepseek-v4-flash`、`deepseek-v4-pro` 三行常显(并标出别名 `deepseek-chat` /
148
+ `deepseek-reasoner`),另有「再加一个模型」可填任意模型名(中转、自建路由的名字不在官方
149
+ 价表里,不覆盖就只能按 `deepseek-flash` 估价)。改价**即时生效**(胶囊与明细页当场跟着变),
150
+ 不需要刷新页面、不需要重启。
151
+ - **留空=沿用官方刊例价**:输入框的灰字占位就是官方价(`officialTripleOf`),填了才算覆盖。
152
+ 一整档三项都清空 → 那一档消失;一个模型两档都空 → 那一整行消失;整表全空 → 设置里这个
153
+ 键直接删掉(`withOverrideValue` / `withoutPricingModel`,纯函数 + 逐条测试)。
154
+ - **非法文本不静默清空**:`1,02`、`2元`、`-1` 会**留在框里标红**(`parsePriceText` 三态),
155
+ 不写进设置 —— 静默清空等于"价格悄悄回到官方价",而用户以为改成功了。
156
+ - **写入是合并 + 认回声**:连改几格只发一次设置写入(300ms 合并),并用规范化比对分辨
157
+ "设置里来的新值"与"自己刚写进去的回声",否则回声会把还没写完的第二格编辑冲掉。
158
+ - 「恢复默认」也会清掉这张表(否则恢复默认之后金额还按旧价算)。
159
+ - 存储上它是**一个字段装一棵树**(`priceOverrides`,键是用户自加的模型名)。schemastery 的
160
+ 对象 schema 表达不了动态键,所以这一项是顶层 `z.any()` + volatile;能不能写进设置文档
161
+ 取决于"字段 volatile / 类型容得下 / 写入校验不在它下面挑刺"三条同时成立,
162
+ `test/client-registration.mjs` 第 12 节把 DSH 设置服务那三步校验**复刻进来跑一遍**。
163
+
164
+ - **峰谷按每笔用量发生的时间判定**(宿主半,`src/usage-fold.ts`):官方按请求发生时刻计费,
165
+ 而 0.8.0 拿"打开面板的那一秒"当所有用量的时刻 —— 昨晚(空闲档)跑的会话今天上午看会整份
166
+ 按高峰价显示。现在折叠时用事件的 `time` 判档、按 `(provider, model, 档位)` 拆桶,
167
+ **金额也由宿主半算好**(用户覆盖价一并读自设置),客户端只负责显示。
168
+ - **同一步的替换增量沿用该步第一次判定的档**:否则一次跨过 09:00 的请求会被拆成两档,
169
+ 凭空多出一个"高峰用量"。
170
+ - 事件既没有 `request/header` 时间、自己也没有 `time` 时,判定函数收到 `NaN`,
171
+ 由宿主半退回"现在"(=旧行为),绝不静默把所有用量判成空闲档。
172
+ - 明细页据此多两行「高峰档 / 空闲档」的 token 与金额,并把两档生效单价分别列出来。
173
+
174
+ - **折叠态胶囊改为向宿主半取"逐笔准时"的金额**(`src/client/session-cost.ts`):
175
+ 客户端投影只有累计 token 桶、**没有任何时间信息**,所以这个数字只能从宿主半拿。
176
+ 取数是"最短 600ms 间隔 + 尾随一次 + 单飞 + 旧响应丢弃":尾随保证流结束后的那个数字是准的,
177
+ 到手之前/取不到时用本地的"按当前档位估算"顶上(胶囊从不空着,面板里会说明当前是哪种口径)。
178
+
179
+ - **宿主半的增量折叠缓存**(`createUsageCache`):金额胶囊要跟着流式用量刷新,而
180
+ `sessionQuery.readSession()` 会深拷贝整份日志并重新校验(几千条事件要几百毫秒),
181
+ 每次都重读不可接受。现在按会话缓存折叠器,用 `session/event` 增量喂,取价是 O(1);
182
+ 首次(或发现 `seq` 落后)才完整读一次。**播种窗口**(读日志期间追加的事件)进缓冲区、
183
+ 读完按 `seq` 补上 —— 不这么做的话水位会跳过快照里那些中间事件,金额**永久少算**
184
+ (`feed()` 的 seq 幂等,补也补不进去)。这条由 `test/usage-fold.mjs` 第 8 节用"读的时候
185
+ 顺手追加两条事件"的假 read 钉住。
186
+
187
+ ### 测试
188
+
189
+ - `test/pricing.mjs` 81 → **107 项**(新增第 9 节:文本三态、空壳回收、删整行、占位价、
190
+ `peak` 参数直选档位、别名键覆盖价);
191
+ - `test/usage-fold.mjs` 40 → **64 项**(新增第 7 节逐笔分档、第 8 节增量缓存与播种窗口);
192
+ - `test/client-registration.mjs` 117 → **141 项**(新增第 11 节护栏与第 12 节"设置服务真能
193
+ 收下这张表");`test/settings-render.mjs` 43 → **56 项**(真渲染第 8 张卡:19 个输入框、
194
+ 空值必须是空串、占位=官方价、已填值原样显示、自定义行给「删除」);
195
+ - `test/mutation-guards.mjs` 57 → **65 条**(新增 BG–BM:非法文本当清空、空壳不收、
196
+ 忽略显式档位、替换增量重判档、播种不缓冲、落后不重读、空框写成 0)。
197
+
198
+ ## [0.9.0] — 2026-09-28(新增:官方持久终端六件套 `terminal_*` 常驻挂载)
199
+
200
+ > 起因:用户了解到「DSH 客户端内置了 bash 之类的终端」,要求核实并把官方那套接进插件。
201
+ > 核实结论是**方向对、但要分四层**:模型侧 shell 工具、交互式 PTY 工具包、GUI 右侧栏终端、
202
+ > 以及 `bash.exe` 本体是否内置 —— 前三层官方都有(GUI 那一层此刻就在界面里活着:运行时 Slot
203
+ > 里 `sidebar.right.pane.tab` 的占位者包含 `@deepseek-ai/dsh-client-ui-sidebar-terminal`,
204
+ > `active: true`),但**第四层不是**:应用目录里没有任何 `bash.exe`,Windows 上靠 `PATH` 解析
205
+ > (本机解析到 Git for Windows)。详见 README「官方持久终端」。
206
+
207
+ ### 新增
208
+
209
+ - **常驻挂载官方 6 个持久终端工具**(`terminal_open` / `terminal_read` / `terminal_send` /
210
+ `terminal_signal` / `terminal_close` / `terminal_list`):`cordis.patch.yml` 在插件行之后
211
+ 插入**三行**(见下),并把 `@deepseek-ai/dsh-tool-terminal` 写进 `package.json` 的
212
+ `dependencies`(区间 `>=0.1.7-rc.1 <0.1.8`)。不需要设置项,也不需要手动开关。
213
+ - **三件套缺一不可,而且必须在同一层**:① `@deepseek-ai/dsh-terminal`(提供
214
+ `ctx.terminals`)② `@deepseek-ai/dsh-terminal-bash`(注册 `type=shell` 的 PTY 后端)
215
+ ③ `@deepseek-ai/dsh-tool-terminal`(那 6 个模型工具)。
216
+ - **为什么不照抄社区 patch 只挂 ③**:这个包官方标为**可选**,没有任何 bundle 默认挂它;
217
+ 更关键的是 **0.1.7-rc.2 的桌面版没随包发布它** —— `app.asar` 里有 `dsh-terminal`、
218
+ `dsh-terminal-bash`、`dsh-api-terminal-controller`、`dsh-client-ui-sidebar-terminal`,
219
+ 唯独没有 `dsh-tool-terminal`(直接写那一行会在启动时解析失败 ⇒ 整棵树挂 ⇒ **白屏**,
220
+ 与 0.6.2 那次同形状)。所以 ③ 写成插件自己的依赖。
221
+ - **① ② 是安装目录里就有的官方包**(按包名引用即可解析,像 dsh-mobile 挂
222
+ `dsh-host-directory-picker-browse` 那样),**而且不能给它们加自检守卫** —— 守卫走
223
+ profile 目录的 `createRequire`,解析不到安装目录里的包,加了会把服务永远禁掉。
224
+ - **③ 的自检式守卫**(`disabled: !!js`):自己 `createRequire().resolve()` 一次包名,
225
+ 解析不到就把自己禁掉。DSH 的兼容性预检只处理 peer 版本冲突,**「包解析不到」它不管**
226
+ (`compatibility-preflight.ts:63-66`),所以这道自检是「缺包 ⇒ 功能不存在」而不是
227
+ 「缺包 ⇒ 白屏」的唯一保障;而 disabled 行加载器根本不初始化
228
+ (`config-schema/document.ts:113`)。表达式必须 `try/catch` 包住(`disabled` 表达式抛错
229
+ 算「条目失败」而不是「被禁用」,`app-boot/src/index.ts:914`),这一行也不能带
230
+ `group: true`(会迫使加载器初始化 disabled 行)。
231
+ - **② 的 shellPath 得自己探**:官方后端默认 `shellPath: '/bin/bash'`(`dsh-terminal-bash`
232
+ 的 `config.ts:54`,Windows 上解析不到),所以用同步 `!!js` 从 PATH 上那份 git 反推
233
+ `…/Git/bin/bash.exe`,外加 Program Files / LOCALAPPDATA / msys64 等落点;探不到就留空
234
+ 并回落官方 pwsh 方言(`resolvePwshPath`)。`timeoutMs` 对齐官方 minimal 预设的 300000。
235
+ - **客户端零改动**:官方客户端已注册这 6 个工具的 `tool.call.toolview` 渲染位。
236
+ - **与「默认终端」互补**:那一栏决定模型用哪个 shell,这一档决定模型有没有持久会话;
237
+ 档位选 `PowerShell` 时这 6 个工具照常在。
238
+
239
+ ### 真机验证发现并修掉的问题(0.9.0 未发布,直接修在本版)
240
+
241
+ - **「挂上了」≠「生效了」:只挂 ③ 会让它静默 pending**。首版只插了 ③ 一行,重启后模型
242
+ 工具表里**什么都没有**。查明:③ 的源码写着
243
+ `const inject = ["terminals", "tools", "systemPrompt"]`,而提供 `terminals` 的 ① 在官方
244
+ 组合里只出现在 `sdk-minimal` 与 web-app 的 `minimal` 预设里(standard 预设与 profile 层
245
+ 都没有)⇒ cordis 把 ③ 挂在 pending 等依赖,**不报错、不白屏、一个工具都不注册**。
246
+ 这与 0.6.2 白屏是**同一形状**(usage-meter 也是 pending),只是那次发生在客户端半、被 web
247
+ 启动审计抓住;宿主半的 pending 没人审计,所以是静默的。修法:补齐 ① ②,三行同层。
248
+ - **「装了」≠「生效了」(第二坑):守卫的基准目录依赖了 DSH 内部上下文**。补齐三行后再重启,
249
+ 工具表**还是没有**。这次用 `plugin_manager list_plugins`(每条给 `enabled` + `fiberPhase`)
250
+ 一眼看到:`composer-ux` / `composer-ux-terminal` / `composer-ux-terminal-bash` 都是
251
+ `active`,唯独 `composer-ux-tool-terminal` 是 **`enabled:false, fiberPhase:null`**
252
+ —— **是被 ③ 自己那道自检守卫禁掉的**。原因:守卫拿 `ctx.get('profileContext')?.dir` 当基准,
253
+ 而它在 `!!js` 的求值上下文里拿不到 ⇒ 表达式按「装不上」处理 ⇒ 行被禁用。
254
+ 失败方向是安全的(没白屏),但功能静默消失。
255
+ 修法:基准改成**只靠通用 Node API 推** —— `DSH_HOME`(有就用)否则
256
+ `os.homedir() + '/.dsh'`(这正是 DSH 自己算 home 的方式),再在 `profiles/node_modules`
257
+ 与 `profiles/*/node_modules` 里找;`profileContext` / `DSH_PROFILE_DIR` 降级为**额外**候选,
258
+ 而且各自包一层 try/catch —— 它抛错也不能把整个守卫带崩。测试里加了「基准发现」一组用例
259
+ 与一条「真环境一致性」用例(守卫的答案必须和测试自己按同样候选查出来的真相一致)。
260
+ - **诊断手法(值得复用)**:`Config.listConfigs` 只报声明与 schema,**不报激活状态**;
261
+ `Service.listService` 能看到 `terminals` 的**能力目录**,但那不代表它在你那一层可用
262
+ (它扫全树,扫到的是 minimal 预设里那份声明)。真正判据有两把:
263
+ ① 去包里读 `inject`,再对照官方哪个 bundle / preset 挂了这个服务;
264
+ ② **`plugin_manager list_plugins` 的 `fiberPhase`** —— 这是唯一能直接看出「行到底活没活」
265
+ 的探针(`active` vs `null`),本轮两个坑都是靠它定位的。
266
+ - **第三个坑(真正的根因):运行时版本不是桌面壳版本,而 DSH 已升级到 `0.2.0-rc.1`**。
267
+ Electron 壳自称 `0.1.7-rc.2`,但应用里所有官方包都是 **`0.2.0-rc.1`**
268
+ (`getDshRuntimeVersion()` 读的是 `@deepseek-ai/dsh-app-boot` 的版本)。而
269
+ `plugin-compatibility.ts` 的判据正是「包的 `@deepseek-ai/dsh-*` peer 区间 vs 运行时版本」,
270
+ 不满足就**直接给这一行打 `disabled`**(patch 里看不见,`list_plugins` 里是
271
+ `enabled:false / fiberPhase:null`)。按 0.1.7-rc.2 装的工具包因此在 0.2.0-rc.1 上被整行判掉
272
+ —— 这才是「三行挂上了、工具一个都不出现」的真因。修法:装同版本的
273
+ `@deepseek-ai/dsh-tool-terminal@0.2.0-rc.1`,依赖窗口写成
274
+ `>=0.1.7-rc.1 <0.1.8 || >=0.2.0-rc.1 <0.3.0`(两个窗口各带 pre 比较器,否则 pre 版本匹配不上)。
275
+ - **探测必须排除 WSL**:第一版按 PATH 顺序命中了 `C:\Windows\System32\bash.exe`(WSL 启动器),
276
+ 真机 PTY 起成了 WSL bash(`pwd` → `/mnt/d/1zcode/dsh插件`、内核 WSL2)。插件自己那条 bash 工具
277
+ 本就硬排除 WSL,YAML 里漏了;现在用 `path.basename/dirname` 判掉 `…\Windows\System32\bash.exe`
278
+ 与 `…\Microsoft\WindowsApps\bash.exe`。
279
+ - **更正开发过程中的一个误判**:`process.getBuiltinModule` 在 `!!js` 里**是**可用的 ——
280
+ 先前那次失败是预检判的,不是表达式抛的(探测表达式确实用它读到了文件系统)。
281
+ - **端到端已实测通过**(真 PTY;且 profile patch 的改动会被 `dsh-hmr` 即时重放,**不用重启**):
282
+ `terminal_open` → `terminal_send` → `terminal_read` → `terminal_close` 全通;
283
+ `uname -a` = `MINGW64_NT-10.0-26200 … Msys`、`$0` = `/usr/bin/bash`、
284
+ `pwd` = `/d/1zcode/dsh插件`、`grep`/`head` 都在 `/usr/bin`。
285
+ - **部署通道的教训**:手工复制进 `profile/node_modules/<pkg>` 的文件会被 DSH 在版本升级时
286
+ 「重新物化 git 依赖」整包冲掉(本轮 `lib/*` 被还原成钉住的 0.6.2 commit,实测)。
287
+ 行级改动放 profile 的 `cordis.patch.yml`(用户层,会保留);要改 `lib/` 只能让包本身带上。
288
+
289
+ ### 测试
290
+
291
+ - 新增 `test/terminal-mount.mjs`(41 项):结构断言(三行齐全且在同一个 insert 里、服务与
292
+ 后端**没有**守卫、全文只有 ③ 一处 `disabled`、守卫包名与行名一致、两行都不带
293
+ `group: true`、后端给了 `backendType`/`timeoutMs`、守卫基准不许依赖 DSH 内部上下文)
294
+ + **用加载器同一个求值器**(`new Function('ctx','expr','with (ctx) { return eval(expr) }')`,
295
+ 出自 `@deepseek-ai/cordis-plugin-loader` 的 `interpolate`)真跑三条表达式:探测在
296
+ 「有 bash / 无 bash / 非 Windows / fs 抛错」四种环境下给出预期值且两条自洽;
297
+ 守卫在「包在当前 profile / 包只在共享层 / `ctx.get` 抛错 / `DSH_PROFILE_DIR` 兜底 /
298
+ `DSH_HOME` 顶上 / 哪都没有 / `readdirSync` 抛错 / 权限错 / node 内建拿不到」九种环境下
299
+ 「该禁就禁、该放就放、永远不抛」;另有一条**真环境一致性**用例:守卫的答案必须与
300
+ 测试自己按同样候选查出来的真相一致。
301
+ - `test/mutation-guards.mjs` 新增 `AQ`–`BF` 十七条:拆掉外层 `try/catch`、守卫极性写反、
302
+ 挂载行包名漂移、加 `group: true`、依赖改通配、依赖钉死单版本、多挂一行服务、
303
+ **删掉服务行**、**方言回落改成 bash**、**shellPath 写死 `/bin/bash`**、
304
+ **给服务行也加守卫**、注释掉守卫、**`ctx.get` 少了自保**、**守卫基准退回空数组**、
305
+ **探测不再排除 WSL**、**依赖窗口丢掉 0.2.0**。
306
+
307
+ ## [0.8.0] — 2026-09-28(新增「金额」:输入框下方显示本会话费用)
308
+
309
+ > 起因是用户装了社区插件 [`dsh-plugin-usage-meter`](https://github.com/fancr-code/dsh-plugin-usage-meter),
310
+ > 但它在本机 **DSH 0.1.7 上打不开网页**(客户端半 `inject` 了 0.1.6 时代的设置服务 `settingsScope`,
311
+ > 0.1.7 已换成 `configForms`,没人提供它 ⇒ 该客户端条目永远 pending ⇒ 网页启动审计抛错 ⇒ 白屏)。
312
+ > 用户的要求是"把这个插件的功能搬到我的插件里",所以本插件按需求移植的是**它的金额能力**,
313
+ > 而不是把它装回来。
314
+
315
+ ### 新增
316
+
317
+ - **输入框下方的极简金额胶囊**(`src/client/CostChipEntry.tsx`):不点时只占一个词的位置(`¥0.42`),
318
+ 点开是贴在它上方的浮层(React portal,因为输入卡片会裁掉溢出内容;官方几颗胶囊也都是浮层)。
319
+ 浮层里有本会话三分项(未缓存输入 / 缓存命中 / 输出)的 token 数与花费、合计、缓存命中率、
320
+ 生效单价、模型名与峰谷档,以及"估算、未含中转加价"的说明。
321
+ - **计价与格式**(`src/pricing.ts`):DeepSeek 官方刊例价(三个模型 × 高峰/空闲 × 人民币/美元)、
322
+ 模型别名(`deepseek-chat`/`deepseek-reasoner`/`deepseek-v4-flash-vision-exp`)、峰谷判定
323
+ (UTC 周一至周五 01–04、06–10)、费用拆分与金额/Token 格式化。
324
+ - **数据全部来自官方,不新增任何采集**:token 桶读官方 `tokenUsage` 投影(与官方那行统计同源),
325
+ 模型读官方 `modelSelection` 投影(`request/header` 事件),所以两个数字不会各算一套。
326
+ - **按 route 分列**(用户追加要求:要把 OpenCode / Zen 那条 route 的用量也算进来、并且看清各花多少):
327
+ 折叠态那一个数字仍是客户端按官方投影算的估值;**点开时向宿主半的只读路由
328
+ `/composer-ux/usage?sessionId=…` 取一次逐请求归因**(`src/usage-fold.ts` 折叠,`session.events`
329
+ 客户端读不到),分列到手后**面板总额改成分列之和** —— 所以"各行加起来等于总额"永远成立;
330
+ 日志与投影对不上(会话刚写入)时退回投影口径并如实说明,不把两套数字并排摆着让人猜。
331
+ 两条容易写错的规则都钉在 `test/usage-fold.mjs` 里:**同一 `(turn, step)` 的上报是替换不是累加**
332
+ (直接累加会重复计费),**usage 归属它之前最近一条 `request/header`**(归错就把 Zen 的花费记到官方头上);
333
+ 另有官方 `llm/retry-started` 的重开替换槽语义。
334
+ - **事件形状按本版官方 `SessionEventMap` 的真实声明,不按记忆**:2026-09-28 真机踩过一次坑 ——
335
+ 最初照抄的社区实现折的是 `assistant/chunk` 事件,而**本版 DSH 根本没有这个事件类型**(usage 藏在
336
+ `assistant/attempt` 的 `stream` 里,或 `assistant/message` 的 `data.usage` 上),于是分列一行都出不来,
337
+ 面板只显示"还没有可归因的用量"。改用官方 `sessionQuery.readSession(sessionId)`(带 data 的完整日志)
338
+ 取事件、按官方 `lastAssistantStreamChunk(stream,'usage')` 取 usage 之后才通。这条教训与更正后的出处口径
339
+ (折叠对齐官方 `token-meter` 的 `usage-projection`,社区实现只贡献了"按 provider/model 分列"这个目标)
340
+ 都写在 `src/usage-fold.ts` 文件头。
341
+ - **同一次真机核对还揪出面板一个算错的数**:折叠态"缓存命中率"原本用 `命中 ÷(未缓存 + 命中)`,
342
+ 漏掉缓存写入,于是屏幕上与官方那行统计对不上(官方 98.013%、面板 100%)。已改成与官方同分母的
343
+ **计费输入**(未缓存 + 缓存读 + 缓存写)。
344
+ - **改完这一条,命中率**仍然**显示 100% —— 因为键名在调用处又写错了一次**(2026-09-28 第二次真机核对):
345
+ 修好的那个 `billedInputTokens()` 只认 `uncachedInputTokens`,而调用处传的是 `{ inputTokens: … }`,
346
+ 于是分母只剩"缓存读 + 缓存写",命中率恒等于 100%(旁边官方胶囊是 98.206%)。教训是"两个键名都认"
347
+ 必须落在**每一处传值**上,而不只是某个函数内部;现在调用处传 `uncachedInputTokens`,
348
+ 并由 `test/client-registration.mjs` 钉住"调用处的键名"与"和输入框下面那一行同一套函数"。
349
+ - **胶囊字号照抄官方 `.root`(修"比旁边的字大")**:官方那颗统计胶囊的字号与行高**不在 `.pill` 上**
350
+ (`.pill` 里只有 `font: inherit`,那是为了抵消 button 的 UA 字体),而在它爸爸 `.root` 上
351
+ (`calc(var(--dsh-content-font-size-secondary, 13px) - 1px)` / `calc(20px + …delta-secondary…)`)。
352
+ 本条目是同一个 dock 槽位里的**另一条**记录,不在这颗 `.root` 里,只写 `font: inherit` 就继承了
353
+ 输入框那一层(`--dsh-content-font-size`,默认 14px)—— 真机上看就是"插件输入框下方的字比官方的大"。
354
+ 现在把 `.root` 那两条显式写在按钮上(并拆掉 `font:` 简写,否则简写会把字号重置回继承),
355
+ 与旁边那颗胶囊同字号同字重,且随用户的字号设置一起变;亮度保持官方 `.pill` 的
356
+ "静止 `label-tertiary`、hover/展开 `label-secondary`"。
357
+ - **点开后补上三分项行**:分列模式下除各 route 与合计,再列出「未缓存输入 / 缓存命中 / 输出」的
358
+ token 与花费。三行的钱**按各 route 自己的单价分别累加**(不是拿合计的桶乘一个单价),
359
+ 所以"三行相加 = 合计"在两条 route 单价不同时也**精确成立**(2026-09-28 实测
360
+ ¥5.17 + ¥5.58 + ¥3.78 = ¥14.53,与合计逐位一致)。
361
+ - **计价不看 provider**:一律按**模型名**查 DeepSeek 官方价目表 —— 所以走 `go`(OpenCode Zen)
362
+ 的用量一样进这个金额、一样按官方价估算。代价是 Zen 自己可能有加价/订阅,屏幕上的数字
363
+ **不等于**你付给 Zen 的钱(浮层里写明"未含中转加价,实际扣费以各家账单为准")。
364
+
365
+ ### 出处(MIT)
366
+
367
+ 价目表、模型别名、峰谷规则、`costOf`/`formatMoney`/`formatTokens` 的**分档规则**来自
368
+ `dsh-plugin-usage-meter@1.9.1`(MIT,Copyright (c) 2026 fancr-code)。`test/pricing.mjs` 把那份实现的
369
+ 几个函数**原样抄进来当基准**,逐样本对拍:刊例价表逐项、峰谷判定(一周 593 个时刻)、
370
+ 定价解析(模型 × 币种 × 时段 × 覆盖价,共 216 组)、费用(30 组)、金额(40 组)、Token 格式(16 组)。
371
+
372
+ ### 与出处有意不同的两处(都钉在测试里)
373
+
374
+ - **金额至少保留两位小数**:出处用 `replace(/0+$/,'')` 收尾,于是"第二位小数恰好是 0"的值会少一位
375
+ (`¥0.1`)、六位全为零的值更会只剩一个小数点(`¥0.`)。我们按官方金额惯例至少两位(`¥0.10` / `¥0.00`)。
376
+ - **覆盖价的币种语义写死在注释里**:出处是"有覆盖价时先整体重建人民币档、美元再从人民币折算",
377
+ 写成"在美元列上套覆盖价再折算"会差一个汇率(≈15 倍)而屏幕上只是个数字。这条差点写错,所以
378
+ `test/pricing.mjs` 第 4 节专门断言它。
379
+
380
+ ### 这一版**还没做**的部分(用户已确认要做,随后接入)
381
+
382
+ - **账户余额**:官方 `remote.account.getBalance()` 就有(充值余额 + 赠金余额,用你已登录的账号,
383
+ **不需要任何 Key**),比出处那套"自己拿 API Key 发 `/user/balance`"更省事。
384
+ - **今日 / 本月累计**:需要宿主半聚合本地会话日志(出处是扫日志,且有"平台 dashboard 按日费用"
385
+ 那条非官方接口 —— 你走 `go` 中转时官网账单本来也不含 Zen 的用量,所以本地聚合才是对的口径)。
386
+ - **设置栏**(第 8 张卡):开关 / 币种(¥/$)/ 单价覆盖 / 折叠态是否带余额。当前这一版是固定人民币、
387
+ 只用内置刊价快照、常驻显示。
388
+
389
+ ## [0.7.0] — 2026-09-28(新增「统计行」:缓存命中三位小数)
390
+
391
+ > 起因是用户看到了社区插件 [dsh-cache-precision](https://github.com/Cheng-cheng9669/dsh-cache-precision),
392
+ > 要求"把这个功能加到我的插件里"。那个仓库做两件事:把输入框下方统计行里的「缓存命中 12%」改写成三位小数,
393
+ > 并把那一行放宽(免得变长的数字被 `...` 截断)。本插件**只移植了第一件**,第二件做完真机核对后被用户拍板撤掉
394
+ > (前提不成立,见下"撤掉的那一半")。**只动客户端半,刷新页面即可生效**(宿主半只是多了一个设置字段)。
395
+
396
+ ### 新增
397
+
398
+ - **三位小数**(`src/client/stats-line.ts` + `src/client/stats-dom.ts` + `src/client/StatsLineEntry.tsx`):
399
+ `缓存命中 12%` → `缓存命中 12.346%`。数字口径与官方**同一份**
400
+ (`缓存读 ÷(未缓存输入 + 缓存读 + 缓存写)`),所以屏幕上两个百分比永远对得上。
401
+ - **设置页第 7 栏「统计行」**(`src/client/SettingsSection.tsx` 末尾那张 `FoldCard`):**只有一个开关**
402
+ (就是标题行上那一个),**默认开** —— 用户要的是"装完立刻看得到效果"。
403
+ - 它是**第一栏不参与"碰过才开"迁移**的:上面五栏默认关,是因为它们已经发布过、一律默认关会在升级那一刻
404
+ 把用户正在用的键位 / 请求头 / 终端当场关掉;这一栏是新能力,没有痕迹可依,所以按普通布尔字段处理
405
+ (与 `headerEnabled` 同写法,只是默认值相反)。这一条区别在两处显式钉住:
406
+ `src/settings-contract.ts` 的注释与 `test/quick-commands.mjs` 的 4 条断言(含变异 `AH`)。
407
+ - **为什么只有一个开关**:早期版本有「三位小数」/「加宽统计行」两个子开关,加宽撤掉后只剩一个功能,
408
+ 再留一个同义的子开关就是"两个开关做同一件事"。按本插件对「OpenCode 请求头」那一栏的既有处理
409
+ (那里的注释:「原来的『附加请求头』本来就是『这一栏要不要生效』,直接搬到了标题行」)收成一个。
410
+ 护栏:`test/stats-line.mjs` 断言 `DEFAULT_SETTINGS` 里没有任何第二个 `stats*` 键,
411
+ `test/client-registration.mjs` 与 `test/settings-render.mjs` 各有一条"别又长回来"的断言。
412
+
413
+ ### 与参考实现有意不同的四处(每处都对应一条护栏)
414
+
415
+ - **① 不撒谎**:参考实现是 `toFixed(3)`,于是命中率只要 ≥ 99.9995% 就会被四舍五入成 `100.000%`,
416
+ 而它**并没有满**。官方那段 `formatCacheHitPercent()` 专门绕开了这件事(源码注释原话
417
+ "without rounding a partial hit to 100%"),所以本插件是"用它的位数 + 官方的诚实性":
418
+ 常态三位,一旦会凑成 `100.000%` 就**继续加位**,宁可显示 `99.9999%`。阶梯实测:
419
+ `99.995% → 99.995`、`99.999% → 99.999`、`99.9995% → 99.9995`(三位会成 100.000)、`99.9999% → 99.9999`。
420
+ - 判据以**官方源码为基准**逐例交叉验证:`test/stats-line.mjs` 第 5 节把
421
+ `packages/client/ui-chat/src/client/chat/token-format.ts`(rc.1)那 4 个函数**原样抄进来当基准**,
422
+ 对 400 个分母全量 + 大分母边界 + 3000 组伪随机**逐例比对 `digits = 0` 档**,全部一致。
423
+ 这一档同时就是"关掉开关时写回去的东西",所以"关掉"不是"什么都不写",而是把官方原版写回。
424
+ - **② 定位走官方属性,不走类名**:参考实现往上找 `className` 里含 `_root` 的祖先。DSH 的 CSS Modules
425
+ 类名带哈希(升级就会变),所以本插件改用官方稳定属性 `[data-composer-stats]`
426
+ (`StatsPills.tsx` 的根元素,紧凑与详细两档都有)。
427
+ - **③ 不扫全页**:参考实现挂在 `document.body` 上跑 TreeWalker + MutationObserver,等于给整个页面
428
+ 挂监听(每个流式 token 都会喂它)。本插件先渲染一个 `display:none` 的锚点落在**同一个** composer dock 里,
429
+ 由它往上找"同时装着我和统计行"的最近祖先(**上限 6 层**),Observer 只盯这一小块;找不到就什么都不做
430
+ —— 宁可功能不生效,也不留一个全页监听。多 composer 的页面上也只动自己这一个。
431
+ - **④ 同步 `aria-label`**:官方那颗胶囊的无障碍名字是 `` `${total} · ${cacheHitText}` ``。参考实现只改可见
432
+ 文本节点,于是**读屏用户听到的还是整数**,与屏幕上的三位小数对不上。本插件两处一起改。
433
+
434
+ ### 撤掉的那一半:为什么没有移植「加宽统计行」
435
+
436
+ 参考实现还给那一行写行内 `max-width`(放宽 260px,上限 `100vw - 48px`)。**真机核对后由用户拍板撤掉**
437
+ (2026-09-28),因为它的前提在 DSH 0.1.7 上不成立 —— 整条证据链都能在源码里读出来:
438
+
439
+ - 约束统计行的是**外层容器**,而 `--dsh-chat-content-width`(= `clamp(680px, 列宽*0.64, 920px)`)只作用在
440
+ **消息列与输入卡片**上:`ui-conversation` 的 `.card` 与 `.composerHero` 才有 `max-width`;
441
+ 真正包着统计行的 `.dock` / `.composerStack` **没有任何宽度上限**(只有 `max-width: 100%`)。
442
+ - `.composerHero` 是**唯一**带聊天列宽度的那个容器,而它只在**空白会话**(hero)生效;统计行却只在
443
+ **活动会话**里渲染(`InputBar` 里那个 dock 槽要求 `variant === 'composer' && sessionId !== undefined`)。
444
+ 两者**永不同时出现** ⇒ 统计行存在时,那一行的可用宽度是**整个会话列**,本来就比聊天列宽得多。
445
+ - 于是参考实现那个上限(聊天列 + 260px)反而**比可用宽度小**:平时它不生效(内容远没那么长),
446
+ 内容极长时还会让那一行比官方**更早**截断。也就是说它无事可做、最坏还更差。
447
+ - 撤掉的实现留在测试里当护栏:`test/stats-dom.mjs` 第 3 节断言"只改文本、不写任何行内样式",
448
+ 且断言契约里不再导出加宽常量;变异 `AL`/`AM` 把那一半重新加回来时必须变红(实测都咬住)。
449
+
450
+ ### 变更
451
+
452
+ - **作用范围只到输入框下面那一行**(用户拍板):两处统计弹窗(点开统计行的弹窗、每轮用量弹窗)里的百分比
453
+ 保持官方原样。它们用的是 `<dt>缓存命中</dt><dd>49.4%</dd>` 结构、拼起来是 `缓存命中49.4%`(中间没有空白),
454
+ 与"整段必须就是 `缓存命中 xx%`"这条判据天然不冲突 —— 这条判据有 5 条"不许碰"的断言盯着。
455
+ - 注意弹窗自己用的是官方**1 位小数**档,所以只改那一行确实会带来"下面 12.346%、弹窗 12.3%"的口径差;
456
+ 这是用户明确选择的范围,不是漏改。
457
+ - `activeSections()` 增加 `stats` 一项;「恢复默认」清掉 `statsEnabled`(清掉后回落到默认 = 开,
458
+ 与上面五栏"清掉即关"方向相反,但"清掉 = 回到从没碰过的样子"这条定义两边都成立)。
459
+
460
+ ### 护栏与验证(本轮全部真跑)
461
+
462
+ - 新增 `test/stats-line.mjs`(**52 项**,已进 `npm test`):小数语义定值 + 四个桶的口径 + 空输入 +
463
+ 「不撒谎」属性扫描(1500+ 组"没满"的比例里没有一组显示成 100、位数一律 ≥ 3)+ 与官方源码逐例一致 +
464
+ 文本节点与 `aria-label` 两个字符串变换(含 5 条"不许碰"、1 条"连续两次调用结果一致",防公共正则留
465
+ `lastIndex` 状态)+ 开关合成与定位常量。
466
+ - 新增 `test/stats-dom.mjs`(**17 项**,已进 `npm test`):用**按官方源码复刻的假 DOM** 钉住 DOM 那半 ——
467
+ 定位(平铺 / 槽位套了一层包裹元素 / 第 7 层才是 body 时放弃)、改写(可见文本 + `aria-label`,
468
+ 弹窗的 `<dt>`/`<dd>` 与同排另一个 composer 一个都不碰、紧凑档与英文界面同样认、值相同时不写)、
469
+ 以及"不碰那一行的样式"。为此把 DOM 助手从 `StatsLineEntry.tsx` 拆成独立的 `src/client/stats-dom.ts`
470
+ (不 import React、顶层不碰 `document`),React 那层只剩"什么时候扫、扫完怎么收尾"。
471
+ - `npm test` **797 → 885 passed / 0 failed**(41+17+**298**+133+199+**52**+**17**+**102**+26);
472
+ `test/settings-render.mjs` **28 → 43 passed / 0 failed**(新增"七张卡都在"+ 统计行卡 5 项 × 3 档)。
473
+ - `test/mutation-guards.mjs` 新增 8 条,**全部咬住**:`AF`(退化成 `toFixed(3)`)/`AG`(判据放宽成"任何 xx%")/
474
+ `AH`(栏开关默认改成关)/`AI`(定位改成扫全页)/`AJ`(不同步 `aria-label`)/
475
+ `AK`(定位去掉层数上限 ⇒ 不同子树时一路走到 body)/`AL`(撤掉的加宽又写回行内样式)/`AM`(加宽常量重新引入)。
476
+ - 顺手修好一条**长期失配**的旧锚点:变异 `X` 的 `also` 还写着 `adoptSettings(ctx.configForms, 'get')`,
477
+ 而代码早已被 `peekService` 包起来 —— 于是 `mutation-guards.mjs` 每跑必报"有变异没被咬住",
478
+ 分不清是"这条护栏松了"还是"别的哪条真漏了"。锚点已对着当前源码校正,现在整套 **38 条变异全绿**。
479
+ - ✅ **真机验证(用户重启后当场做的,不是推断)**:
480
+ - 宿主进程 **16:47:07** 启动 > 产物 15:48 覆盖 ⇒ 跑的是新产物;
481
+ - 活宿主 schema 里 `statsEnabled` 已存在(`default: true` + `volatile`)⇒ 开关真能写;
482
+ - 活槽位 `conversation.composer.dock` 的 occupants = 官方 `stats`(order 0)+ 本插件
483
+ `composer-ux-stats-line`(order 99),**两个都 active、官方那一格没被顶替**(靠 `Slots` 探针读的);
484
+ - 屏幕上那一行显示 `缓存命中 97.964%` → 稍后 `97.773%`(三位小数、随用量在变);
485
+ - `dsh-cache-precision` **没有安装** ⇒ 不存在两个插件抢同一段文本。
486
+ - 产物与哈希:`lib/index.js` = `08ae98dae8774737cd241a31eded8b5b`、
487
+ `lib/client.js` = `53aba71728a1196ffd8f090b2e2f22af`(上一版发布版分别是 `46EEFE79…` / `9017EBC3…`)。
488
+ 两个 profile(`desktop` 与 `web`)都已用 `rm` + `cp` 覆盖(`profiles/<p>/node_modules/dsh-composer-ux/lib/`
489
+ 里的文件与 pnpm store 是同一 inode,`cp -f` 会**写穿** inode 污染 store —— 2026-09-25 真出过),
490
+ 覆盖后确认 store 里那两个 0.6.2 的旧 blob 仍是 `9017EBC3…` / `46EEFE79…`、链接数已归 1。
491
+ - ⚠️ 真机确认后**仍待用户点头才发布**(用户选的是"确认了我再发 0.7.0"),
492
+ 所以此刻 `npm publish` / GitHub Release / git tag **尚未执行**。
493
+
5
494
  ## [0.6.2] — 2026-09-25(浮层磨砂修复 + 设置页「刷新」按钮 + 默认终端「接管前自检」)
6
495
 
7
496
  > 三块内容合成一版:①用户截图报的「快捷指令面板透明看不清」;②桌面版里「重启 DSH」不可能生效,补一个轻量替代;③