dsh-cua-driver-indicator
给 DSH 官方内置的 Cua Driver 通道(cua_driver_native__*)补上"看得见 + 能喊停"这一层。
官方 CUA Driver 通道本身没有任何屏幕提示(只有一个默认关闭的指针浮层),所以你无从 知道控制从什么时候开始、到什么时候结束。这个插件只补这一层,不参与任何控制动作: 不点鼠标、不选窗口,也不注册任何能动手的工具。
0.6.5:说明「横幅画在哪台机器上」
用户问:"WSL 不是 Windows 的子系统吗?要控制也只能控制 Windows 桌面吧?"
- 横幅由宿主在 Windows 侧绘制(
powershell.exe+ WinForms),所以它出现在 运行 DSH 的那台机器上;它不是"画在远端",也不是网页里的元素。- 浏览器打开的 web 界面(Windows 上跑
dsh web)→ 连的是同一个 Windows 宿主 → 横幅照常显示,功能与桌面应用完全一致。- WSL 里的 DSH → 宿主是 Linux:官方 Cua Driver 在那里驱动的是 WSLg 的虚拟 显示(跑在 WSL 里的 Linux 图形程序),碰不到真正的 Windows 窗口;而横幅要 画给你看,同样得由 Windows 侧的进程来画。结论:WSL 里的实例既控制不了你的 Windows 电脑,也画不出横幅。
- 因此本包标注
"os": ["win32"]:在 Linux 上 pnpm 直接拒装(EBADPLATFORM), 而不是"装了没反应"。
0.6.4:团队/子代理会话的横幅身份 + 机器话标题不进横幅
用户实机反馈:横幅显示成
DSH 正在控制电脑 · 会话《AgentTeams automatic task ass...》· 正在操作桌面 · 目标:…并问"这个应该是子智能体吧?怎么样没有子智能体的提示啊?"。
- 根因不是"认不出团队会话",而是"标题永远压过身份"。
sessionLabel()原来 一拿到标题就直接返回,「子代理」那条回落分支根本走不到。团队成员会话的标题 是它的第一条 user 消息(AgentTeams 派发文本)自动生成的,于是机器话原样上屏。isDerivedSession(session)(lib/goal.mjs,零 import):按宿主SessionHeader里真实存在的三个信号判断"这是子会话"——origin === 'subagent'、delegationDepth > 0、parentSession非空。实测:AgentTeams 成员会话的 持久化头是origin:"subagent", delegationDepth:1, parentSession:<队长会话>; 普通顶层会话是delegationDepth:0且无parentSession。三个信号都收, 宿主只标一个也认得出。isMachineTitle(title):机器话判定复用本文件已有的词表 (MACHINE_TEXT_PREFIXES/MACHINE_TEXT_FRAGMENTS,也就是isHumanInstructionText背后的那份),没有第二份词表。宿主会把标题截断, 所以"以已知开头开头"的半截也算机器话。- 回落文案:团队/子代理会话 → 「子代理任务」;普通会话 → 「主会话」; 人类标题照常显示(子代理会话标题是人话时仍然显示标题)。
- 判定全在
lib/goal.mjs,lib/index.js一行没改(横幅本来就调sessionLabel())。闸门三条语义、Esc、租约、动作词、问句排除全部不变。
0.6.3:强制门的"接线"整体搬进被测单元
0.6.1 把判定规则抽成了纯函数,但评审指出一个缝隙:判定规则测试全绿,并不代表 接线是对的。接线(读声明目标、读本轮拦截记录、决定放行还是退回、记住本轮、 取轮次)当时还在
lib/index.js里,那里没有宿主就跑不了测试——于是把alreadyBlocked写成常量false、或把turn传丢,整套测试仍然全绿,闸门却 退化成"永久拦"(死锁)。0.6.2 把判定收进纯函数,但"记住本轮"还留在index.js, 评审仍然判 needs_revision。这一版把它彻底搬进去。
evaluateGoalGate(store, states, context)(lib/goal.mjs,零 import): 读声明目标 + 读本轮拦截记录 →{ decision, declared, alreadyBlocked, nextState, stateKey, missingTurn }。它只读状态容器,不写。runGoalGate(store, states, context):评估之后,被拦时由它自己states.set(stateKey, nextState)——"记住本轮"这一步从此在被测单元里,lib/index.js再也没有任何states.set(...)。放行时容器不动。返回{ decision, declared, alreadyBlocked, stateKey, recorded, missingTurn, state }。lib/index.js只剩三件事:调runGoalGate→ 按decision抛错或放行 → 必要时打一条 warn。它不再拼alreadyBlocked、不再判断decision、不再写状态。- 轮次缺失显式处理(fail-open):
turn为undefined/null时闸门放行并 带上missingTurn: true,index.js见到这个标记就打一条 warn。理由:拿不到轮次 就永久拦 = 死锁,这是用户明确不能接受的;宁可这一次闸门不生效,也不能把桌面锁死。 同时execTurn(exec)把"轮次怎么从宿主对象里读"这条规则也搬进了lib/goal.mjs, 插件与闸门不会再各算各的。- 键统一:
gateStateKey(sessionId, agentId)是闸门状态键的唯一入口, 写入(runGoalGate)与清理(clearGate)走同一个函数,两边不会再指向不同的桶。- 用例钉住接线(
tests/goal.test.mjs):被拦时容器里真的出现这一轮 (states.get('s1').blockedTurns=['1'])、同轮重试放行、换轮再拦、缺轮次放行 且missingTurn为真、turn=0也算一轮、状态容器不存在时放行…… 外加GATE_ALLOW/GATE_BLOCK两个字面值断言。- 只保留三条极简源码断言(它们只是补充防线,行为由上面的用例保证):
index.js必须调用runGoalGate(且不得出现evaluateGoalGate(/decideGoalGate(/gateStateBlocks(/nextGateState(/ 任何states.set(; 调用点的实参对象里必须带turn;clearGate必须用gateStateKey(。闸门的行为语义一点没变(有声明放行 / 无声明首次拦 / 同轮重试放行 / 换轮重拦 / 摘要不算声明),工具名
computer_control_goal不变。唯一的差别在退化宿主上: 轮次不可用时闸门选择放行而不是把桌面锁死。
0.6.1:给强制门补上常驻回归测试,目标存储键统一
0.6.0 的强制门当时没有任何常驻测试——唯一证据在一个临时 harness 里,删了就没 了。这一版把它钉死在仓库里:
闸门判定抽成纯函数:判定规则搬到
lib/goal.mjs(零 import 的纯逻辑文件),lib/index.js只调用它,不再内联第二份判定——将来改一处不会漏另一处。 纯函数有两个:decideGoalGate(hasDeclaredGoal, alreadyBlocked)→'allow'/'block';nextGateState(state, turn)→ 下一份拦截状态(不累积、不突变)。五条语义各有常驻用例(
tests/goal.test.mjs):
场景 期望 有模型声明的目标 放行 无声明 + 本轮首次 退回一次 无声明 + 本轮已退回过一次 放行(不死锁的保险) 换一轮(turn 变了) 重新退回一次,拦截不累积 目标只是自动摘要 仍然退回(摘要不能绕过闸门) 目标存储键统一成
session.id:以前模型声明的目标按exec.agent.id存,横幅 却按session.id读——今天宿主强制两者相等所以没出事,但将来宿主放宽这条不变量时, 横幅会静默退回自动摘要。现在存与读都用session.id,agent/disposed两种 id 都清(防御)。工具名
computer_control_goal不变,行为不变(强制门仍然只拦一次、拦截不占 租约、按轮次重置)。0.6.0:加一道软强制门——动桌面前先说清目的
用户的要求原话:「不能让调用电脑控制的模型自己先说清楚目的才给调用吗?就是拦住 一轮问目的,回答了才给用」。
第一次动桌面会被退回一次:会话还没声明目标时,它第一次调官方桌面工具会被 拦下,模型收到一条可执行的错误:
先调用 computer_control_goal 声明本轮目标(不超过 24 字,说清要做什么),用户屏幕上会显示它;声明之后重试这个桌面操作。模型声明完目标重试同一个调用就放行——只拦一次,绝不死锁。模型要是压根不 声明就重试,同一轮里的第二次调用也会放行,同时在日志里留一条 warn(带 sessionId 与工具名),方便事后排查。
拦截不占租约:拦截发生在取得桌面租约之前,被拦的调用不碰租约、不影响 Esc 停止与空闲收起。拦截计数按轮次重置(
turn/end清空),下一轮重新算一次。动作词不再留过期值:一轮结束或序列结束(空闲 30 秒收起)时,「正在做什么」 回到「正在操作桌面」,横幅的会话名与目标段照常显示。
动作词补齐:
move/move_cursor→ 正在移动鼠标,bring_to_front→ 正在切换窗口,start_recording/stop_recording→ 正在录制屏幕,剪贴板类 → 正在操作剪贴板,浏览器类 → 正在操作浏览器;未知名仍兜底「正在操作桌面」。工具名
computer_control_goal不变。0.5.0:横幅多一段「正在做什么」,目标不再乱跳
横幅是:
DSH 正在控制电脑 · 会话《<会话标题>》· 正在点击 · 目标:<目标>
多开了几个会话也认得出来:会话标题取自宿主的 session-title 服务;取不到时 普通会话显示「主会话」、子代理会话显示「子代理」。标题超过 30 字自动截断。
多了一段永远是真的「正在做什么」:宿主知道当前在调哪个官方桌面工具,所以 这一段是事实,不是猜的——
正在看屏幕/正在点击/正在输入/正在按键/正在滚动/正在拖拽/正在启动程序/正在查看窗口;认不出来的工具一律 兜底成「正在操作桌面」,不会露出英文工具名,也不会空着。模型声明的目标跨轮保留:以前每轮结束就清掉,后续轮次只能退回引用用户原话, 于是横幅上出现了用户的抱怨原话。现在声明的目标一直留着,只在会话关闭或 用户下了新的真人指令时才换掉。
问句和抱怨不再是目标:真人消息里命中下面任一关键词就判为非指令 (关键词出现在任意位置都算,不是只看开头):
似乎 好像 为什么 怎么 怎么回事 没看懂 看不懂 是不是 能不能 可不可以 啊这另外:文本带中文或英文问号(
?/?)→ 非指令;文本少于 4 个字符 → 非指令。 非指令的消息不作为目标,也不会把已经有的目标顶掉。工具名
computer_control_goal不变。0.4.0:横幅改成三段式(会话身份 + 目标)
横幅当时是:
DSH 正在控制电脑 · 会话《<会话标题>》· 目标:<目标>
- 目标里不再有机器话:自动摘要只接受人写的指令。系统注入的上下文(AgentTeams 派发文本、
<system-reminder>、runtime context、continuum_resume、模型切换、 计划模式切换、审批策略变更、后台子代理通知等)一律不作为目标,横幅里那一段 干脆不出现,不会留一个空的"目标:"。工具名
computer_control_goal不变。
0.3.0 是改名版本(破坏性变更)
包名从
dsh-computer-control-indicator改为dsh-cua-driver-indicator, 让人一眼看出它补的是官方内置 Cua Driver 那条通道(官方包名@deepseek-ai/dsh-experimental-computer-use-cua-driver-native)。 插件在配置树里的 行 id 也随之从computer-control-indicator改为cua-driver-indicator:你自己在 profile 的cordis.patch.yml里写过- id: computer-control-indicator的覆盖行,需要改成新 id,否则不再生效。 模型可见的工具名computer_control_goal不变。
安装
插件是 Windows 专用(画面层是 WinForms 浮层)。在 DSH 里装:
dsh plugin --profile <profile> add dsh-cua-driver-indicator
把 <profile> 换成你正在用的 profile 名(例如 default)。装完重启 DSH 生效。
非 Windows 平台不会启动画面层:插件照常加载,只在日志里留一条
cua-driver-indicator: takeover indicator is Windows-only; disabled on linux
其余功能全部退场,不报错、不崩溃。
它做什么
- 亮条:官方通道第一次真正操作桌面时,在屏幕顶部(或底部)出现一条不抢焦点、 鼠标可穿透的提示条;指针旁出现光环与 DSH 徽标。
- 四段式横幅:提示条上的文字是
DSH 正在控制电脑 · 会话《<会话标题>》· 正在点击 · 目标:<目标>。
- 谁在控制:会话标题来自宿主,取不到时回落成「主会话」/「子代理」;
- 正在做什么:由当前正在跑的官方桌面工具推出来,是事实不是猜测;
- 目标:两个来源:
- 打底:自动摘要你最近一条真人指令(注入的环境上下文不算);
- 覆盖:模型可以调用
computer_control_goal工具上报更准确的目标,它在本会话内 优先于自动摘要,跨轮保留,直到你换目标或用户下了新的真人指令。 暂时没有目标时,整段「· 目标:…」不出现,不会留一个空的"目标:"; 「正在做什么」那一段永远在。
- 收条:本轮结束、或最后一次桌面调用之后空闲 30 秒,提示自动收起。
- Esc 真停:物理 Esc 会中断正在执行的官方桌面调用,并封禁本回合剩余的桌面 调用(下一轮可以正常使用)。这是本插件相对"只有提示"的关键增量。
- 先说目的再动手(软强制门):会话还没声明目标时,它第一次调官方桌面工具会被
退回一次,模型收到一条可执行的错误(叫它先调
computer_control_goal声明本轮目标,声明后重试同一个调用即可)。- 只拦一次,绝不死锁:同一轮里的第二次调用一定放行,模型不听话也不会被卡住; 这种情况下日志里会留一条 warn(带 sessionId 与工具名)。
- 拦截不占租约:拦在取得租约之前,被拦的调用什么都不碰。
- 按轮次重置:
turn/end清掉计数,下一轮重新算一次。 - 已声明目标的会话不受影响:目标跨轮保留,下一轮的第一次调用直接放行。
关于 Esc 的边界(必须知道)
- Esc 只能中断正在飞行中的那一次调用,以及阻止后续调用。 已经交给应用的输入撤不回来——点击已经落下、字符已经打进输入框,物理上无法回收。
- 只有物理按键有效;程序模拟的按键(例如 agent 自己发
press_key)不会被当作停止信号, 否则 agent 一次按键就能把自己停掉。
自检:官方工具名变了要看得见
本插件与官方通道的耦合点只有一个:官方工具名的前缀 cua_driver_native__。
官方哪天改了前缀,横幅会无声消失——没有任何报错,用户只会觉得"插件坏了"。
为此插件挂了一个 60 秒一跳的自检:
- 每次看到官方通道的工具调用,就记下时间;
- 从未看到过,或者距上次看到已超过 10 分钟,就写一条日志;
- 最多每 30 分钟写一条,避免刷屏。
日志长这样(cua-driver-indicator: 是日志前缀):
cua-driver-indicator: no official CUA tool call seen for 12 minutes — if the takeover
banner does not appear, the official tool names may have changed (currently matching:
cua_driver_native__*)
看到这条,就去确认官方工具名是不是改了,然后同步更新 lib/index.js 里的
OFFICIAL_TOOL_PREFIX 与 lib/series.mjs 里的 NATIVE_TOOL。
这条自检只读两个时间戳、只写一行日志,不影响横幅、Esc 停止与租约。
配置项
在 profile 的 cordis.patch.yml 里改,改完重启 DSH 生效:
- id: cua-driver-indicator
config:
indicatorEnabled: true # 屏幕边缘 + 提示条
cursorIndicatorEnabled: true # 指针光环 + DSH 徽标
remoteIndicatorVisible: true # 远程桌面 / 录屏 / 屏幕共享里可见
indicatorText: 'DSH 正在控制电脑' # 横幅首段文字
goalTemplate: '目标:{goal}' # 目标段模板,{goal} 会被替换
goalMaxChars: 24 # 目标摘要最大字数,超出自动截断
indicatorPosition: top # top / bottom
indicatorIdleMs: 1200 # 收起前的淡出延时(200–3000 毫秒)
idleMs: 30000 # 空闲多久视为控制结束(毫秒)
stopOnEscape: true # 物理 Esc 停止
goalTemplate在 0.3.0 里描述的是整条横幅(DSH 基于「{goal}」正在控制电脑), 0.4.0 起只描述目标那一段(默认目标:{goal})。如果你的 profile 里显式写过 旧的goalTemplate,删掉它即可回到默认值。
结构
| 文件 | 职责 |
|---|---|
lib/index.js |
监听 tools/execute、维护提示显隐、组装横幅(会话 + 动作 + 目标)、软强制门(未声明目标先退回一次)、从宿主取会话标题、拉起并驱动 Windows 画面层、把 Esc 事件接到停止逻辑、注册 computer_control_goal 工具、跑"官方工具名"自检 |
lib/goal.mjs |
纯逻辑:目标摘要、人话判定(机器话不进目标)、问句/抱怨判定、动作词映射表、横幅拼装、会话身份回落、按会话存放的目标状态(含"有没有模型声明的目标")、强制门判定(放行/退回、按轮次的拦截状态、目标存储键)与闸门接线(evaluateGoalGate 评估 + runGoalGate 记住本轮 + execTurn 取轮次 + gateStateKey 统一键)、消息来源判定。可脱离 DSH 单测 |
lib/series.mjs |
纯状态机:接管租约、在途调用登记、Esc 停止与封禁范围。可脱离 DSH 单测 |
scripts/windows-takeover.* |
Windows 画面层(复制自上游,见 THIRD_PARTY.md) |
tests/series.test.mjs |
node --test 可跑的单元测试(租约与停止语义) |
tests/goal.test.mjs |
node --test 可跑的单元测试(目标摘要、人话判定、动作词、横幅拼装、来源过滤、跨轮保留、模型目标标志、强制门五条语义、闸门接线用例、记住本轮/缺轮次用例) |
验证
node --check lib/index.js
node --check lib/goal.mjs
node --check lib/series.mjs
node --test tests/series.test.mjs
node --test tests/goal.test.mjs
许可证
MIT,见 LICENSE。画面层来自上游,见 THIRD_PARTY.md 与
LICENSE-THIRD-PARTY。
No comments yet. Be the first to write one.