dsh-composer-ux 0.4.0 → 0.5.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,9 +2,88 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.5.0] — 2026-09-21
6
+
7
+ > **本版含宿主半新能力**(六个折叠卡的栏开关进了 settings schema),装完必须**重启 DSH** 才会作用到会话上。
8
+ > 只动客户端半的改动(设置页版式、右键菜单卡那两处)刷新页面即可。
9
+
10
+ ### 新增
11
+
12
+ - **两层开关:总开关 + 每栏开关**(用户要求「插件的每个条目都增加一个开关按钮默认关闭,总开关默认打开」)。
13
+ - 设置页顶部「启用输入增强」= **总开关,默认开**(原本就是),它是一道总闸;六张折叠卡的**标题行右端各一个卡级开关,默认关**。生效条件是 `enabled && 该栏开关`,判据只有一个 `activeSections()`,不在各组件里各写一遍。
14
+ - **卡内已有的开关全部搬到标题行**(用户要求),其中**右键菜单那 7 项后来又搬回了卡内**(见下一条):设置面板的「导航滚动 / 边缘缩放」变成标题行的两个小开关;默认终端的三档变成标题行的 `compact` 胶囊;**OpenCode 请求头那一栏没有新键** —— 原来的 `headerEnabled` 本来就是"这一栏要不要生效",直接当卡级开关,不造同义的第二个键。
15
+ - **右键菜单卡:点哪档只显示哪档的说明 + 那 7 项只在「自定义」档出现**(用户看过真实界面后提的两条)。
16
+ - 原来三档说明 + 剪贴板授权注意事项一共 6 段**全铺开**,一屏文字、还要人自己找哪段跟自己选的那档有关;现在 `官方 / 浏览器 / 自定义` **各一段互斥渲染**,「自定义」那段后面才接它专用的 Chrome / Edge、Firefox 剪贴板授权说明。
17
+ - 那 7 个条目开关(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)先做成标题行的「**7 项 ▼**」条(7 个滑块平铺会把标题行挤爆),后来改成**平铺在「自定义」档里**:这 7 项本来就只对「自定义」档有意义,摆在标题行等于在任何档位都能改一堆当时不生效的东西。strip 那一套(按钮 + 展开条 + `dsh-ux-stripButton` / `dsh-ux-cardStrip` 两个类)**整套删掉**,并留了"不许复活"的护栏(变异 Q 咬住)—— 设置页布局没有自动化测试,护栏只能保到这里,挤不挤得下由用户的眼睛判。
18
+ - 顺手删掉标题行下面那行**重复当前档位**的正文 `hint`(`MENU_MODES[].hint` 仍是三枚胶囊的悬停提示,只是不再有第二处正文)。
19
+ - 标题行因此从"整行一个 `<button>`"改成"展开按钮 + 控件区"两个兄弟节点:`<button>` 里塞按钮是无效 HTML,点击还会冒泡成"展开整张卡"。
20
+ - **关掉一栏 = 这一块完全不介入**,栏内的值全部保留(打开即原样恢复):键位 → DSH 原生键位;右键菜单 → 不介入;快捷指令 → 输入框那枚按钮消失、面板收掉、**附加批次直接给空**(键位发送与官方发送按钮两条路共用这一个闸);设置面板 → 不滚动、不拖大小;OpenCode 请求头 → 停止注入并撤销已写入的头;默认终端 → 宿主半撤销已下发的 `pwsh` 压制并回写"未启用"。
21
+ - **未启用时的观感**:概览行加「未启用 · 」前缀、卡片描边变虚线、展开区压暗;栏内控件仍可编辑(先配好再打开)。
22
+ - **「默认关」对老用户是迁移出来的,不是硬编码**(用户拍板 A 方案)。
23
+ - 判据是**"这一栏的值不等于从没碰过的样子"**(改过键位 / 选过浏览器或自定义菜单档 / 有面板尺寸记录 / 终端档位不是自动或填过路径)。全新安装(什么痕迹都没有)才是六栏全关。
24
+ - ⚠️ **不能按"键存在"判断**:`settings.get()` 给的是**已解析**的值(schema 默认值 + 组合 base + 用户层),从没碰过的人的文档里也照样有 `sendKey: 'Enter'`、`panelScroll: true` —— 按"键存在"判断会让全新安装六栏全开,与"默认关"正好相反。这条被测试当场逮住(「全新安装(空文档)→ 五栏全关」变红),判据改成"出现过 **且** 不等于默认值"。
25
+ - 同理,**宿主半自持字段不许当判据**(`terminalCandidates` / `terminalStatus` / `terminalEffective` / `headerApplied*` 是插件自己写的,全新安装也会出现),有专门一条测试盯着。
26
+ - **五栏开关在 schema 里是 `.required(false)`、故意不给默认值**:schemastery 对缺省的可选键会**整个省掉**,所以"文档里没有这个键"与"明确关掉"能分开 —— 迁移要用的就是这个区别。有测试专门钉住"缺省 ≠ false"(谁把它改成 `.default(false)`,迁移会静默失效)。
27
+ - **「快捷指令」栏多一条文件判据**:0.3.0 起条目搬到了 `quick-prompts.json`,用过的人和没用过的人的设置文档可以一模一样。所以宿主半启动时读一次书本文件,**有非内置内容就把 `quickEnabled: true` 写回文档**(写回之后两端都只需要看那一个布尔)。只在文档里还没有这个键时写:用户明确关掉之后不会被重新打开(有测试)。
28
+ - **升级前可以先自查**:新增 `test/check-sections.mjs` —— 拿真实的 `settings.yaml` 跑一遍迁移,打印六栏会变成什么,**只读**、不写任何文件。本机实测输出:六栏全开(「快捷指令」靠文件判据)。
29
+ - **「设置 → 输入体验 → 默认终端」**(只对 Windows 生效):把模型用的终端工具从 PowerShell(`pwsh`)换成 **Git Bash**。
30
+ - **三档**:自动(探测到 Git Bash 就用)/ Git Bash / PowerShell(保持 DSH 默认、本插件完全不介入)。默认「自动」。
31
+ - **「裸本体」不列为候选**:同根下既有 `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`)照常列出;**手填路径不受此限**。
32
+ - **探测顺序**(同一路径只留优先级最高的一次,候选会在卡片里列出并标注来源):设置里填的路径 → **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) 等),并逐条写了测试。探测只做有限次存在性检查 + 三处目录列举,**不递归扫盘、不跑进程**。
33
+ - **硬排除 WSL 的 `bash.exe`**(`System32` 与 `WindowsApps` 两个来源)——它把 `D:\x` 解释成 `/mnt/d/x`,与模型手里的 Windows 工作目录不兼容,而且只有在它**确实存在**时才会出现在「已排除」说明里。
34
+ - 本机就是这条规则的受益者:Git for Windows 装在 `D:\Git`(不在 Program Files),只按固定目录找是找不到的——「先找 git 再反推」才能命中 `D:\Git\bin\bash.exe`。
35
+ - **立刻生效**:改档位后宿主半会**遍历所有在跑会话**重新下发,不需要开新会话(详见下节「为什么这样实现」)。
36
+ - **工具契约与官方 `@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、严格更宽、审批文案)。
37
+ - **零新增运行时依赖**:宿主半仍是自包含产物(只内联 schemastery/cosmokit),官方的五个运行时符号(`defineTool` / `TOOL_ABORTED` / `HarnessError` / 沙箱标记与升级助手 / `DSH_ENV_PREFIX`)在 `src/terminal/` 下等价实现,避免因「依赖共享宿主包」被市场体检警告。
38
+ - **失败不伤会话**:探测不到、拿不到 `subprocess`、`restrict` 被拒(例如该会话本来就看不到 `pwsh`)、非 Windows —— 一律只降级 + 在设置页状态行写明原因,不抛错、不 veto 别的插件。
39
+ - 设置页只读展示三项宿主半自持字段(`terminalStatus` / `terminalEffective` / `terminalCandidates`),并加了「自动发现」按钮与候选点选。
40
+ - 设置接口 `GET/POST /composer-ux/terminal`:查状态、重扫候选。与另外两条路由一样受官方 `connection.requestRejection` 守卫(本机 webServer 绑 `0.0.0.0`,而这条接口会回传本地路径)。
41
+ - 设置页抬头(「输入体验」那张卡)右端加了 **「GitHub ↗」**:真实 `<a target="_blank" rel="noreferrer noopener">`,地址取自契约里的 `REPO_URL`(与 `package.json` 的 `repository.url` 一致,客户端不硬编码第二份 —— 有测试盯着两处不许分叉)。
42
+ - **「重启 DSH」按钮**(`src/restart.ts` + `GET/POST /composer-ux/restart`):位置是「输入体验」卡片**抬头右端、GitHub 链接左边**,确认条展开在抬头正下方。
43
+ - **机制照搬插件市场 [dsh-market](https://github.com/dsh-market/dsh-market)**(`src/restart.ts` + `src/dsh-cli.ts`,本机 profile 里就有它的源码与注释)。为什么抄它而不是自己发明:它文件顶部挂着一串 issue 号,每条都是"重启按钮按下去没用"的具体死法。
44
+ - **两步确认**(用户拍板保留):点按钮先读"会怎么重启、当前有几个会话在跑",确认条在抬头正下方,点「确认重启」才真重启 —— 重启会打断正在跑的会话(包括正在生成的那一轮)。
45
+ - **分离一个 node 助手进程**(`node -e <源码>`,detached + unref + `stdio: 'ignore'`),宿主自己 **500ms 后退出**,好让这个 HTTP 响应先发出去。
46
+ - **助手等端口真的空出来**:每 250ms `connect` 探一次、最多 30 秒,通了再等 300ms(Windows 的 TIME_WAIT 尾巴)。用 connect 而不是 bind:bind 一下自己就把那个马上要交出去的端口占住了。固定 sleep 会让新宿主 `EADDRINUSE` 当场死掉、而失败还被 `catch {}` 吞了(市场 issue #177)。
47
+ - **用隐藏控制台的 PowerShell 起新宿主**:Windows 上 `detached` = `DETACHED_PROCESS` = **没有控制台**,新宿主之后起的每个控制台子进程都会新建一个可见窗口(#40);而 `-WindowStyle Hidden` 管不到 spawn 交给 PowerShell 的那个控制台,所以助手那层还得自己带 `windowsHide`(CREATE_NO_WINDOW,#624)。裸 `dsh` 要显式补成 `dsh.cmd`:PowerShell 会优先选被默认策略拒绝的 `dsh.ps1`(#397)。
48
+ - **起来之后再验证 20 秒**:端口没人监听就把诊断写进日志 —— 本来该记日志的宿主进程已经退出了,重启失败必须留证据。
49
+ - **界面靠 `boot` 号判断成功**:每 1.5 秒问一次状态,号变了(新进程接管了端口)就 `location.reload()`;60 秒还没变才报超时,并把日志路径显示出来(市场同款判定)。
50
+ - **两道关卡**:先过官方 `connection.requestRejection`(Host/Origin 围栏 + 浏览器令牌),再过本插件自己的"回环 peer + 无转发头 + `Origin` 与 `Host` 同源"。这是"杀进程"的接口,跨站页面一定带自己的 `Origin`,挡在这里。非 GET/POST 回 405。
51
+ - **不该从界面里杀掉的宿主会拒绝**:宿主正被调试器附着(`inspector.url()` / `--inspect` 家族,按 token 前缀匹配以免把 `inspect-tool.js` 这种路径误判),或它在 systemd 下当服务跑(`INVOCATION_ID`/`JOURNAL_STREAM` **且**父进程是 PID 1 或 comm 为 `systemd` —— 只看环境变量会把普通终端与 CI runner 误判成"有 supervisor",市场 issue #229/#471 就是这么踩的)。这时按钮禁用并说明原因。
52
+ - **退出方式与市场刻意不同**:用 `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)`。
53
+ - **第一次点的时候,正在跑的宿主还是上一版**(新路由要重启后才加载):宿主半没有 `boot` 字段时,界面如实说明"这次走旧机制,重启之后按钮就是新版了"。旧版 handler 在同一路径上,所以第一次点照样能把 DSH 重启起来。
54
+ - **去掉「可选重启命令」与那张「维护」卡**(用户拍板):机制换成市场那套之后,这个字段只剩"多一个会填错的地方"。老文档里可能留着的 `restartCommand` 由「恢复默认」顺手清掉;schema 里不再有它。
55
+ - 新增 `GET /composer-ux/restart`(只读:boot 号、重放命令、日志落点、在跑会话数、是否被拦)与 `POST`(真重启,回 **202** + boot 号)。**测试里绝不让 POST 走到成功路径**:那会真的重启测试进程。
56
+
57
+ ### 为什么这样实现(三条来自官方源码的硬约束)
58
+
59
+ 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` 同形)。
60
+ 2. **`restrict()` 会校验名字必须已在全局注册表里**,否则抛 `names unknown global tool "pwsh"` —— 该会话本来就看不到 `pwsh` 时会命中这条,本插件记一行原因跳过(这正是官方 `tool-bash` 的日志里 `restrict(deny:[pwsh]) refused, skipping` 的那种情况)。
61
+ 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` 同一手法)。
62
+
63
+ ### 开发中发现并修掉的问题
64
+
65
+ 0. **把 Niubash 从"单条候选"改成"安装根分组"时它整个消失了。** 新加的来源分组只会试
66
+ `<root>/bin|usr/bin|mingw64/bin/bash.exe`,而 Niubash 的可执行文件叫 `niu.exe` 且就在根目录下 ——
67
+ 分组形状不匹配。是**测试逮住的**("Niubash 仍是候选"当场变红),不是靠肉眼复查。
68
+ **下面这条是用户实测发现的**(他在卡片上点了 `D:\Git\usr\bin\bash.exe` 那个候选,模型的命令就大面积失败):
69
+ `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**。
70
+ 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」)防止回归。
71
+ 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)。
72
+ 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 后是否消失,**等实测确认**(本版不宣称已解决)。
73
+
74
+ ### 测试
75
+
76
+ - 新增 `test/terminal-policy.mjs`(162 条):探测顺序与 WSL 排除、渲染与 `parseExitStatus`(含官方 `[exit code: null]` 怪癖)、升级审批 fail-closed、bash 工具的参数/输出 schema 与执行路径(argv / cwd / env / stdio 上限 / confine 必须 await / 拒绝识别 / 后台钩子 / 中止与超时)、宿主半接线(在跑会话当场下发、新会话下发、幂等、切回 PowerShell 时撤销、非 Windows 与缺服务的降级)。
77
+ - `test/quick-commands.mjs` 第 10 节重写(重启机制):启动命令重建的两种形态(dsh 入口 vs 裸 `dsh`)、`nodeExecutableOf`、Windows 的 PowerShell 包装与 `.cmd` 补全、端口解析与同源关卡逐条、调试器 / systemd 判定、助手源码的每条"为什么"、排期与优雅退出(spawn / 定时 / 退出全部注入)、以及**真的跑一遍助手**(替换进程的标记文件出现 / 起不来时 err 日志里有诊断 / 端口一空出来就起进程 —— 这条才是"等端口"逻辑真正的护栏)。
78
+ - `test/client-registration.mjs` 新增第 8 节:按钮在抬头且**在 GitHub 链接左边**、确认条在抬头正下方、第一步只 GET 第二步才 POST、靠 `boot` 号 `location.reload()`、被拦时禁用并说明、老宿主如实提示、类名真的进了产物与样式表(中文在产物里是 `\uXXXX`,所以只查 ASCII 标记)。
79
+ - 关键护栏做过**变异测试**(`test/mutation-guards.mjs`,手动跑):把「遍历在跑会话」去掉、把同源关卡去掉、把官方信任关卡去掉、把抬头按钮拿掉、把助手的 `windowsHide` 关掉、把"等端口"换成固定 sleep、把「可选重启命令」加回来、把栏开关一律返回 true、把迁移判据丢掉一半、把终端/键位的栏门控去掉、把五栏开关改成带默认值、把快捷指令的文件迁移去掉、把「自定义」档的门控去掉、把三档说明的互斥条件去掉、把 strip 那条死路接回来、把标题行下重复的 hint 加回去、把那 7 个条目开关整段删掉、把那 7 行渲染短路掉、把「浏览器」档的说明短路掉、把三档说明全铺开 —— **21 条全部当场变红**。跑变异必须带上 `node build.mjs`:宿主半的用例打的是构建产物,只改 `src/` 不重建会得到假的"没咬住"。
80
+ - 新增 `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 点不动 ⇒ 打包时在内存里改成展开,锚点找不到就报错)。
81
+ - 开关与迁移的测试(共 60+ 条):全新安装六栏全关、总开关默认开、显式值优先于迁移、每栏判据各一条、宿主半自持字段不当判据、`activeSections` 的总闸语义、**用户真实文档的回归**(六栏保持全开)、schema 的"缺省 ≠ false"、快捷指令的文件迁移(非内置 → 写回;内置 → 不写;已关 → 不写回;幂等;没文件 → 不写)、终端栏关掉时撤销已下发的压制、总开关关掉时同样撤销、拦截器/入口按钮/面板把手各自的栏门控、标题行拆成两个兄弟节点、六张卡各有一个开关。
82
+
5
83
  ## [0.4.0] — 2026-09-15
6
84
 
7
- > 尚未发布:先在用户机器上实测,通过后再发 GitHub / npm。
85
+ > 已发布:用户实测通过后授权发布,已上 npm(`latest`)、GitHub 标签与 Release(`v0.4.0`)。
86
+ > 从 0.3.0 升级注意:本次含**宿主半 schema 改动**(新增 `menuMode`,并把旧布尔 `menuNative` 改成可选),**必须重启 DSH** 才会生效。
8
87
 
9
88
  ### 新增
10
89
 
@@ -48,7 +127,7 @@
48
127
 
49
128
  ## [0.3.0] — 2026-09-15
50
129
 
51
- > 尚未发布:先在用户机器上实测,通过后再发 GitHub / npm。
130
+ > 已发布:实测通过后发布到 GitHub 与 npm(本条目的「尚未发布」声明在 0.4.0 发布时才补正)。
52
131
 
53
132
  ### 变更(含一处破坏性调整)
54
133
 
package/README.md CHANGED
@@ -4,21 +4,35 @@
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
+ - **老用户不会被升级弄坏**:卡级开关的默认值不是硬编码的 `false`,而是按「设置文档里有没有『你在用』的痕迹」迁移 —— 碰过的栏保持开着、没碰过的才是关;全新安装(空白文档)才是六栏全关。「快捷指令」那一栏多一条文件判据(0.3.0 起条目存在 `quick-prompts.json`,设置文档里看不出来)。
24
+ - **升级前可以先看一眼**:`node test/check-sections.mjs` 拿你真实的 `settings.yaml` 跑一遍迁移,打印六栏会变成什么(**只读**,不写任何文件)。
25
+ - **关掉一栏 = 这一块完全不介入**,且栏内的值全部保留(打开即原样恢复):键位关 → 输入框按 DSH 原生键位;右键菜单关 → 本插件不介入;快捷指令关 → 输入框那枚按钮消失;设置面板关 → 不滚动、不拖大小;OpenCode 请求头关 → 停止注入并撤销已写入的头;默认终端关 → 保持 PowerShell。
26
+ - 「OpenCode 请求头」那一栏**没有额外的开关**:原来的「附加请求头」本来就是「这一栏要不要生效」,直接搬到了标题行。
15
27
  1. **设置 → 输入体验**(设置页新增条目)
16
- - 版式对齐社区插件 `@linxin666/dsh-web-all` 的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名 `dsh-composer-ux` 的一行描述 + 总开关」;其下五个栏目为**可折叠卡片**(标题行只放标题 + 一句动态概览,长说明与控件都在展开后的内容区),**默认全部折叠、不记忆展开状态**,可同时展开多个。
28
+ - 版式对齐社区插件 `@linxin666/dsh-web-all` 的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名 `dsh-composer-ux` 的一行描述 + 总开关」;其下六个栏目为**可折叠卡片** —— 标题行左侧是「标题 + 一句动态概览 + 展开箭头」,右侧是**这一栏的开关与从属控件**(设置面板的两个开关、默认终端的三档都在这里),长说明与其余控件在展开后的内容区;**默认全部折叠、不记忆展开状态**,可同时展开多个。标题行**只放卡级开关与从属控件**:右键菜单那 7 个条目开关按用户后来的反馈**搬回了卡内**(它们只对「自定义」档有意义,见下一条)。
17
29
  - **键位**:分别配置「发送键」「换行键」——常用预设(Enter / Ctrl+Enter / Alt+Enter / Shift+Enter)+ 点击「自定义…」后直接按任意组合键录制(Esc 取消,Backspace 清除),支持清空为「无」;发送与换行不能设为相同按键;可一键恢复默认。
18
- - **右键菜单**:三档「菜单来源」(官方不介入 / 浏览器菜单 / 自定义菜单,见下「右键菜单」一节);自定义档那 7 个条目(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)可单独开关。
30
+ - **右键菜单**:三档「菜单来源」(官方不介入 / 浏览器菜单 / 自定义菜单,见下「右键菜单」一节);卡内**只显示当前选中那一档的说明**(点官方看官方的、点浏览器看浏览器的、点自定义看自定义的;自定义档还带 Chrome / Edge 与 Firefox 的剪贴板授权说明);那 7 个条目(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)**只在「自定义」档显示**、逐个开关(旁边一个「全部开启」)。这三件事有**真渲染测试**盯着(`node test/settings-render.mjs`:把设置页渲染成 HTML,断言三档正文互斥、那 7 行只在自定义档、标题行没有第二个入口)。
19
31
  - **快捷指令**:分类增删改 / 条目增删改与上下移 / 跨分类移动 / 每条一个插入模式(关 · 每次 · 仅首次)/ 优化强度三档(详见下节)。
20
- - **设置面板**:导航可滚动开关;边缘拖拽调整面板大小(尺寸记忆持久化)与尺寸预设。
32
+ - **设置面板**:导航可滚动 / 边缘调整大小两个开关(都在标题行);尺寸预设与拖拽说明在展开区。
21
33
  - **OpenCode 请求头**:给 OpenCode 的模型请求自动附加 `x-opencode-session`(详见下节)。
34
+ - **默认终端**:Windows 上把模型用的终端工具从 PowerShell 换成 Git Bash(三档在标题行;详见下节)。
35
+ - **重启 DSH**:卡片抬头右端(GitHub 链接左边)那枚按钮,两步确认后原地重启(详见「维护:重启 DSH」)。
22
36
  2. **快捷指令按钮**:输入框工具行里、「展开」按钮左侧的胶囊按钮,点开是常备提示词清单 + 「优化提示词」。
23
37
  3. **键位生效**(仅主聊天输入框):默认值 = 现状(Enter 发送、Shift+Enter 换行、Ctrl+Enter 加速提交),改动即时生效并持久保存。
24
38
 
@@ -144,6 +158,54 @@ OpenCode 的接口要求客户端在每次请求里带上一个稳定的会话 I
144
158
  - **带着它时真实调用成功,且前缀缓存在工作**:同一段前缀连发两次,`input` 174 → 46、`cacheRead` 192 → 320(总前缀 366 不变),第二次有更多内容直接命中缓存。
145
159
  - **线级证据**:用 `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
160
 
161
+ ## 默认终端(Windows)
162
+
163
+ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`),而模型的训练语料里 bash 占绝对多数。这一栏把终端换成 **Git Bash**:模型看到的工具就叫 `bash`,`pwsh` 从它的工具列表里消失 —— 一个会话只面对一个终端工具。
164
+
165
+ - **设置页位置**:设置 → 输入体验 → 「默认终端」(折叠栏目):档位 / Git Bash 路径 /「自动发现」/ 候选点选,附两行宿主半回写的只读状态(当前生效 shell、状态行)。
166
+ - **三档**:`自动`(探测到 Git Bash 就用,找不到就保持 PowerShell —— 默认,开箱即用)/ `Git Bash`(强制换,没探测到会回落并在状态行说明)/ `PowerShell`(保持 DSH 默认,本插件完全不介入终端)。
167
+ - **探测顺序**(同一路径只留优先级最高的那一次;多个候选会在卡片里列出来让你点,并标注来源):
168
+ 1. 你在设置里填的路径;
169
+ 2. **PATH 上正在用的那份 git**(由 `git.exe` 反推同一个安装根 —— 本机就是这样命中 `D:\Git` 的);
170
+ 3. Git 的官方落点:`Program Files\Git`、`Program Files (x86)\Git`、`%ProgramW6432%\Git`、`%LOCALAPPDATA%\Programs\Git`(**安装器以普通用户身份运行时默认落这里**,以管理员运行才落 Program Files);
171
+ 4. **Scoop**(`%SCOOP%\apps\git\current`)与 **Chocolatey 便携包**(`…\chocolatey\lib\git.portable\tools`);
172
+ 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`)—— 这三处带版本号,靠"列一眼子目录"枚举(新的排前面);
173
+ 6. MSYS2(`C:\msys64\usr\bin`)、Cygwin(`C:\cygwin64\bin`);
174
+ 7. **各盘符根下的 `Git` / `PortableGit` / `msys64` / `cygwin64`**(覆盖装在 `D:\Git`、`D:\PortableGit`、自己解压到任意盘的情况);
175
+ 8. Niubash(`%LOCALAPPDATA%\Programs\Niubash\niu.exe`);
176
+ 9. PATH 兜底(**这里的 `bash.exe` 很可能就是 WSL 的启动器**,见下条)。
177
+ - **为什么不"扫全盘"**:探测只做有限次存在性检查 + 极少数目录列举(带版本号的那三处),全程不递归遍历、不跑进程,因此没有"装完卡几秒"这种代价。找不到就如实说找不到,由你手填路径。
178
+ - **"裸本体"不列为候选**:同一个 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`)照常列出;**你自己手填的路径不受这条限制**(那是你的选择)。
179
+ - **为什么「先找 git」**:Git for Windows 不一定装在 `Program Files`(本机就在 `D:\Git`)。只按固定目录找会一边找不到、一边退到 PATH,而 Windows 上 PATH 里的 `bash.exe` 很可能就是 **WSL 的启动器**。
180
+ - **WSL 被硬排除**:`C:\Windows\System32\bash.exe` 与 `…\Microsoft\WindowsApps\bash.exe` 一律不用 —— 它把 `D:\x` 解释成 `/mnt/d/x`,与模型手里的 Windows 路径、工作目录、`%TEMP%` 全都不兼容。只有在它确实存在时,卡片的状态行才会提一句「已排除 N 个 WSL 的 bash.exe」,不会在没装 WSL 的机器上凭空报警。
181
+ - **改完立刻生效**:宿主半会在 `agent/created`(新会话)与设置变更时**遍历所有在跑会话**重新下发,不需要开新会话;切回 PowerShell 会把之前下发的限制**撤销**。
182
+ - **与官方终端逐字对齐的行为**:工具描述、参数与输出 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` 升级审批。非零退出**不是错误**,只是末尾一个标记。
183
+ - **失败不伤会话**:探测不到 bash、宿主没有 `subprocess`、某个会话本来就看不到 `pwsh`(此时官方 `restrict` 会拒绝)、非 Windows —— 一律只降级并在状态行写明原因,不抛错、不 veto 别的插件、不影响会话本身。
184
+ - **一个诚实的副作用**:会话内工具面会随档位变化,而**会话历史里可能还留着旧工具名**。如果模型按旧名字调用,会拿到 unknown tool 之类的错 —— 让它换成 `bash` 重试即可(卡片上也写了这句)。
185
+ - **非 Windows**:直接不接管(官方 bash 工具本来就在),卡片显示一行说明。
186
+
187
+ ## 维护:重启 DSH
188
+
189
+ 装了新插件、改了宿主半代码(比如本插件的「默认终端」、键位 schema)之后,DSH 需要重启才会加载新代码。设置页「输入体验」卡片**抬头右端**(GitHub 链接左边)有一枚「重启 DSH」。
190
+
191
+ 机制照搬插件市场 [dsh-market](https://github.com/dsh-market/dsh-market)(它的 `src/restart.ts` 里挂着一串 issue 号,每条都是"重启按钮按下去没用"的具体死法):
192
+
193
+ 1. **两步确认**:点按钮先问宿主半"会怎么重启、当前有几个会话在跑",确认条出现在抬头正下方,点「确认重启」才真重启 —— 重启会打断正在跑的会话(包括正在生成的那一轮)。
194
+ 2. **分离一个 node 助手进程**(`node -e <源码>`,detached + unref),宿主自己 500ms 后退出(延迟是为了让这个 HTTP 响应先发出去)。
195
+ 3. **助手等端口真的空出来**:每 250ms `connect` 探一次,最多 30 秒,通了再等 300ms(Windows 的 TIME_WAIT 尾巴)。固定 sleep 会让新宿主 `EADDRINUSE` 当场死掉。
196
+ 4. **用隐藏控制台的 PowerShell 起新宿主**:Windows 上 `detached` = `DETACHED_PROCESS` = 没有控制台,新宿主之后起的每个控制台子进程都会弹一个黑窗口;`powershell -NoProfile -WindowStyle Hidden` 给它一个隐藏控制台,助手那层再带 `windowsHide`。
197
+ 5. **起来之后再验证 20 秒**:端口没人监听就把诊断写进日志 —— 本来该记日志的宿主进程已经退出了,重启失败必须留证据。
198
+ 6. **界面靠 `boot` 号判断成功**:每 1.5 秒问一次状态,号变了(说明新进程接管了端口)就 `location.reload()`;60 秒还没变才报超时,并把日志路径告诉你。
199
+
200
+ 其它几点:
201
+
202
+ - **日志**:助手写 `<系统临时目录>/composer-ux-restart-<时间戳>.out.log|err.log`(失败时界面会把路径显示出来)。
203
+ - **两道关卡**:先过官方 `connection.requestRejection`(Host/Origin 围栏 + 浏览器令牌),再过本插件自己的"回环 peer + 无转发头 + `Origin` 与 `Host` 同源"——这是"杀进程"的接口,跨站页面一定带自己的 `Origin`,挡在这里。
204
+ - **不该从界面里杀掉的宿主会拒绝**:宿主正被调试器附着(`--inspect` / `inspector.url()`),或者它在 systemd 下当服务跑(重启权归 supervisor,自己重启会把 cgroup 里的接管进程一起收掉)。这时按钮禁用并说明原因。
205
+ - **退出走 `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)`。
206
+ - **第一次点的时候,正在跑的宿主还是上一版**(新路由要重启后才加载):界面会如实说明"这次走旧机制,重启之后按钮就是新版了"。
207
+ - 为什么 DSH 需要插件自己干这件事:官方没有重启宿主的机制(插件市场那边只提示「更改将在下次启动生效」),所以这件事只能由插件做。
208
+
147
209
  ## 结构
148
210
 
149
211
  仓库根目录(克隆或解压出来即是):
@@ -156,18 +218,34 @@ dsh-composer-ux/
156
218
  ├── CHANGELOG.md # 版本更新日志
157
219
  ├── build.mjs # esbuild 构建:lib/index.js(Host)+ lib/client.js(浏览器),并做发行后处理
158
220
  ├── src/
159
- │ ├── host.ts # Host 半:注册 settings namespace + 把请求头镜像进 llm-pi-ai
160
- │ ├── settings-contract.ts # 字段/默认值/菜单元数据(零依赖共享)
221
+ │ ├── host.ts # Host 半:注册 settings namespace + 请求头镜像 + 「默认终端」+ 「重启 DSH」路由
222
+ │ ├── settings-contract.ts # 字段/默认值/菜单元数据 + 栏开关的迁移判据(零依赖共享)
223
+ │ ├── restart.ts # 「重启 DSH」:助手进程/等端口/隐藏控制台/信任关卡/优雅退出(零 import)
161
224
  │ ├── client.tsx # Browser 半:设置页 + 菜单浮层 + 拦截器
225
+ │ ├── terminal/ # 「默认终端」(Windows:pwsh → Git Bash),全部零官方运行时依赖
226
+ │ │ ├── discover.ts # 探测:git 锚定反推 bash、硬排除 WSL、多候选排序(纯函数)
227
+ │ │ ├── render.ts # 结果渲染与 exit 状态解析(与官方逐字对齐)
228
+ │ │ ├── sandbox.ts # 沙箱策略面 + 升级审批(fail-closed,与官方同语义)
229
+ │ │ ├── tool.ts # bash 工具定义:schema / 执行 / 后台 / 中止 / 终端卡片
230
+ │ │ ├── contracts.ts # 字段、三档、候选净化、生效判定、状态行文案(纯函数)
231
+ │ │ └── host.ts # 宿主半接线:按会话下发 restrict+register+section、立刻覆盖在跑会话
162
232
  │ └── client/
163
233
  │ ├── chords.ts # 键位编码/录制校验
164
234
  │ ├── interceptors.ts # keydown/contextmenu 捕获拦截 + 回放
165
- │ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目)
235
+ │ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目 + 默认终端卡片)
166
236
  │ ├── settings-style.ts # 折叠卡片样式表(dsh-ux-* 类名注入)
167
237
  │ ├── ContextMenuHost.tsx # shell.overlay 右键菜单
168
238
  │ └── styles.ts # --dsw-* 令牌内联样式
239
+ ├── docs/settings-panel-0.5.0.png # README 顶部那张设置页截图(用 tag 固定的 raw 链接引用,不进 npm 包)
169
240
  ├── test/
170
241
  │ ├── host-header-mirror.mjs # 请求头镜像的行为测试(假 settings 驱动构建产物)
242
+ │ ├── quick-commands.mjs # 快捷指令 / 优化接口 / 路由注册 / 重启机制
243
+ │ ├── quick-store.mjs # 快捷指令文件存储与写回路径
244
+ │ ├── terminal-policy.mjs # 默认终端:探测 / 渲染 / 升级审批 / bash 工具 / 宿主半接线
245
+ │ ├── client-registration.mjs # 客户端注册协议、右键行为、抬头按钮、六栏开关与标题行布局
246
+ │ ├── mutation-guards.mjs # 变异测试(手动跑):把每条护栏拆掉,测试必须变红(21 条)
247
+ │ ├── settings-render.mjs # 真渲染测试(手动跑):借 profile 的 react 把设置页渲染成 HTML
248
+ │ ├── check-sections.mjs # 升级前自查(只读):拿真实 settings.yaml 跑一遍栏开关迁移
171
249
  │ └── opencode-header-wire-probe.mjs # 线级探针:本地端点,用来看 DSH 出网请求带了什么头
172
250
  └── lib/ # 构建产物(运行所需)
173
251
  ```
@@ -176,7 +254,12 @@ dsh-composer-ux/
176
254
 
177
255
  ```sh
178
256
  node build.mjs # 产出 lib/index.js + lib/client.js
179
- node test/host-header-mirror.mjs # 宿主半行为测试(写入/撤销/改名/幂等/不误删)
257
+ npm test # 五个套件;当前 632 passed, 0 failed
258
+ node test/mutation-guards.mjs # 手动跑:变异测试,证明那套护栏真的在咬人
259
+ node test/settings-render.mjs # 手动跑:把设置页真渲染成 HTML,断言版式与互斥显示(20 项)
260
+ node test/check-sections.mjs # 只读:升级前看六栏会变成什么
261
+ node test/host-header-mirror.mjs # 只跑宿主半的请求头测试(写入/撤销/改名/幂等/不误删)
262
+ node test/terminal-policy.mjs # 只跑「默认终端」(探测 / 渲染 / 审批 / 工具 / 接线)
180
263
  # 线级探针(可选):起一个本地端点,再把某条路由临时指过来,即可看到真实出网请求头
181
264
  node test/opencode-header-wire-probe.mjs 8799 600000
182
265
  # 若 DSH 源码检出不在默认路径: