dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
让AI在对话里重启 DeepSeek Harness,然后自己回到工作里 —— 不需要人打字说「继续」。
在对话里重启 DeepSeek Harness,然后自己回到工作里 —— 不需要人打字说「继续」。
模型通过一个专用工具 restart_dsh,以两步的方式重启宿主:
$DSH_HOME/restart/handoff.json):重启时正在跑的会话、原因、待重放的指令(continuePrompt),以及可继续的子代理。会话日志落盘,工具等当前轮次变空闲,然后进程给自己发信号;由 Restart=always 的单元拉起新进程。continuePrompt 交给发起重启的那个会话;上面是计划内重启。非计划重启 —— 崩溃、被杀、断电 —— 不会留下交接记录,所以下次启动时插件会扫描持久化的会话,把「最后一轮被打断」的那些带回来,外加「收件箱里还排着没开始过的提示」的那些。
在服务内部执行 systemctl --user restart dsh.service,会死在自己要求的那个停止动作上。这样的 bash 调用还会丢掉工具结果、丢掉当前轮次、丢掉会话的活跃 Agent。因此插件:
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。`--profile` 是必需参数 —— CLI 会把其余参数转发给该 profile 目录里的 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这会写入 profile 的 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"] } }然后在 profile 目录里跑 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 是等价的 —— CLI 会把在途工作排空并以 0 退出,而 Restart=always 会拉起新进程,所以即使重启失败也不会留下孤儿进程。
退出发生在工具已经返回结果、轮次已经变空闲之后。一个吞掉自己工具结果的重启,等于一个谁都不知道发生了什么就消失的重启。
没有记录,「进程死了」和「工作继续了」就是两件互不相干的事。记录是原子写入的(临时文件 + rename),会话列表在退出前的最后一刻重新采样,回放一次后即删除。属于当前运行进程的记录,绝不会被同一个进程重放 —— 那将是一次根本没发生的重启。
被打断的会话靠它最后一个轮次结束的原因、以及是否还有工作在等它来识别:
最后一个 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。
年龄上限用最后一个事件的时间戳,而不是会话的创建时间:合成出来的收尾复用了真实事件的时间,所以两者一致。
围栏是文本启发式,所以它的全部价值在于它看哪里。两个作用域,一个内核:
sudo、env、nohup、nice、command、VAR=value),看每一段的首个动词,再问这个动词是不是冲着这台机器来的。提交信息里提到「kill -1」不是 kill,因为首个动词是 git;systemctl --user restart dsh.service 的文件是文档,拦下它反而会阻止 Agent 写自己的测试和笔记。一句话:围栏覆盖执行通道;纯数据载荷不当命令判。
The code-shaped self-harm signals — os.kill(os.getppid(), …), process.kill(process.ppid, …), os.killpg, process.kill(-1, …) — are denied in every tool, because a sentence never contains them.
内核还会递归进入解释器的载荷、任何把 -c 载荷交给程序的东西,以及执行器调用:
The kernel also recurses into the payload of an interpreter (sh -c '…'), of anything that hands a -c payload to a program (busybox sh -c '…') and of an executor call (execSync('pkill -f node'), subprocess.run(['kill', '-1']), tools.bash({ command: 'pkill -f node' })), because their argument runs exactly what a shell segment would.
最后,插件跑在宿主进程内部,所以它知道宿主从外面看是什么样子:它的 pid、它的父进程,以及它启动时的那条命令行。于是判据不再是"文本里有没有那个数字",而是"这条查找命令按它自己的匹配规则,会不会选中宿主":
写法 · 结果 kill -TERM · 拦住 kill $PPID · 拦住 P=$(pgrep -f "apps/cli/src/bin.ts web") && kill -TERM $P · 拦住 kill $(pgrep -f "apps/cli/src/bin.ts web") · 拦住 pkill -f node · 拦住 kill 0(杀整个进程组) · 拦住 P=$(pgrep -f "my-unrelated-service") && kill -TERM $P · 放行 kill -9 12345(某个具名进程) · 放行 kill -- -12345(宿主以外���进程组) · 放行 pgrep -f "apps/cli/src/bin.ts web"(只查看,不发信号) · 放行
这是 shell 自己的匹配规则,不是猜测:pkill -f 拿 pattern 去匹配每个进程的完整命令行,所以凡是 pattern 出现在宿主命令行里的查找,必然会选中宿主;反之,查找一个无关进程是日常工作,必须放行。要绕过它仍然不难(base64、脚本文件、把 pid 编码进变量),所以围栏的定位始终是引导和后果说明 —— 真正的兜底是恢复机制:无论宿主是被杀、被崩还是断电,正在跑的会话、子代理和队友都会回来。
恢复的会话保留自己的 provider、模型、思考强度和 agent 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
三个决定带来了这个结果:
turn/start,以及它后面有没有跟着 turn/end。日志是多帧 zstd 文件,所以从末尾回溯帧、只解压最后几帧 —— 最大的那个会话读了 88 条事件,而不是 21 486 条。尾部判据与全量判据在整个存储上逐个会话比对过,全部一致。followup 是把消息排进 Agent 的下一轮;正在跑的 Agent 会在自己的轮次边界消费它。消息待在运行中 Agent 的队列里是正常状态,不是失败,所以投递不会等它变空闲。剩下的约 12 秒,是真正把恢复出来的会话和它们的子代理启动起来的代价,而不是检查它们的代价。
两个已知成本不在这个插件的掌控范围内:
readSession 这条路把大部分时间花在 CPU 上(解压、解析、structuredClone、重放校验),而不是 I/O —— 这正是「读尾部」收益如此大的原因,也是「并行读日志」收益甚微的原因。base64 -d | sh、把 pid 编码进变量、藏进脚本文件)。它的价值在于把模型引导到 restart_dsh 并解释后果;真正的隔离需要 PATH 白名单或独立 cgroup,那属于宿主改动。真正的兜底是:即便宿主被杀,工作也会回来 —— 非计划恢复那条路不在乎进程是怎么死的。continuable 子会话和 one-shot 子会话一视同仁:如果它自己的日志显示有未闭合的轮次,就被唤醒并被告知接着干。跑完的子会话没有活可接,继续睡。aborted + user 被明确排除,completed 和 error 也是 —— 除非收件箱里还排着一个从未开始的提示。recoverMaxSessions 个会话,且只恢复比 recoverMaxAgeMs 新的,最新的优先。年龄窗口在读日志之前用文件 mtime 判定,它严格比它所预判的规则更保守:刚刚被打断的会话绝不会被它滤掉。Restart=always。ctx.inject(['schedule'], …) 这种),要到这个 agent 的 scope 在第一轮装配完才补齐。实测:一个被恢复的会话第一轮带 123 个工具,下一轮就是完整的 153 个 —— 差的那 30 个是一个客户端插件、一个 MCP server 和一组提醒工具。这不是本插件造成的(恢复路径与扫描路径都一样),也改不了:插件只能恢复会话,装配工具集是宿主的事。宿主会把它记成一条「工具已更新 · 移除 N 个」,下一轮就补上了。 本插件能做的是等全局注册表安静下来再恢复(resumeSettleMs),因为有些插件是在启动之后才注册自己的工具的;这消除了全局层面的竞争,但管不到 scope 层面那部分。
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