让工具调用先弹卡片,等你同意、修改或拒绝
30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/4 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add YunpengDon/dsh-hitl该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
图例:✓ 通过! 需注意— 未判定 / 无数据· 「未判定」= 本站尚无数据,不代表插件有问题
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 工具调用人工确认
- 审批修改拒绝决策卡片
DSH 工具调用的人工确认关卡:在 DSH Web UI 里把任意工具挂到审批 / 修改 / 拒绝的决策卡片后面。
README
由 DeepSeek 最新模型翻译生成dsh-hitl · 给任意工具挂上人工决策(Human-in-the-loop)
让 Agent 在动手之前,先问你一句。
一行配置、一行 API,就能把任意工具调用变成一张等你点头的卡片:看清它要做什么,然后同意、改一改,或者拒绝并留下意见。
dsh-hitl 是 DeepSeek Harness (DSH) 的可安装插件(bundle):零依赖、零构建——index.js(宿主半)+ client.js(浏览器半)+ lib/ 全是可直接加载的纯 JS,装上刷新一次页面就能用。
访问网站
主要功能
- 一句话挂载:其他工具 / 插件用一行 API 或一行配置,把自己的工具挂进 HITL 流程;
- 执行前的决策卡:工具真正运行之前,DSH Web 界面里出现 标题 + 待决策提案 + 决策按钮;
- 多种形式显示提案:左右分栏 diff(只读)、纯文本输入框、可编辑 + 可渲染的 markdown 输入框;文本与 markdown 字段支持只读与编辑两种模式的呈现。
展开完整文档(README 剩余 1 节)
dsh-hitl · 给任意工具挂上人工决策(Human-in-the-loop)
让 Agent 在动手之前,先问你一句。
一行配置、一行 API,就能把任意工具调用变成一张等你点头的卡片:看清它要做什么,然后同意、改一改,或者拒绝并留下意见。
dsh-hitl 是 DeepSeek Harness (DSH) 的可安装插件(bundle):零依赖、零构建——index.js(宿主半)+ client.js(浏览器半)+ lib/ 全是可直接加载的纯 JS,装上刷新一次页面就能用。
访问网站
主要功能
- 一句话挂载:其他工具 / 插件用一行 API 或一行配置,把自己的工具挂进 HITL 流程;
- 执行前的决策卡:工具真正运行之前,DSH Web 界面里出现 标题 + 待决策提案 + 决策按钮;
- 多种形式显示提案:左右分栏 diff(只读)、纯文本输入框、可编辑 + 可渲染的 markdown 输入框;文本与 markdown 字段支持只读与编辑两种模式的呈现。
- 修改模式:编辑过提案文本后,主按钮从【同意】变成【修改】——改动作为修订意见交回 Agent;
- 带反馈文本的拒绝:【拒绝】可以带一个反馈栏,在反馈栏中写下的意见会随拒绝一起交给 Agent——它知道"不要做",也知道"为什么不要做";
- 支持倒计时配置:可配置倒计时与三种结局(自动同意 / 自动拒绝 / 把超时事件反馈给 Agent);
为DSH定制的适配
子代理穿透:子代理里的工具调用也会触发主对话的批准请求。 同进程子代理(spawn / fork)里的每一次工具调用都走同一道门禁——请求会同时挂到"发起它的子会话"和"你正在看的会话"上:无论你在哪个对话里,卡片都支持弹出,并标着「来自子代理会话 xxx」+【前往该会话】。
(跨进程后端不进这道门禁,见 §6.1。)
在 landing page 上看这张卡 →
不在对话界面上时,支持弹出迷你批准请求 切到插件页、打开设置、甚至没选会话时,页面顶部会浮出一条提醒事项,减少用户不在对话页时HITL对流程的阻塞。
(条数、工具名、来源会话、剩余时间 +【同意】【拒绝】【前往该会话】)。
在 landing page 上看这条浮层 →
1. 安装
从 npm 装(推荐方式)
dsh plugin --profile <你的 profile> add dsh-hitl
直接从 GitHub 装(无需构建)
dsh plugin --profile <你的 profile> add github:YunpengDon/dsh-hitl
目录方式(开发期最方便:改了代码重启 dsh 即可)
dsh plugin --profile <你的 profile> add /path/to/dsh-hitl
或用 GUI:设置 → 插件 → 安装 bundle
装上之后:
1. 刷新一次页面(重要)。插件的令牌通过 index 注入到页面(window.__DSH_HITL__)。
已经打开的页面即使被 HMR 热加载了客户端半,也拿不到这个注入值——所以第一次安装后必须刷新页面,之后的每次重启都不需要再刷。
2. 在 cordis.patch.yml 里给 hitl 这一行写上要保护的工具(见下一节),或让其他插件调用 ctx.hitl.protect(...)。
卸载:dsh plugin --profile <profile> remove dsh-hitl(会自动移除它带来的配置层)。
2. 三十秒上手
2.1 实现方案1:通过配置实现(不改目标工具代码)
cordis.patch.yml:
- id: hitl
name: 'dsh-hitl'
config:
protect:
- tool: bash # 工具名;支持 'mcp__' 这类通配 title: 执行命令前确认 # 面板标题(粗体、自动换行)
countdown: { seconds: 30, action: reject }
reject: { feedback: true } # 拒绝时显示反馈栏
patch 覆盖某一行时替换的是整份 config:改这一行就要把所有键重述一遍。
2.2 实现方案2:在目标插件代码中挂载HITL
export const name = 'my-plugin'
export const inject = ['hitl'] // 等 hitl 服务就绪后再 apply
export function apply(ctx) {
// 第三个参数传自己的 ctx = 把挂载的生命周期绑到本插件
ctx.hitl.protect('bash', {
title: '这条命令要执行吗?',
countdown: { seconds: 30, action: 'reject' },
reject: { feedback: true },
}, ctx)
ctx.hitl.protect(['write', 'edit'], { // 数组、通配、RegExp、函数谓词都可以
title: '改动前确认',
diff: { path: 'file_path', before: 'old_string', after: 'new_string' }, // 自动折成并排 diff
countdown: null, // 不设倒计时 = 一直等(仍可被中断) }, ctx) }
一定要带 owner(第三个参数):挂载是记在 hitl 插件里的状态,不是你的插件状态。
不传 owner 时,protect() 返回的 disposer 就是唯一的解绑手段——而"从 apply 里 return 一个函数" 不算生命周期:实测过,插件行被卸载后挂载仍然生效,那个工具会被永久拦下去。
传了 ctx 之后,hitl 会在你的插件上下文上注册一个 effect,插件卸载即自动解绑。
另一种等价写法是把它包进你自己的 effect:
ctx.effect(() => ctx.hitl.protect('bash', {...}), 'my-plugin: hitl mount')。
万一留下了残留挂载,用 ctx.hitl.list() 看一眼,ctx.hitl.unprotect('bash') 清掉;或者把 hitl 这一行在插件页里关掉再打开(等价于重建插件状态)。
2.3 服务 API
| 成员 | 说明 |
| protect(matcher, options?, owner?) | 挂载一个工具;owner 传调用方 ctx 即可自动解绑;返回 disposer |
| unprotect(matcher) | 按 matcher 描述移除挂载,返回移除数量 |
| list() | 当前所有挂载(诊断用) |
| pending() | 当前所有等待人类决策的请求(诊断用) |
matcher:工具名 / 含 的通配串 / RegExp / 上面几者的数组 / (exec) => boolean。
同一个工具被挂载多次时,后挂载的覆盖先挂载的(配置行先注册,插件后注册,所以插件可以覆盖配置)。
3. 默认提案规则
不配置 fields 时,提案就是本次调用的全部 parameters:
- 字段标题 = parameter 的 key(工具 schema 里给了 title 就用 schema 的);
- 字段值 = parameter 的 value,统一用 markdown 输入框渲染(非字符串值渲染成缩进 JSON 文本);
- 可编辑性:markdown / text 默认可编辑,diff / json / hidden 永远只读;
- 参数为空对象或非对象时(例如只有一个标量参数),显示一个只读的 (arguments) 字段;
- 面板标题缺省为 请确认 <toolName> 的执行方案(跟随界面语言)。
4. 挂载选项(protect() 的第二个参数 / 配置行的每个条目)
| 键 | 类型 | 默认 | 说明 |
| tool / matcher | string \| RegExp \| array \| function | 必填 | 要保护的工具 |
| title | string | 见 §3 | 面板标题(粗体、自动换行) |
| layout | stacked \| split | stacked | 提案区排布:纵向堆叠 / 左右分栏 |
| labels | string[] | [] | 面板级标签(0 到多个) |
| buttons | {approve?, modify?, reject?} | 跟随语言 | 按钮文案覆盖 |
| fields | FieldSpec[] | 全部参数 | 要展示哪些参数、怎么展示 |
| diff | {path?, before, after} | 无 | 把两个参数折成一个并排只读 diff |
| countdown | {seconds, action, freezeOnInteract?} \| null \| false | null | 倒计时与超时行为;null/false = 不限时 |
| reject.feedback | boolean | false | 是否显示拒绝反馈栏 |
| reject.feedbackPrompt | string | 内置文案 | 反馈栏 placeholder |
| reject.requireFeedback | boolean | false | 反馈必填(为空时拒绝按钮会提示) |
| modify.mode | revise-request \| allow-and-inform | revise-request | 用户改了文本后点【修改】的语义,见 §5 |
| whenUnavailable | reject \| wait | reject | 没有任何浏览器连接时的行为:reject = fail-closed;wait = 一直等,务必配 countdown,否则那次调用会挂到会话结束(挂载时宿主会给 dsh-hitl: 警告),见 §7.1 |
| enabled | boolean \| (exec) => boolean | true | 临时关闭 / 条件挂载 |
| maxFieldChars | number | 20000 | 单字段渲染上限,超出会截断并提示 |
| resolveTimeoutMs | number | 5000 | 单个计算字段的解析上限;超时或抛错只让那个字段显示失败,不阻断这次调用 |
FieldSpec
{ param: 'content', title: '新内容', description: '会写入文件的内容', render: 'markdown', // markdown | text | diff | json | hidden editable: true, // 覆盖默认可编辑性
labels: ['不可撤销'], // 字段级标签
value: '固定文案', // 该字段的文本:字面量,或 (exec) => string | Promise<string>
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // render: 'diff' 时用
- render 缺省时:能配出 diff 就渲染 diff,否则渲染 markdown 输入框。
- fields 给定时只显示列出的字段(未列出的参数不会出现在面板里)。
- 配置行里可以简写成字符串:fields: ['command', { param: 'cwd', title: '工作目录' }]。
计算字段:把参数之外的东西放进卡里
diff.before / diff.after / diff.path 平时填的是参数名(宿主取 args[名字])。它们与字段的 value 一样,也可以填函数:宿主在弹卡前用这次调用的 exec 调它,把返回值当作文本。
ctx.hitl.protect('update_index', {
title: '更新知识树索引',
fields: [
{ param: 'content', title: '新索引正文', render: 'markdown' }, { param: 'index', title: '索引文件的变化', render: 'diff', editable: false, // before 读磁盘上的旧索引,after 由新正文推出来——两者都不在参数里
diff: { path: 'index.md',
before: async exec => readIndexFile(exec),
after: exec => # 知识树索引\n\n${exec.arguments.content} } },
],
})
- 字符串形式的 diff.path 若不是某个参数名,就当作字面路径使用:被改的文件常常不是这次调用的参数。
- 函数可以是 async;每个字段的解析上限是 resolveTimeoutMs(默认 5s)。
- 解析失败不阻断:函数抛错或超时,只会把那个字段变成一行 ⚠️ 无法解析该字段:…,卡照常弹出、人照常决策。整个解析过程坏掉时,提案退回到"参数原样",而不是拒绝调用。
- wire 格式与浏览器半都没有变化:diff 的两侧本来就在帧里,前端只是照常渲染。
- diff 的每一侧超过 maxDiffLines(2000 行)会被截断并标注,避免整文件 diff 变成无上限的一帧。
倒计时的三种结局
countdown.action 决定倒计时归零后宿主怎么判:
| action | 工具是否执行 | Agent 看到 |
| approve | 执行 | 无额外消息(工具正常返回) |
| reject | 不执行 | Error: HITL: no human decision arrived within Ns, so tool "x" did not run (timeout policy: reject). |
| notify | 不执行 | Error: HITL: no human decision arrived within Ns, so tool "x" did not run. Tell the user this call timed out and wait for an explicit instruction before running it. |
freezeOnInteract(默认 true):用户一旦聚焦或编辑任何输入框,客户端会请求宿主暂停倒计时;失焦即恢复。暂停有上限(插件配置 holdGraceMs,默认 5 分钟),防止关掉页面把工具永久卡住。
超时不是用户做的决定,所以面板不会在归零瞬间消失:它会停约 5 秒,倒计时 chip 转成「已超时,等待宿主处理」、按钮与输入框置灰、底部显示「已按超时策略处理」,然后才收起——否则你只会看到卡片凭空不见了。
用户自己点的那三个按钮则是立刻收起(那是他自己按的)。
5. 决策语义(用户点了什么,Agent 收到什么)
| 用户动作 | 工具是否执行 | Agent 收到 |
| 【同意】 | 执行 | 工具正常结果 |
| 改了文本后【修改】(默认 modify.mode: revise-request) | 不执行 | Error: HITL: the user revised the proposal, so tool "x" did not run. Re-issue the call with these changes: + 每个改动字段的 - param: 新文本 |
| 改了文本后【修改】(allow-and-inform) | 执行 | 工具正常结果 + 一条紧随其后的 user 消息,内容是用户改后的文本 |
| 【拒绝】 | 不执行 | Error: HITL: the user rejected tool "x" and it did not run.;填了反馈栏则是 … User feedback: <原文> |
| 倒计时 / 无决策端 / 请求被撤回 | 见 §4 与 §7 | 上表里的对应文案;撤回时返回的是 DSH 的规范取消结果 |
为什么【修改】默认不让工具执行? DSH 目前不允许在执行前改写工具参数(PreToolDecision 只有 allow / deny / cancel / ask,改写参数仍是 proposed 状态的设计)。
所以「修改」是把用户改后的内容交回 Agent,让 Agent 用新参数重新发起调用——语义最诚实、日志也自洽。
如果你的工具本来就能接受"先执行、再被告知修改"(例如只是记录/展示类),把 modify.mode 设为 allow-and-inform。
所有拒绝/修改文案都带 HITL: 前缀,方便在会话记录里检索;错误码分别是 HITL_REJECTED / HITL_REVISED / HITL_TIMEOUT / HITL_UNAVAILABLE。
6. 面板行为
- 落点:会话输入区(composer)座位,和 DSH 自带的审批面板、提问面板同一个位置——这是"agent 卡住等你决定"的既有位置。
- 折叠:标题右侧的「收起 / 展开」按钮把整个待决策提案收起来,只留标题、倒计时和决策按钮(图标按"可收/可展"的方向:展开时 ∨、收起时 ∧),这样面板不会挡住上面的会话上下文。折叠是会记住的:收起一次,后面的请求也默认收起;折叠状态下按【拒绝】而该挂载配了反馈栏时,面板会先展开并把焦点放到反馈框,不会丢掉你的话。
- 优先级:HITL 决策优先于审批/提问面板;同一会话里同时有多个待决策时,最早的先显示,面板右上角提示"另有 N 项待决策"。
- 倒计时:位于底部按钮行左侧(和【拒绝】【同意】同一行,顶到最左),是一枚醒目的 chip(不是浅灰小字):正常态实底 + 加粗等宽数字,剩余 ≤10 秒转为警示色并轻微脉动(遵循 prefers-reduced-motion),暂停态弱化样式,超时态错误色并显示「已超时,等待宿主处理」。归零后按钮禁用,面板再停留约 5 秒并写出「已按超时策略处理」,然后才收起。
- 倒计时永远是宿主的数字:每一帧(新请求、重连 snapshot、client 模块热重载后的重发)携带的都是宿主当下算出的剩余时间(宿主半的 liveRequest() 投影 + 前端每次收到都重新基准化时钟)。
所以刷新页面、切标签页、热重载都不会让倒计时"退回满值",也不会出现「界面还剩 10 秒、宿主已经判超时」的错位。
- 交互即暂停:聚焦/编辑任何输入框时客户端向宿主申请冻结倒计时,宿主同意后才显示「已暂停(交互中)」——显示与宿主时钟一致,剩余时间真的停住;失焦即恢复,并受 holdGraceMs(默认 5 分钟)上限保护。
重连后宿主下发的 snapshot 会带上 countdown.held,暂停状态不会因为刷新而假装还在暂停。
- markdown 字段:默认停在【预览】(决策面板先给人看结果,源码是次一级的信息), 预览 / 编辑 / 并排 三个视图可切换。渲染器是本插件自带的受控子集(标题、列表、引用、围栏代码、行内代码、粗斜体、链接、分割线、段落),行内代码与配色对齐宿主 markdown(沿用 --dsw-alias-markdown-inline-code、--ds-font-family-code)。
先转义再生成标签,源文本里的 HTML 不会变成标签;javascript: 之类的链接目标会被丢弃。
- 字段头说的就是控件现在能做到的事:字段标题旁的小字标「可编辑 / 只读」,它由和渲染区同一个判断得出(isFieldEditable)——字段自己说 editable: true,但面板正在提交 / 已提交 / 已超时时,渲染区已经变成只读块,小字也同步变成「只读」,不会出现"标着可编辑、点不动"的状态。
- 只读字段:editable: false 的字段不会渲染成可输入控件——text 渲染成块状只读区, markdown 只保留 预览 / 并排 且页签里没有【编辑】,标题旁标「只读」。
【并排】左边的源码窗格是块状只读区(可选中、可复制),不是 disabled 的输入框——浏览器里 disabled 框里的字既选不中也复制不了,那等于把内容摆出来又不让人拿走。
- 输入框自适应:单行内容只占一行高,随内容长高,到 320px 封顶后框内滚动;从不在面板里铺满一大片空白。
增高发生在绘制之前(layout effect),否则会有一帧"1 行高"把提案区撑矮、被浏览器把滚动位置夹回顶部。
- 切视图不丢位置:点【编辑】/【预览】/【并排】会保留提案区当前的滚动锚点(切换前记录、绘制前还原),长提案在中间切换视图不会跳回顶部。
- diff 字段:左右分栏、只读、行号对齐,新增/删除分别用 --dsw-alias-code-diff-added / -deleted 着色;任一侧超过 2000 行时不做对齐,改为左右原文对照并提示。
- 多窗口:所有打开的标签页都会收到同一请求;先决策者生效,其他标签页收到 settled 帧后自动收起面板。
- 刷新/断线:页面重连后宿主会下发一次 snapshot,待决策项重新出现,不会丢。
- 样式:只用 DSH 主题令牌(--dsw-alias-),跟随明暗主题;面板自带 error boundary,出错时降级成一行提示而不是把输入区弄白。
6.1 子代理(subagent)调用的落点
子代理调用的工具同样会被拦(同进程子代理,见下表),但那一次调用发生在子会话里,而 DSH 的待决策值是按会话归属的:只有"你正在看的那个会话"有请求,面板才会接管它的输入区。
所以本插件给每个请求做两份归属:
| 归属 | 作用 |
| 发起调用的会话 | 你点进那个子代理会话(对话里的子代理卡片 → 打开子会话)时,面板就在那里 |
| 你当前正在看的会话 | 无论你在看哪个会话,面板都出现在你眼前的输入区;标题行标出「来自子代理会话 4e05464d5b7d」并给一个【前往该会话】 |
- 两份归属是同一个请求:在哪一处决策都结算同一次调用,先答的先算;面板里的「另有 N 项待决策」按全部待决策计算。
- 你切换会话时,这份"跟随"归属会自动迁移(客户端订阅宿主的 main-view 绑定源),不需要刷新页面。
- 为什么必须做:DSH 侧栏那一行的状态点只认内置的 approval / plan-review / question 三种种类,本插件的 kind: 'hitl' 不在其中,所以子会话那一行不会有任何红点;没有"跟随"归属时,一个子代理的挂载调用会静默等到倒计时结束。
会触发吗?(在本机 profile 实测过一次,2026-04)
| 子代理后端 | 是否触发 | 说明 |
| spawn / fork(同进程,subagent-in-process-driver) | 会 | 子代理就是同进程里的另一个 agent,工具流水线是同一条;本插件的监听器没有 agent 作用域标签,按 scopeTarget 的规则"未打标签的监听器一律放行" |
| dsh-sdk / acp / claude-code / codex(跨进程) | 不会 | 它们的工具调用在另一个进程里发生,根本不经过 DSH 的 tools/pre-execute;只有 subagent 这个调用本身能被挂载拦住 |
触发之后会发生什么:子代理那一次调用停在门禁上(它的 turn 卡住),父代理的 subagent 调用在等子代理,于是整轮对话一起停——你在界面上看到的可能只是"子代理还在跑"。
等待期间可以在面板上作答;超时按该挂载的 countdown.action 结算,子代理会收到一条工具错误,形如
Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. Tell the user this call timed out and wait for an explicit instruction before running it.
(事件里带 error: {name:'HitlTimeout', code:'HITL_TIMEOUT'}),之后由它自己决定汇报还是重试。
建议给可能被子代理调用的挂载配 countdown:没有倒计时就是无限等,而子代理那边没有人会替你点。
6.2 帧级兜底提示(shell.overlay)
输入区座位并不是"挂载了就能被看到":没选会话、主面板切到了插件页、被更高优先级的宿主面板占用、 或者被一个全屏模态盖住(设置面板就是:它是一个 z-index:1000 的 body portal,而对话和输入区仍然挂在后面,并没有卸载)——这些情况下请求都需要另一个落点。
本插件另外注册了一个 root 作用域的浮层(shell.overlay 里的 dsh-hitl.notice,加法式注册,不替换任何内置条目):
- 出现条件 = 有可决策的请求,且(没有输入区座位 或 座位上的面板已经够不着了)。
"够不着"由面板自己每秒做一次命中测试得出(document.elementFromPoint 打到面板之外 = 被盖住/被裁剪/滚出视口),而不是靠猜会话状态;面板一旦真的在眼前,浮层立刻让位,绝不双份显示。
- 能被盖住也看得见:浮层会用 popover="manual" + showPopover() 提升到浏览器的 top layer,于是 z-index:1000 的模态也压不住它(不支持该 API 的环境退化成 shell 浮层里的固定定位,此时模态仍会盖住它)。
- 显示条数、工具名、来源(本会话 / 子代理会话)和宿主倒计时;最多列 3 条,其余收成「另有 N 条」。
- 直接提供【同意】【拒绝】这两条不需要编辑的路径 +【前往该会话】;「修改」和带原因的拒绝仍然在会话面板里做。
- 自身包在 error boundary 里,且 failure 时渲染 null:一个盖在全帧上的组件坏掉时必须消失,而不是留一个空盒子。
6.3 界面语言
面板跟随应用当前语言,不是"跟着挂载方"也不是"写死中文":
- 当前语言从哪来:先看用户在 设置 → 通用 → 语言 里的选择(持久化在 DSH 设置里,跨浏览器生效);没选过就看浏览器——navigator.languages 里第一个能匹配已注册语言的标签(先整串匹配,再退回主语言标签,所以 zh-CN、zh-TW、zh-Hans 都命中 zh),都不匹配就用 English(DSH 的 FALLBACK_LOCALE,理由是"没点名任何已注册语言的读者最不可能读中文")。所以:中文浏览器→中文面板,其它语言(日/德/法/…)→英文面板。
- 键级回退链:当前语言字典 → 各语言的 fallback(语言包声明)→ en → 公共命名空间 → 最后才是键名本身。
本插件的 zh / en 字典键集完全一致(有测试锁住),所以不会漏出键名;即使装了第三种语言包(界面本身变成日文等),本插件的文案也会落到英文,而不是显示 label.countdown 这种东西。
- 切换语言立即生效,不需要刷新页面(slot 的 locale 绑定与字典注册都会推进 revision,已渲染的座位会重取文案)。
- 插件列表里的名字/简介走 DSH 的另一条通道:包里的 locale/<语言>.json,形状必须是
{"meta":{"title":…,"description":…}},且 exports 里要有 ./locale/.json、files 里要有 locale。
缺任一项,卡片标题就静默退回包名 dsh-hitl(本仓库有测试盯着这件事)。
- 不会被本地化的东西:protect() 的 title、labels、字段 title/description(那是挂载方自己的文案)、工具名,以及提案里的参数原文。想给中英双语标题,目前只能由挂载方按语言分别传字符串。
- 宿主半给模型看的文本固定英文(拒绝理由、超时提示、修订说明),与 DSH 其它执行前策略一致——它进的是模型的上下文,不是人的界面。
7. 安全模型与失败关闭
- 面板与宿主之间是本插件自己的一条同源 HTTP 通道:GET <endpoint>/events(SSE 下行,连接即收 snapshot)
+ POST <endpoint>/decide(JSON 上行)。默认 endpoint = /dsh-hitl。
- 通道由每进程一个随机令牌保护:令牌通过 DSH 的结构化 index 注入行(webserver/index-inject 的 {kind:'global'})写进页面 window.__DSH_HITL__,不写日志、不落盘。
- 服务端还会校验:来源必须是回环地址;POST 必须带 content-type: application/json 与 Origin(有的话)与 Host 同源;请求体上限 256 KiB;令牌比较用 timingSafeEqual。
这些检查挡的是"本机别的网页"(CSRF / DNS rebinding),不是本机进程——本机进程本来就能读 ~/.dsh。
- 没有任何浏览器连接时默认 fail-closed:挂载在 HITL 上的工具会被拒绝,模型看到 Error: HITL: no browser is connected to decide, so tool "x" did not run (fail-closed).。
想让它在无人值守时一直等,把 whenUnavailable 设为 wait。
- 插件在没有任何 Web 服务的组合里(headless/TUI)仍然激活并继续拦——"没有 UI 就不拦"不是本插件的语义。
7.1 安全边界:HITL 拦得住什么、拦不住什么
- 它拦的是"这一次调用在真正执行之前",不是世界状态。 如果工具在自己的执行层已经产生了副作用(并发竞态、异步落盘、已经发出去的外部请求),拒绝只能阻止"接下来",回滚不了"已经"——HITL 不是事务,也不是沙箱。
- 所以实际安全程度取决于两件事:① 你的 matcher 覆盖了多危险的操作;② 工具是否把不可逆动作放在最后一刻。 把 bash / write / edit 这类"入口即危险"的工具挂上是对的;若某个工具的风险要到执行中途才出现,HITL 只能替你决定"要不要开始"。
- 用户的同意 ≠ 越权:同意之后沙箱、guard、hooks、审批照常随后生效(§8);反过来,别的执行前策略也可能先于本插件拒绝——实测过一次:fs-observation-policy 在用户点【同意】之前就把调用挡了,HITL 连面板都没弹(见 §8)。
- whenUnavailable: wait 是 fail-open:没有任何浏览器连接时会一直等,没配 countdown 就是等到会话结束。
想让"无人值守也不卡死",用默认的 reject,或者 wait + countdown(挂载时宿主会对这种组合给出警告)。
- 本机信任边界:决策通道与 /status 都只认回环地址。同机任意进程可以读 /status,它只含挂载列表与待决策的工具名 / 会话 id / 剩余毫秒 / 是否暂停——没有工具参数,也没有令牌。要把这个口子量准:同机进程本来就能读 ~/.dsh/sessions/* 的会话日志,而那里有完整的工具参数,所以 /status 的暴露面是它严格更小的子集。
这个口子是有意保留的:没有它,在终端里回答"HITL 现在拦着什么"就只能靠翻页面。
8. 与其他执行前策略的关系
- 本插件用 tools/pre-execute 且 prepend: true,也就是先问人;用户同意后,沙箱、guard、hooks、审批等其它策略照常随后生效。用户的同意不等于越权。
- 因此可能出现"用户同意了,但工具仍被沙箱/守卫拒绝"——这是设计使然,不是 bug。
- 反过来也成立:别的执行前策略可能在本插件之前就拒绝,此时 HITL 面板根本不会出现——所以"没弹面板就直接执行了/就失败了" 两种现象都要先看别的策略。实测过一次:fs-observation-policy 要求"重写已删除文件前先重读",它在用户点【同意】之后把调用挡了下来(面板照常弹过),也在另一轮里先于本插件直接拒绝(面板没弹)。
- 本插件只做"执行前问人",不改变工具参数、不改变工具结果。
9. 已知限制
- 不能改写工具参数(见 §5),所以【修改】默认走"拒绝并交回修改内容"。
- HITL 不是副作用边界(见 §7.1):它拦的是"调用开始之前",回滚不了已经发生的副作用;安全性取决于 matcher 覆盖面与工具自身的设计。
- 挂载文案不本地化:title / labels / 字段标题都是挂载方给的字符串,本插件不翻译,也不接受 {zh, en} 映射(见 §6.3)。
- 不写会话审计事件:DSH 不允许插件追加新的事件类型,决策痕迹只存在于工具结果(和 allow-and-inform 的那条 user 消息)里。
- 只有 Web 界面有面板:ACP/TUI/headless 下按 whenUnavailable 处理(默认拒绝)。
- 只支持 Web 界面同源部署(页面与插件路由同一个 dsh web 地址);Desktop 的 worker 形态未验证。
- allow-and-inform 模式下的那条 user 消息是按 createUserMessage 的形状手工构造的
({id, role:'user', source:{kind:'user'}, content}),因为纯 JS 插件不能 import 宿主工厂。
- 面板不注册全局快捷键(避免抢宿主 Enter/Esc);请用鼠标点按钮。
- 跨进程子代理不受本插件管辖(见 §6.1):dsh-sdk / acp / claude-code / codex 后端里的工具调用不经过 tools/pre-execute,只有 subagent 调用本身能被挂载拦住。
- 帧级浮层不是完整面板:它只能【同意】/【拒绝】(不带原因),「修改」和「带原因的拒绝」需要在会话面板里做。
- 子代理请求会让父代理一起等:这不是本插件能改的——父代理在等子代理返回,子代理在等你的决策。
10. 故障排查
| 症状 | 原因 / 处理 |
| 工具直接被拒绝,提示 no browser is connected to decide | 页面没连上决策通道:装完插件后刷新一次页面;确认浏览器控制台没有 401 |
| 面板一直不出现,工具一直卡着 | 该请求的会话不是你当前正在看的会话。本插件会把请求同时挂到你正在看的会话上(§6.1),所以正常情况下不会发生;真的没出现时,看帧级浮层(§6.2),或切到发起会话 |
| 子代理的调用没人管 / 子代理卡住整轮对话 | 预期行为:同进程子代理的调用会真的拦下来,父代理一起等(§6.1)。给它配 countdown,或在面板/浮层上作答 |
| 跨进程子代理里的工具完全不拦 | 预期行为(§6.1):那些工具在别的进程里跑,不经过 tools/pre-execute |
| 侧栏子会话那一行没有红点 | 预期行为:DSH 的侧栏状态点是编译期闭集(approval/plan-review/question),本插件的 kind: 'hitl' 不在其中。用输入区面板或帧级浮层 |
| countdown 不生效 | 检查 seconds 是正整数、action 是三个取值之一;非法配置会在宿主日志里给出 dsh-hitl: 警告并退化成"不限时" |
| 改了配置没反应 | patch 覆盖是整份 config 替换;确认 id: hitl 这一行的 config 完整 |
| 装了插件但宿主半没起来 | 看 dsh 的 stderr:行加载失败不会让 dsh 退出,日志里会出现 dsh-hitl: ... 或行 id 的报错 |
| 升级插件版本后行为没变 | 替换已安装包需要重启 dsh(插件管理器不会让新版本就地生效) |
| 改了插件代码但行为没变 | 浏览器半(client.js)会被客户端热重载自动拾取;宿主半(index.js、lib/)受 Node 的 ESM 模块缓存影响,禁用→启用 这一行不会重新加载代码,必须重启 dsh |
| 想一眼看清宿主状态 | curl -s http://127.0.0.1:3081/dsh-hitl/status(仅回环、无需令牌、只读):返回当前连接的浏览器数、本进程签发过的令牌数、正在挂载的工具、以及待决策列表(含实时剩余毫秒与是否暂停)。它不含令牌、不含任何工具参数 |
宿主半的诊断日志统一带 dsh-hitl: 前缀(非法挂载、丢连接、通道拒绝等)。
附录 · 协议(想自己写 UI 的话)
下行(SSE,data: <json>):
{ "type": "snapshot", "version": 1, "requests": [Request, ...] } // 连接即发 { "type": "request", "version": 1, "request": Request } // 新的待决策 { "type": "settled", "version": 1, "id", "outcome": "user|timeout|abort|host" } { "type": "holdAck", "version": 1, "id", "held": true, "expiresAt": 1712 }
Request:
{ "id", "sessionId", "toolName", "callId?", "title?", "layout", "labels": [],
"fields": [ { "param", "title", "description?", "render", "editable", "labels": [],
"value"?, "truncated"?, "diff"? { "path?", "oldText", "newText" } } ],
"buttons": { "approve"?, "modify"?, "reject"?, "feedback": { "enabled", "prompt"?, "required" } },
"countdown": { "remainingMs", "action", "freezeOnInteract" } | null,
"createdAt" }
上行(POST <endpoint>/decide,Authorization: Bearer <token>):
{ "type": "decide", "id", "decision": { "kind": "approve" } }
{ "type": "decide", "id", "decision": { "kind": "modify", "fields": [{ "param", "text" }] } }
{ "type": "decide", "id", "decision": { "kind": "reject", "feedback"?, } }
{ "type": "hold", "id", "held": true }
应答:{ ok: true, accepted: true, held?, expiresAt? } 或
{ ok: false, code: "unauthorized|bad-payload|unknown-request|already-settled|host-gone", message }。
附录 · 文件结构
dsh-hitl/
├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入 ├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import) ├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试 │ ├── protocol.js 195 帧与上行校验、上限、错误码(协议规范的唯一出处) │ ├── resolve.js 363 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断 │ ├── fields.js 449 默认提案推导、diff 配对、计算字段解析、决策 → 模型可见文本 │ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算 ├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3) │ ├── en.json 6 │ └── zh.json 6 ├── docs/ 截图源文件,故意不进 npm 包:README 改为链接 landing page ├── tests/ 零依赖 Node 测试,共 167 个用例 │ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat │ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本 │ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待) │ ├── resolve.test.js 224 (22)matcher 与选项归一化 │ ├── protocol.test.js 117 (16)帧与上行校验 │ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字 │ └── manifest.test.js 77 (4)包元数据与 locale 资源形状 ├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC) ├── package.json 68 清单:exports / dsh.bundle.patch / dsh.client / icon ├── cordis.patch.yml 17 bundle 的配置层(插入 id 为 hitl 的那一行) ├── icon.svg 6 插件列表图标 ├── LICENSE MIT ├── README.md 本文档(英文版) └── README.zh.md 本文档的中文版
改哪个文件,怎么生效(和 §10 的排错表是同一条规则):
| 文件 | 属于哪一半 | 改动如何生效 |
| index.js、lib/.js | 宿主半(Node) | 必须重启 dsh。Node 的 ESM 模块缓存不会因为"禁用→启用这一行"而失效 |
| client.js | 浏览器半 | 客户端热重载自动拾取(宿主每 500ms stat 一次各行的 client 产物并广播 /plugins/events),不用刷新页面 |
| locale/.json | 插件列表元数据 | 插件管理器重读元数据时生效(打开插件页 / 重启 dsh) |
| docs/.png | 截图源文件 | 只留在仓库里;发布的包里不再包含(见附录说明) |
| package.json、cordis.patch.yml | 清单与配置层 | 需要重启或重新加载该行;已安装包不会被就地替换 |
| tests/.js | 开发期 | 不参与运行,只由 npm test / node --test 执行 |
为什么长这样(三条约束,改代码前先看):
- client.js 必须是一个自包含的 classic script。DSH 把它原样作为 /plugins/dsh-hitl/client.js 提供给浏览器,入口固定是 window.__ModuleLoader__.load({ id, factory(require) }),只能从模块表里取 react, 不能 import 任何 @deepseek-ai/ 客户端包(没有打包步骤会替你解析它们)。
所以 markdown 渲染器、并排 diff、倒计时、面板样式全部写在这一个文件里,CSS 只用 --dsw- 主题令牌。
- lib/ 是宿主半专用的纯模块。浏览器半不能 require 它们,宿主半也不在其中引入 Cordis 或 HTTP——这样每个模块都能被 node --test 直接跑,协议/状态机的行为不依赖运行时。
- client.js 的纯逻辑通过工厂返回值的 __test 暴露给测试:tests/client.test.js 用桩 window.__ModuleLoader__ + 桩 React 加载真实的 client.js,所以浏览器半的逻辑改动同样有回归保护(markdown 渲染、diff、时钟、落点归属、座位可见性都在里面)。
开发与自测
node --check index.js client.js lib/.js # 语法
npm test # 167 个纯函数/状态机/通道用例,零依赖
npm run check # 上面两步合起来(语法 + 全部用例)
每个文件的作用、行数、以及"改它之后要不要重启 dsh"见上面的 附录 · 文件结构(目录不在下面重复第二遍,避免两处描述走偏)。
本插件的宿主半与浏览器半的约定以 lib/protocol.js + tests/protocol.test.js 为准。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
YunpengDon/dsh-hitl
让工具调用先弹卡片,等你同意、修改或拒绝
相关插件 · UI / 主题
破甲增强版JeffKeeping/dsh-purge-plus
dsh-purge 的非官方分支fork:适配 DeepSeek Harness 0.2.1-rc.2、破甲内核…
★ 387已汉化DeepSeek 客户端
71.9
无代码业务系统平台nocobase/nocobase
NocoBase 是什么
★ 24,804已汉化DSH
71.8
1Panel 服务器面板1Panel-dev/1Panel
全球 250 万+ 自托管用户共同喜爱。
★ 37,362已汉化DSH
71.4
终端编码代理esengine/DeepSeek-Reasonix
· 指南 · ACP · 扩展开发 · 规格 · 官方网站 · Discord · QQ
★ 36,076已汉化DSH
71.2
工作状态行动态ccch1mneyyy/working-activity
作者:chimney@ccch1mneyyy。社区项目,非 DeepSeek 官方项目。
★ 664已汉化DSH
71.2
多模型配置管家farion1231/cc-switch
跨平台桌面一体化助手,支持 Claude Code、Codex、OpenCode、DeepSeek Harness 等工具,唯一官网 ccswitch.io
★ 151,814已汉化DSH
71.1
这个插件是你维护的吗?
作者本人可认领并核对本条目;认领通过后,详情页会显示「作者已认领」标记。