Skip to content
dsh.fish
Bundle

dsh-codex-micro

把 Vaydeer 九键小键盘当 Codex Micro 用:键盘发唯一组合键,本插件把它们翻成 DSH 的动作

Source
harmless0819-dev
License
MIT
Updated
Updated 6 hours ago

Readme

# dsh-codex-micro — 把九键小键盘当 Codex Micro 用

把 Vaydeer 九键小键盘变成 DSH 的编程代理遥控器:**键盘固件只负责发没人占用的组合键,
本插件把组合键翻成 DSH 的动作**,并提供「按键到底有没有触发 / 页面上到底有什么」的证据链。

- 键盘:`VID_0483&PID_5752`,序列号 `VAJP101130904085D8`,控制程序 `Vaydeer keyboard 1.2.3`
  (状态文件 `%APPDATA%\vaydeer-keyboard\data_v7.2.json`,4 层 × 9 键)。
- 它**是标准 HID 键盘**,发的键和笔记本键盘无法区分 → 所以只能用组合键,绝不能用裸数字。
- 方案背景与差距分析见 `D:\deepseek\docs\vaydeer-9key-codex-micro-plan.md`。

## 0. 依赖关系(重要)

- **日常使用不需要 Vaydeer keyboard 控制程序。** 键位存在键盘**固件**里,那个程序只是"配置工具":
  绑好之后关掉它、甚至卸载它,按键照常发出。
- 它唯一还有用的场景是"改键位"——现在连这个也不必须用它了:
  `D:\deepseek\tools\vaydeer_hid.py` 能直接跟键盘对话(**已实测读通**设备信息、层状态、
  逐键绑定),协议档案见 `D:\deepseek\docs\vaydeer-device-protocol.md`
  (写键位的帧格式已完整解出,待实现 `--write`)。
- 它在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` 里**开机自启**。管理:
  `& D:\deepseek\tools\vaydeer-app-control.ps1 -Status | -Stop | -Start | -DisableAutostart | -EnableAutostart`
- ⚠️ **不要**在键盘上绑这几类动作——它们由控制程序在**电脑侧**模拟(robotjs / nut-js / clipboardy),
  程序一关就失效:打字(Text)、宏(Macro)、打开网址或程序(Trigger)、鼠标键(Mousefunction)、托盘切层。
  本方案只用「组合键」和「上一层/下一层」,两者都在固件里跑。

## 1. 它做什么 / 不做什么

能:把 9 个键(× 两层)翻成 DSH 的动作——发送、停止/中断、新会话、模型、侧栏、设置、
复制最近一条、重试,以及第二层的指令/工具/文件/插件/查看/关闭等面板入口。

不能(Codex Micro 那部分的硬差距):Agent 键的实时 RGB 状态灯、旋钮、摇杆实体,
以及 ChatGPT 桌面 App 的设备级识别。本插件用**屏幕左下角的角标 + 短暂提示**
(`CM L1 Agent` / `L1 · 3 新会话`)替代「按键亮没亮」这件事。

## 2. 安装(必须由用户在自己的终端里跑)

```powershell
& D:\deepseek\dsh-codex-micro\install.ps1            # 装(= dsh plugin --profile web add link:<dir>)
& D:\deepseek\dsh-codex-micro\install.ps1 -DryRun    # 只看要执行什么
& D:\deepseek\dsh-codex-micro\install.ps1 -Remove    # 卸载
```

装完**重启 dsh web**(`lib/index.js` 的改动必须重启;之后改 `lib/client.js` 只要浏览器 F5):

```powershell
& D:\deepseek\dsh-whale-emote\restart-web.ps1
```

沙箱里不要尝试 `dsh web`:写不了 `~/.dsh/profiles/web/cordis.yml`(EPERM),起来的也是残的。

## 3. 键位怎么进去的(已经写好了,一般不用再动手)

**2026-09-17 已完成**:这套键位是用自写工具**直接写进键盘固件**的,全程没有使用控制程序:

```powershell
python D:\deepseek\tools\vaydeer_hid.py backup --out D:\deepseek\logs\vaydeer-keymap-backup.json
python D:\deepseek\tools\vaydeer_hid.py plan   --out D:\deepseek\docs\vaydeer-profile-dsh.json
python D:\deepseek\tools\vaydeer_hid.py write  --plan D:\deepseek\docs\vaydeer-profile-dsh.json --dry-run
python D:\deepseek\tools\vaydeer_hid.py write  --plan D:\deepseek\docs\vaydeer-profile-dsh.json
```

实测结果:**18/18 键回读与计划字节一致**;层0 = `Ctrl+Alt+Shift+1..8` + 下一层,
层1 = `Ctrl+Alt+Shift+F1..F8` + 上一层;层2/层3(SolidWorks、密码层)原样未动。
协议细节与硬约束(VK 必须 < 0x80、必须"开层→逐键→提交层")见
`D:\deepseek\docs\vaydeer-device-protocol.md` §5。

下面这段手工步骤只在你想**改动键位**、或想用控制程序重新刷一遍时才需要。

### (备查)在 Vaydeer app 里手工绑键

触发词(两层必须互不重叠,因为键盘不会告诉插件当前是哪一层):

| 层 | 主用(勾三个修饰键) | 回退(app 只让勾两个修饰键时) | 插件里的名字 |
|---|---|---|---|
| Layer1 | `Ctrl+Alt+Shift+1..9` | `Ctrl+Shift+1..9` | Agent 主控 |
| 第二层 | `Ctrl+Alt+Shift+F1..F9` | `Ctrl+Shift+F1..F9` | UI 面板 |

> ⚠️ 别用 `Ctrl+Alt+数字`:你的 app 设的是 `Distribution-DE` 布局,`Ctrl+Alt` = `AltGr`,
> 会在文本框里打出字符(而且插件也认不出那是按键信号)。

步骤:

1. 插好键盘,打开 Vaydeer keyboard,确认设备是 `VAJP101130904085D8`。
2. 选 **Layer1**,把第 1–8 键设成「组合键」→ 勾 `Ctrl`、`Alt`、`Shift` → 依次选 `1`…`8`。
3. 第 9 键设成 app 自带的 **「下一层」**(Vaydeer 那一类动作)。**别**改成组合键——
   它就应该继续当层切换键,和你现在的用法一致。
4. 切到 **第二层**,把第 1–8 键设成 `Ctrl+Alt+Shift+F1…F8`,第 9 键设成 **「上一层」**。
5. **第三层**(建议保留数字键盘):9 个键绑 `1..9` 数字即可(这层不经过插件)。
6. **第四层**(可选):从 app 原生的「多媒体 / 系统功能」里挑(音量、播放、任务视图、锁屏…),
   或者继续放你原来的 SolidWorks 快捷键——那一层同样不经过插件。
7. 设置里**关掉「托盘切换层」**,只留按键切换;否则误点托盘会悄悄换层,表现就像「按键失灵」。
8. 改之前先备份 `%APPDATA%\vaydeer-keyboard\data_v7.2.json`(app 运行时别手改这个文件)。

## 4. 键位表

### 插件接管的两层

| 键 | 层0 · Agent 主控 | 层1 · 界面与诊断 |
|---|---|---|
| 1 | 发送消息(找不到按钮则 Ctrl+Enter) | 指令面板 |
| 2 | 停止 / 中断(按钮只在有轮次运行时才存在,找不到则 Esc) | 添加附件 |
| 3 | 新建会话 | 更多操作 |
| 4 | 模型(加载中是「正在加载模型…」,加载完需按真实文案补 candidate) | 视图选项 |
| 5 | 访问模式(工作区内修改 / 只读,相当于 Codex Micro 的批准档位) | 打开右侧边栏 |
| 6 | 侧栏开关(收起 / 展开侧边栏) | 展开底部面板 |
| 7 | 任务看板 | 搜索会话 |
| 8 | 轨迹视图 | 探测 DOM(抓页面结构回传,排错用) |
| 9 | **「下一层」**(键盘固件动作,不经插件) | **「上一层」** |

> 键位表照 2026-09-17 真实页面抓到的 aria-label 定(快照存在 logs/dsh-dom-labels.txt)。
> 键盘固件里存的标签名(SEND/STOP/NEW/…)是旧文案,只影响厂商 app 里显示的名字,不影响功能。

第三 / 第四层是「原生层」,插件完全不参与,随便你放什么。

## 5. 验证(不看计数就不算生效)

```powershell
# 客户端有没有注入成功:clientsLoaded 应该 >= 1
Invoke-WebRequest http://127.0.0.1:3080/dsh-codex-micro/health.json -UseBasicParsing | Select-Object -Expand Content

# 按一下触发键后(主键盘上按 Ctrl+Alt+Shift+1 也能测):seq 增长、lastReport 里出现 label / ok
# 最近 300 条事件(含失败原因):
#   http://127.0.0.1:3080/dsh-codex-micro/events.json
# 页面上真实存在的按钮清单(用来修点击目标):
#   http://127.0.0.1:3080/dsh-codex-micro/dom.json
```

日志文件:`D:\deepseek\logs\dsh-codex-micro.jsonl`(一次按键/一次探测一行;写不进去时
health 里 `logWritable:false`,不会抛错)。

**键盘侧**单独验证用 `D:\deepseek\tools\key-probe.html`:浏览器里打开,逐个按 9 个键,
看收到的是不是预期的组合键(`code` 应该是 `Digit1`…/`F1`…,且带 `Ctrl+Shift`)。

## 6. 排错

| 现象 | 先看什么 |
|---|---|
| 按键完全没反应 | `health.json` 的 `seq` 涨不涨。不涨 = 插件没加载(看是否已重启 dsh web)或组合键没绑对(用 key-probe.html 复核键盘实际发什么) |
| **一出现选择,键盘就跳回层0** | 见 §13。已修(2026-09-17 晚):取消 panic 不再发 `set-layer 0`。改的是 `lib/client.js` → **F5** 生效 |
| `seq` 涨但动作不对 | `events.json` 里那条事件的 `ok`/`reason`;失败时插件会自动抓一份 `dom.json`,按里面的真实按钮文案改 `lib/client.js` 的 `candidates`(改完 F5 即生效) |
| 触发了别的程序 | 说明组合键被占用了 → 换一套(例如 `Ctrl+Alt+Shift+F1..F9` 与数字层互换) |
| 想加危险动作(批准/拒绝/删除) | 目前**故意没放**:先按下 8 让插件抓到真实按钮文案,确认无误后再加进 `ACTIONS` |

## 7. 卸载与恢复

1. `& D:\deepseek\dsh-codex-micro\install.ps1 -Remove`,再重启 dsh web。
2. 键盘侧:把 Layer1 / 第二层改回原样(原绑定见方案文档 §1.2 的表格,备份的 JSON 也在手边)。
3. 本插件不改任何第三方包、不写 profile 之外的文件,删目录即干净。

## 8. 离线自测

```powershell
node D:\deepseek\dsh-codex-micro\test-harness.mjs   # 期望 11/11 passed(宿主侧:路由/日志/环形缓冲/TDZ 回归)
node D:\deepseek\dsh-codex-micro\test-panic.mjs     # 期望 7/7 passed (浏览器侧:九键紧急关闭判定)
```

`test-harness.mjs` mock 掉 `ctx.webServer`,覆盖:路由注册、health/report/dom/events、
`tapIndex` 注入幂等、坏 JSON 400、环形缓冲上限、`ctx.effect` 清理,
外加两条 TDZ 回归(panic/panic-cancel/set-layer 三条分支必须回 200、`source` 必须进日志);
它**完全离线**(`daemonPath`/`toolPath` 指向不存在文件),不会碰真键盘。

`test-panic.mjs` 用 `node:vm` 造受控 `window`/`document` + 可控时钟,把 `lib/client.js` 真跑起来,
断言:逐个点按 9 键**不**触发、9 码在 600ms 内铺开**立即**触发、立刻松手也能触发、
铺开超 600ms 不触发、8 码不触发、旧残留码不挡真全按、三层对称。
它存在的理由见 §11 —— panic 判定已经错过**三**次,而它是安全功能。
**验证过它抓得住四种错法**:① 去掉同时性门槛 → 2 条失败;② 「同层 9 码齐」→ 真全按那条失败;
③ 「按住 3 秒」→ 3 条失败;④ 假阴性变体(用全部残留码算铺开度)→ 对应那条失败。

两者都证明不了浏览器里点得对不对——那要靠 §5 的真页面证据。

## 9. 脚本与编码(踩过的坑)

- 本仓库的 .ps1 **必须是 ASCII-only 或带 UTF-8 BOM**。PowerShell 5.1 把无 BOM 的文件按 ANSI 解码,
  中文注释会变乱码并吃掉引号,于是报出莫名其妙的「不允许使用与号(&)」「语句块中缺少右 }」。
  dsh-codex-micro/install.ps1 与 tools/vaydeer-app-control.ps1 现在是 **ASCII-only + BOM**;
  改完请用 [System.Management.Automation.Language.Parser]::ParseFile 复核「parse errors: 0」。
- 装插件:从 GitHub 用 `dsh plugin --profile web add github:harmless0819-dev/dsh-codex-micro`;
  从本地克隆则走 **pnpm 的 link: 前缀 + 正斜杠**:
  `dsh plugin --profile web add link:D:/path/to/dsh-codex-micro`
  直接写裸盘符路径会被 pnpm 当成另一种 spec 去找 git 仓库,报 fatal: not a git repository。
- 写含中文的 JSON/文档一律显式 encoding=utf-8;用重定向(大于号)会按控制台 GBK 落盘,读回来就炸。

## 10. 选项层也管提权确认卡(2026-09-17 实测通过)

DSH 弹出确认卡(按钮是「拒绝」/「允许一次」这类)时,不用鼠标:

| 键(键帽) | 动作 |
|---|---|
| 物理 7 | 允许一次(主按钮 _button_..._primary) |
| 物理 8 | 拒绝(次按钮 _button_..._outline) |
| 物理 9 | 始终允许(卡上真有这颗才生效,否则退化成允许) |
| 物理 2 | 也等于允许一次 |

实测证据(events.json):action 选项1 ok=True mode=approval:pointer-sequence gate=True display=允许一次,
随后那次提权写文件真的成功了 —— 键盘点的就是确认卡上的真按钮。

安全闸(都是踩过坑加上的):

- 屏幕上没有问句/确认卡时,选项键什么都不点,只记一条 reason=no-question。
  踩坑经过:早期启发式会退化成「把整页按钮当选项」,于是按物理 7 去点了左侧栏的 newSession,
  表现就是「选项键变成新建会话」。
- 自己输入只在问句容器内找输入框,绝不碰其它插件(曾抓到过鲸鱼挂件的 input.dshwv-number)。
- 输入框为空时发送键返回 empty-composer、不发(DSH 的输入框是「发消息或创建任务」,空着发会新建会话)。
- 确认卡识别:全页找短标签的 允许/批准/同意/Approve 与 拒绝/Deny/Reject,必须至少存在一侧才认为卡在屏幕上。

## 11. 九键紧急关闭(panic switch,2026-09-17 加入)

触发:**9 个不同的键码在一次约 600ms 的「铺开」内全部按下**,凑齐即**立即**弹红屏。
(三层键码合并统计——因为第9键会顺手切层,按「同一层的 9 个码」永远凑不齐。)

> ⚠️ **判据改过三次,前两次都是错的 —— 这段是结论,别再走回头路。**
>
> **① 原实现「9 码在 3 秒窗口内出现就触发」→ 会被常规操作误触。**
> 用户报的「一出现选择就跳回层0」就是它:按 9 进选项层(F9 属 agent 集)再逐个点选项键,
> 9 个不同码照样凑齐。日志:41 次 panic 里 27 次是这种误触。
>
> **② 我曾改成「同一层 9 码齐」→ 被实测推翻。**
> 第 9 键是固件的「下一层」,按下它就切层,切层后那个键发出的码属于**另一层** ——
> 真·全按在浏览器侧**必然跨层**。证据(`keypad-power.log` = 设备事件通道,最权威):
> 设备确认 held=9 的真全按 13 次,其中 **5 次浏览器侧最大单层只有 6~8 个码**
> (例:12:24:30 真全按 → 浏览器收到 `Digit8,F9,Digit2..7,KeyQ`,`KeyQ` 属选项层)。
> 用「同层齐」会在 5/13 次真全按时**漏报**。
>
> **③ 我又改成「静默武装 + 持续按住 3 秒」→ 造成真实回归(用户报「按全部键关闭没了」)。**
> 我把触发从"立即"改成"先静默武装、按住满 3 秒才开火",同时**去掉了红屏反馈**。
> 结果实测 6 次真全按**全部**是 `armed → 约 1.27 秒 → disarmed`:
> 用户按下后屏幕上什么都没发生,以为坏了就松手 → 功能等于失效。
> 教训:**浏览器侧的安全功能必须有立即反馈**;"按住达时长"只有在有可见进度时才对用户可用。
> **设备侧守护也踩了同一个坑**(实测 21:03 之后的尝试全是 `cancelled`:铺开 0~3000ms、
> 用户没等满 3 秒就松手)—— 它同样缺少反馈,只是没有 UI 可显示。**若要让"电源键"真正可用,
> 设备侧也得改成同时性判据**(或加可见反馈);目前它只靠 3 秒门槛,实测成功率很低。
>
> **④ 当前判据 = 同时性**:真全按实测是 9 个 down 在 **204ms** 内一次铺开
> (13:34:22.262→.466);误触则是伴随 action、分散在 2 秒以上逐个点出来的。
> 所以要求**最近按下的 9 个码挤在 `PANIC_SPREAD_MS` = 600ms 内**,凑齐立即触发 ——
> 既保留"立即红屏"的原始交互,又挡住逐个点按。
> 取"最近 9 个"而不是"全部残留码"很重要:否则 3 秒窗口里零散点过的旧码会把铺开度拉老,
> 真全按反被漏判(假阴性)。
>
> **600ms 是量出来的,不是拍的**:拿全部历史 panic 回算 9 码铺开跨度,两群干净分开 ——
> 真全按 **188 / 197 / 200 / 204 / 204 / 204 / 208 / 240 / 285 ms**(9 次,全 ≤600),
> 误触 **1304 / 1617 / 1812 / 1820 / 2896 / 6252 ms**(6 次,全 >600)。
> 最大真全按 285ms 与最小误触 1304ms 之间有 4.6 倍空档,600ms 居中(两侧裕度 315 / 704ms)。
> 回归测试 `test-panic.mjs`(**7/7**),已验证能抓住上述 ①②③ 三种错法 + 假阴性变体。

1. 客户端立刻在整页盖一层红屏:键盘紧急关闭已触发 / DSH 前后端将在约 3 秒后关闭 / 按任意键取消
2. 客户端上报 kind=panic,宿主记一条日志,然后 **3 秒后 process.exit(0)** ——
   也就是杀掉 dsh web 进程本身(和 dsh-power-controls 的自动关闭同一套机制),
   顺手 kill 掉常驻 HID 助手。
3. 取消:**9 颗键全部松开才取消**(松手可撤销,不必再"保持按住")。
   实现:为每个「九键码」跟踪按着/抬起状态,每 150 ms 检查一次;只有当**一个都没按着**且
   连续两个检查周期(约 300 ms)都是这样,才发 panic-cancel。
   **取消不再动物理层**(2026-09-17 晚修):以前这里无条件发 `set-layer 0`,
   于是误触取消会把键盘从选项层拨回层0 —— 那正是用户看到的现象。取消=无副作用。
   触发后 400 ms 内的检查忽略。
   早期版本是「按任意一颗键取消」和「≤2 颗还按着就算松手」,都被键盘在同时按 7 颗以上时
   吐出的假 keyup 误触发(6KRO/鬼键挤压),所以改成"必须全部抬起"。
   鼠标点红屏上的取消按钮同样有效(via=mouse-button)。
   红屏期间其它按键一律被忽略(panicFired 闸),不会在 9 连按的尾巴上再点出一堆动作。
   历史教训:早先的「红屏后按任意一颗键即取消」会被 9 连按的尾巴误触发,panic 一出现就被撤掉。
   正在按住的那 9 颗键不会误取消(按住不再产生 keydown,keydown 的 repeat 也被忽略)。

边界(诚实说明):

- 网页无法主动关掉浏览器标签(浏览器禁止脚本关闭非脚本打开的窗口)。所以「关前端」的实际表现是:
  后端进程没了,页面掉线;标签页需要你自己关。
- 关掉的是整个 dsh web 进程(这台机器上所有会话/插件一起停),不只是本插件。
- 会中断当前这个 agent 会话;恢复要手动:在用户自己的终端跑 restart-web.ps1(或开 DSH 桌面版)。
- 想先只测「检测 + 红屏 + 取消」而不真的关机:在 profile 的补丁层加一段按 id 的覆盖,然后重启 dsh web:

      - id: dsh-codex-micro
        name: dsh-codex-micro
        config:
          panicExit: false

  这样 panic 只写日志(panic-dryrun),不会 exit。

实测记录:待你触发一次;events.json 里应看到 panic ->(若取消)panic-cancel,
或 panic -> panic-exit;日志文件 D:/deepseek/logs/dsh-codex-micro.jsonl 同样有记录。

### 前端能一起关掉吗(2026-09-17 补充)

诚实结论:网页无法主动关闭不是自己打开的标签页(浏览器安全限制,window.close() 会被静默拒绝)。
所以到点(保持按住 3 秒)之后按三层收尾:

1. POST /dsh-power/close -> 现成的关闭入口,后端 400 ms 后 process.exit(0)
   (日志:panic-commit -> panic-close status=200)
2. window.close() —— 在脚本打开的窗口、以及某些桌面壳(桌面版 DSH)里能真的关掉窗口;
   普通标签页会被拒绝,于是走第 3 步
3. 兜底:整页盖一块「DSH 已关闭(键盘紧急关闭)」黑屏,1.5 秒后 location.replace('about:blank')
   把页面清空 —— 视觉上等于前端关掉了(要回来得重新输入地址 / 重开标签页)

桌面版外壳会不会自己退出取决于它自己的实现;后端一死它至少会掉线。

### 把键盘当电源键(目标:DSH 没开时,九键全按 + 保持 3 秒 = 启动 DSH)

硬事实:DSH 没开时浏览器里没有任何页面,前端不可能检测到按键 —— 必须由宿主机上一个
**独立于 dsh web 的进程**来做(计划任务),它只依赖键盘自己的通道:

- 键盘有独立的事件通道(vendor usage 2):**每次按键设备都会主动上报 (layer, key)**,
  不需要 DSH、不需要浏览器焦点、也不需要窗口在前台。
- 因此这个"电源键守护"进程可以:看到 9 个键在 3 秒内按齐 → 若 dsh web 在跑 → 关掉它;
  若不在跑 → 启动它(复用 dsh-power-controls 已经在用的 Task Scheduler 条目)。
- 现状:常驻 HID 助手(tools/vaydeer_hid_daemon.py)已加后台事件监听线程,会把帧打成
  EVT <hex> layer=N key=M 行,宿主记进 events/dsh-codex-micro.jsonl;**待重启 dsh web 生效**,
  并需要用一次"九键全按 + 保持 + 松开"确认事件帧里是否包含**抬起**信息(决定保持/松手语义能不能在宿主机侧实现)。

## 12. 键盘电源键(2026-09-17 全循环实测通过)

- 守护进程:tools/vaydeer-power-key.py;计划任务:dsh-keypad-power-key
  安装与管理:tools/install-keypad-power.ps1(无参数即安装并启动;-Status / -Restart / -Remove)
- 语义(**2026-09-17 晚改**):**9 键在 600ms 内一次按齐 = 触发**(同时性判据,见下);
  不再要求"保持 3 秒"。
- 依据:键盘事件通道(vendor usage 2)的帧 = [layer, key, state],state 0x00 = 按下 / 0x02 = 抬起
  (实测抓到 18 帧:9 按下 + 9 抬起)。这条通道不需要 DSH、不需要浏览器、不需要窗口在前台。
- 触发时(**2026-09-17 22:48 改,见下**):
  - **有浏览器页面在看 → 设备侧不关**,把关闭交给页面(页面有红屏倒数 3 秒、可松手取消);
  - 没有页面在看 → POST /dsh-power/close,1.5 秒后仍活着就 taskkill 兜底;
  - dsh web 不在 -> 依次尝试 wscript.exe + Startup 的 DSH Web Autostart.vbs
    -> powershell -WindowStyle Hidden -File D:/deepseek/dsh-autostart/start-dsh-web.ps1 -Tune
    -> 最后兜底 restart-web.ps1;每条等 30 秒看 3080 端口起没起。
- 日志:logs/keypad-power.log(逐键 down/up、FIRING、skip close、launch issued via、dsh web is up again)
- **不需要窗口、不需要前台(2026-09-18 实测并修正)**:计划任务的动作必须是 **`pythonw.exe`**,
  不是 `python.exe`。用 `python.exe`(控制台子系统)时,登录后桌面上会冒出一个控制台窗口
  (Win11 把它交给 Windows Terminal,标题就是 `C:\Program Files\Python311\python.exe`)并抢焦点;
  实测 2026-09-18 09:47:05 守护进程启动、09:47:06 Windows Terminal 出现 —— 用户看到的
  「小键盘监控程序」就是它。守护进程本身**不看焦点、不需要前台**:它经 `hid.dll` 直接读键盘的
  vendor 事件通道(usage 2)。**关掉那个窗口 = 杀掉守护**(九键电源键静默失效到下次登录),
  所以要么最小化,要么重装成无窗口:`install-keypad-power.ps1` 已改为优先 `pythonw.exe`
  (备用 `C:/Windows/pyw.exe`),并且 `netstat` / `taskkill` 子进程都带 `CREATE_NO_WINDOW`
  (pythonw 下父进程没有控制台,否则这两个控制台子进程会各自弹一个窗口抢焦点)。
  pythonw 下 `sys.stdout is None`,`log()` 里的 `print()` 是静默 no-op(实测 `print_ok=True`),
  文件日志一字不少。
- 插件侧常驻的 HID 助手 `tools/vaydeer_hid_daemon.py` 由插件 `spawn('python', …, {windowsHide:true})`
  拉起,**本来就是无窗口的**(`logs/dsh-codex-micro.jsonl` 的 `daemon-spawn` 带 pid,可与
  `Get-Process` 对上),所以桌面上看到的那个 python 窗口一定是电源键守护,不是它。
- 离线测试:`python tools/test-power-key.py`(**6/6**,覆盖 fire() 的四个分支:有页面跳过 /
  无页面关 / 探测失败保守关 / dsh 没跑则开机)
- **真机验证状态(2026-09-17 晚,用户逐条测过)**:
  - 关(有页面在看)→ 红屏倒数 3 秒,松手可取消 ✅(日志 `skip close` + `panic-cancel`)
  - 关(设备侧自己动手)→ `close -> 200` → `dsh web is down now` ✅
  - **开(DSH 没跑时按九键)→ 能启动回来 ✅(用户 2026-09-17 确认「测过了,没问题」)**
  - 即「按九键关 / 开」这两条路都已真机跑通。

> 🐞 **已修:设备侧抢先关闭,把浏览器侧的 3 秒确认期架空(2026-09-17 22:45,用户报)。**
> 用户原话:「不到 3 秒后端就退了,前端按满 3s 才关」。实测同一次按键的时间轴:
>
>     22:45:36.589  浏览器侧 panic(红屏开始,倒数 3 秒)
>     22:45:36      设备侧 FIRING -> /dsh-power/close -> 200   ← 同一秒抢先
>     22:45:38      设备侧 dsh web is down now                 ← 后端 2 秒就死了
>     22:45:39.6    浏览器侧本该倒数完才发请求(此时后端早没了)
>
> 三次 `panic`(14:38 / 14:42 / 14:45)后面**都没有 `panic-commit`** —— 页面在倒数途中掉线。
> **根因:设备侧守护没有 UI,检测到九键就立即关,必然抢在确认期之前。**
> 修法:`fire()` 先查 `/dsh-power/health` 的 `visibleClients`;有可见客户端就让页面处理
> (日志打 `N browser client(s) watching -> let the page handle it (skip close)`),
> 没有(或探测失败,保守保功能)才自己关。
> **教训:两个能关闭后端的路径必须约定主从**——有交互反馈的一方主导,无反馈的一方让位,
> 否则"更快的那个"总是赢,用户看到的确认界面就成了摆设。

> ⚠️ **"保持 3 秒"判据已废弃(与浏览器侧同一个错误)。** 实测证据:21:03 之后的尝试
> **全部**是 `all 9 keys seen -> armed` 紧接 `cancelled`(铺开 0~3000ms),因为用户松开得太早 ——
> 这条通道没有任何 UI/反馈,用户无从知道"要按满 3 秒"。现改为**同时性**:9 键在
> `SPREAD_MS` = 600ms 内按齐即触发(与浏览器侧 `PANIC_SPREAD_MS` 同值)。
> 实测真全按铺开约 200ms,读事件按 150ms 分批,所以 600ms 对真全按很宽松、对逐个按键很严格。

> ✅ **已恢复(2026-09-17 21:53)。根因是"孤儿进程占着失效句柄",不是设备坏。**
> 排查过程和结论(都留了证据,供下次直接照抄):
> 1. 用户跑 `install-keypad-power.ps1` → `task registered / task state: Running`,但日志尾部
>    仍全是旧代码措辞的 `... retrying`,且**没有新 python 进程**。
> 2. 查找**旧代码特征**:新代码启动行会打 `spread<=600ms`、出错打 `streak=`;
>    日志里一个都没有 → **新代码从未运行成功**。
> 3. 实测**新进程能正常打开事件通道**(`v.find_targets()` + `open_device(USAGE_EVENT)` 成功,
>    `in_len=17`)→ 说明 1167 **不是设备/驱动问题**。
> 4. 关键判据:那个旧守护(**pid 48688,起于 20:27**,跑旧代码)一直活着并持有失效句柄、
>    疯狂刷 1167。**杀掉它之后,1167 立刻停止**(`log mtime` 不再推进)。
> 5. 用新代码启动 → 打出 `watching the keypad event channel (... spread<=600ms)`,
>    **零 1167**。设备与代码都是好的。
>
> **教训:换代码后必须杀掉旧进程。** 新版文件写好了、任务也注册了,但**旧进程还占着设备句柄**,
> 于是看起来"修了没用"——而且旧进程的日志会污染判断(它打的 `retrying` 与新版 `streak=` 混在
> 同一个日志文件里)。**判断"新代码有没有真的跑起来",要认新代码独有的日志措辞**,不要只看
> 任务状态或"日志在动"。
>
> 另:计划任务在沙箱侧查不到(`Get-ScheduledTask` 报 not installed、`schtasks` 报
> "cannot find the path"),这是 **CIM 在沙箱不可用**造成的假象,**不代表任务不存在** ——
> 用户终端里的 `task registered / Running` 才是真的。别据此下结论。
>
> 本次还给 `vaydeer-power-key.py` 补了**启动/退出留痕**(`starting (pid=…)` / `exiting` /
> `FATAL <类型>: <消息>`):21:51 那次任务注册成功、state=Running,进程却不见了而日志**一行都没有**,
> 崩了不留证据无法排查。

> ✅ **最终验证通过(2026-09-17 22:34:15)——全链路端到端首次跑通。** 用户跑改进版
> `-Restart` 得到 `RESULT: OK`,守护(pid 31756)由计划任务拉起后**第一次成功触发**:
>
>     22:34:15  down ×9(183ms 内一次铺开)→ FIRING (9 keys within 183ms, 9 held)
>     22:34:16  dsh web is up -> closing
>     22:34:16  /dsh-power/close -> 200
>     22:34:17  dsh web is down now
>
> 183ms 铺开远低于 600ms 门槛,走的是**同时性判据**;`close -> 200` 走的是**快速关闭路径**
> (不再白等 6 秒兜底)。设备侧与浏览器侧两条判据现已**完全一致**(都是 600ms 同时性)。
> 至此「按全部键关闭 / 开机」的闭环在两边都经真机验证。

实测日志(2026-09-17 20:27,**旧判据**时代的成功记录,保留作触发链路证据):

    20:27:29  FIRING (held 9 keys for 3116ms)
    20:27:29  dsh web is up -> closing                关掉成功
    20:27:36  FIRING (held 9 keys for 3127ms)
    20:27:37  dsh web is down -> starting it
    20:27:37  launch issued via wscript.exe DSH Web Autostart.vbs
    20:27:47  dsh web is up again                     10 秒后起来

### 踩过的坑(都是实测出来的)

0. **"必须保持按住 N 秒"这个设计在无反馈的通道上不可用**(2026-09-17,浏览器侧 6/6 失败、
   设备侧 21:03 后全 cancelled)。判据要用**同时性**(一次按齐的铺开窗口),不要用按住时长。

1. restart-web.ps1 不能被非用户上下文代跑:它自己的注释写着非用户上下文起 dsh web 可能写不了 profile、起出来是残的
   (实测 20:24 / 20:25 两次都是 "started, but the port never came up")。真正的启动器是 Startup 里的
   DSH Web Autostart.vbs -> start-dsh-web.ps1 -Tune(隐藏窗口)。
2. Git Bash 会把 schtasks 的 /end、/run 当路径翻译(变成 C:/Program Files/Git/end)——必须从 PowerShell 里调。
3. 沙箱建不了计划任务(Register-ScheduledTask 报 0x80070005),安装必须由用户在自己的终端里跑。
4. 键盘同时按 7 颗以上时会吐假 keyup(6KRO / 鬼键挤压)——所以取消判定必须等全部松开;
   早期版本按任意一颗键取消、或"≤2 颗还按着就算松手",都被这个假抬起误触发过。
5. 宿主兜底 exit 原是 3 秒,会和客户端调 /dsh-power/close 撞车(日志出现 panic-close-failed);
   现已改成 6 秒,让正路先干净成功。

## 13. 「一出现选择就跳回层0」的真因与修复(2026-09-17 晚)

用户报告:**页面上刚出现选择,键盘就自动跳回层0**。根因不是选项层逻辑,而是**九键紧急关闭被误触**,
取消时又把键盘物理拨回了层0。日志(`logs/dsh-codex-micro.jsonl`)定量坐实:

- `set-layer target:0` 共 **114** 次,按语义前驱分类:**`panic-cancel` 占 25 次**(另一大类是
  F9/F10/F11 层跳转循环本身)。
- `panic` 共 33 次,其中 **21 次是「跨层混合」码集**。最后三次签名完全相同:
  `Digit8,F9,KeyQ,KeyW,KeyE,KeyR,KeyT,KeyY,KeyU` → panic → 约 2 秒后 `set-layer 0`。

**机制(两个缺陷叠加):**

1. `PANIC_SETS` 的三个码集**就是三个功能层本身的键集**:agent `Digit1..8,F9`、
   选项 `KeyQ..I,F10`、界面 `F1..8,F11`;而 `panicCheck` 把三个集**合并计数**,
   「3000 ms 内出现过 **9 个不同码**」即触发——**不要求同时按住**。
2. 进选项层要按第 9 键 = **F9,而 F9 本身属于 agent 集**。于是「按 9 进选项层 + 按满 8 个选项键」
   正好凑够 9 个码 → 误触 panic → 松手取消 → `doCancel` **无条件**发 `set-layer target:0`。

**修复(用户选定方案 A):**

- `lib/client.js` 的 `doCancel` **不再发 `set-layer`**:取消 = 什么都没发生过,物理层由键盘自己的
  上一层/下一层键决定,插件不替它做主。**改完 F5 即生效**(路由每次从磁盘读)。
- 角标也不再因取消而改写——不谎报硬件状态。
- 副作用解释:为什么以前"看着像自己跳层"而不是"红屏闪一下"——因为取消是在松手后约 300 ms 内
  静默完成的,红屏几乎看不见,用户只看到层号变了。

**顺带发现并修复的宿主端缺陷(需重启 dsh web 生效):**

- **TDZ 崩溃**:`lib/index.js` 里 `let entry` 原先声明在 `set-layer` 分支**之后**,
  而 `panic` / `panic-cancel` / `set-layer` 三条分支都要先给它赋值 → 抛
  `ReferenceError: Cannot access 'entry' before initialization`。
  危害被副作用遮掩了:`record()` 已落盘、`setDeviceLayer()` 已切层,**HTTP 响应却发不出去(400)**,
  而且 **panic 的 6 秒兜底 `process.exit` 永远没被装载**(日志里 `panic-exit` 计数为 0)。
  实测复现:POST `{"kind":"panic-cancel"}` → 计数器 4→5(记录成功)但 HTTP 400。
  修法:把 `let entry` 提到所有分支之前。
- **`source` 字段被丢**:`set-layer` 分支原先只记 `target`+`spawnResult`,而客户端上报里带着
  `source`(`boot` / `key:F9` / `panic-cancel` / `after-confirm`)。丢掉它就只能靠时间关联猜
  「谁把键盘拨走的」——正是本次定因多花时间的原因。现已记入日志。
- 自测补齐:`test-harness.mjs` 新增两个回归测试(三条分支必须回 200、`source` 必须进日志),
  并改为**完全离线**(`daemonPath`/`toolPath` 指向不存在的文件 + `panicExit:false`),
  以前它会在自测时真的去 spawn HID 助手。**验证过测试有效**:把 `let entry` 改回旧位置,
  测试报 `Cannot access 'entry' before initialization` 并失败;改回修复版 11/11 通过。

### 13.1 附带的第三个 bug:选项文案里没有「判据词」就认不出提问卡

同一轮测试暴露出来的(用户反馈「选不了这个」)。`optionRoot()` 原先是**文案否决制**:
候选节点必须 `textContent` 含 `提交|确认|确定|其他|自定义|请选择|请确认|选项`,否则跳过。

而 DSH 提问卡的真实结构(源码 `@deepseek-ai/dsh-client-ui-user-questions/lib/client.js`):

    div.Mbwy4a_frame[data-question-key] > section.Mbwy4a_card
      > header > h2.Mbwy4a_title          ← 「请选择…」这类词只长在这里
      > div.Mbwy4a_options[role=radiogroup|group] > button.Mbwy4a_option[role=radio|checkbox]
          (选项按钮的 aria-label 只有 display.label,即选项自己那句话)

`options` 容器的 textContent **只含选项文案本身**,标题在它的祖先里。于是:

- 单选题那次侥幸成功:选项文案是「选项 A · 默认推荐 / 选项 B…」,自带「选项」二字 → 判据通过。
- 多选题全灭:选项文案是「勾选 1 · 收到 / 勾选 2 · 可以多选…」,一个判据词都没有 →
  `textContent` 不含关键词 → 整张卡被否决。**实测 88 次选项动作里 83 次 `reason=no-question`,
  唯一成功那次正是文案自带「选项」的单选题。**

修复:改成**结构优先**,不再用文案否决 ——
1. `[data-question-key]`(组件自带的结构锚点,最可靠);
2. `[role="radiogroup"]` / `[role="group"]` 且含 ≥2 个可见 radio/checkbox(单选卡是 radiogroup、
   多选卡是 group,见源码 L648);
3. 兜底类名启发式,但仍要求 ≥2 个 radio/checkbox —— 这条守卫就是 §10 里
   「不能把整页按钮当选项」那条,安全边界没有放松。

同时给 `no-question` 加了取证:失败时回传 `diag`(frames / framesWithPicks / radiogroups /
groups / anyRadioCheckbox / optionClassed / composer),下次一看就知道是哪条路径没中,
不用再靠猜。`lib/client.js` 改动 → **F5** 生效。

**2026-09-17 21:31 真机验证通过**:多选卡四个选项全部 `ok=True`(`hitCount=4`,
命中 aria 分别为「选项一…选项四」),选中态真的变了。认卡修复确认生效。

### 13.3 第五、六个问题:提交键文案随题号变化 + 「自己输入」框被自己排除

同一次真机测试暴露的两处,都由日志定量指出(源码 `dsh-client-ui-user-questions`):

**(a) 提交键在多题卡里叫「下一题」,导致找不到按钮。**
源码 L780:`index === questions.length - 1 ? t("submit") : t("action.next")` ——
最后一题才是「提交」,前面的题是 **「下一题」/「Next」**。
`optionConfirmButton()` 原先只认 `提交|确认|确定|Submit|Confirm`,于是多题卡前几题永远失败。
实测:13:30–13:31 一轮 `option-confirm` **成功 50 / `no-confirm-button` 73**。
修复:识别词扩到 `提交|确认|确定|下一题|下一步|继续|Submit|Confirm|Next|Continue`,
并显式**排除跳过键**(`t("action.skip")` =「跳过本题」/ Skip,L802),否则会把跳过当提交。
搜索层级也从 4 层放宽到 6 层(按钮在 footerActions 里,离选项容器较深)。

**(b) 「自己输入」永远找不到输入框 —— 因为拿它当成了聊天输入框排除掉。**
`optionCustomInput()` 用 `findComposer()` 取"聊天输入框"然后把它排除掉;
但提问卡在场时 `findComposer()` 按面积选中的恰恰是**提问卡自己的** `textarea.Mbwy4a_fieldInput`
(L420 的 `fieldInput`)—— 也就是唯一的目标框本身。于是每次都 `no-custom-input`。
证据:13:31:03 / 13:31:17 两次失败的 dom 快照里 `composer.cls = Mbwy4a_fieldInput`。
修复:只有当 composer **不在提问卡内**时才排除它(`composerInRoot` 判断)。

两处都只改 `lib/client.js` → **F5** 生效。

### 13.2 第四个问题:HID 事件通道失效后不重连(两个守护进程都有)

同样是这一轮从日志里发现的,跟选项功能无关但更影响可用性:

2026-09-17 21:19:43,**两个**读 usage-2 事件通道的进程在**同一秒**一起开始报
`1167`(= `ERROR_DEVICE_NOT_CONNECTED`):

- `tools/vaydeer-power-key.py`(键盘电源键守护):原先出错只是
  `log(...) + sleep(0.5) + continue` —— **拿同一个已经死掉的句柄空转**,
  实测连续报了 **541 次**错误、一直没有恢复。
- `tools/vaydeer_hid_daemon.py` 的 `watch_events` 线程:出错直接 `return` ——
  **线程永久退出**,键盘事件在日志里从此断掉。

后果:那个**宿主机侧**的九键电源键(不依赖浏览器、不依赖 DSH 是否在跑)会静默失效。
这比浏览器侧的 panic 更要紧,因为它正是"DSH 没开时也能开机"的唯一实现。

修复:两者都改成**真正重开设备**(`CancelIoEx` + `CloseHandle` → 重新 `find_targets()/open_device()`),
并在恢复时打一条 `reopened/recovered` 日志;拿不到设备时短暂等待后重试,不再空转。
浏览器侧不受影响(它用的是键盘发出的普通 HID 按键,不走这条事件通道)。
(注:旧守护当时已累计 **3000+ 次** 1167;要生效必须重启守护,见 §12。)

### 13.4 第七个问题:关闭请求被自己拆掉的页面中止(panic-close-failed)

2026-09-17 21:41:53 真机测试成功关闭了后端(`panic-exit` 首次出现),但**主关闭路径其实是失败的**:

- 客户端在 3 秒倒数结束的回调里**先** `fetch('/dsh-power/close')`,**紧接着同步**调用
  `window.close()`、并挂一个 1.5 秒后的 `location.replace('about:blank')`。
- 结果那条请求在途时页面已被拆除 → `Failed to fetch`。**实测 5 次里 4 次这样失败**
  (`panic-close-failed` 5 次 vs `panic-close` 成功 1 次),且失败发生在发起后约 **3ms** ——
  典型的"请求被中止",不是网络超时。
- 之所以最终还能关掉,是因为**宿主侧 6 秒兜底** `process.exit(0)` 救了场(`panic-exit`)。
  也就是说:功能可用,但白等 6 秒、且主路径形同虚设。

修复(只改 `lib/client.js` → F5):
1. 先用 **`navigator.sendBeacon`** 打底 —— 它的设计目的就是"页面离开也能送达",不会被拆除中止;
2. beacon 没发出去才用 `fetch` 补一发(成功就不发,免得日志里多一条吓人的 409);
3. **拆前端延后 1.2 秒**,给请求落地的时间。

> **生产实证(2026-09-17 22:08:28,用户真机测试)——修复后的第一次成功走快速通道:**
>
>     22:08:28.369  panic               9 码铺开 → 立即红屏
>     22:08:31.372  panic-commit        3 秒倒数结束
>     22:08:31.373  panic-close-beacon  beaconSent=True
>     22:08:31.378  panic-close status=409
>
> 409 = "close already scheduled",**是成功的标志不是失败**:beacon 先到、拿到 200 并调度
> `process.exit(0)`(+400ms);fetch 后到,服务端已在关闭中,回 409。**DSH web 在按键后
> ~3.4 秒退出**(3 秒倒数 + 0.4 秒),对比修复前靠 6 秒宿主兜底。
> 对比 22:07:29 那次(F5 之前、旧代码):`panic-close-failed: Failed to fetch` —— 同一台机器、
> 同一双手,只差一次 F5。
> (后来把 fetch 改成"beacon 成功就不发",以后日志里不会再出现这条 409。)

> 教训:**任何"发请求 + 立刻销毁页面"的收尾都必须先确保请求已送达**(beacon 或延后拆除)。
> 否则你会看到"功能好像能用",实际只是兜底路径在起作用 —— 而兜底通常更慢、更不可控。
> 另外:**一条 409/异常必须对照上下文解读**(这条 409 恰恰是双保险生效的证据),别见非 2xx 就当失败。

Install

dsh plugin --profile web add github:harmless0819-dev/dsh-codex-micro

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source