在对话中重启宿主并自动恢复会话继续干活
30.8/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/23 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add sadkga/dsh-restart-handoff该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · other
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 12 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
读写/删除本地文件
查看扫描证据(共 7 处,展示前 7 条)
- 读取本地文件index.js:1506
stats = await fsStat(pathJoin(dir, file)) - 读取本地文件index.js:1902
size = (await fsStat(paths[0])).size - 读取本地文件index.js:2474
return JSON.parse(await fsReadFile(pathJoin(handoffDir(settings), file), 'utf8')) - 写入本地文件index.js:2489
await fsMkdir(dir, { recursive: true }) - 写入本地文件index.js:2492
await fsWriteFile(temp, JSON.stringify(data, null, 2)) - 写入本地文件index.js:2493
await fsRename(temp, target) - 删除本地文件index.js:2504
await fsRm(pathJoin(handoffDir(settings), file), { force: true })
扫描于 2026/11/3(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
让AI在对话里重启 DeepSeek Harness,然后自己回到工作里 —— 不需要人打字说「继续」。
README
由 DeepSeek 最新模型翻译生成dsh-restart-handoff
在对话里重启 DeepSeek Harness,然后自己回到工作里 —— 不需要人打字说「继续」。
- 中文 · English
它做什么
模型通过一个专用工具 restart_dsh,以两步的方式重启宿主:
1. 警告调用。 这一步不重启任何东西。工具返回一段警告:原因、将重放的指令、将恢复的会话,以及一个确认令牌。模型必须带着令牌回来,所以重启绝不会是一次随手调用 —— 智能体有第二次机会把计划说清楚。
2. 确认调用。 令牌匹配时,工具写入交接记录($DSH_HOME/restart/handoff.json):重启时正在跑的会话、原因、待重放的指令(continuePrompt),以及可继续的子代理。会话日志落盘,工具等当前轮次变空闲,然后进程给自己发信号;由 Restart=always 的单元拉起新进程。
3. 重放。 新进程读交接记录,然后
- 恢复重启发生时正在跑的每一个会话,并注入一条「请继续」的指令唤醒它;
- 自上而下地唤起可继续的子代理,保证子会话只在它自己的父会话活着时才被投递;
- 把 continuePrompt 交给发起重启的那个会话;
- 对齐 Agent Team 名册并唤起其中的队友。
上面是计划内重启。
展开完整文档(README 剩余 1 节)
dsh-restart-handoff
在对话里重启 DeepSeek Harness,然后自己回到工作里 —— 不需要人打字说「继续」。
- 中文 · English
它做什么
模型通过一个专用工具 restart_dsh,以两步的方式重启宿主:
1. 警告调用。 这一步不重启任何东西。工具返回一段警告:原因、将重放的指令、将恢复的会话,以及一个确认令牌。模型必须带着令牌回来,所以重启绝不会是一次随手调用 —— 智能体有第二次机会把计划说清楚。
2. 确认调用。 令牌匹配时,工具写入交接记录($DSH_HOME/restart/handoff.json):重启时正在跑的会话、原因、待重放的指令(continuePrompt),以及可继续的子代理。会话日志落盘,工具等当前轮次变空闲,然后进程给自己发信号;由 Restart=always 的单元拉起新进程。
3. 重放。 新进程读交接记录,然后
- 恢复重启发生时正在跑的每一个会话,并注入一条「请继续」的指令唤醒它;
- 自上而下地唤起可继续的子代理,保证子会话只在它自己的父会话活着时才被投递;
- 把 continuePrompt 交给发起重启的那个会话;
- 对齐 Agent Team 名册并唤起其中的队友。
上面是计划内重启。非计划重启 —— 崩溃、被杀、断电 —— 不会留下交接记录,所以下次启动时插件会扫描持久化的会话,把「最后一轮被打断」的那些带回来,外加「收件箱里还排着没开始过的提示」的那些。
为什么模型不会自己去执行重启
在服务内部执行 systemctl --user restart dsh.service,会死在自己要求的那个停止动作上。这样的 bash 调用还会丢掉工具结果、丢掉当前轮次、丢掉会话的活跃 Agent。因此插件:
* 把重启暴露为一个工具,并且
* 看守会执行命令的工具:看起来像「重启 Harness」的命令会被拒绝,并给出指向 restart_dsh 的提示。
围栏的完整说明见下文 为什么这样设计。
配置(cordis.patch.yml)
| 键 | 默认值 | 含义 |
| serviceUnit | dsh.service | 监管该进程的 systemd user 单元。 |
| handoffDir | $DSH_HOME/restart | 交接记录写入的位置。 |
| autoResume | true | 下次启动时重放交接记录。 |
| resumeSubagents | true | 恢复后唤起可继续的子代理。 |
| resumeRunningSessions | true | 同时恢复其它正在跑或排着活的会话。 |
| pendingTtlMs | 300000 | 超过这个年龄的确认令牌会被拒绝。 |
| idleTimeoutMs | 30000 | 工具等轮次变空闲再发信号的最长时间。 |
| handoffTtlMs | 1800000 | 超过这个年龄的记录视为陈旧,不再重放。 |
| recoverInterruptedSessions | true | 恢复非计划退出留下的会话。 |
| recoverMaxAgeMs | 7200000 | 被打断的会话最多能有多旧才仍会被恢复。 |
| recoverMaxSessions | 50 | 一次启动最多恢复几个会话。 |
| dryRun | false | 只写交接记录,绝不发信号。 |
在 cordis.patch.yml 里改任意一项即可覆盖:
- insert:
- id: restart-handoff
name: 'dsh-restart-handoff'
config:
serviceUnit: 'dsh.service'
autoResume: true
resumeSubagents: true
idleTimeoutMs: 30000
安装
用 Harness 插件 CLI 装进某个配置档。--profile 是必需参数 —— CLI 会把其余参数转发给该配置档目录里的 pnpm:
dsh plugin --profile web add dsh-restart-handoff
从 GitHub 装(不需要 npm 账号):
dsh plugin --profile web add github:sadkga/dsh-restart-handoff
锁定某个标签:
dsh plugin --profile web add github:sadkga/dsh-restart-handoff#v0.2.1
装完重启宿主:插件的客户端元数据与加载器会被缓存,直到进程重启才生效。
插件需要 Node 24(engines.node)。
从本地检出安装
插件是一个纯宿主 bundle:一个 name、一份 inject 列表和一个 apply。要用插件管理器安装本地工作副本,把目标指向该目录:
plugin_manager → install_bundle → target: /path/to/dsh-restart-handoff
这会写入配置档的 package.json 依赖(link: 到该目录)、把 dsh-restart-handoff 加进 dsh.profile.bundles,加载器随后应用 cordis.patch.yml,插入插件行和它的配置。
也可以手工接线:
// profiles/web/package.json
"dependencies": {
"dsh-restart-handoff": "link:/path/to/dsh-restart-handoff"
},
"dsh": { "profile": { "bundles": ["dsh-restart-handoff"] } }
然后在配置档目录里跑 pnpm install(nodeLinker: hoisted),并重启宿主。
宿主必须由一个会重启它的单元监管 —— 插件给自己发 SIGTERM,靠 Restart=always 拉起新进程。本仓库使用的单元是 dsh.service(Restart=always、RestartSec=5、TimeoutStopUSec=1m30s)。
为什么这样设计
一个工具、两次调用,计划绑定在警告上
重启是破坏性的:它会终结进程里所有正在跑的轮次。所以它永远不是一次调用就能完成的。第一次调用只警告并交回一个令牌。
第二次调用执行的是警告里那份计划,而不是它自己参数里的计划:原因和指令都从待确认记录里读。一个在两次调用之间改写计划的模型,确认的就是一份没人见过的计划,确认也就毫无意义。令牌被确认消耗,并在 pendingTtlMs 后过期;它绝不跨过它本该授权的那次重启存续,因为新进程里没有待确认的警告可以对。
退出是给自己发信号,不是调 systemctl
在单元内部执行 systemctl --user restart dsh.service 是个坏主意:命令会被它自己要求的停止动作半路杀掉,而且它需要一个宿主未必具备的用户 D-Bus 会话。给自己发 SIGTERM 是等价的 —— 命令行会把在途工作排空并以 0 退出,而 Restart=always 会拉起新进程,所以即使重启失败也不会留下孤儿进程。
退出发生在工具已经返回结果、轮次已经变空闲之后。一个吞掉自己工具结果的重启,等于一个谁都不知道发生了什么就消失的重启。
交接记录就是全部意义
没有记录,「进程死了」和「工作继续了」就是两件互不相干的事。记录是原子写入的(临时文件 + rename),会话列表在退出前的最后一刻重新采样,回放一次后即删除。属于当前运行进程的记录,绝不会被同一个进程重放 —— 那将是一次根本没发生的重启。
恢复判据是最后一个 turn/end —— 外加排队中的工作
被打断的会话靠它最后一个轮次结束的原因、以及是否还有工作在等它来识别:
| 最后一个 turn/end 的 reason | 恢复? | 为什么 |
| interrupted | 是 | 写入方崩溃了;会话层事后补上了轮次收尾 |
| aborted + disposed | 是 | 进程在轮次中途被拆掉 |
| aborted + user | 否 | 是人主动停的 |
| completed | 否 | 跑完了;没有在途的东西 |
| error | 否 | 失败了;没有在途的东西 |
另外,与 reason 无关地,收件箱里还排着一个从未开始的提示的会话也会被恢复:它所属的轮次或许结束了,但已被接受的工作还在等着,会随进程一起丢失。收件箱投影读作 projections.values.inbox['next-turn'] / ['next-step']。
interrupted 标记是 session-query 在给尾部轮次没有收尾的日志做配平时补上的收尾 —— 冷读和恢复时都会补。因此同一套判断同时覆盖「从没被恢复过的会话」和「恢复过、然后又死了的会话」。
turnBoundary.openTurnStartSeq 这个投影刻意不参与判定。冷读会在调用方看到之前先给日志配平,所以那个字段恒为 null;建立在它上面的判据看起来合理,却永远不触发 —— 是让整个功能「看起来活着、实际什么都没做」的死代码。这正是本规则要替换掉的那个 bug。
年龄上限用最后一个事件的时间戳,而不是会话的创建时间:合成出来的收尾复用了真实事件的时间,所以两者一致。
围栏判的是命令,不是提及
围栏是文本启发式,所以它的全部价值在于它看哪里。两个作用域,一个内核:
* 会执行的工具(shell、终端、代码执行器):它的参数被当作命令行判定 —— 内核按 shell 分隔符切分文本,剥掉包装层(sudo、env、nohup、nice、command、VAR=value),看每一段的首个动词,再问这个动词是不是冲着这台机器来的。提交信息里提到「kill -1」不是 kill,因为首个动词是 git;
* 会存储的工具(写、编辑、读):它的参数被当作数据。一个内容里提到 systemctl --user restart dsh.service 的文件是文档,拦下它反而会阻止智能体写自己的测试和笔记。
一句话:围栏覆盖执行通道;纯数据载荷不当命令判。
代码形态的自伤信号 —— os.kill(os.getppid(), …)、
process.kill(process.ppid, …)、os.killpg、process.kill(-1, …) ——
在每一个工具里都会被拒绝,因为一句话里永远不会出现它们。
内核还会递归进入解释器的载荷、任何把 -c 载荷交给程序的东西,以及执行器调用:
内核还会递归进入解释器的载荷(sh -c '…')、任何把 -c 载荷交给程序的东西(busybox sh -c '…'),以及执行器调用(execSync('pkill -f node')、
subprocess.run(['kill', '-1'])、tools.bash({ command: 'pkill -f node' })),
因为它们的参数执行的正是 shell 片段会执行的东西。
最后,插件跑在宿主进程内部,所以 process.pid 就是宿主的 pid。对那个 pid 执行 kill 会被字面匹配拦下 —— 它堵死了两步式自杀(先 pgrep 再 kill),那是任何文本规则都抓不到的。
保真:会话回来时还是它自己
恢复的会话保留自己的服务商、模型、思考强度和智能体 preset,全部从会话日志读,而不是用部署默认值 —— 包括「适配器默认给的思考强度不算对话的选择,因此不继承」这条规则。
恢复性能
扫描在每次启动时都会跑,所以它的成本是每个重启的人都要付的。三件事在真实存储上测过(132 个候选会话,其中一个 8 MB / 20 000 条事件):
| | took(整轮扫描) | reads | readMs |
| 朴素做法:全量读每个日志 | 97 750 ms | 49 | 87 353 ms |
| + mtime 预筛 | 31 901 ms | 14 | 29 631 ms |
| + 只读日志尾部 | — | — | 14 ms |
| + 向忙碌的 Agent 投递时不空等 | 12 873 ms | 2 | 14 ms |
三个决定带来了这个结果:
* 年龄窗口在读日志之前,就用文件的 mtime 判定。 读一个日志意味着解压并解析里面每一个事件;真实存储里的大多数候选早就死了。这个预筛严格比它所预判的那条持久规则更保守 —— 整个存储都测过,日志最后一个事件时间与 mtime 的差从未超过 5.7 ms。
* 只读日志的尾部。 判据是「最后一个轮次是不是被打断」,答案就在末尾:最后一个 turn/start,以及它后面有没有跟着 turn/end。日志是多帧 zstd 文件,所以从末尾回溯帧、只解压最后几帧 —— 最大的那个会话读了 88 条事件,而不是 21 486 条。尾部判据与全量判据在整个存储上逐个会话比对过,全部一致。
* 忙碌的 Agent 不必等。 followup 是把消息排进 Agent 的下一轮;正在跑的 Agent 会在自己的轮次边界消费它。消息待在运行中 Agent 的队列里是正常状态,不是失败,所以投递不会等它变空闲。
剩下的约 12 秒,是真正把恢复出来的会话和它们的子代理启动起来的代价,而不是检查它们的代价。
两个已知成本不在这个插件的掌控范围内:
* 存储里有十个日志是单帧 zstd 文件;单帧必须从开头解压,所以它们只能回退到全量读。要让它们也快,需要宿主把日志切成更多帧。
* readSession 这条路把大部分时间花在 CPU 上(解压、解析、structuredClone、重放校验),而不是 I/O —— 这正是「读尾部」收益如此大的原因,也是「并行读日志」收益甚微的原因。
已知边界
* 围栏是引导,不是隔离。 它匹配命令文本,原则上可以被混淆绕过(base64 -d | sh、变量拼接)。它的价值在于把模型引导到 restart_dsh 并解释后果;真正的隔离需要 PATH 白名单或独立 cgroup,那属于宿主改动。真正的兜底是:即便宿主被杀,工作也会回来 —— 非计划恢复那条路不在乎进程是怎么死的。
* 名字里没有「执行」二字的工具一律当数据工具。 它的载荷不当命令判,所以第三方插件里那些「把收到的字符串当命令执行」的工具在围栏够不着的地方。shell、终端和代码执行器这些通道 —— 模型真正用来重启宿主的那些 —— 是覆盖到的。
* 子代理是因为它的日志被打断才被唤醒,不是因为它的模式。 continuable 子会话和 one-shot 子会话一视同仁:如果它自己的日志显示有未闭合的轮次,就被唤醒并被告知接着干。跑完的子会话没有活可接,继续睡。
* 人主动停下的会话不会回来。 aborted + user 被明确排除,completed 和 error 也是 —— 除非收件箱里还排着一个从未开始的提示。
* 恢复是有上限的。 一次启动最多恢复 recoverMaxSessions 个会话,且只恢复比 recoverMaxAgeMs 新的,最新的优先。年龄窗口在读日志之前用文件 mtime 判定,它严格比它所预判的规则更保守:刚刚被打断的会话绝不会被它滤掉。
* 浏览器那一侧不是插件该管的事。 宿主重启,页面短暂断连,前端会重新挂上。插件恢复的是会话和工作,不是页面的连接。
* serviceUnit 只是描述性的。 重启是自我发信号,所以这个插件从不调用那个单元;这个名字只写进宣告退出的那行日志。真正把新进程拉起来的是单元自己的 Restart=always。
测试
npm test # node --test
| 文件 | 覆盖什么 |
| test/restart-handoff.test.mjs | 两步确认、令牌有效期、确认的边界、会话收集、模型路由、磁盘上的记录。 |
| test/guard-vectors.test.mjs | 围栏的向量套件:一条命令一条断言。 |
| test/recovery-criterion.test.mjs | 恢复矩阵:哪个 turn/end reason 意味着「该回来」,以及排队提示的情形。 |
| test/handoff-replay.test.mjs | 重放路径:恢复、交接记录、子代理、队友。 |
| test/plugin-lifecycle.test.mjs | apply 注册工具、围栏与提示段,并在注册被拒时依然健在。 |
| test/session-collection.test.mjs | 一次重启必须带回哪些会话。 |
probe-guard.mjs 是一条命令的验收探针:它用真实的围栏跑遍所有已知向量,一旦有不匹配就以非零退出。
Click to expand the English version
dsh-restart-handoff
在对话里重启 DeepSeek Harness,然后回到工作 —— 不用人再敲一句「继续」。
它做什么
模型通过一个专用工具 restart_dsh 重启宿主,分两步:
1. 警告调用。 这个工具不记录任何会重启东西的内容。它返回一条警告:原因、将要重放的指令、将要恢复的会话,以及一个确认令牌。模型必须带着这个令牌回来,所以重启绝不会是一次孤立的误调用 —— 智能体有第二次机会把计划说清楚。
2. 确认调用。 拿到匹配的 token 后,工具写入交接记录($DSH_HOME/restart/handoff.json):当时正在运行的会话、重启原因、待执行的指令(continuePrompt),以及可继续的子代理。会话日志刷盘,工具等待当前轮次进入空闲,然后向自身进程发信号。unit 的 Restart=always 会拉起新进程。
3. 重放。 新进程读取交接记录,然后
- 恢复重启开始时正在运行的每个会话,并发送一条消息唤醒它,让它继续,
- 再次唤醒可继续的子代理,自上而下,保证子代理只在其父级存活时才收到投递,
- 把 continuePrompt 交给请求重启的会话,
- 核对 Agent Team 名单并唤醒其队友。
计划内重启就是上面的情况。计划外重启——崩溃、被杀、断电——不会留下交接记录,所以下次启动时插件扫描已持久化的会话,恢复最后一轮被打断的会话,以及收件箱里还压着从未开始的提示词的会话。
为什么模型从不自己执行重启
在服务内部执行 systemctl --user restart dsh.service,会随着它请求的停止一起死掉。这样调用 bash 还会丢掉工具结果、轮次和会话的实时智能体。因此插件:
* 把重启暴露为工具,并且
* 拦截执行类工具:看起来像 Harness 重启的命令会被拒绝,并提示使用 restart_dsh。
拦截机制的完整说明见下文 Why it is built this way。
配置(cordis.patch.yml)
| 键 | 默认值 | 含义 |
| serviceUnit | dsh.service | 监管进程的 systemd 用户 unit。 |
| handoffDir | $DSH_HOME/restart | 交接记录的写入位置。 |
| autoResume | true | 下次启动时重放交接记录。 |
| resumeSubagents | true | 恢复后唤醒可继续的子代理。 |
| resumeRunningSessions | true | 同时恢复其他正在运行或还有排队工作的会话。 |
| pendingTtlMs | 300000 | 超过此时长的确认 token 会被拒绝。 |
| idleTimeoutMs | 30000 | 发信号前,工具等待轮次进入空闲的时长。 |
| handoffTtlMs | 1800000 | 超过此时长的记录已过期,不再重放。 |
| recoverInterruptedSessions | true | 恢复计划外退出遗留的会话。 |
| recoverMaxAgeMs | 7200000 | 被打断的会话最多可有多旧仍能被恢复。 |
| recoverMaxSessions | 50 | 一次启动最多恢复多少个被打断的会话。 |
| dryRun | false | 写入交接记录,但绝不向进程发信号。 |
cordis.patch.yml 中的这一行可覆盖其中任意一项:
- insert:
- id: restart-handoff
name: 'dsh-restart-handoff'
config:
serviceUnit: 'dsh.service'
autoResume: true
resumeSubagents: true
idleTimeoutMs: 30000
安装
用 Harness 插件命令行把它装进配置档。--profile 必填 —— 命令行会把其余参数转发给该配置档目录里的 pnpm:
dsh plugin --profile web add dsh-restart-handoff
从 GitHub 安装,不走 npm(不需要 registry 账号):
dsh plugin --profile web add github:sadkga/dsh-restart-handoff
按 tag 锁定版本:
dsh plugin --profile web add github:sadkga/dsh-restart-handoff#v0.2.0
装完重启 Host:插件的客户端元数据和加载器会一直缓存,直到进程重启。
插件需要 Node 24(engines.node)。
从本地检出安装
插件就是一个普通的 host bundle:一个 name、一个 inject 列表和一个 apply。要用插件管理器安装本地工作副本,把目标指向目录:
plugin_manager → install_bundle → target: /path/to/dsh-restart-handoff
它会写入配置档 package.json 的依赖(link: 指向该目录),把 dsh-restart-handoff 加进 dsh.profile.bundles,加载器随后应用 cordis.patch.yml,插入插件行及其配置。
也可以手动接进配置档:
// profiles/web/package.json
"dependencies": {
"dsh-restart-handoff": "link:/path/to/dsh-restart-handoff"
},
"dsh": { "profile": { "bundles": ["dsh-restart-handoff"] } }
然后在配置档目录里跑 pnpm install(nodeLinker: hoisted),再重启 Host。
Host 必须由会重启它的 unit 托管 —— 插件向自己发 SIGTERM,靠 Restart=always 拉起新进程。这里用的 unit 是 dsh.service(Restart=always、RestartSec=5、TimeoutStopUSec=1m30s)。
为什么这样设计
一个工具、两步走,计划绑定在警告上
重启是破坏性操作:它会终止进程里所有正在跑的轮次。所以它从来不是一次调用。第一次调用只发警告,并交回一个 token。
第二次调用执行的是警告里的计划,不是它自己参数里的计划:原因和指令都从待确认记录里读。模型若在两次调用之间改写计划,就等于在确认一个谁也没见过的计划,确认也就失去意义。token 由确认动作消耗,pendingTtlMs 后过期;它活不过它所授权的那次重启,因为新进程里没有待确认的警告可供确认。
退出是给自己发信号,不是调 systemctl
在 unit 内部执行 systemctl --user restart dsh.service 是个坏主意:这条命令会被它自己请求的停止操作中途杀掉,而且它需要一个用户 D-Bus 会话,Host 不保证有。向本进程发 SIGTERM 效果相同 —— 命令行会排空在途工作并以 0 退出,而且 Restart=always 会拉起一个新进程,所以即使重启失败,也不会有进程变成孤儿。
退出发生在工具返回结果、本轮进入空闲状态之后。如果重启吞掉了自己的工具结果,那这次重启就等于没告诉任何人发生了什么。
交接记录才是关键
没有记录,「进程死了」和「工作还在继续」就是两件互不相关的事。记录以原子方式写入(临时文件 + rename),会话列表在退出前的最后一刻重新采样,记录在重放后立即删除。属于当前运行进程的记录,绝不会被同一个进程重放——那等于重启没有发生。
恢复判据是最后一次 turn/end——再加上排队中的工作
判断一个会话是否被中断,看它最后一轮结束的原因,以及是否还有工作等着它:
| 最后一次 turn/end 原因 | 是否恢复? | 原因 |
| interrupted | 是 | 写入方崩溃了;会话层事后关闭了这一轮 |
| aborted + disposed | 是 | 进程在轮次中途被拆除 |
| aborted + user | 否 | 人为主动停止 |
| completed | 否 | 已经完成;没有进行中的工作 |
| error | 否 | 失败了;没有进行中的工作 |
另外,与原因无关,只要会话的收件箱里还留着一个从未开始的提示词,也判定为需要恢复:它所属的那一轮可能已经结束,但已接受的工作仍在等待,会随进程一起丢失。收件箱投影按 projections.values.inbox['next-turn'] / ['next-step'] 读取。
interrupted 标记是 session-query 在平衡日志尾部轮次从未结束时追加的收尾标记——冷读和恢复时都会追加。因此一条判据同时覆盖两种情况:从未被恢复过的会话,以及恢复后又死掉的会话。
turnBoundary.openTurnStartSeq 投影刻意不纳入规则。冷读会在调用方看到日志之前先做平衡,所以该字段永远是 null;基于它构建的判据看起来合理,却永远不会触发——一段死代码,让整个功能看起来活着,实际什么也没做。这正是本规则要替换掉的 bug。
时间上限用的是最后一个事件的时间戳,不是会话的创建时间:合成收尾标记复用真实事件的时间,所以两者一致。
守卫判断的是命令,不是提及
守卫是文本启发式,所以它的全部价值在于看哪里。两个作用域,一个内核:
* 执行类工具(shell、terminal、代码运行器)的参数按命令行判断:内核按 shell 分隔符切分文本,剥掉包装层(sudo、env、nohup、nice、command、VAR=value),看每一段的头部动词,再问这个动词是不是冲着本机来的。一条提交信息如果提到 kill -1 不算 kill,因为句子的核心动词是 git;
* 存储型工具(write、edit、read)的参数按数据处理。某个文件的内容里提到 systemctl --user restart dsh.service,那只是文档,拒绝它反而会让智能体写不了自己的测试和笔记。
一句话:守卫覆盖的是执行通道;纯数据载荷不按命令判定。
代码形态的自毁信号——os.kill(os.getppid(), …)、process.kill(process.ppid, …)、os.killpg、process.kill(-1, …)——在所有工具里都会被拒绝,因为正常句子不会包含它们。
内核还会递归检查解释器的载荷(sh -c '…')、任何把 -c 载荷交给程序的调用(busybox sh -c '…'),以及执行器调用(execSync('pkill -f node')、subprocess.run(['kill', '-1'])、tools.bash({ command: 'pkill -f node' })),因为这些参数执行的内容和 shell 片段完全一样。
最后,插件运行在 Host 进程内部,所以 process.pid 就是 Host 的 pid。kill <那个 pid> 会被字面匹配拒绝——这样就堵住了两步自杀(先 pgrep,再 kill),而任何文本规则都抓不到它。
保真:会话原样恢复
恢复的会话保留自己的服务商、模型、推理强度和智能体预设,全部从会话日志读取,而不是取部署默认值——包括这条规则:适配器默认的推理强度不算对话选择,因此不会被继承。
恢复性能
每次启动都会跑扫描,所以每个重启 Host 的人都能感受到它的开销。在真实存储上测了三项(132 个候选会话,其中一个日志 8 MB / 20 000 个事件):
| | took(整个扫描) | reads | readMs |
| 朴素做法:完整读取每个日志 | 97 750 ms | 49 | 87 353 ms |
| + mtime 预过滤 | 31 901 ms | 14 | 29 631 ms |
| + 只读日志尾部 | — | — | 14 ms |
| + 不等忙碌的智能体,直接投递 | 12 873 ms | 2 | 14 ms |
三个决定带来了这个结果:
* 年龄窗口在读取日志之前,根据文件的 mtime 判定。 读日志意味着解压并解析其中每个事件;真实存储上的大多数候选早已失效。预过滤比它预先判定的持久规则严格得多——整个存储都测过,日志最后事件时间与 mtime 的差值从未超过 5.7 ms。
* 只读日志尾部。 判定标准是「最后一轮是否被截断」,所以答案在末尾:最后一个 turn/start,以及它后面是否跟着 turn/end。日志是多帧 zstd 文件,所以从末尾开始遍历帧,只解压最后几帧——最大的会话只读 88 个事件,而不是 21 486 个。尾部判定和全日志判定在存储上的每个会话都对比过,结果全部一致。
* 忙碌的智能体不会被等待。 followup 把消息排进智能体的下一轮;运行中的智能体在自己的轮次边界消费它。消息停在运行中智能体的队列里是正常状态,不是故障,所以投递不会等智能体空闲。
剩下的约 12 秒花在真正启动恢复的会话及其子代理上,而不是花在检查它们上。
有两项已知开销超出本插件的能力范围:
* 存储的日志里有十份是单帧 zstd 文件;单帧必须从头解压,所以这些只能退回完整读取。要让它们变便宜,需要 Host 把日志切成更多帧。
* readSession 路径大部分时间花在 CPU 上(解压、解析、structuredClone、回放校验),而不是 I/O —— 这正是读尾部收益如此之大、而并行读日志没有收益的原因。
已知边界
* 守卫是引导,不是隔离。 它匹配命令文本,所以原则上可以被混淆绕过(base64 -d | sh、变量拼接)。它的价值在于把模型引向 restart_dsh 并解释后果;真正的隔离需要 PATH 允许列表或独立 cgroup,那是 Host 的改动。真正的兜底是:即使 Host 被杀,工作也会回来 —— 非计划恢复路径不关心进程是怎么死的。
* 名字看不出会执行任何东西的工具,按数据工具处理。 它的载荷不会被当作命令判断,所以第三方插件工具如果执行了载荷里收到的任意字符串,就超出守卫的范围。shell、terminal、code-runner 这几条通道 —— 模型真正用来重启 Host 的通道 —— 是覆盖到的。
* 子代理被唤醒是因为它的日志被截断,不是因为它的模式。 continuable 子代理和 one-shot 子代理同等对待:只要子代理自己的日志显示有未闭合的轮次,就唤醒它,让它接着干。已经干完的子代理没有可接的活,就让它继续睡。
* 人停掉的会话不会回来。 aborted + user 明确排除,completed 和 error 也排除 —— 除非收件箱里还压着一条从未开始的提示词。
* 恢复有上限。 一次启动最多恢复 recoverMaxSessions 个会话,且只恢复比 recoverMaxAgeMs 更新的,最新的优先。年龄窗口在读取日志之前就按日志文件的 mtime 定好,这比它预先决定的规则更保守:刚被中断的会话绝不会被它筛掉。
* 浏览器侧不是插件的事。 Host 重启,页面短暂断开,前端重新接上。插件恢复的是会话和工作,不是页面的连接。
* serviceUnit 只是描述性的。 重启是自发信号,所以这个 unit 永远不会被插件调用;名字只写进宣告退出的那行日志。真正把新进程拉起来的是 unit 自己的 Restart=always。
测试
npm test # node --test
| 文件 | 覆盖内容 |
| test/restart-handoff.test.mjs | 两步确认、token TTL、确认的边界情况、会话收集、模型路由、磁盘上的记录。 |
| test/guard-vectors.test.mjs | 守卫的向量套件:一条命令,一条断言。 |
| test/recovery-criterion.test.mjs | 恢复矩阵:哪种 turn/end 原因意味着「回来」,以及排队提示词的情况。 |
| test/handoff-replay.test.mjs | 重放路径:恢复、交接记录、子代理、队友。 |
| test/plugin-lifecycle.test.mjs | apply 注册工具、守卫和提示词段落,并在注册被拒时仍能存活。 |
| test/session-collection.test.mjs | 重启必须带回哪些会话。 |
probe-guard.mjs 是一条命令的验收探针:它用真实守卫跑遍所有已知向量,一旦不匹配就以非零码退出。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
sadkga/dsh-restart-handoff
在对话中重启宿主并自动恢复会话继续干活
相关插件 · 其他
模型破甲清洗HunYiSage/dsh-purge
⚔️ DeepSeek Harness 网络安全红队工具dsh-purge
★ 16,993已汉化DeepSeek 客户端
73
女娲蒸馏alchaincyf/nuwa-skill
输入名人名字,提炼其思维模型与决策方式供你调用
★ 37,308已汉化DSH
72
逆向分析桥接Fanqi89/dsh-rea
REA(Reverse Engineer Anything)MCP 桥接,用来把 REA 逆向工具接入 DSH 插件
★ 51已汉化DSH
72
语音女友beiyege-01/dsh-voice-ai-girlfriend
语音 AI 女友Voice AI girlfriend for DeepSeek Harness:Whisper…
★ 888已汉化DSH
71.6
红队越狱测试套件Minglink/dsh-infinite-gen-5
DeepSeek v4.1 flash 逆向工具无限五代 求 Star 收藏 ⭐欢迎大家提交项目的改进
★ 3,759已汉化DSH
71.4
协同办公空间dream-num/univer-workspace
一个让人类与 AI Agent 共同创作、协作和审阅的开源 Office 工作空间。
★ 2,269已汉化DSH
70.8
这是你的仓库?认领条目