Archived chats for DeepSeek Harness
设置里的一页「已归档的聊天」:按工作区分组浏览已归档会话,搜索、按工作区筛选、恢复,或真正删除。
DSH 自带归档(隐藏),但没有删除会话的能力;这个插件补上浏览与管理那半边,并且是唯一能真正删掉会话日志的实现。
安装
dsh plugin --profile <your-profile> add github:uugvkbvkdjbsdbvwxxl/dsh-archived-chats
也可以在插件市场(dsh-market)的「发现」里搜 Archived chats 一键安装。
装完刷新窗口,打开 设置 → 已归档的聊天。


为什么需要 Host 半
官方没有会话删除能力,两处文档都写明了:
@deepseek-ai/dsh-session-persistence-jsonl:Nothing deletes session files — 日志会一直累积在根目录下,seam 没有删除 API。@deepseek-ai/dsh-workspace:归档是唯一的「隐藏」手段,session deletion 是 absent capability;删除工作区不会删除会话。
所以恢复用官方 API(workspaceController.unarchiveSession),删除由本插件在 Host 侧完成。
页面
settings.section 的一位,id = archived-chats,order = 12(模型之后、内置插件之前)。
- 数据全部来自设置页 slot 的标准 props:
useWorkspaces(工作区行、sessionIds归属、archivedSessionIds)与useSessions(标题、更新时间)。 - 分组:有归属的会话落在其工作区下;找不到归属的进「无项目」。组内按更新时间倒序。
- 按工作区筛选:搜索框右侧的自绘 listbox(
aria-haspopup="listbox"+role="option",方向键/Home/End/Escape 可用)。选项只列当前有归档会话的工作区并带条数,选中后只显示该工作区的内容,标题下的统计同步变成「<工作区> · N 个归档聊天」;工作区被清空时自动回到「所有工作区」。 - 操作:恢复(走
ctx.workspaces.unarchiveSession)、删除(走下面的 HTTP 路由)、全部删除(删除当前可见范围内的会话)。 - 状态:冷启动时会话目录还没返回时显示骨架占位,而不是把每一行都说成「会话信息缺失」;错误与成功提示分别用
role="alert"/role="status"播报。 - 列表靠 workspace 流的
archived增量自动刷新,不做轮询。
交互与样式约定
- 颜色只用
--dsw-alias-*;所有可交互控件都有:focus-visible焦点环,图标按钮带aria-label。 - 不弹
window.confirm,也不用原生<select>:删除确认就地展开,筛选是自绘 listbox。 prefers-reduced-motion下关闭过渡与骨架动画。
Host 路由
| 方法 | 路径 | 作用 |
|---|---|---|
POST |
/api/dsh-archived-chats/delete |
body { sessionIds: string[] },返回 { deleted: [...], refused: [{ sessionId, reason, message }] } |
POST |
/api/dsh-archived-chats/prune |
维护清扫:把工作区账本里已经找不到日志的会话 id 摘掉(工作区成员、归档集、置顶集),返回 { stored, pruned } |
GET |
/api/dsh-archived-chats/health |
解析出的会话根目录、是否存在、项目目录与会话目录计数、Host 代码 revision |
请求围栏:composed connection 存在时用 ctx.connection.requestRejection()(Host/Origin 校验 + 浏览器签名凭据);没有 Connection 时退回显式回环围栏。两个路由都注册在 ctx.effect 里,插件卸载即摘除。
删除的三道自检
路径规则逐字取自已安装的 dsh-session-persistence-jsonl/lib/index.js
(encodeSegment / projectKey / sessionDir),不自行发明:
- 身份:
sessionPersistence.stat(id)必须存在,且header.id === id; - 路径:会话目录名必须等于
encodeSegment(header.id),父目录名必须等于projectKey(header.cwd)(无 cwd 时_no-cwd); - 包含:最终路径必须严格落在会话根目录内,目录里必须存在
session.*.jsonl[.zstd]。
任一条不满足就拒绝并报错,一个字节都不动。
「正在运行」怎么判断:直接问 Workspace registry 归档时用的同一个 seam ——
ctx.waterfall('workspace/session-activity', { sessionId }, next)。它由 Agent / jobs /
subagent / schedule 四个 provider 回答「这个会话还有没有在跑的东西」,返回数组
(元素形如 { kind: 'turn' | 'job' | 'subagent' | 'schedule' });非空即拒绝,并把 family
列表回给页面。「在内存里驻留」不算运行:会话只是被打开着、或刚被归档,都不该挡住删除
(1.0.0 用 agents.get() / sessions.get() 判断,正是它把刚归档的会话误报成「正在运行」;
1.0.1 修正)。文件仍被占用时(Windows 的 EBUSY/EPERM)单独回 session-open。
删除流程(1.0.2 起):
- 删除前先记下所属工作区实体(
Workspace.sessionIds会按 header 索引过滤,日志删掉后就再也找不到它了); unarchiveSession去掉归档标记(幂等、不探测存在性);- 删掉会话目录,再尽力清理
storages/session_projcache/sessions/<uuid>.json(纯派生缓存); WorkspaceEntity.detachSession(id)把 id 从工作区账本里摘掉 —— 它的mutate还会顺手 剪掉该工作区里所有不再通过candidateMatches的候选。
第 4 步是 1.0.1 漏掉的:只删文件不动账本,会话会以「幽灵行」的身份留在侧边栏(用户看到的
「删除后又回到工作区」),点开时 DSH 为这个已不存在的日志创建/恢复会话,一发送就
ENOENT ... open '<已删除目录>/session.v4.jsonl.zstd'。历史遗留的幽灵行用
POST /api/dsh-archived-chats/prune 清理(它按 sessionPersistence.list() 的现存 header 逐条比对)。
配置
apply(ctx, config) 读取可选 config.root;缺省为 $DSH_HOME/sessions,再缺省为 ~/.dsh/sessions。
已知边界
- 删除是硬删除,没有回收站;页面上的两次确认是唯一防线。
- 1.0.2 起删除会同时摘掉工作区账本,1.0.3 起还会 emit
api-session/removed,所以删除后不会再留行; 历史遗留的用/prune清。 - 早期版本留下的残留行只存在于当时那个页面里:Host 的
session.list不返回它 (用全新页面实测未分组为 0 行),刷新窗口即可清除,不必重启进程。 - Host 内存里会留下一个「已无日志的活会话」(
/health的liveOnly能看见)。它没有任何 API 可 主动销毁(ctx.sessions只暴露 create/prepare/enter/announce/flush/get/list/fork),进程重启后消失, 不占磁盘、也不出现在列表里。 - 删除后工作区
sessionIds里的悬挂 id 不会被立即改写:workspace 读取时会把无 header 的 id 过滤掉,并在下一次变更时剪除。 - FTS 索引是派生库,下次搜索时自行对账已删除的来源。
验证记录(2026-10-27,本机 desktop profile)
装好后在同一 Host 上跑过:
| 验证 | 结果 |
|---|---|
| Host 行与设置分区注册 | Config 目录出现 include:archived-chats;settings.section 出现 archived-chats(order 12,active) |
| 路由围栏 | 无 cookie 请求 /api/dsh-archived-chats/health → 401(Connection 认证生效,不是 404) |
| 健康检查 | root=C:\Users\lenovo\.dsh\sessions,6 个项目目录 / 71 个会话目录,与磁盘一致 |
| 拒绝路径 | 不存在的 id → session-missing;有运行中工作 → session-active(带 family 明细) |
| 真实删除 | 自建的合成会话目录 → 返回 deleted,目录从磁盘消失(现有 6 条归档会话全程未动) |
| 页面渲染 | 6 条归档、按 2 个工作区分组(测试 5 / default-workspace 1),标题与时间正确 |
| 单条删除确认 | 点垃圾桶 → 行内变为「确认删除 / 取消」并显示不可恢复提示;取消恢复原状 |
| 全部删除确认 | 点「全部删除」→「删除全部 6 个归档会话?/ 确认删除 6 个 / 取消」;取消恢复原状 |
| 搜索 | 输入 number → 只剩 1 行;清除后恢复 6 行 |
| 恢复 | 「The Number 11」→ 侧边栏 archivedSessionIds 少一条、页面剩 5 条 |
| 状态还原 | 验证后把该会话从侧边栏「归档会话」移回归档,workspace.json 的归档集与动手前逐条一致,会话目录数仍为 71 |
工作区筛选与样式改版(第二轮)
| 验证 | 结果 |
|---|---|
| 筛选器选项 | 「所有工作区 6 / 测试 5 / default-workspace 1」,带路径副标题与条数 |
| 选中工作区 | 只剩「测试」一组 5 行,标题统计变为「测试 · 5 个归档聊天」,触发按钮显示「测试 5 个」 |
| 回到全部 | 两个工作区、6 行全部回来 |
| 冷启动占位 | 会话目录未就绪时改为骨架占位(该分支按第一轮实测到的「整页会话信息缺失」状态补的,短时间没能再复现出截图) |
1.0.1:修「已归档却提示会话正在运行」
线上反馈:刚归档的会话点删除,报「会话正在运行」。归档本身能成功,说明 Host 自己的
archive admission 已经判定它没有在跑的工作 —— 拒绝来自我旧版的 agents.get() /
sessions.get() 判断,它把「在内存里驻留」当成了「正在运行」。
改法是换成 Host 归档时用的同一个 seam(workspace/session-activity waterfall)。
离线 harness(archived-chats-work/host-harness.mjs,用假 ctx + 临时根目录真删文件)跑过 4 个用例:
| 用例 | 结果 |
|---|---|
| 驻留但空闲 | 200,deleted: 1,目录被删,归档标记先被摘掉 |
| 有运行中工作(turn + job) | 200,refused: [{ reason: 'session-active', message: 'turn, job' }],目录未被删 |
| 未知 id | 200,refused: [{ reason: 'session-missing' }] |
| 再次空闲 | 200,deleted: 1,目录被删 |
1.0.3:修「删掉后侧边栏还留一行(切对话也不消失)」
现象:删除后侧边栏仍有一行(落在「未分组」),切到别的对话不消失,只有重启进程才消失。
排查(这一步很关键,别再靠猜):
/health新增liveOnly:["session-8315b0b8-…"]—— Host 内存里确实有一个已无日志的活会话;- 但用全新页面连同一个 Host 打开侧边栏,
未分组下面是 0 行 —— 说明 Host 的session.list不返回它。
结论:那一行是调用方页面自己的客户端目录残留(它把这个 Session 留在自己的目录里),
切对话不会清,只有重新加载页面或收到 api-session/removed 才会丢。
修法:删除成功后由 Host 主动 ctx.emit('api-session/removed', sessionId) ——
这正是 Session Controller 在 session/disposed 时发出、并由应用转发给所有页面的事件。
/prune 里对每条被清扫的记录也发同一个事件。离线 harness 记录到
emitted: [["api-session/removed","session-test-0001"]]。
给用户的答案:这种残留行刷新窗口(Ctrl+R)就能清,不需要重启进程。
1.0.2:修「删掉后又回到工作区,一发送就 ENOENT」
线上反馈:删除后会话又出现在工作区里,点进去发消息报
ENOENT … open '…\session-66e05842-…\session.v4.jsonl.zstd'。
原因:1.0.1 只删了日志文件,没把 id 从工作区账本 sessionIds 里摘掉。行还在 → 点开时
DSH 为这个已不存在的日志创建/恢复会话 → 后端首次 append 往已删除的目录写文件 → ENOENT。
| 验证 | 结果 |
|---|---|
离线 harness 补了 list()/detachSession 假实现 |
deleted: 1 之后 detached: ["session-test-0001"],说明摘账本被调用 |
| 首次加载 1.0.2(改 patch 触发重组) | revision 3 → 4,约 2 秒 |
现场清扫 POST /prune |
stored: 70,摘掉两条幽灵记录:session-66e05842-…、session-219285fc-…(都在「测试」工作区) |
清扫后核对 workspace.json |
两个 id 已不在任何 sessionIds 里;归档集 6 条,成员总数 52 |
Host 半怎么热重载(2026-10-27 实测)
三件事分别验证过:
| 做法 | 结果 |
|---|---|
| 重启进程 | 生效(revision 2,删除成功:归档集 7 → 6、会话目录 71 → 70) |
| 在插件市场把插件关掉再打开 | 无效,页面仍回旧版文案 —— 那条开关走的是 dsh-market 自己的路径,不触发 profile 重组 |
改一次 $DSH_PROFILE_DIR/cordis.patch.yml(加一行注释) |
约 2 秒生效:revision 2 → 3,无需重启 |
原理:DSH 自带的 @deepseek-ai/dsh-hmr 已经在做这件事,而且做法比 URL cache-busting 更稳 ——
它从 Node 内部的 ModuleLoader.loadCache(以及 CJS 的 require.cache)里逐出已改动文件的模块,
保留模块身份,再让 Loader 重新 import。给 import() 拼 ?v= 只会制造同一包的第二份实例,
instanceof、Cordis 服务注册、单例都会跟着坏掉,所以不要那样做。
这个 profile 的 hmr 行是 root: [](只监听配置与清单,不监听源码),所以默认不会自动追文件改动;
要让它自己追,可以在 profile patch 里给 hmr 行配 root(示例):
- id: hmr
disabled: false
config:
root: ["."]
注意 ignored 默认排除 **/node_modules,而 linked 插件在 <profile>/node_modules/@<scope>/...,
所以更可靠的是把链接目标目录直接写进 root;不配 root 也可以,靠「profile 重组」这条已测通的路径即可。
局限(HMR README 明确列出):packed worker 加载器、Worker 线程与 TSX loader 的模块缓存换不掉;
Host 依赖图之外被静态导入的模块改动会保持 pending;package.json 单独改动不触发。这些仍需重启。
Client 半(client.js)不受这些限制,刷新窗口即可,这也是改版式/加筛选能立刻看到的原因。
/health 里的 revision 就是用来判断当前进程跑的是哪一版 Host 代码。
No comments yet. Be the first to write one.