dsh-composer-ux 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,9 +2,211 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.6.1] — 2026-09-24
6
+
7
+ > 起因是一次真机故障:设置页的开关"点了没反应",而界面上**一句提示都没有**。查到最后是两层独立原因,
8
+ > 两层都在这一版里处理掉。**含宿主半改动**(启动时新增一次孤儿锁回收),装完需要**重启 DSH**。
9
+
10
+ ### 修复
11
+
12
+ - **volatile 标记改成逐字段(不再标根),对齐官方形状**。这是"设置改完不生效"的根因:
13
+ schemastery 3.18.4 一旦看到**根**带 volatile,就把**整棵解析结果**包成**一个根引用**
14
+ (`Config({}).enabled === undefined`,值只在 `.get()` 里);而 loader 把配置变更提交回
15
+ **运行中引用**的那条路(`_commitVolatile`)是按**叶子路径**设计的。官方插件
16
+ (`ui-theme` / `locale` / `llm-deepseek` / `ui-conversation` …)**一律逐字段标,没有一个标根**。
17
+ 改完的形状是"普通对象壳 + 叶子引用",真机验证:`Config({})` 不再是引用,`parsed.enabled` 是叶子引用。
18
+ - 顺带把两条护栏补成"字段级":`host-settings-generations` 新增「每个字段都带标记」「根节点不标」,
19
+ 变异 Z 改为动**方法分支**(3.18.4 下标记走 `.volatile()`,动 meta 兜底那行已经不参与执行、
20
+ 护栏会"咬不住"——这是升级后的新事实,之前那条变异因此失效)。
21
+ - **孤儿写入锁会让整个 profile 的设置写入静默失效**。DSH 用 `<profile>/package.json.lock` 串行化跨进程写入,
22
+ 且规定争用方**永不删除**已存在的锁(孤儿锁由操作者处理);而 `restart-webui.bat` 的 `taskkill /T /F`
23
+ 硬杀只要落在一次写入中间,锁就永久留下 —— 此后**每一次**设置写入都在 2 秒后超时,读得到、写不进,
24
+ 界面只表现为"点了没反应"(2026-09-23 连撞两次,两次都是重启留下的)。
25
+ - 新增 `src/settings-lock.ts`:宿主半启动时读一次锁里的 PID,**只在确认该进程已不存在时**删除;
26
+ 内容认不出 / PID 还活着 / 权限不足无法判定 → 一律不动(误删活锁会让两个写入者交错提交同一个 profile patch)。
27
+ 判据是纯函数,逐条钉在测试第 12 节(19 条)。
28
+ - **设置页不再"静默装死"**:写入被宿主拒绝、或**写入被接受但运行时的值没变**时,把原因显示在设置卡顶部
29
+ (`writeNotice`,与「重启 DSH」横幅同一位置)。以前这两种情况都只往控制台写一行,用户能看到的只有"开关不动"。
30
+
31
+ ### 文档
32
+
33
+ - README「设置持久化」补「⚠️ 设置写不进去 / 点了没反应:两种成因与恢复」(含手工回收锁的一行命令)。
34
+ - README 更正一处过期描述:0.1.7 起设置存在 **profile 的 `cordis.patch.yml`**,不再是 `$DSH_HOME/settings.yaml`。
35
+
36
+ ### 测试
37
+
38
+ - `test/quick-commands.mjs` 新增第 12 节:孤儿锁判据 + 真实文件系统上的四条路径(无锁 / 认不出 / 持有者活着 / 持有者已死)。
39
+ - 适配 DSH 随包的 **schemastery 3.18.4**:它开始认 `meta.volatile` 并在解析时把节点包成**引用**
40
+ (3.18.2 没有这个分支)。于是"从 schema 解析结果直接读普通值"的断言(`quick-commands` 第 6 节、
41
+ `host-header-mirror` 第 15 节、`host-settings-generations` 第 1 节)改为先解引用;断言要分的那几件事没有变。
42
+
43
+ ## [0.6.0] — 2026-09-22(**未单独发布**,内容并入 0.6.1)
44
+
45
+ > **适配 DSH 0.1.7**(对 `dsh-v0.1.7-alpha.1` 逐项核对)。同一个产物同时支持 0.1.6 与 0.1.7:
46
+ > 两代的设置服务形状不同,代码按**能力探测**分支,不维护两份产物。
47
+ > **本版含宿主半改动**(注册方式、字段表、事件名),装完必须**重启 DSH** 才生效。
48
+ >
49
+ > ⚠️ **0.1.7 自己的升级动作会搬走 `~/.dsh/settings.yaml`**:它把该文件改名成 `settings.yaml.imported`
50
+ > 并逐节导入 profile patch(**一次性、部分导入过的文件不会重跑**)。本插件这一段在 0.1.7 上
51
+ > **导不进去**(那时插件还没有 Config,服务报 `No configurable plugin entry`),所以旧值只会留在
52
+ > 那个 `.imported` 文件里;0.1.6 又会重新生成一份干净的 `settings.yaml`。
53
+ > 结果就是:**升级到 0.1.7 之前,请先备份 `settings.yaml`(或那个 `.imported`),旧值需要手动迁回。**
54
+
55
+ ### 破坏性变更
56
+
57
+ - **删掉「设置面板 → 导航滚动」这一栏**:DSH 0.1.7 的官方设置页已给导航列自带 `overflow-y: auto`
58
+ (`ui-settings-general` 的 `.navList`),本插件再注入一套就是重复实现。整项删除:字段 `panelScroll`、
59
+ `html.dsh-ux-panel-scroll` 那组注入样式、标题行那个小开关、栏判据里的那一条。**「边缘缩放」照旧保留。**
60
+ - 迁移影响:只开过「导航滚动」、从没拖过尺寸的旧文档会被判成"这一栏从没碰过" ⇒ 该栏回到默认关。
61
+ 这是刻意的:导航滚动已经不存在,那一栏剩下的唯一功能是缩放,而这些人从没用过它。
62
+ - 旧文档里残留的 `panelScroll` 不会让升级失败:schemastery 对**未声明键是原样放行**(已实测),
63
+ 所以不需要为它留一个宽容字段。
64
+
65
+ ### 适配(DSH 0.1.7)
66
+
67
+ - **两代设置服务都认领**(客户端半):0.1.6 及以前是 `ctx.settingsScope.bind({ namespace })`,
68
+ 0.1.7 起是 `ctx.configForms.get(namespace)`。两者**不能同时写进静态 `inject`** —— 各自在对方那一代
69
+ 永远不出现,插件会静默等依赖、**整块不挂载**(设置页、键位、右键菜单、快捷指令一起消失)。
70
+ 所以静态 inject 只留 `slots`,两个名字各用 `ctx.inject` 等一次,再加一次"直接读一次"兜底。
71
+ - **宿主半按能力分支注册**:`settings.register` 存在才注册(0.1.6);0.1.7 的命名空间由导出的 `Config` 推导。
72
+ - **"自己那一行"换了读法**:0.1.7 没有 `settings.get()`。自己的行从 Config 读(volatile 引用要解引用),
73
+ 别人的行(只读 llm-pi-ai 的 providers.headers)从 `describe()` 读;两代差异收口在 `makeReader()`。
74
+ - **事件名换代**:0.1.6 发 `settings/updated`,0.1.7 发 `settings/document-updated`(旧名在 0.1.7 里
75
+ **整个不存在**)。两个都听,任一世代只会有一个触发。
76
+ - **manifest 的 `dsh.client.inject` 改成包行名**:`["slots","settingsScope"]` → `["@deepseek-ai/dsh-client-ui-settings"]`
77
+ (0.1.7 起这个字段的语义是"本行物化前必须先到位的包行",写服务名没有意义)。
78
+ - **volatile 标记靠数据、不靠方法**:宿主半把 schemastery **内联**进产物(取自构建时的 DSH 检出),
79
+ 所以"在 0.1.6 上构建出来的包"里没有 `.volatile()`(它是 schemastery 3.18.3 才加的)。
80
+ 改为直接写 `schema.meta.volatile = true` —— 0.1.7 的设置服务读的正是这个标记,与构造它的库版本无关。
81
+ 0.1.7 里**只有 volatile 字段会被服务、也只有 volatile 路径能被写入**,所以整棵 Config 标 volatile。
82
+
83
+ ### 提示词优化改成「条目 + 逐字依据」(机制取自对方 0.6 线)
84
+
85
+ - **背景(核对过的实话)**:0.5.x 那三档文本逐字取自对方 `lib/index.js` 的 **`LEGACY_V011`** ——
86
+ 该常量自己的注释写着"逐字节取自 0.1.1 的 system 提示词",且只在**回退策略** `v011-ptc` 下使用;
87
+ 对方默认策略是 `STRATEGY_DEFAULT = 'v6'`(`buildSystem()` 走 `LANG_ONLY_SYSTEM` /
88
+ `STRATEGY_V5_SYSTEM` + `CLOSING_SELFCHECK` + 收件人契约)。**即:抄到的是对方历史回退路径上的那一层。**
89
+ 更根本的问题是:自由改写器下"模型替用户发明一条需求"在结构上无法被发现,
90
+ 提示词里写多少遍"不许新增"都只是空话。
91
+ - **机制照对方 0.6 线重写**(`po06/lib/interpreter.js` 的 `validateProvenance`、
92
+ `po06/lib/compiler.js` 的固定节序 + 预算丢弃):
93
+ - 模型不再自由改写,只产出条目;`rewrite` / `requirement` / `quality` **必须**附一段在用户原话里
94
+ **逐字存在**的引文,宿主做字面子串比对,**对不上就只丢那一条**并记账(单点不得废整轮——
95
+ 这条纪律是对方 2026-09-22 真机回归换来的);找不到逐字匹配时退一步做**空白归一**比对,
96
+ 免得折行/多空格复述白白丢条目。
97
+ - `rewrite` 按引文在原话里的**位置**回填(右侧先动,下标不串位),没被覆盖的原话**原样保留**,
98
+ 其余条目按固定节序追加在末尾、每条带自己的逐字依据 —— 于是"凭空加需求"拿不出依据。
99
+ - 篇幅预算按档位给(普通 1.4 倍 / 高级 2.6 倍 / 极端 4 倍,另有绝对上限),超预算按固定顺序丢
100
+ **可选的节**、必保节永不丢,并把"省略了几条"写进成品(降级要出声,不静默截断)。
101
+ - 空产出**重试一次**(话术取自对方 `retryEmpty`,它来自对方真机"思考完成却没有产出")。
102
+ - **两条兜底**:模型没按信封输出 → 按旧行为**整段照收**(保证这次改造不会让原本能用的优化变成失败);
103
+ 输出像信封但半截坏掉 → **直接失败**,绝不把坏 JSON 写进输入框(这是真机上最伤人的一种"优化")。
104
+ - **新增**:设置页每一档的提示词**可编辑**(`optimizerPromptBasic/Advanced/Extreme`,留空 = 用内置),
105
+ 带「载入内置版」「恢复内置」,并显示插件固定追加的那段输出契约;状态行如实回报
106
+ `N 条补全 · 丢弃 M 条 · 重试过一次 · 自定义提示词`。**JSON 输出契约不由自定义提示词控制**
107
+ (它是"逐字依据"能被校验的前提)。
108
+ - **未采纳(如实记录)**:对方的会话上下文读取(回合 0–10 / 全文)、只读工具(read/glob/grep)、
109
+ 发送前拦截 + 原话不改 + 随行包注入、每会话隔离与 `po06.json` 状态文件 —— 这些都要新的服务接线或
110
+ 会改掉本插件"点按钮 → 回填输入框"的既有交互,本次不动。
111
+ - 提示词仍是三档(普通 / 高级 / 极端)与原有温度(0.2 / 0.3 / 0.3),**对外接口未变**:
112
+ `POST /composer-ux/optimize` 的请求体与 `ok/text/error/provider/model/truncated` 字段照旧,
113
+ 新增字段(`itemCount` / `dropped` / `warnings` / `fallback` / `retried` / `promptSource` / `budget` / `chars`)
114
+ 都是**附加**的,老客户端不读也不会坏。
115
+
116
+ ### 测试
117
+
118
+ - 新增 `test/settings-service-adopt.mjs`(23 项):用真产物跑三种服务组合(只有 `settingsScope` /
119
+ 只有 `configForms` / 两个都没有),并钉住"静态 inject 只能写 `slots`"。
120
+ - 新增 `test/host-settings-generations.mjs`(14 项):两代服务形状各跑一遍宿主半,钉住
121
+ `Config.meta.volatile`、0.1.7 形状下"没有 register 也不抛"、"Config 读不出来要退回 describe"。
122
+ - 变异护栏 21 → 26 条:新增 W(静态 inject 绑回某一代)、X(0.1.7 的认领被删)、
123
+ Y(register 不按能力调用)、Z(volatile 标记丢失)、AA(Config 空值不退回 describe)。
124
+ - 提示词优化这一块:`test/quick-commands.mjs` 从 3 节扩到 4 节(新增「依据校验与宿主装配」),
125
+ 逐条钉住"引文对不上 → 只丢那一条并记账"「原文没被改写覆盖的部分原样保留」「半截 JSON 绝不写回」
126
+ 「没给信封 → 整段照收兜底」「空产出重试一次」「自定义提示词不顶掉输出契约」等 50+ 项。
127
+
128
+ ## [0.5.0] — 2026-09-21
129
+
130
+ > **本版含宿主半新能力**(六个折叠卡的栏开关进了 settings schema),装完必须**重启 DSH** 才会作用到会话上。
131
+ > 只动客户端半的改动(设置页版式、右键菜单卡那两处)刷新页面即可。
132
+
133
+ ### 新增
134
+
135
+ - **两层开关:总开关 + 每栏开关**(用户要求「插件的每个条目都增加一个开关按钮默认关闭,总开关默认打开」)。
136
+ - 设置页顶部「启用输入增强」= **总开关,默认开**(原本就是),它是一道总闸;六张折叠卡的**标题行右端各一个卡级开关,默认关**。生效条件是 `enabled && 该栏开关`,判据只有一个 `activeSections()`,不在各组件里各写一遍。
137
+ - **卡内已有的开关全部搬到标题行**(用户要求),其中**右键菜单那 7 项后来又搬回了卡内**(见下一条):设置面板的「导航滚动 / 边缘缩放」变成标题行的两个小开关;默认终端的三档变成标题行的 `compact` 胶囊;**OpenCode 请求头那一栏没有新键** —— 原来的 `headerEnabled` 本来就是"这一栏要不要生效",直接当卡级开关,不造同义的第二个键。
138
+ - **右键菜单卡:点哪档只显示哪档的说明 + 那 7 项只在「自定义」档出现**(用户看过真实界面后提的两条)。
139
+ - 原来三档说明 + 剪贴板授权注意事项一共 6 段**全铺开**,一屏文字、还要人自己找哪段跟自己选的那档有关;现在 `官方 / 浏览器 / 自定义` **各一段互斥渲染**,「自定义」那段后面才接它专用的 Chrome / Edge、Firefox 剪贴板授权说明。
140
+ - 那 7 个条目开关(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)先做成标题行的「**7 项 ▼**」条(7 个滑块平铺会把标题行挤爆),后来改成**平铺在「自定义」档里**:这 7 项本来就只对「自定义」档有意义,摆在标题行等于在任何档位都能改一堆当时不生效的东西。strip 那一套(按钮 + 展开条 + `dsh-ux-stripButton` / `dsh-ux-cardStrip` 两个类)**整套删掉**,并留了"不许复活"的护栏(变异 Q 咬住)—— 设置页布局没有自动化测试,护栏只能保到这里,挤不挤得下由用户的眼睛判。
141
+ - 顺手删掉标题行下面那行**重复当前档位**的正文 `hint`(`MENU_MODES[].hint` 仍是三枚胶囊的悬停提示,只是不再有第二处正文)。
142
+ - 标题行因此从"整行一个 `<button>`"改成"展开按钮 + 控件区"两个兄弟节点:`<button>` 里塞按钮是无效 HTML,点击还会冒泡成"展开整张卡"。
143
+ - **关掉一栏 = 这一块完全不介入**,栏内的值全部保留(打开即原样恢复):键位 → DSH 原生键位;右键菜单 → 不介入;快捷指令 → 输入框那枚按钮消失、面板收掉、**附加批次直接给空**(键位发送与官方发送按钮两条路共用这一个闸);设置面板 → 不滚动、不拖大小;OpenCode 请求头 → 停止注入并撤销已写入的头;默认终端 → 宿主半撤销已下发的 `pwsh` 压制并回写"未启用"。
144
+ - **未启用时的观感**:概览行加「未启用 · 」前缀、卡片描边变虚线、展开区压暗;栏内控件仍可编辑(先配好再打开)。
145
+ - **「默认关」对老用户是迁移出来的,不是硬编码**(用户拍板 A 方案)。
146
+ - 判据是**"这一栏的值不等于从没碰过的样子"**(改过键位 / 选过浏览器或自定义菜单档 / 有面板尺寸记录 / 终端档位不是自动或填过路径)。全新安装(什么痕迹都没有)才是六栏全关。
147
+ - ⚠️ **不能按"键存在"判断**:`settings.get()` 给的是**已解析**的值(schema 默认值 + 组合 base + 用户层),从没碰过的人的文档里也照样有 `sendKey: 'Enter'`、`panelScroll: true` —— 按"键存在"判断会让全新安装六栏全开,与"默认关"正好相反。这条被测试当场逮住(「全新安装(空文档)→ 五栏全关」变红),判据改成"出现过 **且** 不等于默认值"。
148
+ - 同理,**宿主半自持字段不许当判据**(`terminalCandidates` / `terminalStatus` / `terminalEffective` / `headerApplied*` 是插件自己写的,全新安装也会出现),有专门一条测试盯着。
149
+ - **五栏开关在 schema 里是 `.required(false)`、故意不给默认值**:schemastery 对缺省的可选键会**整个省掉**,所以"文档里没有这个键"与"明确关掉"能分开 —— 迁移要用的就是这个区别。有测试专门钉住"缺省 ≠ false"(谁把它改成 `.default(false)`,迁移会静默失效)。
150
+ - **「快捷指令」栏多一条文件判据**:0.3.0 起条目搬到了 `quick-prompts.json`,用过的人和没用过的人的设置文档可以一模一样。所以宿主半启动时读一次书本文件,**有非内置内容就把 `quickEnabled: true` 写回文档**(写回之后两端都只需要看那一个布尔)。只在文档里还没有这个键时写:用户明确关掉之后不会被重新打开(有测试)。
151
+ - **升级前可以先自查**:新增 `test/check-sections.mjs` —— 拿真实的 `settings.yaml` 跑一遍迁移,打印六栏会变成什么,**只读**、不写任何文件。本机实测输出:六栏全开(「快捷指令」靠文件判据)。
152
+ - **「设置 → 输入体验 → 默认终端」**(只对 Windows 生效):把模型用的终端工具从 PowerShell(`pwsh`)换成 **Git Bash**。
153
+ - **三档**:自动(探测到 Git Bash 就用)/ Git Bash / PowerShell(保持 DSH 默认、本插件完全不介入)。默认「自动」。
154
+ - **「裸本体」不列为候选**:同根下既有 `bin\bash.exe` 又有 `usr\bin\bash.exe` / `mingw64\bin\bash.exe` 时只列前者。本机实测:前者 `MSYSTEM=MINGW64`、`PATH` 前置 `/mingw64/bin:/usr/bin`、`head`/`grep`/`uname` 都在且中文文件名当参数正常;后者 `MSYSTEM` 为空、`PATH` 只有继承来的 Windows PATH,**coreutils 全部 command not found**(交给模型就是命令大面积失败)。没有 `bin\bash.exe` 兄弟的来源(如 MSYS2 只提供 `usr\bin\bash.exe`)照常列出;**手填路径不受此限**。
155
+ - **探测顺序**(同一路径只留优先级最高的一次,候选会在卡片里列出并标注来源):设置里填的路径 → **PATH 上正在用的那份 git(由 `git.exe` 反推安装根)** → Git 官方落点(`Program Files` / `x86` / `%ProgramW6432%` / **`%LOCALAPPDATA%\Programs`**——安装器以普通用户运行时的默认落点)→ **Scoop / Chocolatey 便携包** → **GitHub Desktop 内嵌 / 旧 GitHub 的 `PortableGit_*` / Visual Studio 内嵌**(带版本号,靠列举子目录,新版本优先)→ MSYS2 / Cygwin → **各盘符根下的 `Git` / `PortableGit` / `msys64` / `cygwin64`** → Niubash(`niu.exe`)→ PATH 兜底。这份清单是**联网核对过**的([SO: Where is git.exe located?](https://stackoverflow.com/questions/11928561/where-is-git-exe-located)、[SO: Why was git installed in AppData](https://stackoverflow.com/questions/32297340/why-was-git-installed-in-appdata-instead-of-program-files)、[cli/cli#2617](https://github.com/cli/cli/issues/2617) 等),并逐条写了测试。探测只做有限次存在性检查 + 三处目录列举,**不递归扫盘、不跑进程**。
156
+ - **硬排除 WSL 的 `bash.exe`**(`System32` 与 `WindowsApps` 两个来源)——它把 `D:\x` 解释成 `/mnt/d/x`,与模型手里的 Windows 工作目录不兼容,而且只有在它**确实存在**时才会出现在「已排除」说明里。
157
+ - 本机就是这条规则的受益者:Git for Windows 装在 `D:\Git`(不在 Program Files),只按固定目录找是找不到的——「先找 git 再反推」才能命中 `D:\Git\bin\bash.exe`。
158
+ - **立刻生效**:改档位后宿主半会**遍历所有在跑会话**重新下发,不需要开新会话(详见下节「为什么这样实现」)。
159
+ - **工具契约与官方 `@deepseek-ai/dsh-tool-bash` 逐字对齐**:描述、参数与输出 JSON Schema、`[stderr]` 头、`(no output)` 兜底、标记顺序(拒绝 → 升级提示 → 超时 → signal 或 exit code 在**最末**)、终端卡片(exit 状态拆成 pill)、后台任务(`{kind:'background', jobId}` + `job_output` / `job_kill`)、超时收敛(默认 120s、上限 600s)、输出截断与落盘位置、沙箱约束与**升级审批**(fail-closed、严格更宽、审批文案)。
160
+ - **零新增运行时依赖**:宿主半仍是自包含产物(只内联 schemastery/cosmokit),官方的五个运行时符号(`defineTool` / `TOOL_ABORTED` / `HarnessError` / 沙箱标记与升级助手 / `DSH_ENV_PREFIX`)在 `src/terminal/` 下等价实现,避免因「依赖共享宿主包」被市场体检警告。
161
+ - **失败不伤会话**:探测不到、拿不到 `subprocess`、`restrict` 被拒(例如该会话本来就看不到 `pwsh`)、非 Windows —— 一律只降级 + 在设置页状态行写明原因,不抛错、不 veto 别的插件。
162
+ - 设置页只读展示三项宿主半自持字段(`terminalStatus` / `terminalEffective` / `terminalCandidates`),并加了「自动发现」按钮与候选点选。
163
+ - 设置接口 `GET/POST /composer-ux/terminal`:查状态、重扫候选。与另外两条路由一样受官方 `connection.requestRejection` 守卫(本机 webServer 绑 `0.0.0.0`,而这条接口会回传本地路径)。
164
+ - 设置页抬头(「输入体验」那张卡)右端加了 **「GitHub ↗」**:真实 `<a target="_blank" rel="noreferrer noopener">`,地址取自契约里的 `REPO_URL`(与 `package.json` 的 `repository.url` 一致,客户端不硬编码第二份 —— 有测试盯着两处不许分叉)。
165
+ - **「重启 DSH」按钮**(`src/restart.ts` + `GET/POST /composer-ux/restart`):位置是「输入体验」卡片**抬头右端、GitHub 链接左边**,确认条展开在抬头正下方。
166
+ - **机制照搬插件市场 [dsh-market](https://github.com/dsh-market/dsh-market)**(`src/restart.ts` + `src/dsh-cli.ts`,本机 profile 里就有它的源码与注释)。为什么抄它而不是自己发明:它文件顶部挂着一串 issue 号,每条都是"重启按钮按下去没用"的具体死法。
167
+ - **两步确认**(用户拍板保留):点按钮先读"会怎么重启、当前有几个会话在跑",确认条在抬头正下方,点「确认重启」才真重启 —— 重启会打断正在跑的会话(包括正在生成的那一轮)。
168
+ - **分离一个 node 助手进程**(`node -e <源码>`,detached + unref + `stdio: 'ignore'`),宿主自己 **500ms 后退出**,好让这个 HTTP 响应先发出去。
169
+ - **助手等端口真的空出来**:每 250ms `connect` 探一次、最多 30 秒,通了再等 300ms(Windows 的 TIME_WAIT 尾巴)。用 connect 而不是 bind:bind 一下自己就把那个马上要交出去的端口占住了。固定 sleep 会让新宿主 `EADDRINUSE` 当场死掉、而失败还被 `catch {}` 吞了(市场 issue #177)。
170
+ - **用隐藏控制台的 PowerShell 起新宿主**:Windows 上 `detached` = `DETACHED_PROCESS` = **没有控制台**,新宿主之后起的每个控制台子进程都会新建一个可见窗口(#40);而 `-WindowStyle Hidden` 管不到 spawn 交给 PowerShell 的那个控制台,所以助手那层还得自己带 `windowsHide`(CREATE_NO_WINDOW,#624)。裸 `dsh` 要显式补成 `dsh.cmd`:PowerShell 会优先选被默认策略拒绝的 `dsh.ps1`(#397)。
171
+ - **起来之后再验证 20 秒**:端口没人监听就把诊断写进日志 —— 本来该记日志的宿主进程已经退出了,重启失败必须留证据。
172
+ - **界面靠 `boot` 号判断成功**:每 1.5 秒问一次状态,号变了(新进程接管了端口)就 `location.reload()`;60 秒还没变才报超时,并把日志路径显示出来(市场同款判定)。
173
+ - **两道关卡**:先过官方 `connection.requestRejection`(Host/Origin 围栏 + 浏览器令牌),再过本插件自己的"回环 peer + 无转发头 + `Origin` 与 `Host` 同源"。这是"杀进程"的接口,跨站页面一定带自己的 `Origin`,挡在这里。非 GET/POST 回 405。
174
+ - **不该从界面里杀掉的宿主会拒绝**:宿主正被调试器附着(`inspector.url()` / `--inspect` 家族,按 token 前缀匹配以免把 `inspect-tool.js` 这种路径误判),或它在 systemd 下当服务跑(`INVOCATION_ID`/`JOURNAL_STREAM` **且**父进程是 PID 1 或 comm 为 `systemd` —— 只看环境变量会把普通终端与 CI runner 误判成"有 supervisor",市场 issue #229/#471 就是这么踩的)。这时按钮禁用并说明原因。
175
+ - **退出方式与市场刻意不同**:用 `process.emit('SIGTERM')` 而不是 `process.kill(pid,'SIGTERM')`。理由是可验证的:DSH 在 `apps/cli/src/profile-boot.ts:290` 注册了 `SIGTERM → interrupt(0)`(先 `fiber.dispose()` 再退出,自带 5 秒上限 `PROCESS_SHUTDOWN_TIMEOUT_MS`),而 **Windows 上 `process.kill` 等价于 `TerminateProcess`** —— 本机实测(node 起子进程、注册 handler、自己杀自己)SIGTERM / SIGINT / SIGKILL 三种写法的 handler **一次都没跑到**,进程直接没了。对"刚装完插件随手重启"没差别,对正在跑长会话的用户就是硬切。兜底:10 秒后还活着就 `exit(0)`。
176
+ - **第一次点的时候,正在跑的宿主还是上一版**(新路由要重启后才加载):宿主半没有 `boot` 字段时,界面如实说明"这次走旧机制,重启之后按钮就是新版了"。旧版 handler 在同一路径上,所以第一次点照样能把 DSH 重启起来。
177
+ - **去掉「可选重启命令」与那张「维护」卡**(用户拍板):机制换成市场那套之后,这个字段只剩"多一个会填错的地方"。老文档里可能留着的 `restartCommand` 由「恢复默认」顺手清掉;schema 里不再有它。
178
+ - 新增 `GET /composer-ux/restart`(只读:boot 号、重放命令、日志落点、在跑会话数、是否被拦)与 `POST`(真重启,回 **202** + boot 号)。**测试里绝不让 POST 走到成功路径**:那会真的重启测试进程。
179
+
180
+ ### 为什么这样实现(三条来自官方源码的硬约束)
181
+
182
+ 1. **`ctx.tools.restrict()` 不能在全局上下文调用**:`packages/core/tools/src/index.ts:1077` 直接抛 `tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent`。所以「隐藏 pwsh」只能发生在**该 agent 自己的 scope** 里;为了让改动立刻生效,本插件在 `agent/created` 与 `settings/updated` 两个时机**遍历在跑会话**逐个下发(与官方 fixture `scoped-tool-subagent` 同形)。
183
+ 2. **`restrict()` 会校验名字必须已在全局注册表里**,否则抛 `names unknown global tool "pwsh"` —— 该会话本来就看不到 `pwsh` 时会命中这条,本插件记一行原因跳过(这正是官方 `tool-bash` 的日志里 `restrict(deny:[pwsh]) refused, skipping` 的那种情况)。
184
+ 3. **不需要再写 `system-prompt/assemble` 过滤器**:工具注册表自己就把 schema 喂给提示词(`packages/core/tools/src/index.ts:834` → `systemPrompt.tools(context => this.wireSchemas(context.scope))`),而 `wireSchemas(scope)` 取的是**已过滤**的可见 schema —— 一次 `deny` 会同时从「可调用注册表」和「提示词工具列表」里消失。提示词侧只做一件事:在本会话作用域里摘掉预设的 `tool:pwsh` 段(按名字过滤 `sections`,与官方 `browser-use-runtime` 同一手法)。
185
+
186
+ ### 开发中发现并修掉的问题
187
+
188
+ 0. **把 Niubash 从"单条候选"改成"安装根分组"时它整个消失了。** 新加的来源分组只会试
189
+ `<root>/bin|usr/bin|mingw64/bin/bash.exe`,而 Niubash 的可执行文件叫 `niu.exe` 且就在根目录下 ——
190
+ 分组形状不匹配。是**测试逮住的**("Niubash 仍是候选"当场变红),不是靠肉眼复查。
191
+ **下面这条是用户实测发现的**(他在卡片上点了 `D:\Git\usr\bin\bash.exe` 那个候选,模型的命令就大面积失败):
192
+ `D:\Git\bin\bash.exe` 与 `D:\Git\usr\bin\bash.exe` 实测差别 —— 前者 `MSYSTEM=MINGW64`、`PATH` 前置 `/mingw64/bin:/usr/bin`、`head`/`grep`/`uname` 都在、中文文件名当参数正常;后者 `MSYSTEM` 为空、`PATH` 只有继承来的 Windows PATH(只有 `D:\Git\cmd`),**coreutils 全部 command not found**。
193
+ 2. **`sandbox.confine()` 是 `async`,而本机装着的 `dsh-windows-shell-policy@0.0.5` 把它当同步用**(`confined = sandbox.confine(...)` 后直接读 `.argv`)。官方签名是 `confine(argv, policy, signal): Promise<ConfinedArgv>`,所以那个插件一旦处于受限模式就拿到 `Promise`、`argv` 变成 `undefined`,命令根本发不起来。本实现 `await` 它,并专门写了一条测试(「实际 spawn 的是 confine 返回的 argv」)防止回归。
194
+ 2. **官方 `tool-bash` 在 win32 上是 `disabled`**(`packages/bundle/base/cordis.patch.yml`),而且它只消费 `ctx.shell` —— win32 的 `ctx.shell` 栈是 `pwsh-sandbox`(PowerShell)。所以「启用官方 bash 工具」拿不到 Git Bash,必须自带工具(这正是 converk/dsh-tweaks 那个插件的做法,本实现沿用并补齐了上面那条 async)。
195
+ 3. **「中文路径不能当参数」的根因很可能是本机 shell 环境而非 Windows**:实测当前 `bash` 工具里的 `grep` 解析到 `…/Niubash/winuxcmd/usr/bin/grep.exe`(版本串 `grep (WinuxCmd) 1.0.5`),且该环境**没有 `/proc`**——不是 MSYS/Git Bash;用它跑 `grep <中文文件名>` 会直接 `cannot open` 并 `0xC0000409` 退出。换成真 Git Bash 后是否消失,**等实测确认**(本版不宣称已解决)。
196
+
197
+ ### 测试
198
+
199
+ - 新增 `test/terminal-policy.mjs`(162 条):探测顺序与 WSL 排除、渲染与 `parseExitStatus`(含官方 `[exit code: null]` 怪癖)、升级审批 fail-closed、bash 工具的参数/输出 schema 与执行路径(argv / cwd / env / stdio 上限 / confine 必须 await / 拒绝识别 / 后台钩子 / 中止与超时)、宿主半接线(在跑会话当场下发、新会话下发、幂等、切回 PowerShell 时撤销、非 Windows 与缺服务的降级)。
200
+ - `test/quick-commands.mjs` 第 10 节重写(重启机制):启动命令重建的两种形态(dsh 入口 vs 裸 `dsh`)、`nodeExecutableOf`、Windows 的 PowerShell 包装与 `.cmd` 补全、端口解析与同源关卡逐条、调试器 / systemd 判定、助手源码的每条"为什么"、排期与优雅退出(spawn / 定时 / 退出全部注入)、以及**真的跑一遍助手**(替换进程的标记文件出现 / 起不来时 err 日志里有诊断 / 端口一空出来就起进程 —— 这条才是"等端口"逻辑真正的护栏)。
201
+ - `test/client-registration.mjs` 新增第 8 节:按钮在抬头且**在 GitHub 链接左边**、确认条在抬头正下方、第一步只 GET 第二步才 POST、靠 `boot` 号 `location.reload()`、被拦时禁用并说明、老宿主如实提示、类名真的进了产物与样式表(中文在产物里是 `\uXXXX`,所以只查 ASCII 标记)。
202
+ - 关键护栏做过**变异测试**(`test/mutation-guards.mjs`,手动跑):把「遍历在跑会话」去掉、把同源关卡去掉、把官方信任关卡去掉、把抬头按钮拿掉、把助手的 `windowsHide` 关掉、把"等端口"换成固定 sleep、把「可选重启命令」加回来、把栏开关一律返回 true、把迁移判据丢掉一半、把终端/键位的栏门控去掉、把五栏开关改成带默认值、把快捷指令的文件迁移去掉、把「自定义」档的门控去掉、把三档说明的互斥条件去掉、把 strip 那条死路接回来、把标题行下重复的 hint 加回去、把那 7 个条目开关整段删掉、把那 7 行渲染短路掉、把「浏览器」档的说明短路掉、把三档说明全铺开 —— **21 条全部当场变红**。跑变异必须带上 `node build.mjs`:宿主半的用例打的是构建产物,只改 `src/` 不重建会得到假的"没咬住"。
203
+ - 新增 `test/settings-render.mjs`(**手动跑,20 项**):这套件一直没有 React 渲染器,设置页版式只能靠搜源码字符串护栏 —— 那是最弱的一层(把 `{settings.menuMode === 'custom' && (` 改成 `{true && (` 它照样绿)。这个文件用 `react-dom/server`(**借 profile 里那份**,不新增依赖、不改 `package.json`)把设置页真的渲染成 HTML,断言:六张卡都渲染出来、右键菜单卡标题行只剩卡级开关、三档正文互斥且两两不同、那 7 行条目开关只在「自定义」档。三处坑记在文件头:两个 React 副本(profile 顶层与 react-dom 的 peer 不是同一个 ⇒ 必须按 react-dom 的解析路径取 react,否则 `dispatcher` 为 null)、三枚胶囊的 `title` 里也带着那三句话(按 HTML 搜会误报"说明没互斥",第一版就假红了 3 项)、`FoldCard` 的展开态在组件内部(SSR 点不动 ⇒ 打包时在内存里改成展开,锚点找不到就报错)。
204
+ - 开关与迁移的测试(共 60+ 条):全新安装六栏全关、总开关默认开、显式值优先于迁移、每栏判据各一条、宿主半自持字段不当判据、`activeSections` 的总闸语义、**用户真实文档的回归**(六栏保持全开)、schema 的"缺省 ≠ false"、快捷指令的文件迁移(非内置 → 写回;内置 → 不写;已关 → 不写回;幂等;没文件 → 不写)、终端栏关掉时撤销已下发的压制、总开关关掉时同样撤销、拦截器/入口按钮/面板把手各自的栏门控、标题行拆成两个兄弟节点、六张卡各有一个开关。
205
+
5
206
  ## [0.4.0] — 2026-09-15
6
207
 
7
- > 尚未发布:先在用户机器上实测,通过后再发 GitHub / npm。
208
+ > 已发布:用户实测通过后授权发布,已上 npm(`latest`)、GitHub 标签与 Release(`v0.4.0`)。
209
+ > 从 0.3.0 升级注意:本次含**宿主半 schema 改动**(新增 `menuMode`,并把旧布尔 `menuNative` 改成可选),**必须重启 DSH** 才会生效。
8
210
 
9
211
  ### 新增
10
212
 
@@ -48,7 +250,7 @@
48
250
 
49
251
  ## [0.3.0] — 2026-09-15
50
252
 
51
- > 尚未发布:先在用户机器上实测,通过后再发 GitHub / npm。
253
+ > 已发布:实测通过后发布到 GitHub 与 npm(本条目的「尚未发布」声明在 0.4.0 发布时才补正)。
52
254
 
53
255
  ### 变更(含一处破坏性调整)
54
256
 
package/README.md CHANGED
@@ -4,21 +4,37 @@
4
4
  > configurable **send / newline keys**, a native-style **7-item right-click context menu**, a resizable &
5
5
  > scrollable **settings panel with persisted size**, a **global on/off switch**, a **quick-command panel**
6
6
  > (built-in prompts, click-to-insert, per-item "always append on send"), a **prompt optimizer** that runs a
7
- > separate model call before you send, and automatic **`x-opencode-session` request-header injection** so
8
- > OpenCode (Go) routes work inside DSH.
7
+ > separate model call before you send, automatic **`x-opencode-session` request-header injection** so
8
+ > OpenCode (Go) routes work inside DSH, and — on Windows — a **default-terminal switch** that replaces the
9
+ > model's PowerShell tool with **Git Bash** (git-anchored discovery, WSL excluded, per-session tool-surface
10
+ > trim, official-identical sandbox/approval/timeout semantics), plus a **one-click "restart DSH"** button
11
+ > in the settings-card header (market-proven self-restart: detached node helper, port-release wait,
12
+ > hidden-console relaunch, same-origin fence, boot-id reload).
9
13
  > Install with `dsh plugin --profile <name> add github:fangwen9527/dsh-composer-ux` — the built `lib/`
10
14
  > ships in this repository, so there is **no build step and no build authorization**. License: MIT.
11
15
 
12
16
  DeepSeek Harness Web 输入体验增强插件:
13
17
 
14
- 0. **全局开关**:设置页顶部「启用输入增强」总开关——关闭后键位、右键菜单、快捷指令、设置面板滚动/缩放全部停用(输入框恢复 DSH 原生行为),设置页保留用于一键恢复;开关状态持久保存。
18
+ ![设置页「输入体验」:顶部总开关 + 六张折叠卡,每张卡的标题行右端各有一个卡级开关](https://raw.githubusercontent.com/fangwen9527/dsh-composer-ux/v0.5.0/docs/settings-panel-0.5.0.png)
19
+
20
+ > 上图即 0.5.0 的设置页(六栏都开着)。注意标题行**只有卡级开关与从属控件** —— 右键菜单那 7 个条目开关已按实测反馈搬进卡内的「自定义」档(见第 0 条)。
21
+
22
+ 0. **两层开关:总开关 + 每栏开关**。设置页顶部「启用输入增强」是**总开关(默认开)**,它是一道总闸;六张折叠卡的标题行右端各有一个**卡级开关(默认关)**,决定「这一栏要不要生效」。两处都满足才生效(`enabled && 该栏开关`)。
23
+ - **兼容性**:同一个产物同时支持 **DSH 0.1.6 与 0.1.7**。两代的设置服务形状不同(`settingsScope.bind` → `configForms.get`、宿主 `settings.register` 被删除、事件名换代),代码按**能力探测**分支,不维护两份产物。
24
+ - ⚠️ **升级 DSH 到 0.1.7 之前先备份 `~/.dsh/settings.yaml`**:0.1.7 的官方升级会把它改名成 `settings.yaml.imported` 并逐节导入 profile patch(一次性、不可逆),而本插件这一段在旧版上**导不进去**(那时还没有 Config 字段表),旧值只会留在那个 `.imported` 文件里。
25
+ - **老用户不会被升级弄坏**:卡级开关的默认值不是硬编码的 `false`,而是按「设置文档里有没有『你在用』的痕迹」迁移 —— 碰过的栏保持开着、没碰过的才是关;全新安装(空白文档)才是六栏全关。「快捷指令」那一栏多一条文件判据(0.3.0 起条目存在 `quick-prompts.json`,设置文档里看不出来)。
26
+ - **升级前可以先看一眼**:`node test/check-sections.mjs` 拿你真实的 `settings.yaml` 跑一遍迁移,打印六栏会变成什么(**只读**,不写任何文件)。
27
+ - **关掉一栏 = 这一块完全不介入**,且栏内的值全部保留(打开即原样恢复):键位关 → 输入框按 DSH 原生键位;右键菜单关 → 本插件不介入;快捷指令关 → 输入框那枚按钮消失;设置面板关 → 不拖大小;OpenCode 请求头关 → 停止注入并撤销已写入的头;默认终端关 → 保持 PowerShell。
28
+ - 「OpenCode 请求头」那一栏**没有额外的开关**:原来的「附加请求头」本来就是「这一栏要不要生效」,直接搬到了标题行。
15
29
  1. **设置 → 输入体验**(设置页新增条目)
16
- - 版式对齐社区插件 `@linxin666/dsh-web-all` 的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名 `dsh-composer-ux` 的一行描述 + 总开关」;其下五个栏目为**可折叠卡片**(标题行只放标题 + 一句动态概览,长说明与控件都在展开后的内容区),**默认全部折叠、不记忆展开状态**,可同时展开多个。
30
+ - 版式对齐社区插件 `@linxin666/dsh-web-all` 的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名 `dsh-composer-ux` 的一行描述 + 总开关」;其下六个栏目为**可折叠卡片** —— 标题行左侧是「标题 + 一句动态概览 + 展开箭头」,右侧是**这一栏的开关与从属控件**(设置面板的缩放开关、默认终端的三档都在这里),长说明与其余控件在展开后的内容区;**默认全部折叠、不记忆展开状态**,可同时展开多个。标题行**只放卡级开关与从属控件**:右键菜单那 7 个条目开关按用户后来的反馈**搬回了卡内**(它们只对「自定义」档有意义,见下一条)。
17
31
  - **键位**:分别配置「发送键」「换行键」——常用预设(Enter / Ctrl+Enter / Alt+Enter / Shift+Enter)+ 点击「自定义…」后直接按任意组合键录制(Esc 取消,Backspace 清除),支持清空为「无」;发送与换行不能设为相同按键;可一键恢复默认。
18
- - **右键菜单**:三档「菜单来源」(官方不介入 / 浏览器菜单 / 自定义菜单,见下「右键菜单」一节);自定义档那 7 个条目(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)可单独开关。
32
+ - **右键菜单**:三档「菜单来源」(官方不介入 / 浏览器菜单 / 自定义菜单,见下「右键菜单」一节);卡内**只显示当前选中那一档的说明**(点官方看官方的、点浏览器看浏览器的、点自定义看自定义的;自定义档还带 Chrome / Edge 与 Firefox 的剪贴板授权说明);那 7 个条目(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)**只在「自定义」档显示**、逐个开关(旁边一个「全部开启」)。这三件事有**真渲染测试**盯着(`node test/settings-render.mjs`:把设置页渲染成 HTML,断言三档正文互斥、那 7 行只在自定义档、标题行没有第二个入口)。
19
33
  - **快捷指令**:分类增删改 / 条目增删改与上下移 / 跨分类移动 / 每条一个插入模式(关 · 每次 · 仅首次)/ 优化强度三档(详见下节)。
20
- - **设置面板**:导航可滚动开关;边缘拖拽调整面板大小(尺寸记忆持久化)与尺寸预设。
34
+ - **设置面板**:边缘调整大小一个开关(标题行);尺寸预设与拖拽说明在展开区。**导航列滚动由 DSH 官方的设置页自带**(0.1.7 起 `.navList` 就有 `overflow-y: auto`),所以 0.6.0 把「导航滚动」那个开关整项删掉了 —— 留着就是重复实现。
21
35
  - **OpenCode 请求头**:给 OpenCode 的模型请求自动附加 `x-opencode-session`(详见下节)。
36
+ - **默认终端**:Windows 上把模型用的终端工具从 PowerShell 换成 Git Bash(三档在标题行;详见下节)。
37
+ - **重启 DSH**:卡片抬头右端(GitHub 链接左边)那枚按钮,两步确认后原地重启(详见「维护:重启 DSH」)。
22
38
  2. **快捷指令按钮**:输入框工具行里、「展开」按钮左侧的胶囊按钮,点开是常备提示词清单 + 「优化提示词」。
23
39
  3. **键位生效**(仅主聊天输入框):默认值 = 现状(Enter 发送、Shift+Enter 换行、Ctrl+Enter 加速提交),改动即时生效并持久保存。
24
40
 
@@ -102,6 +118,9 @@ dsh plugin --profile web add <你克隆或解压出来的目录>
102
118
  - 为什么是一个三选一控件、而不是两个勾选框:「每次」与「仅首次」互斥,两个独立勾选框能造出「同时又每次又仅首次」的矛盾状态;三选一只发出一个 mode,由 `withInsertMode` 一次把两个标志写对。
103
119
  - 手工编辑文件时若把 `autoSend` 与 `autoSendFirst` 都写成 `true`,按「每次」处理(每次插入本来就包含第一次),并在下次写盘时修正回互斥状态。
104
120
  - **优化提示词**:把输入框里的话交给**另一个 AI** 整理成一条能直接发给工作 AI 的清晰指令,结果**直接写回输入框**(Ctrl+Z 可还原)。三档强度:普通 / 高级(默认)/ 极端。
121
+ - **0.6.0 起它不再"自由改写"**:模型只产出**条目**,每条 `rewrite`/`requirement`/`quality` 必须附一段**在你原话里逐字存在**的引文;宿主逐条做字面比对,对不上就**只丢那一条**并记账。`rewrite` 按引文位置回填,没被覆盖的原话原样保留,其余条目按节追加在末尾。于是"替你发明一条你没说过的需求"在结构上做不到。
122
+ - 三档的差别是**依据预算**:普通 = 只做语言层修复;高级 = 可以补"能指回原话某一句"的必要要求;极端 = 再加分阶段计划与预案。成品有篇幅预算(普通档 1.4 倍),超了就按固定顺序丢可选的节,并把"省略了几条"写进成品(绝不静默截断)。
123
+ - 状态行会如实交代这一轮的结果:`N 条补全 · 丢弃 M 条 · 重试过一次 · 自定义提示词`;模型没按条目契约输出时走**整段照收**的兜底并标注 `未校验依据`(保证改造不会让原本能用的优化变成失败),输出像条目信封但半截坏掉时**直接失败**、绝不把坏 JSON 写进输入框。
105
124
 
106
125
  ### 数据存在哪
107
126
 
@@ -117,7 +136,9 @@ dsh plugin --profile web add <你克隆或解压出来的目录>
117
136
 
118
137
  ### 为什么优化要走宿主的 HTTP 接口
119
138
 
120
- 出网请求由宿主的模型适配器发出,浏览器侧碰不到模型路由;宿主半与客户端半之间也没有别的受支持通道。所以「优化提示词」是一次往返:浏览器 `POST /composer-ux/optimize` → 宿主用 `ctx.get('llm').stream(...)` 独立跑一次模型调用 → 把优化后的正文回给浏览器填进输入框。这条路径与 [WestFox-AwA/dsh-prompt-optimizer](https://github.com/WestFox-AwA/dsh-prompt-optimizer) 同构,系统提示词也逐字提取自它(BSD-3-Clause,作者「啃轮胎的西狐」),落在 `src/optimizer-prompt.ts` 并保留署名。
139
+ 出网请求由宿主的模型适配器发出,浏览器侧碰不到模型路由;宿主半与客户端半之间也没有别的受支持通道。所以「优化提示词」是一次往返:浏览器 `POST /composer-ux/optimize` → 宿主用 `ctx.get('llm').stream(...)` 独立跑一次模型调用 → 把装配好的正文回给浏览器填进输入框。这条路径与 [WestFox-AwA/dsh-prompt-optimizer](https://github.com/WestFox-AwA/dsh-prompt-optimizer) 同构。
140
+
141
+ **提示词的来历(如实写)**:0.5.x 那三档是逐字提取自它 0.5 线的 `lib/index.js`(BSD-3-Clause,作者「啃轮胎的西狐」)。0.6.0 起机制与提示词都改按它 **0.6 线**(`po06/lib/interpreter.js` 的 `SYSTEM_PROMPT` + `validateProvenance`、`po06/lib/compiler.js` 的固定节序与预算丢弃)**重写**,不再是逐字提取 —— 那些文件里没有现成可抄的"三档改写提示词",能借的是机制本身:条目化产出、逐字引文、只丢单条、降级出声。落在 `src/optimizer-prompt.ts`(提示词)与 `src/optimizer-assemble.ts`(校验 + 装配)两个文件,署名与来源说明保留在各个文件头。
121
142
 
122
143
  优化用的模型**跟随你当前的默认模型**(`agentDefaultModel.currentSelection()`),不额外配置;每次优化会花一次模型调用,但**不占对话轮次、不进会话历史**。
123
144
 
@@ -144,6 +165,54 @@ OpenCode 的接口要求客户端在每次请求里带上一个稳定的会话 I
144
165
  - **带着它时真实调用成功,且前缀缓存在工作**:同一段前缀连发两次,`input` 174 → 46、`cacheRead` 192 → 320(总前缀 366 不变),第二次有更多内容直接命中缓存。
145
166
  - **线级证据**:用 `test/opencode-header-wire-probe.mjs` 起一个本地端点,DSH 发过去的推理请求上确实带着 `x-opencode-session: <值>`,以及它自己的 `user-agent: deepseek-harness/0.1.5-rc.2 (+https://github.com/deepseek-ai/deepseek-harness)`(正好满足 OpenCode 文档对客户端标识的第 2 条要求)。
146
167
 
168
+ ## 默认终端(Windows)
169
+
170
+ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`),而模型的训练语料里 bash 占绝对多数。这一栏把终端换成 **Git Bash**:模型看到的工具就叫 `bash`,`pwsh` 从它的工具列表里消失 —— 一个会话只面对一个终端工具。
171
+
172
+ - **设置页位置**:设置 → 输入体验 → 「默认终端」(折叠栏目):档位 / Git Bash 路径 /「自动发现」/ 候选点选,附两行宿主半回写的只读状态(当前生效 shell、状态行)。
173
+ - **三档**:`自动`(探测到 Git Bash 就用,找不到就保持 PowerShell —— 默认,开箱即用)/ `Git Bash`(强制换,没探测到会回落并在状态行说明)/ `PowerShell`(保持 DSH 默认,本插件完全不介入终端)。
174
+ - **探测顺序**(同一路径只留优先级最高的那一次;多个候选会在卡片里列出来让你点,并标注来源):
175
+ 1. 你在设置里填的路径;
176
+ 2. **PATH 上正在用的那份 git**(由 `git.exe` 反推同一个安装根 —— 本机就是这样命中 `D:\Git` 的);
177
+ 3. Git 的官方落点:`Program Files\Git`、`Program Files (x86)\Git`、`%ProgramW6432%\Git`、`%LOCALAPPDATA%\Programs\Git`(**安装器以普通用户身份运行时默认落这里**,以管理员运行才落 Program Files);
178
+ 4. **Scoop**(`%SCOOP%\apps\git\current`)与 **Chocolatey 便携包**(`…\chocolatey\lib\git.portable\tools`);
179
+ 5. **GitHub Desktop 内嵌**(`%LOCALAPPDATA%\GitHubDesktop\app-*\resources\app\git`)、**旧 GitHub for Windows 的 PortableGit**(`%LOCALAPPDATA%\GitHub\PortableGit_*`)、**Visual Studio 内嵌**(`…\Microsoft Visual Studio\<年份>\<版本>\Common7\IDE\…\Team Explorer\Git`)—— 这三处带版本号,靠"列一眼子目录"枚举(新的排前面);
180
+ 6. MSYS2(`C:\msys64\usr\bin`)、Cygwin(`C:\cygwin64\bin`);
181
+ 7. **各盘符根下的 `Git` / `PortableGit` / `msys64` / `cygwin64`**(覆盖装在 `D:\Git`、`D:\PortableGit`、自己解压到任意盘的情况);
182
+ 8. Niubash(`%LOCALAPPDATA%\Programs\Niubash\niu.exe`);
183
+ 9. PATH 兜底(**这里的 `bash.exe` 很可能就是 WSL 的启动器**,见下条)。
184
+ - **为什么不"扫全盘"**:探测只做有限次存在性检查 + 极少数目录列举(带版本号的那三处),全程不递归遍历、不跑进程,因此没有"装完卡几秒"这种代价。找不到就如实说找不到,由你手填路径。
185
+ - **"裸本体"不列为候选**:同一个 Git 安装根下如果既有 `bin\bash.exe` 又有 `usr\bin\bash.exe`(或 `mingw64\bin\bash.exe`),**只列前者**。本机实测两者的差别:前者会给出 `MSYSTEM=MINGW64`、把 `PATH` 前置成 `/mingw64/bin:/usr/bin`,于是 `head` / `grep` / `uname` 都在、中文文件名当参数也正常;后者 `MSYSTEM` 为空、`PATH` 只有继承来的 Windows PATH(里面只有 `D:\Git\cmd`),**coreutils 全部 command not found** —— 交给模型就是命令大面积失败。没有 `bin\bash.exe` 兄弟的来源(例如 MSYS2 只提供 `usr\bin\bash.exe`)照常列出;**你自己手填的路径不受这条限制**(那是你的选择)。
186
+ - **为什么「先找 git」**:Git for Windows 不一定装在 `Program Files`(本机就在 `D:\Git`)。只按固定目录找会一边找不到、一边退到 PATH,而 Windows 上 PATH 里的 `bash.exe` 很可能就是 **WSL 的启动器**。
187
+ - **WSL 被硬排除**:`C:\Windows\System32\bash.exe` 与 `…\Microsoft\WindowsApps\bash.exe` 一律不用 —— 它把 `D:\x` 解释成 `/mnt/d/x`,与模型手里的 Windows 路径、工作目录、`%TEMP%` 全都不兼容。只有在它确实存在时,卡片的状态行才会提一句「已排除 N 个 WSL 的 bash.exe」,不会在没装 WSL 的机器上凭空报警。
188
+ - **改完立刻生效**:宿主半会在 `agent/created`(新会话)与设置变更时**遍历所有在跑会话**重新下发,不需要开新会话;切回 PowerShell 会把之前下发的限制**撤销**。
189
+ - **与官方终端逐字对齐的行为**:工具描述、参数与输出 JSON Schema、`[stderr]` 分段、`(no output)` 兜底、标记顺序(沙箱拒绝 → 升级提示 → 超时 → `[killed by signal: X]` **或** `[exit code: N]` 在最末)、终端卡片(exit 状态拆成 pill,可点开看命令 / cwd / 输出)、后台任务(`run_in_background` + `job_output` / `job_kill`)、超时(默认 120s、上限 600s)、输出截断并把完整输出落盘、沙箱约束与 `sandbox_permissions` 升级审批。非零退出**不是错误**,只是末尾一个标记。
190
+ - **失败不伤会话**:探测不到 bash、宿主没有 `subprocess`、某个会话本来就看不到 `pwsh`(此时官方 `restrict` 会拒绝)、非 Windows —— 一律只降级并在状态行写明原因,不抛错、不 veto 别的插件、不影响会话本身。
191
+ - **一个诚实的副作用**:会话内工具面会随档位变化,而**会话历史里可能还留着旧工具名**。如果模型按旧名字调用,会拿到 unknown tool 之类的错 —— 让它换成 `bash` 重试即可(卡片上也写了这句)。
192
+ - **非 Windows**:直接不接管(官方 bash 工具本来就在),卡片显示一行说明。
193
+
194
+ ## 维护:重启 DSH
195
+
196
+ 装了新插件、改了宿主半代码(比如本插件的「默认终端」、键位 schema)之后,DSH 需要重启才会加载新代码。设置页「输入体验」卡片**抬头右端**(GitHub 链接左边)有一枚「重启 DSH」。
197
+
198
+ 机制照搬插件市场 [dsh-market](https://github.com/dsh-market/dsh-market)(它的 `src/restart.ts` 里挂着一串 issue 号,每条都是"重启按钮按下去没用"的具体死法):
199
+
200
+ 1. **两步确认**:点按钮先问宿主半"会怎么重启、当前有几个会话在跑",确认条出现在抬头正下方,点「确认重启」才真重启 —— 重启会打断正在跑的会话(包括正在生成的那一轮)。
201
+ 2. **分离一个 node 助手进程**(`node -e <源码>`,detached + unref),宿主自己 500ms 后退出(延迟是为了让这个 HTTP 响应先发出去)。
202
+ 3. **助手等端口真的空出来**:每 250ms `connect` 探一次,最多 30 秒,通了再等 300ms(Windows 的 TIME_WAIT 尾巴)。固定 sleep 会让新宿主 `EADDRINUSE` 当场死掉。
203
+ 4. **用隐藏控制台的 PowerShell 起新宿主**:Windows 上 `detached` = `DETACHED_PROCESS` = 没有控制台,新宿主之后起的每个控制台子进程都会弹一个黑窗口;`powershell -NoProfile -WindowStyle Hidden` 给它一个隐藏控制台,助手那层再带 `windowsHide`。
204
+ 5. **起来之后再验证 20 秒**:端口没人监听就把诊断写进日志 —— 本来该记日志的宿主进程已经退出了,重启失败必须留证据。
205
+ 6. **界面靠 `boot` 号判断成功**:每 1.5 秒问一次状态,号变了(说明新进程接管了端口)就 `location.reload()`;60 秒还没变才报超时,并把日志路径告诉你。
206
+
207
+ 其它几点:
208
+
209
+ - **日志**:助手写 `<系统临时目录>/composer-ux-restart-<时间戳>.out.log|err.log`(失败时界面会把路径显示出来)。
210
+ - **两道关卡**:先过官方 `connection.requestRejection`(Host/Origin 围栏 + 浏览器令牌),再过本插件自己的"回环 peer + 无转发头 + `Origin` 与 `Host` 同源"——这是"杀进程"的接口,跨站页面一定带自己的 `Origin`,挡在这里。
211
+ - **不该从界面里杀掉的宿主会拒绝**:宿主正被调试器附着(`--inspect` / `inspector.url()`),或者它在 systemd 下当服务跑(重启权归 supervisor,自己重启会把 cgroup 里的接管进程一起收掉)。这时按钮禁用并说明原因。
212
+ - **退出走 `process.emit('SIGTERM')` 而不是 `process.kill(pid,'SIGTERM')`**:DSH 在 `apps/cli/src/profile-boot.ts` 注册了 SIGTERM handler(先 `fiber.dispose()` 再退出,自带 5 秒上限)。而 Windows 上 `process.kill` 等价于 `TerminateProcess` —— 本机实测 handler 一次都跑不到。兜底:10 秒后还活着就 `exit(0)`。
213
+ - **第一次点的时候,正在跑的宿主还是上一版**(新路由要重启后才加载):界面会如实说明"这次走旧机制,重启之后按钮就是新版了"。
214
+ - 为什么 DSH 需要插件自己干这件事:官方没有重启宿主的机制(插件市场那边只提示「更改将在下次启动生效」),所以这件事只能由插件做。
215
+
147
216
  ## 结构
148
217
 
149
218
  仓库根目录(克隆或解压出来即是):
@@ -156,18 +225,36 @@ dsh-composer-ux/
156
225
  ├── CHANGELOG.md # 版本更新日志
157
226
  ├── build.mjs # esbuild 构建:lib/index.js(Host)+ lib/client.js(浏览器),并做发行后处理
158
227
  ├── src/
159
- │ ├── host.ts # Host 半:注册 settings namespace + 把请求头镜像进 llm-pi-ai
160
- │ ├── settings-contract.ts # 字段/默认值/菜单元数据(零依赖共享)
228
+ │ ├── host.ts # Host 半:注册 settings namespace + 请求头镜像 + 「默认终端」+ 「重启 DSH」路由
229
+ │ ├── settings-contract.ts # 字段/默认值/菜单元数据 + 栏开关的迁移判据(零依赖共享)
230
+ │ ├── restart.ts # 「重启 DSH」:助手进程/等端口/隐藏控制台/信任关卡/优雅退出(零 import)
161
231
  │ ├── client.tsx # Browser 半:设置页 + 菜单浮层 + 拦截器
232
+ │ ├── terminal/ # 「默认终端」(Windows:pwsh → Git Bash),全部零官方运行时依赖
233
+ │ │ ├── discover.ts # 探测:git 锚定反推 bash、硬排除 WSL、多候选排序(纯函数)
234
+ │ │ ├── render.ts # 结果渲染与 exit 状态解析(与官方逐字对齐)
235
+ │ │ ├── sandbox.ts # 沙箱策略面 + 升级审批(fail-closed,与官方同语义)
236
+ │ │ ├── tool.ts # bash 工具定义:schema / 执行 / 后台 / 中止 / 终端卡片
237
+ │ │ ├── contracts.ts # 字段、三档、候选净化、生效判定、状态行文案(纯函数)
238
+ │ │ └── host.ts # 宿主半接线:按会话下发 restrict+register+section、立刻覆盖在跑会话
162
239
  │ └── client/
163
240
  │ ├── chords.ts # 键位编码/录制校验
164
241
  │ ├── interceptors.ts # keydown/contextmenu 捕获拦截 + 回放
165
- │ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目)
242
+ │ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目 + 默认终端卡片)
166
243
  │ ├── settings-style.ts # 折叠卡片样式表(dsh-ux-* 类名注入)
167
244
  │ ├── ContextMenuHost.tsx # shell.overlay 右键菜单
168
245
  │ └── styles.ts # --dsw-* 令牌内联样式
246
+ ├── docs/settings-panel-0.5.0.png # README 顶部那张设置页截图(用 tag 固定的 raw 链接引用,不进 npm 包)
169
247
  ├── test/
170
248
  │ ├── host-header-mirror.mjs # 请求头镜像的行为测试(假 settings 驱动构建产物)
249
+ │ ├── host-settings-generations.mjs # 宿主半两代设置服务(0.1.6 的 get/register 与 0.1.7 的 describe)
250
+ │ ├── quick-commands.mjs # 快捷指令 / 优化接口 / 路由注册 / 重启机制
251
+ │ ├── quick-store.mjs # 快捷指令文件存储与写回路径
252
+ │ ├── terminal-policy.mjs # 默认终端:探测 / 渲染 / 升级审批 / bash 工具 / 宿主半接线
253
+ │ ├── client-registration.mjs # 客户端注册协议、右键行为、抬头按钮、六栏开关与标题行布局
254
+ │ ├── settings-service-adopt.mjs # 客户端两代设置服务认领(settingsScope / configForms / 都没有)
255
+ │ ├── mutation-guards.mjs # 变异测试(手动跑):把每条护栏拆掉,测试必须变红(26 条)
256
+ │ ├── settings-render.mjs # 真渲染测试(手动跑):借 profile 的 react 把设置页渲染成 HTML
257
+ │ ├── check-sections.mjs # 升级前自查(只读):拿真实 settings.yaml 跑一遍栏开关迁移
171
258
  │ └── opencode-header-wire-probe.mjs # 线级探针:本地端点,用来看 DSH 出网请求带了什么头
172
259
  └── lib/ # 构建产物(运行所需)
173
260
  ```
@@ -176,7 +263,13 @@ dsh-composer-ux/
176
263
 
177
264
  ```sh
178
265
  node build.mjs # 产出 lib/index.js + lib/client.js
179
- node test/host-header-mirror.mjs # 宿主半行为测试(写入/撤销/改名/幂等/不误删)
266
+ npm test # 七个套件;当前 669 passed, 0 failed
267
+ node test/mutation-guards.mjs # 手动跑:变异测试,证明那套护栏真的在咬人
268
+ node test/settings-render.mjs # 手动跑:把设置页真渲染成 HTML,断言版式与互斥显示(20 项)
269
+ node test/check-sections.mjs # 只读:升级前看六栏会变成什么
270
+ node test/host-header-mirror.mjs # 只跑宿主半的请求头测试(写入/撤销/改名/幂等/不误删)
271
+ node test/host-settings-generations.mjs # 只跑宿主半的「两代设置服务」兼容(0.1.6 / 0.1.7 形状)
272
+ node test/terminal-policy.mjs # 只跑「默认终端」(探测 / 渲染 / 审批 / 工具 / 接线)
180
273
  # 线级探针(可选):起一个本地端点,再把某条路由临时指过来,即可看到真实出网请求头
181
274
  node test/opencode-header-wire-probe.mjs 8799 600000
182
275
  # 若 DSH 源码检出不在默认路径:
@@ -207,13 +300,27 @@ pnpm dsh web --patch <本仓库的绝对路径>/cordis.dev.patch.yml
207
300
 
208
301
  ## 设置持久化
209
302
 
210
- 除快捷指令之外的设置存在 Host 用户设置文档(默认 `$DSH_HOME/settings.yaml`)的 `composer-ux` 分区,随工作区/机器生效;删除该分区即恢复这些设置的默认值。
303
+ 除快捷指令之外的设置存在 **profile 的 `cordis.patch.yml`** 里本插件那一行(`$DSH_HOME/profiles/<profile>/cordis.patch.yml` 的 `- id: composer-ux` → `config`)——0.1.7 起上游已废弃 `$DSH_HOME/settings.yaml`(它被改名成 `settings.yaml.imported` 后一次性导入);0.1.6 及以前才是 `settings.yaml` 的 `composer-ux` 分区。删掉那一项即恢复这些设置的默认值。
211
304
 
212
305
  快捷指令(分类 + 条目 + 插入模式)**不在**设置文档里,它存 `$DSH_HOME/quick-prompts.json`(见上「数据存在哪」)。设置文档里那份 `quickPrompts` 只是 0.2.x 的**迁移种子**:只在 `quick-prompts.json` **不存在**时被读一次(用来把旧列表搬成「默认」分类),平时不参与任何行为,插件也不会再写它。
213
306
 
214
307
  - 确认不需要这份回滚兜底后,**可以从设置文档里手动删掉它**(连同 `quickPrompts:` 这一项)——不影响插件运行;之后若 JSON 丢失,只会回落到内置 9 条。删掉后它不会自己长回来。
215
308
  - 想要**真备份**请复制 `quick-prompts.json`,不要指望这份种子:它是旧快照,不会随你的后续修改更新。
216
309
 
310
+ ### ⚠️ 设置写不进去 / 点了没反应:两种成因与恢复
311
+
312
+ **第一层:写入锁成了孤儿锁。** DSH 用 `<profile>/package.json.lock`(`wx` 独占创建 + 内容为持有者 PID)串行化跨进程写入,对争用方的规定是「**绝不删除已存在的锁**——锁的年龄证明不了持有者已经停下;孤儿锁属于操作者动作」。而**硬杀**(`restart-webui.bat` 的 `taskkill /T /F`)只要正好落在一次设置写入中间,这把锁就永久留在磁盘上。后果不是"某次写入失败",而是**该 profile 此后每一次设置写入都在 2 秒后超时**:读得到、写不进,界面上只表现为"点了没反应",一句报错都没有(2026-09-23 真机连撞两次,两次都是重启留下的)。
313
+
314
+ - **0.6.1 起的自动回收**:宿主半在启动时读一次锁里的 PID,**只有确认该进程已不存在**才删(内容认不出、PID 还活着、权限不足无法判定 → 一律不动)。判据与理由见 `src/settings-lock.ts`,逐条钉在测试第 12 节。
315
+ - **手工恢复**(自动回收没赶上时):
316
+
317
+ ```powershell
318
+ Get-Content "$env:USERPROFILE\.dsh\profiles\web\package.json.lock" # 先确认持有者 PID 是否还活着
319
+ Remove-Item "$env:USERPROFILE\.dsh\profiles\web\package.json.lock" -Force
320
+ ```
321
+
322
+ **第二层:写入落盘了,但界面还是旧值。** 这说明**运行中的进程没有把这次配置变更接进运行时**(文件里的值是对的)。设置页自 0.6.1 起会在写入被拒、或"写了但运行时没变"时把原因显示在卡片顶部;遇到第二种情况**重启一次 DSH** 即可(重启会按文件里的值启动)。
323
+
217
324
  ## 发行包与「装前体检」
218
325
 
219
326
  插件市场的装前体检会扫描宿主代码里的混淆/动态执行特征(`eval` / `new Function` / 超长 base64 块)。本插件的 `lib/` 里这些特征**为零**: