← Back to home@Chlyz621

dsh-unstuck

Auto-recovery for DeepSeek Harness: continues turns that ended abnormally or were swallowed into plain text, and revives stalled model requests.

Stars
0
Language
JavaScript
Created
Oct 20, 2026
Updated
Oct 20, 2026

Introduction

dsh-unstuck

给 DeepSeek Harness 用的监督员插件。dsh 跑着跑着自己断了、卡住了、闷头不动了,它负责发现并把你解开(unstuck),不需要你手动发消息。

English — A watchdog plugin for DeepSeek Harness. When a run stops on its own and waits for you to say something, this continues it.

It handles three cases, and nothing else:

  • Abnormal turn end (error, aborted not caused by you, max-tokens, interrupted) — sends one "continue" after a short delay.
  • Swallowed turns — the turn is logged as completed even though the model meant to call a tool and an OpenAI-compatible relay emitted the call markup as plain text instead, leaving the agent silently idle. Detected structurally (the reasoning block and the text block are byte-identical, or the text is empty while reasoning is not, or the tail is a broken call tag) and continued with a pointed message asking for the tool call again.
  • Stalled requests — a model request was issued and then no stream frame arrived for stallMs. The turn is cancelled (kind: 'hook', inbox kept) and continued.

Waiting on an approval or on a question is logged and notified, never answered on your behalf. A stop you pressed yourself, a turn that really finished, and non-retryable errors (auth, quota, context overflow) are all left alone. Detection is pure structure over the session log — no judge model, no network calls.

Install: dsh plugin --profile <profile> add github:Chlyz621/dsh-unstuck (or run install.ps1 from a local clone). Config: <DSH_HOME>/unstuck/config.json. Log: <DSH_HOME>/unstuck/unstuck.jsonl. The detailed documentation below is in Chinese; PLUGIN-NOTES.md records why each decision was made.


它管三件事

现象监督员怎么做
一轮对话异常结束(上游报错、输出被截断、进程崩溃留下的残局、非你按的中断)等一小会儿,补发一条「继续」
「假完成」:日志写着这轮正常结束,其实工具调用被吞了、活没干完(最常见,见下节)补一条带指向性的「继续」,让它重新发工具调用
模型请求发出去了,但很久没有任何流片段(假死:TCP 断了但不报错)掐掉当前这一轮,再补「继续」
卡在等你审批 / 等你回答问题只记录 + 通知,绝不替你回答

不插手的情况:你按了停止、你这轮真的干完了、你正在输入(inbox 里有你的消息)、连续中断超过预算、认证失败这类再试也没用的错误。

「异常结束」具体指这几种 turn/end 原因:error(上游报错)、aborted(不是你按的停止)、max-tokens(输出被 token 上限截断)、interrupted(进程中断留下的残局)。


断连根因:中转站把工具调用吞成了正文

这不是猜的,是从真实会话日志里挖出来的。同一会话三轮,每一轮都是这么断的:

turn末 step耗时块结构reasoning 与 texttool-call正文尾部
12110.6sreasoning+text逐字节相同无…</||DSML|| calls>
21384.5sreasoning+text逐字节相同无…temp dir.</||DSML|| parameter>
31018.4sreasoning+text逐字节相同无Hmm, the(断在词中间)

同一会话 172 条 assistant 消息里,「reasoning 和 text 逐字节相同且没有 tool-call」只出现这 3 次 —— 正是被吞的 3 次,其余 169 条零误报。

机理:模型本来要发工具调用,中转站(api: openai-completions 兼容层)没能把 DeepSeek 原生的 ||DSML|| 工具调用标记转成 OpenAI 的 tool_calls,那段标记被当普通正文吐了出来。于是 assistant 消息里只剩 reasoning + text、没有 tool-call;agent 循环认为「模型没要求调工具 = 这轮说完了」,turn/end 记成 completed,agent 静默 idle。

你看到的现象就是「跑着跑着自己断了,得手动发一句才继续」。耗时正常(4.5–18.4s,不是超时)、长度不一(2868–8892 字符,不是 token 上限),所以既不是超时也不是截断,是协议转换层偶发失败。

dsh 自带的「继续」按钮也救不了它 —— 那个按钮把 completed 当「已收工」,直接拒绝续跑。本插件的 continueOnSwallowed 就是专门补这个洞。

想根治的话(可选)

根因在中转站那一层,插件只能兜底。能减轻的办法:

  • 换模型:glm-5.3 / kimi-k3-1 / minimax-m3 这些模型原生就是 OpenAI 风格的工具调用,不走 DSML,理论上不会踩这个坑。改 profile 的 agent-default-model 即可。
  • 降 reasoningEffort:max 会让 reasoning 特别长,长流更容易在中转站出错。降到 high 试试。
  • 换官方端点:DeepSeek 官方 API 不会有这层转换。

三个必须知道的默认行为

  1. 只补「继续」两个字。 它不猜你想干什么,也不改你的需求。续跑消息的来源标记是 dsh-unstuck,所以它不会把自己发的消息当成你在说话。
  2. 有预算。 同一个会话连续中断最多自动续跑 continueMax(默认 3)次,用完就停手并在日志里记一条 continue-budget-exhausted。你只要手动发一条消息,预算立刻清零。
  3. 有退避。 第 1/2/3 次续跑前分别多等 1.5s / 5s / 15s,避免把上游打爆、也避免烧钱。

安装

两种装法。只想装上用走第一种;要改代码或者离线装走第二种。

一、从 GitHub 装(推荐)

dsh plugin --profile tauri add github:Chlyz621/dsh-unstuck

dsh 会把仓库取到 profile 的 node_modules 下并登记进 dsh.profile.bundles。装完完全退出 DeepSeek Harness(含托盘图标)再打开。

二、从本地目录装(改代码时用)

cd <仓库根目录>
pwsh -File .\install.ps1

装完完全退出 DeepSeek Harness(含托盘图标)再打开。

脚本做三件事,每一步都会先备份:

  1. 把插件复制到 <DSH_HOME>\plugins\dsh-unstuck
  2. 在 <DSH_HOME>\profiles\tauri\package.json 里登记依赖并加进 dsh.profile.bundles
  3. 在 profile 的 node_modules 下建一个目录联接,让 dsh 能 require 到它(不联网、不跑 pnpm)

参数:

pwsh -File .\install.ps1 -Profile tauri -DshHome C:\Users\me\.dsh -Force

卸载:

pwsh -File .\uninstall.ps1          # 只从 profile 摘掉,插件文件留着
pwsh -File .\uninstall.ps1 -Purge  # 连插件文件一起删

怎么确认它活着

Get-Content "$env:USERPROFILE\.dsh\unstuck\unstuck.jsonl" -Tail 20

开机应该有一行 {"event":"boot",...},里面能看到当前生效的配置和挂上了几个监听器。之后每次动手都有记录:

event含义
boot / dispose装上了 / 卸载了
continue-sent补了一条「继续」(带 attempt、kind、code)
turn-swallowed发现「假完成」(带 why:dsml-leak / echo-reasoning / no-output)
continue-skipped判断过但没补(带 why:user-abort / policy / stall-already-handled)
continue-deferred本来要补,但你在输入或已经跑起来了(user-took-over / inbox-busy)
continue-budget-exhausted预算用完,需要人看一眼
stall-detected判定假死(带 silenceMs)
clock-jump机器睡眠/事件循环被卡,这一轮巡检只搬时间锚点、不判定(带 gapMs)
question-asked / approval-asked记录了你被卡住
question-blocked / approval-blocked卡太久,提醒你
request-retry重试了一次模型请求(默认关闭)

debug 级的记录(比如每轮一条 running)默认不落盘,只有 verbose: true 才写,免得把上面这些有用的记录淹掉。


调参

默认值够用。想改就把示例配置复制过去,改完重启 dsh:

Copy-Item .\unstuck.config.example.json "$env:USERPROFILE\.dsh\unstuck\config.json"

优先级:内置默认 < profile 里 cordis.patch.yml 的 config < 这个 config.json(后者赢)。

键默认说明
enabledtrue总开关,关掉后一个监听器都不装
autoContinuetrue续跑总开关
continueMax3每个会话最多自动续几次
continueText"继续"续跑发什么
continueOnSwallowedtrue「假完成」也续跑,见「断连根因」一节
continueTextSwallowed带指向性的一句「假完成」时发的措辞
continueDelayMs700发现中断后先等多久
backoffMs[1500,5000,15000]每次续跑额外等待
continueOn异常类原因只在这些 turn 结束原因上续跑
skipCodes认证类错误码命中就不续(试也没用)
autoRetryfalse模型请求层重试,默认关,见下
retryMax2请求重试次数
stallEnabledtrue假死检测开关
stallMs180000请求发出后多久没流片段算假死(3 分钟)
stallResumeMs800掐掉后等多久再补「继续」
tickMs15000巡检间隔
hardStallMs0整轮零事件的兜底阈值,0=关闭(长工具本来就很久没事件)
notifyBlockedtrue卡审批/等回答时提醒
blockedNotifyMs120000卡多久开始提醒
notifyCommand""额外执行的命令,{msg} 会替换成正文;留空只写日志
skipOwnedAgentstrue子 agent 不插手
verbosefalse连 debug 级也打印到控制台

和 dsh 自带「继续」按钮的关系

桌面端本来就有一个「继续」按钮(POST /api/tauri/ui/session/resume),它做的事和本插件一样:判断上一轮是不是正常结束,不是就补一条 Continue the interrupted task...。

区别在于它是手动的——得你去点。本插件把那套判断搬进进程内、按事件自动触发,而且更保守:

  • 你按了停止 → 不续
  • 认证失败 / 余额不足 / 上下文超长 → 不续(点了也没用)
  • 连续中断超过 continueMax 次 → 停手并提醒
  • 你的消息一进来 → 立刻让位

它发的消息 source.kind 是 dsh-unstuck,跟桌面端按钮的 continue 区分开,日志里一眼能看出是谁干的。反过来,如果发现会话里已经有一条 continue 或 dsh-unstuck 消息,本插件不会再补第二条。

和 dsh-purge 的关系

dsh-purge 自带一个 autoRetry(profile 里默认开着),做的是同一件事。所以本插件 autoRetry 默认 false,避免两边叠成 6 次重试。

想让监督员统一管:在 profile 的 cordis.patch.yml 里把 dsh-purge 的 autoRetry 设成 false,再把本插件的 autoRetry 设成 true。


桌面通知(可选)

Windows 上想收气泡提醒,把 notifyCommand 设成:

{ "notifyCommand": "msg * {msg}" }

或者用 BurntToast:pwsh -NoProfile -c "New-BurntToastNotification -Text 'dsh', '{msg}'"。


它做不到的事

  • dsh 进程整个没了(崩溃、被关掉、桌面端退出) —— 插件在进程内,进程死了它也跟着死,救不了。这种要靠外部守护进程,不在本插件范围。
  • 模型一直返回同样错误 —— 预算用完后它只提醒,不硬刚。
  • 它不替你回答提问、不替你点审批。 这是故意的:那类决定不该自动化。

开发

cd <仓库根目录>
node --test "test/*.test.js"
  • lib/core.js 纯逻辑(判原因、算退避、造消息、读 session 事件),无副作用,好测
  • lib/config.js 默认值 + 归一化 + 用户配置文件
  • lib/index.js cordis 装配层,只管接事件和发消息
  • test/core.test.js 逻辑单测;test/plugin.test.js 用假 ctx / 假 agent 跑装配层

改完 lib/ 想让它生效,有两条路(实测过,别信「改 profile patch 就能热重载」这种说法,那个不成立):

  1. 改已安装目录里的文件 —— dsh 在监听 ~/.dsh/plugins/ 下的插件目录,文件一变,1~2 秒内自己 dispose + boot,不用重启。所以先同步:
    Copy-Item .\lib\*.js $env:DSH_HOME\plugins\dsh-unstuck\lib\ -Force
    
    然后看 ~/.dsh/unstuck/unstuck.jsonl 尾部有没有新的 boot 行。
  2. 重跑 install.ps1 或直接重启桌面端 —— 最稳,任何情况下都算数。

注意:只往 profile 的 cordis.patch.yml 里加注释不会触发重载(实测等 30 秒无反应)。想改配置,写进 <DSH_HOME>\unstuck\config.json 再重启。


关于名字和市场里的描述

为什么叫 unstuck:它的三件事——异常结束、假完成、请求假死——本质都是「卡住了」,本插件做的就是把卡住解开。这个名字在 npm 和插件市场 catalog 上都是空闲的;而 dsh-supervisor 那个名字已经被人发布后撤回(registry 里只剩墓碑,0 版本),发不了,也进不了 catalog。

为什么插件市场里一开始没有描述:市场只显示 catalog 里已发布插件的描述,不读本地 package.json。本地 file: 安装的插件没有 catalog 条目,所以只有包名、没有描述。目前的解决办法是把一条说明写进市场备注(.dsh-market\state.json 的 notes),市场「已安装」那一行会显示它;你可以在行尾「···」里改成自己的话。

想让它有真正的「作者描述」,只能发布到 npm 并被 catalog 收录——catalog 支持 description.en 与 description.zh-CN 两栏。发布前请确认 package.json 里的 files、dsh.bundle.patch、dshTarget 都对(现在是对的)。