dsh-stop-kills-jobs
按下对话的停止按钮时,除了中断当前回复,还会结束这一轮发起的后台任务。
解决的问题
后台任务(run_in_background: true 的 bash、后台子代理)拿到 job id 之后,
就不再跟随对话的中断信号了。这是 DSH 的明确设计 —— dsh-tool-bash 自己的模块文档写着:
Background calls register process handles with
ctx.jobs; their work uses job cancellation rather than the tool-call signal after an id is returned.
代码里这个对比很直白:
| 前台命令 | 后台命令 | |
|---|---|---|
| 调用 | ctx.shell.run({... signal: exec.signal}) |
jobs.start({... owner: exec.agent ...}) |
| 按停止 | ✅ 传了 signal,会被杀掉 |
❌ 没有传 signal |
后果就是:你按了停止,任务还在跑;跑完之后它的完成通知会唤醒模型, 为一次你已经取消的对话多花一次请求 —— 而你还得再点一次停止。
这个插件把这个缺口补上:它监听 UI 同样在监听的 turn/end 事件,
认出「用户按了停止」这个原因,然后取消该会话自己的后台任务。
安装
dsh plugin --profile web add link:/xxx/dsh-stop-kills-jobs
装完重启 DSH 生效(客户端部分需要重新加载)。
设置
设置位置:设置 → 插件 → 插件配置,和「插件市场」「Shell」「Agent loop」等并列。
这些开关确定后基本不会再改,所以做成插件配置里的一张卡片, 而不是占用设置栏顶层 —— 顶层要留给经常调整的项。 卡片默认折叠,只显示标题和一行说明,和旁边的 Shell / Agent loop 等保持一致, 点标题才展开开关,不占版面。折叠箭头用的是同一套
IconChevronDownOutline14图标。
总开关联动:关闭总开关后,「停止时结束什么」整段隐藏(因为此时插件不做任何事), 但已保存的设置原样保留,重新开启即恢复 —— 不会因为关一下就被重置。
正文分两段,因为它们不是一类东西:
| 分段 | 内容 | 与 Stop 的关系 |
|---|---|---|
| 停止时结束什么 | 后台命令 / 后台子代理 | 受总开关管辖 |
| 插件自身的输出 | 显示提示 / 输出日志 | 无关,任何时候都生效 |
平铺成一张列表时,「显示提示」「输出日志」会被误读成也会随停止按钮被终止 —— 这是分段的原因。
卡片标题为「停止时结束后台任务」(英文界面显示 "End background jobs on Stop"):
| 开关 | 默认 | 说明 |
|---|---|---|
| 启用 | 开 | 总开关。关掉即完全恢复 DSH 原行为 |
| 后台命令(bash) | 开 | 是否结束 run_in_background 的 bash 任务 |
| 后台子代理(subagent) | 开 | 是否结束后台子代理 |
| 在对话中显示提示 | 开 | 在设置页显示最近结束的任务 |
| 输出详细日志 | 关 | 把每次判断写进服务端日志,排查问题用 |
卡片里没有长篇说明
正文只有开关本身,没有"为什么需要它"那类解释 —— 会装这个插件的人看名字就知道用途, 展开后应该直接看到可调项。改这个插件时不要再把说明加回去。
卡片是怎么进到「插件配置」的
那个标签页会遍历宿主已注册的 settings namespace,逐个配对同 key 的卡片:
const served = new Set(mirrored.view?.namespaces.map((v) => v.ns) ?? [])
entries().flatMap((e) => served.has(e.options.key) ? [e.options.key] : [])
所以 settings.plugin.item 注册时的 key 必须等于插件注册的 settings
namespace(stop-kills-jobs)。key 写错不会报错,只是卡片静默消失 ——
排查时先看这一点。
作用范围:只影响当前会话发起的任务,不会碰其他会话。
子代理任务注册时用的是 owner: parent,所以它的 ownerSession 就是父会话 id ——
一个过滤条件就同时覆盖了「本会话 + 它派生的子代理」,不用走任何树。
实测结果
在独立实例上、用真实鼠标/键盘事件(CDP Input.dispatchMouseEvent)驱动真实 GUI 测得:
| 场景 | Stop 后存活的后台进程 | 结论 |
|---|---|---|
| 插件启用 | 0 | ✅ 任务被结束 |
| 插件禁用 | 1 | ✅ 任务存活(恢复原行为) |
两个基线都是 0,所以这个对照是可信的。
其他已验证项:
- 配置接口返回正确的 schema 默认值
- 设置页 5 个开关全部渲染,点击后持久化生效(
verbose往返 true→false→true) - 回归:正常跑完的回合不会误杀自己的后台任务
- 回归:没有后台任务时按停止是干净的空操作,无报错
服务端日志(开启 verbose 时):
[stop-kills-jobs] watching for stopped turns
[stop-kills-jobs] kill bash-2 -> requested
[stop-kills-jobs] stopped 1 job(s) on user Stop: bash-2
[stop-kills-jobs] disabled; leaving jobs alone
实现要点(踩过的坑)
1. 作业访问是按「所有者身份」授权的
这是这个插件最容易写错的地方。第一版写的是 jobs.list(),
日志显示 total_jobs=0 —— 明明有任务在跑却看不见。
根因在 dsh-jobs-local 的隔离栅栏:
assertAccess(job, caller) {
if (job.owner !== void 0 && job.owner.id !== caller?.id)
throw new Error(`job ${job.id} belongs to another session`);
}
传 undefined 等于「无代理调用者」,永远匹配不上有主作业。
必须先用 ctx.agents.get(session.id) 拿到那个会话的 Agent 再传进去:
const caller = ctx.agents.get(sessionId) // 这是授权凭据,不是查表便利
jobs.list(caller)
jobs.kill(job.id, caller, reason)
2. 识别「用户按停止」要精确匹配
reason?.kind === 'aborted' && reason?.reason?.kind === 'user'
只看 aborted 不够 —— 父代理中断、插件 hook 也都会 abort,
为那些情况取消用户的任务是意外行为。取消原因一共四种:
'user' / 'parent' / 'hook' / 'disposed',只有 'user' 是停止按钮。
3. ctx.logger 在这个部署里是黑洞
两个坑叠在一起:
ctx.logger是可调用对象,要先传频道名:ctx.logger('name').warn(...)。 写成ctx.logger.warn(...)会拿到undefined,被可选链静默吞掉。- 更关键的是:这个部署没有安装任何 logger exporter,
所以即使调用方式对了,
ctx.logger的输出也没有任何地方显示。
所以诊断信息走 process.stderr(带 [stop-kills-jobs] 前缀),
它能进服务端日志和启动它的终端。一个「verbose 开关写进虚空」的功能,
本身就和这个插件要消灭的静默失败是同一类问题。
4. 客户端插件必须声明 inject
client 半边如果用了 ctx.locale / ctx.slots 却没声明依赖:
failed to apply loader entry: cannot get property "locale" without inject
而且失败方式是整个界面渲染不出来(页面只有 "Failed to load plugins"), 不是只有这个插件坏掉。所以必须:
const inject = ["slots", "locale"];
exports.inject = inject;
5. 插件在工作区里,解析不到 @deepseek-ai/*
插件放在 ~/dsh_projects/ 下,在 $DSH_HOME/profiles 树之外,
Node 向上找 node_modules 时永远走不到 DSH 在
~/.dsh/profiles/node_modules 挂的 fallback。加载会直接失败:
Cannot find package '@deepseek-ai/schemastery'
运行 tools/link-runtime-deps.sh 只给实际用到的包建软链(不是整个 scope),
保持依赖集可见且最小。
pnpm install之后需要重新跑这个脚本(如果软链被清掉)。
文件
| 路径 | 说明 |
|---|---|
lib/index.js |
host 半边:监听 turn/end、取消任务、配置路由 |
lib/client.js |
client 半边:设置页 UI |
cordis.patch.yml |
把插件插进 profile 层栈 |
tools/ |
安装/自检脚本(WSL 用,避免 Windows PATH 破坏引号) |
verify_plugin.py |
端到端验证(配置 + 启用/禁用对照) |
regression.py |
回归验证 |
verify_result.json |
验证结果记录 |
卸载
dsh plugin --profile web remove dsh-stop-kills-jobs
然后重启 DSH。
No comments yet. Be the first to write one.