← 返回列表
需源码安装
dsh-codex-micro — 把九键小键盘当 Codex Micro 用
暂不能直接安装(需源码编译或环境不满足):仓库 package.json 标记 private,未发布到 npm,需从源码安装。 · 最近上游提交 2026/9/21 · 已提供中文文档
将 Vaydeer 9 键小键盘变成 DSH Codex Micro:小键盘发送唯一组合键,插件将它们映射到 DSH 操作。
综合分
29.8
GitHub 分
29.8
用户评分
—
★ Stars
0
周下载量
—
安装插件(需先安装 dsh CLI 引擎:npm install -g @deepseek-ai/dsh)
dsh plugin --profile web add harmless0819-dev/dsh-codex-micro仓库 package.json 标记 private,未发布到 npm,需从源码安装,改用 GitHub 源安装
信任档位:已验证本站已于 1 天前真实安装成功
- 是什么
- dsh 原生插件 · tool
- 装得上吗
- 本站已真实安装成功(非静态推断)
- 安全吗
- 本站尚未对该插件做风险分级(暂未覆盖,不等同于无风险)
- 还在维护吗
- 活跃:最近一次提交在 4 天前
档位由下列信号合成:本站实装验证(真实安装,当前最高到 L4)· 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。下方各区块是它的证据明细。 验证判据与等级说明 →
🟢实装验证通过· 2026/9/25
由本站实装验证器在真实 dsh 环境安装成功,非静态推断。
数据截至 2026/9/23(元数据每日更新 · 实装验证按队列轮转,单条结论的验证时间见上方)
安装兼容性检查需源码安装
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✗npm 包dsh-codex-micro(未发布到 npm,仅可源码安装)
✓Node 引擎要求 >=22.19.0 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
仓库 package.json 标记 private,未发布到 npm,需从源码安装
验证方式:npm registry 存在性 + package.json 静态校验 · 最后验证 2026/9/23 21:26:56
用户评分
还没有人投票,来当第一个
订阅周报,不错过优质插件更新
每周一封 · 高评分插件 + 新用户活动
README
由 DeepSeek 最新模型翻译生成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. 安装(必须由用户在自己的终端里跑)
& D:\deepseek\dsh-codex-micro\install.ps1 # 装(= dsh plugin --profile web add link:)
& 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):
& D:\deepseek\dsh-whale-emote\restart-web.ps1
沙箱里不要尝试 dsh web:写不了 ~/.dsh/profiles/web/cordis.yml(EPERM),起来的也是残的。
3. 键位怎么进去的(已经写好了,一般不用再动手)
2026-09-17 已完成:这套键位是用自写工具直接写进键盘固件的,全程没有使用控制程序:
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 必须 ⚠️ 别用 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. 验证(不看计数就不算生效)
客户端有没有注入成功: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. 离线自测
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 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 日志里一个都没有 → 新代码从未运行成功。
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 零 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,
And explicitly exclude the skip key (t("action.skip") = "Skip this question" / Skip, L802), otherwise it will treat skip as submit.
The search hierarchy is also relaxed from 4 levels to 6 levels (the button is in footerActions, deeper from the option container).
(b) "Enter your own" can never find the input box — because it was treated as the chat input box and excluded.
optionCustomInput() uses findComposer() to get the "chat input box" and then excludes it;
but when the question card is present, what findComposer() selects by area is exactly the question card's own textarea.Mbwy4a_fieldInput
(the fieldInput at L420) — that is, the only target box itself. So every time it's no-custom-input.
Evidence: in the two failed dom snapshots at 13:31:03 / 13:31:17, composer.cls = Mbwy4a_fieldInput.
Fix: only exclude the composer when it is not inside the question card (the composerInRoot check).
Both changes only modify lib/client.js → takes effect on F5.
13.2 The fourth problem: no reconnect after the HID event channel fails (both daemons have this)
This was also discovered from the logs in this round; it's unrelated to the option feature but affects usability more:
2026-09-17 21:19:43, two processes reading the usage-2 event channel began reporting
1167 (= ERROR_DEVICE_NOT_CONNECTED) together in the same second:
- tools/vaydeer-power-key.py (keyboard power key daemon): previously on error it just did
log(...) + sleep(0.5) + continue — spinning on the same already-dead handle,
measured to have reported 541 consecutive errors with no recovery.
- The watch_events thread of tools/vaydeer_hid_daemon.py: on error it directly returned —
the thread exits permanently, and keyboard events stop appearing in the logs from then on.
Consequence: that host-side nine-key power key (which doesn't depend on the browser or on whether DSH is running) silently stops working.
This is more important than the browser-side panic, because it is precisely the only implementation of "can power on even when DSH isn't running".
Fix: change both to actually reopen the device (CancelIoEx + CloseHandle → re-find_targets()/open_device()),
and log a reopened/recovered line on recovery; when the device can't be obtained, wait briefly and retry instead of spinning.
The browser side is unaffected (it uses ordinary HID key presses emitted by the keyboard, not this event channel).
(Note: the old daemon had already accumulated 3000+ 1167s at the time; to take effect the daemon must be restarted, see §12.)
13.4 The seventh problem: the close request is aborted by the page tearing itself down (panic-close-failed)
On 2026-09-17 21:41:53, a real-device test successfully shut down the backend (panic-exit appeared for the first time), but the main close path actually failed:
- In the callback at the end of the 3-second countdown, the client first does fetch('/dsh-power/close'), then immediately and synchronously calls
window.close(), and schedules a location.replace('about:blank') 1.5 seconds later.
- As a result, the page was already torn down while that request was in flight → Failed to fetch. Measured: 4 out of 5 times it failed this way
(panic-close-failed 5 times vs panic-close succeeded 1 time), and the failure occurred about 3ms after initiation —
a classic "request aborted", not a network timeout.
- The reason it could still shut down in the end is that the host-side 6-second fallback process.exit(0) saved the day (panic-exit).
In other words: the feature works, but you wait 6 seconds for nothing, and the main path is effectively useless.
Fix (only modify lib/client.js → F5):
1. First use navigator.sendBeacon as the baseline — its design purpose is "deliverable even when the page is leaving", and it won't be aborted by teardown;
2. Only if the beacon didn't go out, use fetch for a follow-up (if it succeeded, don't send, to avoid an extra scary 409 in the logs);
3. Delay the frontend teardown by 1.2 seconds, to give the request time to land.
Production evidence (2026-09-17 22:08:28, user real-device test) — the first successful run through the fast path after the fix:
22:08:28.369 panic 9 keys spread → immediate red screen
22:08:31.372 panic-commit 3-second countdown ends
22:08:31.373 panic-close-beacon beaconSent=True
22:08:31.378 panic-close status=409
409 = "close already scheduled", it's a sign of success, not failure: the beacon arrived first, got 200 and scheduled
process.exit(0) (+400ms); the fetch arrived later, the server was already shutting down, and returned 409. DSH web exits
~3.4 seconds after the key press (3-second countdown + 0.4 seconds), compared with relying on the 6-second host fallback before the fix.
Compare with the 22:07:29 run (before F5, old code): panic-close-failed: Failed to fetch — same machine,
same pair of hands, the only difference being one F5.
(Later, fetch was changed to "don't send if the beacon succeeded", so this 409 will no longer appear in the logs.)
Lesson: any cleanup that "sends a request + immediately destroys the page" must first ensure the request has been delivered (beacon or delayed teardown).
Otherwise you'll see "the feature seems to work", when in fact only the fallback path is doing the work — and the fallback is usually slower and less controllable.
Also: a 409/exception must be interpreted against its context (this 409 is precisely evidence that the double safeguard worked); don't treat anything non-2xx as a failure.