deepseek-harness
deepseek-ai
DeepSeek Harness: Everything is a Plugin.
PROJECT TOPICS
INSTALL REFERENCE
dsh plugin --profile web add github:harmless0819-dev/dsh-codex-micro
该命令指向仓库当前默认分支;尚无绑定当前 commit 的完整验证结果。
PROJECT README
把 Vaydeer 九键小键盘变成 DSH 的编程代理遥控器:键盘固件只负责发没人占用的组合键, 本插件把组合键翻成 DSH 的动作,并提供「按键到底有没有触发 / 页面上到底有什么」的证据链。
VID_0483&PID_5752,序列号 VAJP101130904085D8,控制程序 Vaydeer keyboard 1.2.3
(状态文件 %APPDATA%\vaydeer-keyboard\data_v7.2.json,4 层 × 9 键)。D:\deepseek\docs\vaydeer-9key-codex-micro-plan.md。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能:把 9 个键(× 两层)翻成 DSH 的动作——发送、停止/中断、新会话、模型、侧栏、设置、 复制最近一条、重试,以及第二层的指令/工具/文件/插件/查看/关闭等面板入口。
不能(Codex Micro 那部分的硬差距):Agent 键的实时 RGB 状态灯、旋钮、摇杆实体,
以及 ChatGPT 桌面 App 的设备级识别。本插件用屏幕左下角的角标 + 短暂提示
(CM L1 Agent / L1 · 3 新会话)替代「按键亮没亮」这件事。
& 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):
& D:\deepseek\dsh-whale-emote\restart-web.ps1
沙箱里不要尝试 dsh web:写不了 ~/.dsh/profiles/web/cordis.yml(EPERM),起来的也是残的。
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 必须 < 0x80、必须"开层→逐键→提交层")见
D:\deepseek\docs\vaydeer-device-protocol.md §5。
下面这段手工步骤只在你想改动键位、或想用控制程序重新刷一遍时才需要。
触发词(两层必须互不重叠,因为键盘不会告诉插件当前是哪一层):
| 层 | 主用(勾三个修饰键) | 回退(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, 会在文本框里打出字符(而且插件也认不出那是按键信号)。
步骤:
VAJP101130904085D8。Ctrl、Alt、Shift → 依次选 1…8。Ctrl+Alt+Shift+F1…F8,第 9 键设成 「上一层」。1..9 数字即可(这层不经过插件)。%APPDATA%\vaydeer-keyboard\data_v7.2.json(app 运行时别手改这个文件)。| 键 | 层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 里显示的名字,不影响功能。
第三 / 第四层是「原生层」,插件完全不参与,随便你放什么。
# 客户端有没有注入成功: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)。
| 现象 | 先看什么 |
|---|---|
| 按键完全没反应 | 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 |
& D:\deepseek\dsh-codex-micro\install.ps1 -Remove,再重启 dsh web。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 的真页面证据。
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。DSH 弹出确认卡(按钮是「拒绝」/「允许一次」这类)时,不用鼠标:
| 键(键帽) | 动作 |
|---|---|
| 物理 7 | 允许一次(主按钮 button..._primary) |
| 物理 8 | 拒绝(次按钮 button..._outline) |
| 物理 9 | 始终允许(卡上真有这颗才生效,否则退化成允许) |
| 物理 2 | 也等于允许一次 |
实测证据(events.json):action 选项1 ok=True mode=approval:pointer-sequence gate=True display=允许一次, 随后那次提权写文件真的成功了 —— 键盘点的就是确认卡上的真按钮。
安全闸(都是踩过坑加上的):
触发: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),已验证能抓住上述 ①②③ 三种错法 + 假阴性变体。
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 同样有记录。
诚实结论:网页无法主动关闭不是自己打开的标签页(浏览器安全限制,window.close() 会被静默拒绝)。 所以到点(保持按住 3 秒)之后按三层收尾:
桌面版外壳会不会自己退出取决于它自己的实现;后端一死它至少会掉线。
硬事实:DSH 没开时浏览器里没有任何页面,前端不可能检测到按键 —— 必须由宿主机上一个 独立于 dsh web 的进程来做(计划任务),它只依赖键盘自己的通道:
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),
文件日志一字不少。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 没跑则开机)skip close + panic-cancel)close -> 200 → dsh web is down now ✅🐞 已修:设备侧抢先关闭,把浏览器侧的 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)。根因是"孤儿进程占着失效句柄",不是设备坏。 排查过程和结论(都留了证据,供下次直接照抄):
- 用户跑
install-keypad-power.ps1→task registered / task state: Running,但日志尾部 仍全是旧代码措辞的... retrying,且没有新 python 进程。- 查找旧代码特征:新代码启动行会打
spread<=600ms、出错打streak=; 日志里一个都没有 → 新代码从未运行成功。- 实测新进程能正常打开事件通道(
v.find_targets()+open_device(USAGE_EVENT)成功,in_len=17)→ 说明 1167 不是设备/驱动问题。- 关键判据:那个旧守护(pid 48688,起于 20:27,跑旧代码)一直活着并持有失效句柄、 疯狂刷 1167。杀掉它之后,1167 立刻停止(
log mtime不再推进)。- 用新代码启动 → 打出
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 now183ms 铺开远低于 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 秒后起来
"必须保持按住 N 秒"这个设计在无反馈的通道上不可用(2026-09-17,浏览器侧 6/6 失败、 设备侧 21:03 后全 cancelled)。判据要用同时性(一次按齐的铺开窗口),不要用按住时长。
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(隐藏窗口)。
Git Bash 会把 schtasks 的 /end、/run 当路径翻译(变成 C:/Program Files/Git/end)——必须从 PowerShell 里调。
沙箱建不了计划任务(Register-ScheduledTask 报 0x80070005),安装必须由用户在自己的终端里跑。
键盘同时按 7 颗以上时会吐假 keyup(6KRO / 鬼键挤压)——所以取消判定必须等全部松开; 早期版本按任意一颗键取消、或"≤2 颗还按着就算松手",都被这个假抬起误触发过。
宿主兜底 exit 原是 3 秒,会和客户端调 /dsh-power/close 撞车(日志出现 panic-close-failed); 现已改成 6 秒,让正路先干净成功。
用户报告:页面上刚出现选择,键盘就自动跳回层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。机制(两个缺陷叠加):
PANIC_SETS 的三个码集就是三个功能层本身的键集:agent Digit1..8,F9、
选项 KeyQ..I,F10、界面 F1..8,F11;而 panicCheck 把三个集合并计数,
「3000 ms 内出现过 9 个不同码」即触发——不要求同时按住。doCancel 无条件发 set-layer target:0。修复(用户选定方案 A):
lib/client.js 的 doCancel 不再发 set-layer:取消 = 什么都没发生过,物理层由键盘自己的
上一层/下一层键决定,插件不替它做主。改完 F5 即生效(路由每次从磁盘读)。顺带发现并修复的宿主端缺陷(需重启 dsh web 生效):
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 通过。同一轮测试暴露出来的(用户反馈「选不了这个」)。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 只含选项文案本身,标题在它的祖先里。于是:
textContent 不含关键词 → 整张卡被否决。实测 88 次选项动作里 83 次 reason=no-question,
唯一成功那次正是文案自带「选项」的单选题。修复:改成结构优先,不再用文案否决 ——
[data-question-key](组件自带的结构锚点,最可靠);[role="radiogroup"] / [role="group"] 且含 ≥2 个可见 radio/checkbox(单选卡是 radiogroup、
多选卡是 group,见源码 L648);同时给 no-question 加了取证:失败时回传 diag(frames / framesWithPicks / radiogroups /
groups / anyRadioCheckbox / optionClassed / composer),下次一看就知道是哪条路径没中,
不用再靠猜。lib/client.js 改动 → F5 生效。
2026-09-17 21:31 真机验证通过:多选卡四个选项全部 ok=True(hitCount=4,
命中 aria 分别为「选项一…选项四」),选中态真的变了。认卡修复确认生效。
同一次真机测试暴露的两处,都由日志定量指出(源码 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 生效。
同样是这一轮从日志里发现的,跟选项功能无关但更影响可用性:
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。)
2026-09-17 21:41:53 真机测试成功关闭了后端(panic-exit 首次出现),但主关闭路径其实是失败的:
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 ——
典型的"请求被中止",不是网络超时。process.exit(0) 救了场(panic-exit)。
也就是说:功能可用,但白等 6 秒、且主路径形同虚设。修复(只改 lib/client.js → F5):
navigator.sendBeacon 打底 —— 它的设计目的就是"页面离开也能送达",不会被拆除中止;fetch 补一发(成功就不发,免得日志里多一条吓人的 409);生产实证(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=409409 = "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 就当失败。
CLASSIFICATION EVIDENCE
系统优先读取 GitHub Topics,再与站内分类词典和词根规则比对。当前命中: 无有效分类标签。