harmless0819-dev/dsh-codex-micro ↗★ 0

dsh-codex-micro

把 Vaydeer 九键小键盘当 Codex Micro 用:键盘发唯一组合键,本插件把它们翻成 DSH 的动作 适合拥有 Vaydeer 九键小键盘并希望用作 DSH 快捷控制器的用户。

패키지
dsh-codex-micro
호환성
미검증
버전
0.1.0
라이선스
MIT
최근 업데이트
2026. 9. 20.

설치

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:harmless0819-dev/dsh-codex-micro

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