Saltar al contenido principal
L

dsh-turn-cleanup

leonlee114514/dsh-turn-cleanup

Blocks the end of a turn while temporary files from that turn are still lying around: it registers a listener on the serial agent/turn-stopping hook and, if anything is left, steers the model with the file list and two ways out — delete them, or justify each one and move it somewhere deliberate. Candidates come from two narrow temp-file sources, plus a backup-retention channel that is on by default and flags excess .bak copies of files touched this turn. The first is the .agent-tmp/ convention directory under the session cwd: whatever is left in it is reported outright, because that directory exists only for throwaway files. The second is the paths this turn actually wrote, captured through the fs/write-intent hook; those are reported only when they sit under that cwd, fall outside .git, node_modules, .tmp-sessions and out, match a temp-file pattern, and the file is still present — which is what keeps junk already sitting in the workspace from being resurrected as a complaint about this turn. One steer per turn, subagent sessions skipped, nothing deleted by the plugin in the default steer mode (only the opt-in mode: prune deletes backups), and the check is readdir plus stat only.

Instalar

dsh plugin --profile web add github:leonlee114514/dsh-turn-cleanup

README

dsh-turn-cleanup

给 DeepSeek Harness 装一道硬闸门:每轮 turn 即将结束时检查 本轮 agent 留下的临时文件,非空就用 agent.steer(...) 把清单推回给模型,逼它在同一个 turn 里 再走一步——删掉,或逐条说明理由后挪走。

不是提醒,是拦路。模型不处理,turn 就结束不了。


为什么需要它

DSH 三层都缺自动清理:

层现状
框架层有 agent/turn-stopping 钩子(serial,await 在边界提交前),是天然闸门。但全仓只有 2 个消费者——dsh-workspace-changes 记变更、dsh-mnemon 写记忆,都不清理。
组件层只清自己的 mkdtemp scratch(dsh-workspace-changes、dsh-sandbox-local、dsh-session-persistence-jsonl、dsh-attachment-local),全是「自己拉屎自己擦」。
提示词层AGENTS.md 里那类「交付前把本轮临时件清掉」的条款只有自觉力,没有强制力。模型忙着收尾时就忘了。

结果就是工作区里慢慢堆起 probe-*.mjs、check*.sh、*.bak-*——每个都是某次探针的遗骸, 没人知道能不能删。

本插件把这条自觉条款变成代码闸门。


钩子语义(这是它能工作的原因)

agent/turn-stopping 是 serial 模式,await 在 turn 边界提交之前:

// dsh-agent-loop/lib/index.js:1009
if (turnEnds && this.inbox.nextStep.length === 0) {
  await this.dispatch.serial('agent/turn-stopping', { turn, signal });
  signal.throwIfAborted();
}
if (turnEnds && this.inbox.nextStep.length === 0) break;

agent.steer(input) 走 send(input, 'next-step', true)。listener 里 steer 之后 nextStep.length 变 1,循环继续,同一个 turn 多跑一步。这就是闸门的全部机制。


检出范围:三个通道,各自独立判定

通道 1 — 约定目录(主机制,零误伤)

临时件一律放 <cwd>/.agent-tmp/(tmpDirName 可配)。turn 结束时该目录存在且非空 → 拦。

好处:不进这个目录就不是临时件。目录一开始是空的,没有存量包袱。

通道 2 — 本 turn 写入追踪(覆盖不守约定的情况)

挂 fs/write-intent waterfall,按 session.id + turn 分桶记录写入路径。turn 结束时只检查 这些路径里文件名匹配临时件特征的(probe-* / tmp-* / scratch-* / check* / diag_* / bench* / *.bak-*,或位于 .agent-tmp/)。

绝不扫全工作区。 存量未跟踪文件不参与判定——判定严格等于:

本 turn 记录 ∩ 模式匹配 ∩ 文件仍存在

这条不是洁癖。一个用了几个月的工作区里躺着几十个 probe-*,扫全量的插件第一天就会被 关掉。

通道 3 — 备份保留(同一原件的 .bak 家族只留最近 N 份)

模型干活爱留备份当回滚点,任务一长就堆成山。本机 profile 目录实测躺着 35 个 *.bak-*,同一个 cordis.patch.yml 就有 28 份。这条按「同目录 + 同原件名」把备份聚成 家族,只留最近 keep 份(默认 3),其余列出来。

家族键只认 .bak 及其后的 - / . 分隔:x / x.bak / x.bak-<后缀> / x.bak.<后缀> 都算同一家族,x.bakfoo 不算。不假定时间戳格式 —— 实测命名五花八门 (.bak-0926、.bak-1793445543005、.bak-autodetect-20261102-080912、.bak-purge、 裸 .bak),任何「按日期正则解析」的做法都会漏掉一半。

只处理本轮碰过的家族:seeds 来自 fs/write-intent 记录 + .agent-tmp/ 扫描结果, 推不出原件名的直接跳过。每个目录只 readdir 一次,不扫全盘。

两种模式:

  • mode: steer(默认):只把超量的列进清单,由模型判断并执行删除。和通道 1/2 一样,插件不动手。
  • mode: prune:插件自己删。删的虽然是副本,但不可逆,所以必须显式选择。

prune 的四道闸:

  1. 原件必须仍在。原件没了,那几份备份可能是唯一副本 —— 整族跳过,一条都不删。
  2. lstat 是普通文件、不是符号链接。不跟链,避免删到链外的真文件。
  3. 不在保护段(.git / node_modules / .tmp-sessions / out),且在工作区之内。
  4. 每轮删除条数上限(maxPrunePerTurn,默认 50)。

刻意不做「备份比原件新就不删」:备份是某个历史状态的副本,原件比它新才是常态; 反过来原件比备份旧往往意味着模型反复备份却没改原件 —— 那正是要清的重复副本。而且 删的永远是最旧的那几份,最新 keep 份一律不动,价值损失有上界。

明确不做

  • 通道 1 / 2 永远不删任何文件 —— 只 steer,删不删、留不留由模型判断(交付物、用户文件、 .tmp-sessions、.git、node_modules、out/ 一律不碰)。只有通道 3 的 prune 模式会删, 且只删备份副本,四道闸见上。
  • 不做 git status 兜底 —— 大仓库慢,且存量未跟踪文件极多,容易误判。
  • 子会话整段跳过 —— origin === "subagent" 或 delegationDepth > 0 时直接 return,它们的 cwd 可能是别人的工作区。
  • listener 里不做慢操作 —— 只有 readdir / stat,不跑子进程、不扫大目录。 turn-stopping 是串行 await,慢了会拖住每一次 turn 结束。

防抖:每 turn 只拦一次

按 (sessionId, turn) 记 tripped Set。第二次进 listener 直接 return,不管还剩几个文件。

没有这一条,模型每多跑一步就被再拦一次,turn 永远结束不了。turn/end 与 session/disposed 清 key。


steer 文案

收尾前清场:这些是本轮你留下的临时文件——
- .agent-tmp/scratch-run.log
- probe-yyy.mjs
删掉它们;要留的逐条说明理由并挪到明确位置。交付物、用户文件、.tmp-sessions、.git 不要动。

超过 maxSteerPaths 时追加 - …还有 N 个。

临时件与超量备份同时存在时,文案分两段,中间空一行:

收尾前清场:这些是本轮你留下的临时文件——
- .agent-tmp/scratch-run.log
删掉它们;要留的逐条说明理由并挪到明确位置。交付物、用户文件、.tmp-sessions、.git 不要动。

备份超量:同一原件的 .bak 只保留最近 3 份,这些是多余的——
- cfg-demo.yml.bak-r1
- cfg-demo.yml.bak-r2
删掉它们;要留的先挪到明确位置并说明理由。不要动原件本身,也不要动最近 3 份。

分两段而不是混在一起,是因为两件事的约束不同:临时件没有「保留几份」的概念,备份有; 而备份段必须显式提醒「不要动原件、不要动最近 N 份」,否则模型容易把整族一起删掉。


安装

包带 dsh.bundle,所以两种装法都行。

A. 走市场 / bundle 安装(推荐)

plugin_manager install_bundle → "dsh-turn-cleanup"

或命令行:

dsh plugin --profile <name> add dsh-turn-cleanup

包根的 cordis.patch.yml 会被自动应用,闸门默认开启,配置用默认值。要改配置就在自己 profile 的 cordis.patch.yml 里按同一个 id 覆盖,不要另加一条 - insert(同 id 两条会被 cordis 拒绝注册)。

B. 手工 link(源码改动时用,改完 HMR 直接生效)

# 1. 克隆到本地
git clone https://github.com/Leonlee114514/dsh-turn-cleanup.git ~/.dsh/local-pkgs/dsh-turn-cleanup

# 2. 在 profile 的 package.json 的 dependencies 里加一行
#    "dsh-turn-cleanup": "link:../../local-pkgs/dsh-turn-cleanup"

# 3. 建符号链接(pnpm install 在有远端依赖时会卡住,手工建更可靠)
node -e "
const { symlinkSync } = require('node:fs');
symlinkSync(
  require('node:path').join(require('node:os').homedir(), '.dsh/local-pkgs/dsh-turn-cleanup'),
  require('node:path').join(require('node:os').homedir(), '.dsh/profiles/desktop/node_modules/dsh-turn-cleanup'),
  'junction'
)"
# 4. 只在手工 link 时需要:在 ~/.dsh/profiles/desktop/cordis.patch.yml 顶层加条目
#    (走 A 的 bundle 安装则跳过这步,包根的 cordis.patch.yml 已经带了同一条)
- insert:
    - id: turn-cleanup
      name: dsh-turn-cleanup
      inject: []
      config:
        enabled: true
        tmpDirName: .agent-tmp
        patterns:
          - "probe-*"
          - "tmp-*"
          - "scratch-*"
          - "check*"
          - "diag_*"
          - "bench*"
          - "*.bak-*"
        scanDepth: 4
        maxLogPaths: 10
        maxSteerPaths: 20
        logFile: ""     # 可选:落盘旁路,留空 = 只走 ctx.logger

不需要重启 DSH。 dsh-hmr 监听 cordis.patch.yml 与 package.json,改动即 reconcileProfile() 重组合,十几秒后新版本就生效。

验证加载成功:

cordis_inspect_query → host / Config / listConfigs, name: dsh-turn-cleanup

status: "absent" 就是正常的——它表示 fiber 已启动、未 disabled,但插件没导出 Config schema(本插件故意不导出,避免 schemastery 依赖)。若显示 inactive,说明 import 失败。


配置

键默认说明
enabledtruefalse → 完全不装闸门(连 listener 都不注册)
tmpDirName.agent-tmp约定目录名,相对 cwd
patterns见上文件名 glob,只支持 * / ?,大小写不敏感
scanDepth4.agent-tmp 递归深度,1..6
maxLogPaths10日志里最多列几条
maxSteerPaths20steer 文案里最多列几条
logFile""落盘旁路;相对 cwd 或绝对路径
steerModefollowup交付方式:followup 另开一轮;steer 同轮多跑一步(见下)
statstruefalse → 不记账、不装 /turn-cleanup/stats 端点
statsFile""账本路径;留空 = logFile 同目录的 turn-cleanup-stats.json
backupRetention.enabledtruefalse → 整个通道 3 关闭
backupRetention.keep3同一原件保留几份,1..20
backupRetention.modesteersteer 只列清单;prune 插件自己删
backupRetention.requireOriginalPresenttrue原件不在的家族整族跳过
backupRetention.maxPrunePerTurn50prune 模式每轮删除上限

交付方式:另开一轮,还是同轮多跑一步

agent/turn-stopping 有两种让模型继续的写法,代价不一样:

followup(默认)steer
清单排进next-turn,另开一轮next-step,同轮多跑一步
本轮正常结束,交付汇报照常显示汇报被这一步顶掉
闸门硬度模型仍可不理,但下一轮开头就撞见不处理这一轮结束不了
依从率复核点turn + 1 的 turn/end本轮的 turn/end

为什么默认 followup:chat UI 按 turn 折叠,latestAnswer(turn) 取的是 turn 内最后一个 step 的 assistant 文本(@deepseek-ai/dsh-client-ui-chat/lib/client.js:10718)。 steer 那一步成为最后一步,于是真正的交付汇报被折进「过程」区,屏幕上只剩清场汇报。 followup 把清单排到下一轮,本轮最后一步仍是汇报本身,折叠不会发生;kick() 的 while (await this.turn()) 会在队列有 next-turn 时自动接着跑,所以清场照样执行。

要更硬的闸门就把 steerMode 设成 steer,接受汇报被折叠的代价。


日志

ctx.logger.info 只写 Electron 主进程 stdout,进程一关就没了。要事后取证就配 logFile, 每次拦截追加一行:

2026-11-07T03:43:31.838Z followup session=<id> turn=2 cwd=<cwd> files=2 paths=[.agent-tmp/scratch-run.log, probe-yyy.mjs]

行首是实际交付方式(followup / steer),prune 模式自己删东西时另有一行 prune …。

写日志失败一律静默——落盘是取证旁路,不能因为路径不可写而拖住 turn 边界。


可视化面板

拦截了多少次、清掉了多少文件、模型配不配合——光看日志要自己数。插件带一个 Web 面板:

  • 设置页 → 收尾清场:四张卡(拦截次数 / 报出文件数 / 已清文件数 / 依从率), 下面是最近拦截记录与按会话的分布表;范围可切「全部会话 / 当前会话」。
  • 每个 turn 末尾一行徽章:这一轮拦了什么、清没清。没拦过就不显示。

依从率是怎么算的

不靠猜。闸门拦下时把清单记进账本(statsFile),下一轮 turn 结束再回头 stat 一遍那些路径:

  • 文件没了 → 算 cleaned
  • 文件还在 → 算 remaining
  • compliance = cleaned / (cleaned + remaining)

只复核「同 sid + 同 turn 且还没复核过」的那条记录。跨轮补算会让更早的 turn 在模型早已 清掉之后才算,依从率虚高。

prune 模式自己删掉的备份直接计入 cleaned(有日志为证,不用等下一轮)。

数据通道

Host 半注册一个只读 JSON 端点,客户端同源 fetch 相对路径:

GET /turn-cleanup/stats                       # 全局汇总
GET /turn-cleanup/stats?scope=session&sid=…   # 单会话汇总
GET /turn-cleanup/stats?sid=…&turn=…          # 单轮记录,null = 这轮没拦过

为什么不走 remote.* RPC:本插件没有 host 服务对象,注册一条 HTTP 路由比造一套 service + RPC 契约轻得多,也不用管 relay / 远程模式的 origin 探测。

stats: false 就完全不记账、不装端点,闸门本身照旧。


实测

在一台真实 DSH 桌面上跑过十一项验收,全部通过:

#项结果
1正例 probe-*.mjs 被拦✅ turn 3 拦到 probe-wi3-*.mjs
2.agent-tmp/ 被拦✅ turn 2 拦到 .agent-tmp/scratch-run.log
3.xlsx 交付物不拦✅ 日志 0 次出现,文件保留
4存量 probe-* 不拦✅ 3 个存量 ps1 全 0 次出现
5防抖只 steer 一次✅ 4 文件全不删,第二趟 tripped=true 直接 return
6subagent 跳过✅ subagent 写文件有 wi 行,gate 行数 = 0
7enabled: false 完全失效✅ 放 .agent-tmp/ 文件结束 turn,无任何 steer
8备份 steer 模式✅ turn 8 拦到 cfg-demo.yml.bak-r1 / -r2,backups=2 keep=3,r3–r5 未被列
9备份 prune 模式✅ 5 份备份 → 删 p1/p2、剩 p3/p4/p5,原件保留,deleted=2 keep=3
10统计端点可访问✅ GET /turn-cleanup/stats → 200,空账本返回 gates: 0
11client bundle 进 boot graph✅ 日志 client entry recovered url=plugins/??dsh-turn-cleanup/client.js&rev=…,该 URL 取到 15957B 可解析 bundle

单元测试(假 ctx 模拟 agent-loop 调用顺序,不依赖 DSH 运行时):

npm test        # 39 + 17 + 27 = 83 checks passed
npm run verify  # syntax check + test

三份测试各管一段:test/sim.mjs 驱动闸门与三个通道(35 项)、test/web.mjs 驱动 StatsStore 与 /turn-cleanup/stats 端点(15 项)、test/client.mjs 用假 window.__ModuleLoader__ 加载真实 client bundle、断言 slot 注册形状与 locale 字典(27 项)。


七个真机踩到的坑

1. 配置归一化把裸前导点削掉

原实现 replace(/^[./\\]+/, '') 会把 .agent-tmp 变成 agent-tmp,约定目录形同失效。 现在只剥 ./、.\ 前缀与首尾分隔符。

2. ctx.logger.info 不落盘

~/.dsh/logs/ 里只有 startup 日志,拦截记录走 stdout,事后无法 grep。所以有了 logFile 配置项。

3. 插件中途热加载会漏检

recorded 原本判 s.turn === turn。但插件若在本 turn 中途才 apply,没赶上 turn/start, s.turn 是 undefined,会被误判成非本 turn 而漏检。改为 s.turn === undefined || s.turn === turn。

4. 「备份比原件新就不删」这条守卫写反了,且本来就不该有

prune 初版带一条 plan.originalMtime < member.mtimeMs → skip。真机一跑,5 份备份 只删掉 1 份(deleted=1,实际剩 4 份)。原因是原件刚被写、mtime 最新,于是每一份 「比原件旧」的备份都被当成「不能删」。守卫的意图(原件内容还没落进备份)根本站不住: 备份是历史状态的副本,原件比它新才是常态。删掉后 deleted=2,剩 3 份 + 原件,符合预期。

5. effect(() => disposer) 会让注册立刻消失

webServer.register() 返回 disposer。写成「先 register 拿到 disposer,再 sub.effect(() => disposer)」时,exact 路由注册完就 404——effect 的返回值语义与 「把 disposer 当返回值」重叠,注册被当场拆掉。正确写法是把注册放进 effect 的 execute 内部、把清理闭包交出去:

sub.effect(() => {
  const off = sub.webServer.register({ kind: 'exact', path: '/turn-cleanup/stats', handler })
  return () => off()
}, 'dsh-turn-cleanup: stats route')

@deepseek-ai/dsh-client-modules 自己也是这种写法。

6. 后补 dsh.client 声明不会自动生效

client-modules 把「这个包有没有 client 半」的结论缓存在 pkgMeta 里(含缓存的 null), 而这份缓存只在进程启动时清一次;internal/plugin 只对新 fiber 重扫,HMR 重跑 apply 救不回来。先装插件、后补 dsh.client 的情况会永久记成「没有 client 半」。

插件在 apply 里自己做一次兜底:清掉本包自己那条 null 缓存、标 dirty、手动 flush() 一次。日志里会有一行 client entry recovered url=…。拿不到内部字段就安静放弃, 退化成「重启 DSH 才出现面板」——那本来也是正常安装路径。

7. steer 会把交付汇报折进「过程」

turn-stopping 里 agent.steer() 让同一轮多跑一步,那一步成了 turn 的最后一步。而 chat UI 的 latestAnswer(turn) 取的是 turn 内最后一个 step 的 assistant 文本 (@deepseek-ai/dsh-client-ui-chat/lib/client.js:10718),于是真正的交付汇报被判成 「过程」折叠起来,屏幕上只剩清场汇报。

改成交付走 agent.followup():清单排进 next-turn,本轮正常收尾、汇报仍是最后一步; kick() 的 while (await this.turn()) 会接着跑下一轮执行清场。默认就是这条路, steerMode: steer 保留硬闸门。


回滚

从 cordis.patch.yml 删掉 - id: turn-cleanup 整条即可,HMR 自动重载后闸门立即失效。 local-pkgs/dsh-turn-cleanup 留着不影响(不被 patch 引用就不会加载)。


License

MIT

Plugins relacionados