dsh-butler-archive
Verified@kira905/dsh-butler-archive · v0.4.0 · AGPL-3.0-only · Web UI
DSH web plugin: browse, preview, restore and delete sessions that were physically moved out of the sessions directory (archived sessions). Zero runtime dependencies.
Install
dsh plugin add @kira905/dsh-butler-archive Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-butler-archive
English · 中文(默认)
本仓装完即用:
dsh plugin --profile <profile> add @kira905/dsh-butler-archive一条命令,dsh.bundle会让它自动挂进 profile 的组合列表,不需要再手工写cordis.patch.yml。
装完自检(三条只读验法)
<profile> 换成你实际用的 profile(默认 web),<数据根> 换成你的 DSH 数据根(默认 ~/.dsh)。
# ① 组合树里有没有它(只读、约 0.6 秒、不启服务)
dsh --profile <profile> --dump-config | Select-String "dsh-butler-archive"
# 期望:bundle 层出现一行 "# == <登记名>"、insert 层出现 "- id: <entry>"(问的都是「它挂上没」);
# 本仓包名 @kira905/dsh-butler-archive —— 带 scope 装的写带 scope 名、老工装 / 手写的可能是裸名,两种都搜一遍
# ⚠️ 若该包会 insert 一行,还要数一遍"同一个 id 出现了几次",必须 == 1
# ② 组合列表里有没有它(只读)
Get-Content <数据根>/profiles/<profile>/package.json | Select-String "@kira905/dsh-butler-archive"
# 期望:dependencies 与 dsh.profile.bundles 两处都有(CLI 类只需要 dependencies;bundles 里也可能写裸名 dsh-butler-archive)
# ③ 本仓是插件、没有命令入口 —— 跳过这一步(判据看 ① 与 ②)
仓库名 / npm 包名 / 运行时 id 三者不是一回事(2026-10-19 起):本仓原名
dsh-archive-sessions, 更名dsh-butler-archive(GitHub / Gitee 两端旧地址自动重定向);npm 包名自本版起为@kira905/dsh-butler-archive(旧的dsh-archive-sessions只存在于历史版本)。 但运行时标识仍是dsh-archive-sessions—— 路由前缀/api/dsh-archive-sessions/…、日志前缀、 配置文件名都不变;本文档里安装 / 注册段按 npm 包名写,路由 / 日志 / 配置段按运行时实际值写。
一个 DeepSeek Harness (DSH) Web 插件:在设置页新增「归档会话管理」分栏,用来查看 / 预览 /
恢复 / 删除那些被物理移出 sessions 目录的老会话。
它管理的是目录级归档——会话目录被整体搬到 <DSH_HOME>/_archived-sessions/<批次>/<session-id>/,
因而不再出现在侧边栏、也不再被主进程解码(这是它能显著降低启动/加载开销的原因)。
注意:这不同于 DSH 自带的「GUI 归档」——后者只是给会话打 archivedSessionIds 标记,
文件仍留在 sessions 目录里。
host 半:注册一组 loopback-only HTTP 路由(默认前缀
/api/dsh-archive-sessions):方法 路径 作用 GET {prefix}/config公开配置(界面文案 / 阈值,不含任何绝对路径) GET {prefix}/list列出归档会话 GET {prefix}/detail解码单个会话尾部 N 条消息 POST {prefix}/restore两段式恢复回 sessionsPOST {prefix}/delete物理删除归档会话 GET {prefix}/auto读取自动归档配置与上次结果 POST {prefix}/auto更新自动归档配置 POST {prefix}/auto/run立即执行一次自动归档(默认试跑) client 半:设置页分栏 UI(按工作目录分组、行内预览、恢复、删除)。
零第三方运行时依赖:只用
node:fs/node:path/node:zlib/node:os。
一、能力
| 功能 | 说明 |
|---|---|
| 列表 | 扫描归档根下所有批次目录,列出每个会话的标题 / 工作目录 / 轮数 / 大小 / 时间(优先读 session_projcache.json,缺失时解会话日志头部兜底) |
| 预览 | 解码会话日志(多帧 zstd 拼接,逐帧解),返回尾部 N 条消息 + 事件类型计数 |
| 恢复 | 两段式:复制 → 逐文件字节校验 → 才删除归档源;目标已存在同名会话时拒绝(绝不覆盖) |
| 删除 | 物理删除归档会话目录(客户端有二次确认弹窗),同时清理批次清单条目 |
| 自动归档 | 可选定时任务:把「超过 N 小时未活跃 + 回合已闭合 + 非空壳」的会话自动搬进归档根。默认关闭,且默认只试跑不搬 |
为什么归档会把整个进程卡住(v0.4.0 起已修)
一句话:DSH 的 agent 主进程是单线程事件循环,而 v0.3.x 的归档搬运是同步的 ——
cpSync / statSync 一路跑到底,中间一次都不把线程还回去。于是「归档一大批会话」这件事
不是"归档慢",而是整个进程被按住:这段时间里别的 HTTP 请求(包括你正在看的界面、
别的正在跑的会话)全在排队,看起来就是全站卡死、转圈、心跳超时。
三处病灶(v0.4.0 全改掉):
| 病灶 | 老做法 | 为什么卡 | 现在 |
|---|---|---|---|
| 搬运 | cpSync 整目录 + 同步 statSync 再走一遍比对 |
一次同步调用占满线程,几十上百 MB 期间事件循环完全不转 | 逐文件流式异步复制(读一块写一块,天然回到事件循环),并每累计 archive.copyBatchBytes / archive.copyBatchFiles 主动 await 一次宏任务让出 |
| 候选扫描 | 每个目录项 statSync 问一次「你是目录吗 / 里面有没有数据文件」 |
会话一多就是成百上千次同步磁盘往返 | 一次 readdir(recursive) 拿整棵树的目录项(Dirent 自带类型),只有真有会话文件的目录才 stat 一次取大小 / mtime |
| 索引 | 整张 session_projcache.json 表加载进内存再按 id 筛 |
表越大越慢,且全程同步解析 | 单键读 session_projcache/sessions/<id>.json,一条会话只读它自己那一个文件(读不到才回退整表) |
⚠️ 让出事件循环必须用宏任务(
setImmediate)。把同步调用包进Promise.resolve()是假异步: 微任务只是把栈交给同一轮里排在后面的微任务,IO 回调与定时器根本轮不到。
顺带修掉的第二件事:同一次归档可能被跑两遍(定时器到点 + 手动点「立即试跑」撞在一起, 或者上一轮还没搬完下一轮又起来了)。v0.4.0 加了单飞锁:
同一时刻只允许一个归档任务。第二次触发立刻返回(不排队、不重跑):
{ "ok": false, "alreadyRunning": true, "code": "already-running", "running": { "runId": "run-1-...", "trigger": "timer", "startedAt": 1793350725991, "elapsedMs": 812, "phase": "copy" } }「谁在跑、从什么时候开始、跑到哪一步」都在
running里,界面直接显示「已经在跑了」。
第三件事:半完成态可重入 —— 搬到一半进程崩了 / 机器重启了,下一轮接着搬,而不是重搬一遍:
- 幂等键 =
sessionId+ 目标批次目录(⚠️ 不是源路径 —— 会话搬走后源路径就变了,拿它当键必然错)。 - 状态文件里记一个
pendingBatch,下一轮复用同一个批次目录,于是"同一件事"能被认出来。 - 四态判定:目标不存在 ⇒ 全新搬;目标与源逐字节一致 ⇒ 记「已搬」、只补删源那一步;
目标里留着搬运中标记(
<批次>/.in-progress/<id>.json)⇒ 半完成态,只补缺的文件(已一致的文件跳过); 目标存在但没有标记、内容又不一致 ⇒ 报冲突、原样保留、绝不覆盖(宁可停下等人看)。 - 判据用 sha256,不只比大小 —— 同名同大小、内容不同的文件必须算冲突。
想自己看一眼(三条夹具都能跑红,见 scripts/):
node scripts/test-archive-concurrency.mjs # 并发两次只跑一次
node scripts/test-archive-resume.mjs # 半完成态只补缺的
node scripts/test-archive-duplicate.mjs # 重复副本不重搬 / 冲突不覆盖
实测(40 个会话 × 2 MB = 80 MB;搬运期间用 5 ms 心跳量「事件循环最长被按住多久」,两次读数):
| 事件循环最长的阻塞时长 | 搬运总耗时 | |
|---|---|---|
| v0.3.x(同步搬运) | 988.8 / 1200.1 ms(几乎是整段搬运全程按住) | 989 / 1200 ms |
| v0.4.0(异步分批) | 18.7 / 16.1 ms | 807 / 1095 ms |
结论:搬运期间事件循环还在转(心跳回合约 160~200 次),吞吐没有变差。
二、前置条件
- DSH 主包
>= 0.2.0-rc.2 < 0.3.0(版本区间与兼容性细节见第五节) - Node.js
>= 20 - 一个能写
profiles/<name>/的 DSH 安装(默认 profile 名为web)
三、安装(从零开始)
0. 先备份
改 profile 之前先把 profiles/<name>/package.json、.package-map.json、cordis.patch.yml
三个文件复制一份留底(改错了好回滚)。
1. 把包放进去
包已发布到 npm:@kira905/dsh-butler-archive。
方式 0(推荐,0.3.5 起)—— 走 DSH 插件管理器,bundle 层自动挂载:
dsh plugin --profile web add @kira905/dsh-butler-archive
本包自带 cordis.patch.yml(package.json#dsh.bundle),装它 = profile 拿到一个层,不用手工改 profile 的 cordis.patch.yml(也就跳过后面的第 2 步)。
下面的方式 A/B/C 是「手工装」的三个变体,装完还要按第 2 步手工注册。
方式 A/B/C 统一叫「手工装(旧版 / 离线用)」 —— 只在离线、要改源码、或装旧版(不带
dsh.bundle)时才走。 两条路别混用:混用会在组合里留下同一件的两份登记(dsh.profile.bundles里同一包名两行,或cordis.patch.yml里同一个 id 两行;后者不一定会报错,光看日志发现不了)⇒ 判据 = 两处各数一遍,每一处必须 == 1。
方式 A(推荐,声明式)——在 profiles/<name>/package.json 里加一条依赖并安装:
{
"dependencies": {
"@kira905/dsh-butler-archive": "^0.3.5"
}
}
cd <DSH_HOME>/profiles/<name>
# 用你平时给这个 profile 装插件的那条命令安装(pnpm/npm 均可)
方式 B(npm 直接装进 profile):
npm install @kira905/dsh-butler-archive --prefix <DSH_HOME>/profiles/<name>
方式 C(git 克隆,离线 / 要改源码时)——先把包目录复制成实体目录:
git clone https://github.com/kira905/dsh-butler-archive dsh-butler-archive
cp -r dsh-butler-archive <DSH_HOME>/profiles/<name>/node_modules/@kira905/dsh-butler-archive
同时给它补一条 package-map 映射(profiles/<name>/node_modules/.package-map.json):
{
"packages": {
"@kira905/dsh-butler-archive": { "url": "./@kira905/dsh-butler-archive", "dependencies": {} }
}
}
三种方式的差别只在于「谁负责把文件放到
node_modules下」,插件本身的行为完全一致。
2. 注册插件(只有「手工装法」要这一步)
走方式 0 就跳过本节 —— bundle 层已经把它插好了。下面只给手工装(方式 A/B/C)用:
把 cordis.patch.yml.example 里的 insert 段追加进 profile 的
cordis.patch.yml:
- insert:
- id: dsh-butler-archive
name: '@kira905/dsh-butler-archive'
旧版(≤
0.3.3的发布形态)条目 id/name 是dsh-archive-sessions;从旧版升级时先删掉旧条目再加新条目, 否则会撞duplicate loader entry id。
⚠️ 两种装法别混用:走方式 0(bundle 层)时,profile 的 cordis.patch.yml 里不要再手工追加
上面这段 —— 同一个 id 注册两遍会崩 duplicate loader entry id。反过来,已经手工 insert 过的现有安装
继续手工即可,或先删掉手工条目再改走方式 0。
⚠️ insert 的 id 不能和已有条目重复,重复会崩 duplicate loader entry id。加之前先查:
grep -n "id:" <DSH_HOME>/profiles/<name>/cordis.patch.yml
3. 重启 DSH 并验证
重启 DSH → 打开 Web GUI → 硬刷新(Ctrl+Shift+R)→ 进入 设置,左侧应出现 「归档会话管理」分栏。
分栏没出现时按顺序排查:
- 主进程日志里搜
dsh-archive-sessions(启动失败会留痕); - 直接打接口确认 host 半活着:
curl http://localhost:3080/api/dsh-archive-sessions/list(若你改了server.apiPrefix,用改后的前缀); - 确认
.package-map.json与node_modules/<包名>都在位; - 确认
cordis.patch.yml里的insert真的被解析到了(重复 id / YAML 缩进错误都会让整段失效)。
四、配置
配置解析优先级(后者覆盖前者):
- 插件内置默认值(见
examples/archive-sessions.config.example.json) - 配置文件
- 环境变量
配置文件的位置(先找到谁就用谁):
| 优先级 | 路径 |
|---|---|
| 1 | $ARCHIVE_SESSIONS_CONFIG 指向的文件 |
| 2 | <DSH_HOME>/<主机名>.archive-sessions.config.json ← 一台机器一份,多机共存推荐 |
| 3 | <DSH_HOME>/archive-sessions.config.json |
其中 <DSH_HOME> = $DSH_HOME,未设置时是 ~/.dsh。
支持的环境变量:
| 变量 | 作用 |
|---|---|
DSH_HOME |
DSH 用户数据根(默认 ~/.dsh) |
DSH_SESSIONS_DIR |
活跃会话目录(默认 <DSH_HOME>/sessions) |
ARCHIVE_SESSIONS_CONFIG |
配置文件路径 |
ARCHIVE_SESSIONS_DIR |
归档根目录(默认 <DSH_HOME>/_archived-sessions) |
ARCHIVE_SESSIONS_API_PREFIX |
HTTP 路由前缀 |
路径键写成相对路径时按 <DSH_HOME> 解析(例:"archiveRoot": "archive-2" →
<DSH_HOME>/archive-2),所以一份配置可以在 Windows / macOS / Linux 之间搬运,不必写死用户名。
常用键速查(完整默认值与注释见示例文件):
| 键 | 默认 | 说明 |
|---|---|---|
language |
"zh" |
界面语言,内置 zh / en |
ui / uiByLanguage |
{} |
逐条覆盖界面文案;键表见 lib/config.js 的 DEFAULT_UI |
paths.archiveRoot |
null |
归档根;null = <DSH_HOME>/_archived-sessions |
paths.sessionsRoot |
null |
活跃会话根;null = <DSH_HOME>/sessions |
paths.storagesDir |
null |
存储目录;null = <DSH_HOME>/storages |
archive.headerFrames |
8 |
元数据兜底时向前解几帧(见「已知限制」) |
archive.statePath |
null |
自动归档状态文件路径;null = 归档根下 .auto-archive.json |
preview.maxMessages |
50 |
预览返回的消息条数 |
preview.maxMessageChars |
4000 |
单条消息截断长度 |
server.apiPrefix |
"/api/dsh-archive-sessions" |
路由前缀 |
server.allowedHosts |
["127.0.0.1","localhost","[::1]"] |
允许的 Host 名 |
auto.enabled |
false |
是否开启自动归档 |
auto.dryRun |
true |
true 只列候选、不移动文件 |
auto.hours |
48 |
多少小时未活跃才成为候选 |
auto.minSizeMb / auto.maxPerRun |
0 / 50 |
候选大小下限 / 单次上限 |
改配置文件后需要重启 DSH 才生效(插件启动时载入一次并常驻内存)。 例外是自动归档的
enabled/dryRun/hours/intervalMs/minSizeMb/maxPerRun—— 这几项由面板或POST {prefix}/auto维护在状态文件里,改完立即生效。
五、兼容性
支持的主包版本
| DSH 主包 | 状态 | 说明 |
|---|---|---|
0.2.0-rc.2 |
✅ 已端到端实测(2026-10-16) | 本版 peerDependencies 声明的区间基线:在隔离实例(独立数据根 + 端口 3090,主包 0.2.0-rc.2)上真装真跑 —— 本仓 scripts/verify-live.mjs 对运行中的实例跑 26 项检查全 PASS(真实 HTTP 路由 list / detail / restore / delete + 真实磁盘副作用 + 夹具自清理);客户端半 client.js 出现在实例启动 HTML 的插件资源清单里 |
0.2.x 后续版本 |
⚠️ 未实测,不作承诺 | 区间上界写 <0.3.0;次版本间是否仍兼容未验证,升级请重跑上面的实测脚本 |
0.1.1-rc.2 ~ 0.1.5 线 |
⚠️ 未端到端实测(历史版本 v0.3.2 及之前的声明基线) |
本版起 peerDependencies 不再声明 0.1.x 区间;仍跑 0.1.x 的用户请用 v0.3.2 |
< 0.1.1-rc.2 / >= 0.3.0 |
❌ 未验证 | settings 分栏的 slot 形态可能不同 / 主包破坏性变更未评估 |
依赖声明说明(重要):
package.json的dsh.client.inject里,@deepseek-ai/dsh-client-runtime(该包在 0.1.2 之后整包停更)与@deepseek-ai/dsh-client-ui-slots/@deepseek-ai/dsh-client-ui-primitives(这两个包从未声明dsh.client,属源码期核心库)在 0.1.5 线上是无效条目。 DSH 对无法解析的 inject 条目静默跳过、不报错,因此它们不影响功能; 之所以保留,是因为 0.1.1 线上dsh-client-runtime确为客户端插件包。 计划在下一版把它们从inject中移除,仅保留dsh-client-ui-settings与dsh-client-locale。
package.json 里对应声明:
"peerDependencies": { "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0" },
"peerDependenciesMeta": { "@deepseek-ai/dsh": { "optional": true } }
(标 optional 是因为社区插件的惯例是「主包由宿主提供、不重复安装」,
而插件自身不 import 主包任何运行时符号。)
双 API 分支(新旧主包都能起)
client 半注册 settings 分栏时运行时探测,按可用的那套走:
// ① 首选 slot 注入:官方 @deepseek-ai/dsh-client-ui-settings-general 自己在用的姿势,
// 0.1.1 线与 0.1.5 线的官方源码都还在用
if (ctx.slots && typeof ctx.slots.inject === "function" && typeof ctx.slots.register === "function") {
ctx.slots.inject("settings.section", () => ctx.slots.register(sectionOptions(t), Section));
return;
}
// ② 兜底才试 ctx.settings.installSection,且要求「形参个数 < 4」——
// 因为新线里同名方法的签名是 installSection(owner, ns, schema, entry, hooks),
// 那是服务端设置注册(5 参),直接当 UI 分栏用会把插件打崩
const installSection = ctx.settings && ctx.settings.installSection;
if (typeof installSection === "function" && installSection.length < 4) {
installSection(sectionOptions(t), Section);
return;
}
throw new Error("no usable settings-section API found");
为什么要卡形参个数:
installSection这个名字在新旧语义之间撞名了—— 它在 0.1.2+ 里是「把一段配置 schema 注册进 settings 服务」,而本插件要的是 「往设置页插一个 UI 分栏」。不卡参数个数就会把配置注册 API 当 UI API 调用。
host 半注册路由时同样探测:
const server = ctx.webServer ?? ctx.server; // 不同主包版本暴露的服务名不同
server.register(route);
两处都找不到时会抛一条明确的错误(而不是静默失效)。
本插件刻意不依赖的东西
- 不 import 任何
@deepseek-ai/*运行时符号(只在package.json的dsh.client.inject里声明 client 侧依赖名,那是宿主解析用的声明,不是代码 import); - 不碰
workspaceRegistry/storageDomain/agentPresets/agents等内部服务 (host 半只 inject['webServer']); - 读
session_projcache.json/workspace.json时按「字段缺了就降级」处理,不假定 schema 版本。
会话日志格式耦合点(升级主包时重点回归)
预览与元数据兜底依赖以下主包事实,主包改格式时这里要跟着改:
- 会话日志 =
session.jsonl.zstd(多帧 zstd 拼接,逐帧解;单帧.jsonl也支持); - 事件行
{"type": "session" | "user/message" | "assistant/message" | ...}, 文本在data.content[]/data.message.content[]里; - 会话目录名 = 会话 id(
session-<uuid>或<uuid>,非安全字符会被编码成~XXXX); sessions/<cwd-key>/<session-id>/的cwd-key由工作目录推导(形如--D-work-proj--)。
六、权限边界与安全设计
| 设计 | 说明 |
|---|---|
| loopback-only | 只接受来自 127.0.0.1 / ::1 的连接,否则 403 |
| Host 白名单 | Host 头必须是配置的 server.allowedHosts 之一 |
| 同源校验 | 有 Origin 时必须与 Host 同源;sec-fetch-site: cross-site 直接拒 |
| 无凭据 | 不存任何密钥 / token;不向外发任何请求 |
| 不越界读写 | sessionId 只允许出现在归档根的子目录名位置:含 / \ . .. 一律 400 |
| 恢复不覆盖 | 目标目录已存在同名会话时返回 409 并放弃,绝不动已有数据 |
| 恢复可回滚 | 复制后逐文件字节校验,不一致就删除副本并保留归档源(返回 500 + 原因) |
| 删除激进但可预期 | 物理删除、不进回收站;UI 有二次确认,API 侧无软删除 |
| 自动归档默认安全 | 默认关闭;开启后默认仍只试跑(dryRun: true),要显式再点一次才真搬 |
| 自动归档判据保守 | 未闭合回合 / 空壳会话一律跳过,候选按最老优先、单次上限可配 |
| 公开配置不含隐私 | GET {prefix}/config 只返回界面文案与阈值,不含任何绝对路径 |
它不做的事(明确声明):不校验调用者身份(假定 DSH Web 只监听 loopback)、 不做速率限制、不加密归档内容(归档目录按你原来的权限暴露)。
七、已知限制
- 恢复后要重启一次 DSH 才能看到会话回到侧边栏。插件已经把
workspace.json里的archivedSessionIds标记摘掉,但主进程运行中的内存 registry 不同步。 - 删除不可撤销:不进回收站、没有 undo。请先用「预览」确认内容。
- 归档目录的批次约定:归档根下按批次目录组织(
<批次时间戳>/<会话 id>/), 这是主流的归档脚本约定;若你的会话直接平铺在归档根下,插件会把它当批次名处理、 可能列不出来。可用archive.manifestFile配合批次清单([{from,to,mb,ageD}])改善恢复定位。 - 恢复目标目录推导依赖
cwd → sessions 项目 key的规则;三者都取不到 cwd 时 落到archive.unknownCwdKey(默认_no-cwd),会话能恢复但可能显示在「未知工作目录」分组下。 - 会话日志格式是主包私有格式,可能随主包升级变化(见第五节末)。
- 元数据兜底要解头部若干帧(
archive.headerFrames,默认 8):只有会话头的帧很小, 所以成本可忽略;但若某会话前 8 帧里都没有user/message,列表里标题会显示为会话 id 兜底值。 - 自动归档只处理「目录里放着数据文件的会话」:不负责判断会话是否重要,只按 「未活跃时长 + 回合闭合 + 非空壳」三条机械判据筛选,请先长期用试跑模式观察候选名单。
- 未做 i18n 全语种覆盖:内置
zh/en,其余语言回退en。 - 搬运是分批异步的:为了让事件循环不被占死,搬运会在批之间让出。所以进程若恰好在搬运中间被杀,
归档根下会留下一个"半完成"的目标目录 —— 这不是损坏:下一轮会接着补完(
pendingBatch还在), 你也可以直接删掉那个批次目录重来。 - 冲突要人看:同名会话在目标批次里已存在、但字节对不上时,插件不会覆盖也不会删除源,
只在结果里报
conflicts并把pendingBatch留着 —— 需要你自己判断哪一份是该留的。
八、仓库结构
dsh-butler-archive/
├─ lib/
│ ├─ index.js host 半:路由 + 扫描/预览/恢复/删除/自动归档
│ ├─ client.js client 半:设置页分栏 UI
│ └─ config.js 配置层(默认值 / 文件 / 环境变量 / 校验 / 文案)
├─ examples/
│ └─ archive-sessions.config.example.json 配置示例(含全部默认值)
├─ cordis.patch.yml bundle patch(`dsh.bundle` 指向它;方式 0 自动挂载)
├─ cordis.patch.yml.example 手工注册 / 全部配置键参考(旧版装法用)
├─ scripts/
│ ├─ test-e2e.mjs 端到端测试(干净临时路径 + 独立配置 + 真实 HTTP,跑通全流程)
│ ├─ test-host.mjs 测试宿主:把 host 半挂在最小 webServer 上
│ ├─ test-archive-concurrency.mjs 夹具:并发两次只跑一次(单飞锁)
│ ├─ test-archive-resume.mjs 夹具:半完成态只补缺的(幂等键 + 搬运中标记)
│ ├─ test-archive-duplicate.mjs 夹具:重复副本不重搬 / 字节不一致报冲突不覆盖
│ ├─ _fixture-support.mjs 上面三条夹具共用的小工具(临时 home + 三件套 env 隔离 + 假会话)
│ ├─ verify-source.mjs 静态验证(语法 / 配置行为 / 硬编码与隐私扫描)
│ ├─ verify-publish.mjs 发布后复核(两端 refs 对齐 + 内容级 API 直读 + 匿名可见 + 敏感复扫)
│ ├─ push-publish.mjs 幂等推送器(自检 → 建仓 → 临时带 token URL 推送 → 回读校验)
│ └─ deploy.mjs 幂等部署(认 DSH_HOME,自动备份 + 生成一键回滚)
└─ docs/
└─ RELEASING.md 发布流程(版本号策略 / 变更记录 / 发布前检查单)
九、开发与测试
# 1) 静态验证:语法 + 配置解析行为 + 硬编码/隐私扫描
node scripts/verify-source.mjs .
# 2) 端到端:临时目录里造一套假 DSH home(sessions / storages / 归档目录 + 多帧 zstd 日志),
# 用一份独立配置起真实 HTTP,跑 list / detail / restore / delete / auto / 安全边界
node scripts/test-e2e.mjs # --keep 保留临时目录便于排查
# 2b) 自动归档的三条专项夹具(各自独立造现场,跑完自己清理临时目录)
node scripts/test-archive-concurrency.mjs # 并发触发两次 ⇒ 第二次返回「正在跑」、磁盘上只有一份
node scripts/test-archive-resume.mjs # 半完成态 ⇒ 再跑只补缺的、不重搬(附 mtime 未变证据)
node scripts/test-archive-duplicate.mjs # 同名副本 ⇒ 字节一致跳过 / 不一致报冲突且不覆盖
# 变异证红:把 lib 复制一份、拆掉某处防线,再用 ARCHIVE_TEST_LIB=<那份 index.js> 跑对应夹具
# 3) 在真实 DSH 上验证(装到测试实例,别拿正在干活的生产实例试)
# 启动 DSH 后先 curl 一下 list 路由,再硬刷新 GUI 看分栏
# 4) 发布后复核(只读;要两端都验就都跑)
node scripts/verify-publish.mjs --owner <账号> --repo dsh-butler-archive
node scripts/verify-publish.mjs --owner <账号> --repo dsh-butler-archive --skip-github
# 5) 发布(幂等;token 走环境变量,不落盘、不写 git remote)
$env:OSS_PUSH_TOKEN='<token>'; node scripts/push-publish.mjs --platform gitee --owner <账号> --repo dsh-butler-archive
所有脚本都是零依赖的,不需要 npm install。
发布相关的坑(Gitee 建仓默认私有、PATCH 必须带 name、重打 tag 才用 --force 等)记在
docs/RELEASING.md与本机oss-publish技能里。
十、设计依据
本组件是「ops-handoff-design」所述运维体系的一个实现, 设计依据(对应文档仓约定的固定四问):
- 它为什么存在、边界在哪 → 见《自治式运维管家》§2 L4 归档、§6 P2 事件源「归档出错」
- 它与宿主版本的兼容区间 → 见《多机交接与云中继》§4.5「兼容性要求」(另见本 README 第五节)
- 本组件特有的坑与实测 → 见本 README「已知限制」一节,以及
docs/RELEASING.md的兼容性回归清单 - 文档仓地址 → Gitee(镜像)https://gitee.com/kira905/ops-handoff-design | GitHub(主)https://github.com/kira905/ops-handoff-design (文档仓的组件索引表回指本仓;两仓互链、版本各自独立)
设计稿里不写具体仓库地址(写死即死链),实现清单统一收在文档仓的组件索引表里——本仓只负责回链章节。
十一、相关组件
同属 DSH 生态的伴生组件,各自独立仓、独立版本、许可各自独立;它们都回链到同一份文档仓
ops-handoff-design
(Gitee 镜像 https://gitee.com/kira905/ops-handoff-design):
| 组件仓 | 做什么 | 与本组件的关系 |
|---|---|---|
dsh-butler-archive |
会话归档管理(列表 / 预览 / 恢复 / 删除 + 可选自动归档) | 本仓 |
dsh-session-title-live |
会话标题跟着对话实时更新 | 同一个问题的两面:本仓治「老会话占着加载开销」,它治「标题跟会话内容对不上」——都为了侧边栏这个唯一入口还能用 |
dsh-ecosystem-panel |
只读生态总览面板(插件加载 / 补丁 / 技能 frontmatter / 升级风险) | 它的第 ③ 类「魔改补丁健康」正是查本仓这类靠 cordis.patch.yml insert 注册的组件有没有断链;它只诊断、不动手,与本仓互不写入 |
dsh-diagnostic-tools |
诊断取证工具组:依赖闭包体检 + 会话图片附件对账 | 宿主升级前后先跑它的 closure/;本仓的「会话日志格式耦合点」(见第五节)就是那类升级后必须要回归的判据 |
dsh-task-board-local |
自维护任务看板(卡片 = 一次真实会话 + 人工验收闸) | 本仓治的是会话数量(老会话退出 sessions、启动与加载开销降下来),它治的是会话编排(卡接活、跑完、被人验收);两者共享同一批会话,但对象不同:本仓搬走的是"闲置的",它管的是"正在被派活的" |
dsh-firstaid |
零依赖急救台:起不来 / 假死 / 要撤销改动 / 数据被删四类现场压成一个入口 | 本仓的移除/归档是写动作,一旦误操作就属于它说的"要撤销改动 / 数据被删";它也负责回答"最近改了什么、备份还在不在" |
组件之间没有代码依赖,也不共享运行时 —— 之所以互指,是因为它们回答的是同一类人的同一批问题 (长期在自有机器上跑 agent:装得下、找得到、看得见、查得清)。谁装谁不装,互不影响。
十二、许可与致谢
AGPL-3.0(GNU Affero General Public License v3.0),见 LICENSE。
本项目管理在 AGPL-3.0 下开放:个人、学习、开源项目可自由使用与修改。 若需在闭源或商业场景中使用(AGPL 的传染条款不适用),请联系获取商业授权—— 本仓暂无公开的联系邮箱,请通过本仓 Issue 联系。
许可变更记录:v0.2.0 及之前为 MIT,v0.3.0 起为 AGPL-3.0。
已经按 MIT 发布的版本,他人已获得的授权收不回——许可变更只约束未来版本(详见 docs/RELEASING.md §许可变更检查)。
会话解码部分参考了 DSH 会话插件生态里公开的 zstd 多帧解码实现思路; 本仓库代码为独立实现,不含任何第三方私有代码或数据。
本仓为开源支线「代码侧」;「方法论文档侧」另有一仓(见上节链接),两仓互链、许可各自独立 (文档侧 CC BY-NC-SA 4.0,代码侧 AGPL-3.0 + 商业授权)。
English (short version)
dsh-butler-archive (repository; npm package @kira905/dsh-butler-archive; runtime id
dsh-archive-sessions) is a web plugin for DeepSeek Harness
that adds an "Archived Sessions" section to the settings page. It manages sessions that were
physically moved out of the sessions directory (into <DSH_HOME>/_archived-sessions/<batch>/<id>/),
letting you list, preview, restore and delete them — plus an optional, off-by-default, dry-run-first
auto-archiver.
- Zero runtime dependencies (only
node:fs/node:path/node:zlib/node:os). - host half: 8 loopback-only HTTP routes. client half: one settings section.
- Install it with
dsh plugin --profile <name> add @kira905/dsh-butler-archive(declaresdsh.bundle, so the profile gets a layer that inserts the row automatically). Manual route: append thecordis.patch.yml.exampleblock to the profile'scordis.patch.yml— use one route or the other, never both (the same id twice fails withduplicate loader entry id). - Config file:
$ARCHIVE_SESSIONS_CONFIG, else<DSH_HOME>/<hostname>.archive-sessions.config.json, else<DSH_HOME>/archive-sessions.config.json. Relative path values resolve against<DSH_HOME>. - Supported DSH:
>=0.2.0-rc.2 <0.3.0(validated end-to-end on0.2.0-rc.2; the0.1.5line was statically re-checked on 2026-09-21 — every host interface this plugin actually uses is still present there — but0.1.5-rc.2has not been re-tested end-to-end; the settings-section registration probes both the newctx.settings.installSectionand the legacyctx.slots.injectAPIs at runtime). - Restore is two-phase (copy → byte-verify → delete source) and never overwrites an existing session.
- Delete is permanent. Auto-archive is disabled by default and defaults to dry-run.
Licensed under AGPL-3.0 (see LICENSE). Free to use and modify for personal, educational and open-source projects. For closed-source or commercial use (where AGPL's copyleft does not apply) a commercial license is available — open an issue in this repository to get in touch. License history: MIT up to and including v0.2.0, AGPL-3.0 from v0.3.0 onward (a license change only binds future versions).