dsh-session-manager
DeepSeek Harness Web GUI 的会话管理插件:在 设置 → 会话管理 里列出全部会话(含已归档),支持筛选、归档/恢复,以及把会话的本地日志目录物理删除。
harness 自带的会话侧栏只能「归档」(单向隐藏),没有取消归档,也没有删除。本插件补齐:
- 全量列表 —— harness 的会话列表本身包含已归档会话(侧栏是用归档集过滤掉的),所以这里能一并列出,并给出「已归档」标记。
- 筛选 / 搜索 —— 全部 / 活动 / 已归档 / 运行中 / 子代理 五种筛选,加上按标题、目录、id、工作区的本地子串过滤。
- 归档与恢复 —— 归档走 harness 的注册表归档动作;恢复改注册表的归档集合(
domain/changed会让侧栏实时同步)。恢复前会确认会话的日志仍在磁盘上:文件已被删除的会话拒绝恢复,否则会把一个点开就 ENOENT 的空壳放回侧栏(见下)。 - 删除 —— 物理移除会话的本地日志目录,不可逆;删除前有二次确认。
- 级联删除子代理 —— 删除父会话时连带清理其子代理会话(默认开启),行上显示
+N 子代理,确认框会写明将连带删除多少个。见下。 - 安全护栏 —— 删除权限只看目标会话自身是否已归档:已归档 = 可以直接删(若还在运行,先中止它的当前回合);未归档 = 拒绝,并提示先去侧栏归档。见下。
- 子代理会话 —— 默认不显示(与侧栏一致,见下),需要时切到「子代理」筛选查看,行上标出父会话 id。
host: sessionManager 服务(facts / remove / removeMany / archive / unarchive)
└─ 信任围栏 HTTP 路由 /session-manager/api/*(服务未被代理时的载体)
client: 设置页「会话管理」(settings.section)+ 删除确认弹窗
安装
从本地路径(开发时最常用):
dsh plugin --profile web add /绝对路径/dsh-session-manager
# 重启 dsh web,然后打开 设置 → 会话管理
从 GitHub 直接安装:
dsh plugin --profile web add git+https://github.com/wjackiedev/dsh-session-manager.git
卸载:
dsh plugin --profile web remove dsh-session-manager
本包在
package.json里保留了"private": true,避免误发布到 npm;从 git 或本地路径安装不受影响。
开发
没有构建步骤,也没有需要安装的依赖(@deepseek-ai/cordis 是 peer dependency,由 DSH 宿主提供):
git clone git@github.com:wjackiedev/dsh-session-manager.git
cd dsh-session-manager
npm test
测试是普通的 node 脚本,会打印 PASS / FAIL 行:
npm run test:host # 只跑宿主策略测试
npm run test:client # 只跑客户端 ghost 行测试
改动代码前请先读 AGENTS.md——它记录了架构、六条硬性不变量(fork 子会话必须存活、SESSION_GONE 拒绝恢复已删除会话等)以及代码与提交约定。
权限边界
- 只读宿主已有的持久化状态:
sessionPersistence.list()/stat()/locate()。 - 写:
workspaceRegistry.archiveSession()/setState()(归档集合),以及删除会话日志目录。 - 不注册任何面向模型的工具,不写会话日志,不触碰会话之外的任何文件。
- 客户端 bundle 只
require冻结基座里的模块(react、@deepseek-ai/dsh-client-ui-primitives),没有非基座 external,也没有构建步骤。
级联删除子代理会话
删除一个会话时,默认连带删除它的子代理后代(整棵子树,深度不限)。三个要点:
- 判别条件是
origin === 'subagent',不是parentSession。SessionHeader.parentSession同时也是 fork 血缘字段("the session this one was forked from")——fork 出来的分叉会话是普通顶层会话,有自己的工作区记账,用户从没要求删它。只按parentSession级联会把它们一起删掉。单测里专门锁了这条(FORK子会话必须留下)。 - 深度优先 + 父会话最后删。 顺序是「最深的子代理 → … → 直接子代理 → 目标会话」,中途失败时留下的是仍在界面上的父会话,而不是一堆孤儿子代理。
- 全有或全无,但闸门只看目标会话自己。 判定是否可删只看目标会话是否已归档:已归档 → 整棵子树都可删;未归档 → 只要有成员仍在进程中活动,整批拒绝(错误码
SESSION_LIVE,回报具体哪些 id 忙),不会删一半。子代理不会被单独归档(它们在侧栏里根本不是可管理的行),所以闸门不能下放到子代理,否则"归档父会话"就永远删不掉了。
remove(id, { cascade: false }) 可以只删目标本身(HTTP 侧对应 { id, cascade: false });单删子代理会话时同样会对它的后代级联。删除完成后界面会用 host 返回的 childrenRemoved 汇报「已删除 N 个会话(含 M 个子代理)」。
已归档就能删,未归档不能删
规则的落点是目标会话自己的归档状态,不是它是否常驻内存:
| 目标 | 在进程中活动 | 结果 |
|---|---|---|
| 已归档 | 否 | 直接删 |
| 已归档 | 是(运行中 / 已打开) | 可删:先 agent.cancel({ kind: 'disposed' }) 中止当前回合,再删文件 |
| 未归档 | 否 | 直接删 |
| 未归档 | 是 | 拒绝(SESSION_LIVE),提示先去侧栏 ⋯ 里归档 |
| 未归档 | 子代理在活动 | 拒绝(提示先归档目标会话) |
为什么这么定:
- "归档"就是"我不用了"的显式声明。 harness 的归档是单向隐藏,用户归档之后唯一的诉求就是清理掉它;此时再以"还在内存里"为由拒绝,等于让归档变成一个无法收尾的操作。
- 归档会话本来就看不见。 我最初拒删的理由是"删了但侧栏还在显示"——而归档集已经把会话从所有分组视图里隐藏了,这条理由对归档会话不成立。
- 未归档的仍然拒删是有必要的:
ctx.sessions里常驻的会话即使文件被删,harness 的列表仍会继续返回它(ApiSessionList.list对 live 会话优先于索引),于是侧栏里会出现一个点开是空壳的行。归档这一动作正好把这个残影藏起来。
技术上做不到的部分:AgentHandle.dispose() 是 capability,只有创建该 agent 的持有者能拆,ctx.agents.get(id) 只给裸 Agent,所以插件无法把会话从进程里真正卸载。能做到的是 cancel() + 有上限的 whenIdle()(保证之后不再往已 unlink 的 inode 写),进程内那条 pending 记录要等重启 dsh web 才消失——这也是为什么 facts() 要额外暴露 onDisk,由客户端用它把"文件已不在"的行立即从管理页隐去。删除成功后的提示会写明「已中止 N 个运行中的会话」。
删除后的残影与恢复闸门
删除只动磁盘,进程/客户端里可能还留着一份"残影",所以两个入口都要挡:
- 管理页不再列出残影。 客户端列表来自 harness 的会话 store,删除后它仍可能保留一条摘要(
ApiSessionList.list对常驻会话优先内存条目;客户端的 ordered-baseline 合并又会跨重连保留旧摘要)。此时 host 的facts()里已经没有这一行了——所以判定条件是「缺 fact 或onDisk === false」,两者都从管理页隐去,顺带把这类 ghost id 从所有统计里剔除。页面还会对这类 ghost id 调用一次sessions.refresh(),把侧栏里的残影也一并冲掉。 - 恢复必须日志仍在。
unarchive()先sessionPersistence.stat(id):返回快照 = 日志在磁盘上(或仍是会落盘的 pending create),允许恢复;undefined= 文件确实没了,直接拒绝(错误码SESSION_GONE)。已删除的会话仍留在归档集合里——它不影响任何界面,反而正好把可能残留的客户端条目继续挡在侧栏之外。
「未归属工作区」是什么意思
就是侧栏里那个「未分组」组(group.ungrouped):该会话不在任何工作区记录的 sessionIds 记账名单里。插件行上会显示 (未归属工作区)。三种来源:
- 子代理会话 ——
session.create({ workspaceId })是唯一的记账入口,而子代理由父 agent 直接派生、不走这条 RPC,所以永远不记账。插件默认隐藏它们(harness 侧栏同样隐藏:tree.ts的sessionVisible()第一条就是origin !== 'subagent'),切到「子代理」筛选才可见。 - 以
cwd而不是工作区创建的会话 ——session.create只接受workspaceId或cwd二选一,传cwd时不记账(headless / ACP / SDK / CLI 路径)。 - 工作区注册被删,或 cwd 失效 —— harness 的"删除工作区"只删注册、保留日志(其会话就落到未分组);而
WorkspaceEntity.sessionIds只投影 canonical cwd 等于记录路径的会话,目录被删/改名/软链变化都会被过滤掉,host 启动日志里会出现filtered session ... from membership警告。
已知代价("不改 harness" 的边界)
- 删除后 workspace 记录里的
sessionIds会留一个死 id。它会被 header 索引过滤、不显示,但没有公开 API 可以摘掉(WorkspaceRegistry.requireTable()是私有的)。 - 投影缓存(
<dsh-home>/storages/session_projcache/sessions/<id>.json)会残留一条;读取侧有 session identity 校验,永远不会被误用。 - 上述两项不影响使用,只是磁盘上的留痕。
兼容性
- 需要当前
sessionPersistence.list()返回{ header, revision, sizeBytes }快照的 DSH(插件内部做了snapshot.header ?? snapshot兼容读取)。 - 依赖的宿主面:
sessionPersistence、workspaceRegistry、sessions(agents为可选查找)。 - 宿主的 HTTP 路由注册面:
webServer.register({ kind: 'prefix', path, handler })。
参与贡献
欢迎 issue 与 PR:
- 提交前请阅读 CONTRIBUTING.md(开发环境、测试要求、提交信息规范)与 AGENTS.md(架构与硬性不变量)。
- 架构或行为变更请同步更新 CHANGELOG.md 的
## [Unreleased]。 - 安全问题不要开公开 issue,请按 SECURITY.md 私密上报。
- 本项目采用 Contributor Covenant 行为准则。
License
MIT © 2026 Jackie Wang (wjackiedev)
No comments yet. Be the first to write one.