wangxiang0605qvq/dsh-session-history

dsh-session-history

DSH 会话历史插件:侧边栏历史会话面板,按工作区分组、搜索、归档/恢复,并把已归档会话从磁盘彻底删除 | DSH session-history panel with guarded permanent deletion of archived session logs

未分類deepseek-harnesssession-history

@deepseek-ai/dsh-session-history

DSH 桌面端的会话历史面板:浏览历史会话、打开、归档(单个 / 批量)、恢复,以及把已归档的会话从磁盘彻底删除。

A session history panel for the DSH desktop app: browse, open, archive (singly or in bulk), restore, and permanently delete an archived session's log from disk.

两种"删除",含义完全不同

归档(Archive)彻底删除(Purge)
谁提供DSH 自带:workspaceRegistry.archiveSession本插件:Host 半边直接删文件
效果离开列表,JSONL 日志与工作区记账保留从磁盘上删掉该会话的日志目录
可恢复可以,unarchiveSession不可以
用在列表里任何会话只有已归档的会话

DSH 本身没有会话硬删除:workspaceRegistry 只有 archiveSession/unarchiveSession/pinSession/unpinSession(唯一的 delete(id) 删的是整个工作区注册,而且它明说 "retains the directory and every session log");sessionPersistence 只有 create/open/flush/stat/list;发行版自己的会话 ... 菜单也只注册了 pin/rename/fork/archive。

所以这一版按你的要求把彻底删除补上了 —— 走本插件自己的私有 RPC 通道 + Host 半边直接删文件,不假装 Host 有这个能力。

长什么样

侧边栏新增一个面板图标(时钟表盘),点开是主列的面板:

┌─────────────────────────────────────────────────────────────────┐
│  会话历史                            [搜索…]  [刷新] [全部归档] │
│  共 12 条,已归档 3 条                                          │
├─────────────────────────────────────────────────────────────────┤
│  默认工作区   C:\…\default-workspace                        5   │
│   ● DeepSeek Harness 实时显示插件                       2 分钟前 │
│   ○ DeepSeek 插件:时钟                                  3 天前 │
│                                                                 │
│  其他目录                                                     1  │
│   ○ 改名过的会话                                         1 周前 │
│                                                                 │
│  已归档                                              3  [全部恢复]│
│   ○ 旧的实验                                   2 周前  ↺  🗑   │
│         ↑ 点 🗑 后原地变成 [🗑 确认删除] [取消],8 秒无操作自动收起│
├─────────────────────────────────────────────────────────────────┤
│  移除会话 = 归档(日志保留、可恢复);已归档的会话可以再「彻底删除」│
└─────────────────────────────────────────────────────────────────┘
  • 分组:按会话 cwd 匹配工作区路径分组,匹配不上进「其他目录」;组内按最近活动倒序。
  • 绿点:该会话正在运行。
  • 搜索:按标题或目录路径过滤。
  • 行悬停:→ 打开、🗄 归档;已归档行是 ↺ 恢复 + 🗑 彻底删除。
  • 二次确认:「全部归档」和「彻底删除」都要点两次,8 秒无操作自动取消。归档一个还在跑工作的会话也要确认一次(见下节)。
  • 归档语义:先不带 stopActivity 地问一次,只有 Host 用 workspace/session-active 拒绝时,面板顶部才出现确认条,把 Host 报的运行内容列出来,等你点「停止并归档」再带 stopActivity: true 重试。不拿 running 猜。

为什么归档要先问一次,而不是先看 running

SessionSummary.running 只是 ctx.remote.session.list() 的轮询快照,而"这个会话现在能不能归档"由 Host 侧的 Agent 注册表当场裁决,两者会不一致。按快照猜的写法一旦猜错(快照说没在跑、Host 说在跑),archiveSession 就会被 workspace/session-active 拒绝,而那时插件把任何拒绝都当"失败"上报 —— 结果就是正在使用的会话永远归档不掉,界面上没有任何出路。这正是修掉的那个 bug。

现在改成和发行版侧边栏完全相同的形状:

archiveSession(id)                       ← 先不带 stopActivity 问一次
  ├─ 成功                    → 「已归档 N 条会话」
  └─ workspace/session-active → 面板顶部确认条:
        列出 Host 报的运行内容(turn / job / subagent / schedule)
        [停止并归档] → archiveSession(id, { stopActivity: true })
        [取消]       → 收起(15 秒无操作自动收起)

判定拒绝用的是 RPC code(error.rpcError.code === "workspace/session-active")而不是 instanceof:workspaces 服务住在另一个 client bundle 里,插件 bundle 之间不共享 error class 身份。发行版自己的 activeSessionRefusal() 按 error.name 判,原因相同。

附带的好处:以前对 running === true 的行直接传 stopActivity,等于不问就取消对方正在跑的轮次、并丢弃排队中的输入;现在这一步必须先经过你确认。

其它错误(例如 session/not-found)仍然直接报错,并且从这一版起报错文案会带上 RPC code,例如 操作失败:session/not-found: …,不用再猜是哪种失败。

彻底删除是怎么做的(以及为什么这样写)

磁盘布局(在本机 0.2.0-rc.2 profile 上实测确认):

<DSH_HOME>/sessions/<projectKey>/<sessionId>/session.v4.jsonl.zstd

sessionId 实际取值都在 [A-Za-z0-9._-] 内(session-<uuid> 与裸 <uuid> 两种形式都出现过),所以 encodeSegment 不会改动它 —— 目录名就是 id 本身。

lib/index.js(Host 半边)因此不复刻后端的 projectKey 编码,而是直接按目录名精确匹配。所有守卫都朝"宁可拒绝,绝不删错"的方向排:

#守卫挡住的情况
1id 必须是单个普通路径段(/^[A-Za-z0-9._-]+$/,且不是 . / ..)路径穿越
2调用方必须显式传 confirm: true误调用
3agents.get(id) 有值就拒绝会话还活着 —— 删掉正在读写的日志会毁掉运行中的会话
4sessionPersistence.stat(id) 必须仍认得这个会话拼错 / 过期的 id 撞上同名目录
5只按目录名精确匹配,且必须恰好一个匹配同名出现在多个 project 目录时拒绝猜
6目录里必须真的有 .jsonl* 日志产物删到不像会话目录的东西
7rm 带 maxRetriesWindows 上读者刚关句柄的 EBUSY 抖动

守卫 3 有个反直觉的后果:归档 ≠ 卸载

agents.get(id) 查的是活着的 Agent。而一个会话只要还被窗口打开着,它的 Agent 就还活着 —— 归档只是把它从列表里移走,不卸载它。所以在列表里"删掉"了某个会话、再去点 🗑,会遇到守卫 3,看起来像"我都删了怎么还说它开着"。

这不是 bug,是守卫在挡"从正在读写的日志底下把文件抽走"。两条出路:

  1. 先离开这个会话 —— 切到别的会话,或新建一个;等它的 Agent 卸载后再回来删。
  2. 重启 DSH,然后不要打开它 —— 直接从本面板的「已归档」分组里删。重启后只有 DSH 自己打开的会话是活的,归档过的那个一定是死的,这条最稳。

面板现在按 RPC code(session-loaded)把这种拒绝本地化成上面那段可执行的说明,而不是原来那句英文 close it before deleting its log —— 后者根本没法照做,因为 DSH 没有"关闭会话"这个按钮。

删完返回 { sessionId, directory, project, files, bytes },面板把它汇总成「已彻底删除 N 条会话,释放 X MB」。多条时是串行的。

投影缓存不删:session-projection-cache 里按键为 session id 的条目在会话消失后是惰性的(几十 KB),为了它去动共享 storage domain 风险远大于收益。

数据来源

用途来源
会话列表(标题 / 时间 / cwd / running / blank)ctx.remote.session.list({}) → SessionSummary[]
标题文本summary.projections.values.title.title(title 投影单元发布的是对象)
分组 / 归档集 / 置顶集useWorkspaces((s) => s.items / s.archivedSessionIds / s.pinnedSessionIds)
归档 / 恢复ctx.workspaces.archiveSession(sessionId)(被拒时经确认再带 { stopActivity: true } 重试)/ unarchiveSession(sessionId)
归档被拒的原因WorkspaceArchiveError.rpcError.code === "workspace/session-active",.details.activity 是 SessionActivity[](turn / job / subagent / schedule)
打开会话ctx.uiWorkspace.openSession(sessionId)
彻底删除私有 RPC 通道 /session-history 的 delete 端点 → 本插件 Host 半边

槽位与「不用写导航」

sidebar.panellist 的契约原文:

Global panel icons. Each list id addresses the matching main panel; the sidebar owns the button and resolves its label from list metadata.

只要 sidebar.panellist 的 id 与 main 的 key 都是同一个 PANEL_ID("session-history"),侧边栏自己渲染按钮、管选中态、切面板 —— 插件里没有一行导航代码。main 的注册用 generator 形式,和发行版唯一的 main 注册(Plugins 页)一致。

私有路由契约(两端都由本插件拥有,因此没有套 Connection 的 RPC 信封 {type,rpcId,result},就是一个普通的 {ok, value|error} JSON):

// Host(webServer 的 prefix 路由,handler 是 node 风格)
ctx.inject(["webServer", "connection"], (scoped) => {
  scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: "/session-history", handler }), label);
});
// Browser(同源 POST,浏览器会话 cookie 随请求带上,正是 Host 准入判据所查的)
await fetch("session-history/delete", { method: "POST", headers: { "content-type": "application/json" }, body: … });
//      → { ok: true, value } | { ok: false, error: { code, message, details } }

为什么不用 connection.rpc.handle:它的实现是 owner.effect(() => owner.webServer.register(route)),owner 取的是 connection 服务自己的 context,注册时机不由本插件掌握。发行版里 client-modules、client-file-upload、client-hmr 三个插件都是直接挂 webServer,本插件跟随这条更可控的路径。

文件

文件作用
lib/client.js浏览器半边:会话 store、面板 UI、行组件、内联 SVG 图标、两个槽位注册
lib/index.jsHost 半边:私有 RPC 通道 + 带守卫的磁盘删除
package.json双面包声明 exports["./client"] + dsh.client.platform = "web" + dsh.bundle.patch
cordis.patch.ymlbundle 层:声明本包自带的挂载行,作为 bundle 安装时由插件管理器读取

安装

方式一:插件管理器(推荐)

本仓库通过 dsh.bundle.patch 声明了自带的 cordis.patch.yml,因此不需要手写补丁行:

dsh plugin --profile desktop add github:wangxiang0605qvq/dsh-session-history

方式二:手动放进解析根(离线可用)

要点:包必须放在 $DSH_HOME\profiles\node_modules\ 下 —— 那是运行时模块解析的查找根。放进 profiles\desktop\node_modules\ 不会被解析到,只写补丁行同样找不到模块。

$dest = "$env:USERPROFILE\.dsh\profiles\node_modules\@deepseek-ai\dsh-session-history"
New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
Copy-Item -Recurse -Force . $dest
Remove-Item "$dest\.git" -Recurse -Force -ErrorAction SilentlyContinue

然后在 ~/.dsh/profiles/desktop/cordis.patch.yml 末尾追加:

- insert:
    - id: session-history
      name: "@deepseek-ai/dsh-session-history"
      config: {}

重启 DSH,然后硬刷新(Ctrl+F5)页面。

方式三:作为 profile 本地插件(本机实测通过的路径)

不需要动 node_modules,把 lib/ 下的两个文件当作插件的两个半边即可:

~/.dsh/profiles/desktop/plugins/session-history/
├── package.json   # 声明 exports["./client"] 与 dsh.client.platform = "web"
├── index.js       # 即 lib/index.js
└── client.js      # 即 lib/client.js

补丁行用相对路径,由 patch 文件所在目录解析:

- insert:
    - id: session-history
      name: ./plugins/session-history/index.js

注意:window.__ModuleLoader__.load({ id }) 必须与 package.json 的 name 完全一致,否则 browser 模块表找不到对应的 factory,插件会静默不生效。本仓库两处都是 @deepseek-ai/dsh-session-history;若你把 name 改成别的值,lib/client.js 里的 load({ id }) 要同步改。

⚠️ 改了 lib/index.js 必须重启 DSH

Host 半边不会热重载。 profile 的 HMR 只重新投递 client 半边(client-modules 按文件 mtime 生成带 revision 的 bundle URL,页面重新拉取),Host 半边是进程内 ESM,同一个 URL 在进程存活期间只求值一次 —— 插件管理器文档也写明 "Package replacements require restarting the process to load a fresh JavaScript module generation"。

症状就是第一次踩到的那个:改了 lib/index.js(哪怕改成完全空的 apply),进程里跑的还是启动时那份,路由根本不注册,客户端 POST 穿透到静态 SPA 回退,报 transport failure for /session-history/delete: HTTP 405。改 lib/client.js 不需要重启。

判断当前进程加载的是哪一份:比对 lib/index.js 的 LastWriteTime 与 DSH 进程的 StartTime(Get-Process -Name "DeepSeek Harness")。前者晚于后者,就说明改动还没生效。

排查

HTTP 405 / transport failure —— 路由没注册,请求落到了静态前端回退(dsh-host-frontend-static 的 fallback 座位只接受 GET/HEAD,其它方法一律 405)。按顺序检查:

  1. lib/index.js 的 mtime 是否晚于 DSH 进程启动时间?是 → 重启 DSH。

  2. 重启后仍 405 → 用未认证探针区分两种情况:

    Invoke-WebRequest "http://127.0.0.1:19387/session-history/delete" -Method POST `
      -ContentType "application/json" -Body '{"sessionId":"x","confirm":true}'
    
    • 405 = 路由仍未注册(ctx.inject(["webServer","connection"]) 没满足)
    • 401 / 403 = 路由已注册,被 auth 判据挡住了(浏览器里带 cookie 就不会)

彻底删除被拒 —— 路由通了,是守卫拒的。Host 现在给每种拒绝一个 code,面板按 code 本地化;没有 code 的走通用 purge-refused:

code含义怎么办
session-loaded会话还活着(Agent 没卸载)先离开这个会话,或重启 DSH 后不打开它 —— 见「守卫 3 有个反直觉的后果」
session-unknownHost 已经不认识这个会话刷新列表;它可能已被删掉或移走
ambiguous-session同名目录出现在多个 project 目录插件拒绝猜,需要手动确认
no-log-artifact目录里没有 .jsonl 日志产物不像会话目录,插件拒绝删
bad-session-id / confirm-required调用参数不合法正常 UI 不会触发

可调项

想改什么改哪里
侧边栏图标位置lib/client.js 注册处的 order: 50
面板 ID / RPC 通道名PANEL_ID、CHANNEL(两端必须同时改,client 与 index 各有一处)
会话根目录lib/index.js 的 sessionsRoot()(默认 $DSH_HOME/sessions,回退 ~/.dsh/sessions)
二次确认宽限时间「全部归档」与「彻底删除」两处 setTimeout(..., 8000)
归档确认条自动收起时间面板里那个 setTimeout(() => actions.cancelStop(), 15000)
归档要不要问(而不是直接停)applyBatch 只在不带 stopActivity 时才把 workspace/session-active 转成确认条

已知边界

  • 彻底删除不可恢复;面板只对已归档的行提供它,且需要二次确认。
  • 正在使用的会话也能归档,但要先确认停止它正在跑的工作:那一停会取消当前轮次并丢弃排队中的输入,所以插件不会替你做这个决定。
  • 已归档但还开着的会话删不掉日志(守卫 3):归档不卸载 Agent。先离开它,或重启 DSH 后不打开它再删。
  • 日志根目录按 $DSH_HOME/sessions 推导。推错不会删错:守卫 5/6 会让操作直接拒绝。
  • 投影缓存里的对应条目会留下(惰性,不影响功能)。
  • 面板依赖 ctx.remote.session.list() 是否包含已归档会话;若某版本把归档会话排除在外,「已归档」分组会少掉那些行(恢复本身仍通过 archivedSessionIds 生效)。
  • 子会话(origin === "subagent" 或有 parentSessionId)不列在历史里。
  • 无头 / TUI 环境没有侧边栏与主列,面板不显示。

License

MIT