qingyou002/dsh-auto-handoff ↗★ 0

dsh-auto-handoff

DSH plugin: summarize a long session into a structured handoff brief and continue the work in a fresh session inside the same workspace. Stops at a step boundary instead of hard-interrupting running work. 适合需要自动或手动将超长会话无缝迁移到新会话的重度用户。

Package
dsh-auto-handoff
Compatibility
Unverified
Harness peer range
>=0.1.5-rc.1 <0.1.6-0 || >=0.1.6-rc.1 <0.1.7-0 || >=0.1.7-rc.1 <0.2.0-0
Cordis peer range
^4.0.2
Version
0.1.0
License
MIT
Last updated
Sep 28, 2026

Install

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:qingyou002/dsh-auto-handoff

5. Token 使用量与判定规则

5.1 来源

优先级从高到低(src/token-threshold.ts 的 readCumulativeUsage):

  1. tokenUsage 投影(ctx.sessionProjections.stateOf(session, 'tokenUsage'))。 数据来自 durable 的 assistant/message.usage,由日志纯折叠得出,重启后按日志重放。
  2. ctx.tokenMeter.measure(session).totalTokens——适配器未上报用量时的固定启发式估算。
  3. 两者都不可用。

5.2 精确 / 估算 / 不可用

标注判定条件自动化影响
exact(精确)四个 bucket 之和 > 0正常参与阈值判定
estimated(估算)和为零,但 measure() 返回 > 0正常参与判定,但 UI 明确标注「估算」
unavailable(不可用)两者都为 0自动功能停用,手动 /handoff 仍可用

5.3 累计口径

cumulative = totals.uncachedInputTokens
           + totals.outputTokens
           + totals.cacheReadTokens
           + totals.cacheWriteTokens

这是会话累计用量,不是单次请求的上下文压力;compaction 只影子化 surface,不会把它归零。


6. 配置继承矩阵

6.1 会继承

配置读取写入
工作区 / cwdsession.header.cwdsessionController.create({ cwd })
工作区归属workspaceRegistry.resolveByPath(cwd)workspace.attachSession(newSessionId)(见 §6.5)
Agent presetsession.header.agentPreset,回退 agentPresets.composedPreset(ctx)create({ agentPreset })
模型日志最新 model/selection → requestHeader().config → agent.options → agentDefaultModel.currentSelection()sessionController.selectModel(...)
权限预设permissionPresets.current(session)permissionPresets.set(newSession, name)
权限为 customapproval.overrideOf(session)approval.setPolicy(newAgent, policy)
Plan 模式按 agent 解析出的 planMode.get(agent).active(见 §6.4)在新会话 agent 自己的实例上 planMode.set(newAgent, active)

每一步都逐项报告:已继承 / 回退 / 未继承(原因)。任何一步失败都不会被静默吞掉。

6.2 不继承

  • 浏览器私有的交互状态(滚动位置、折叠状态、选中项、未提交草稿);
  • UI 里的临时选择(临时切换的模型、临时权限,未落到会话配置上的);
  • custom 权限预设下的 sandbox 档位——仓库没有「自定义组合」这个名字可以重放, 因此只能继承审批策略,并在结果里明确写明「sandbox 档位未继承」;
  • 已在原会话里写入的日志与附件(新会话从零开始,只带简报)。

6.3 服务缺失时的降级

缺失的服务后果
sessionController迁移失败并明确报错;自动功能停用;不静默失败
llm摘要无法生成 → 放弃迁移,保留原会话
agentPresetspreset 行标记「未继承」,使用部署默认
workspaceRegistry工作区归属行标记「未继承」;新会话仍会创建,只是不进入任何项目分组(见 §6.5)
permissionPresets / approval权限行标记「未继承」
planMode(两边都没有)Plan 模式行标记「未继承」
tokenMeter / sessionProjectionsToken 标注为「不可用」,自动功能停用
settings设置只在本进程内有效(见 §12)
webServer设置卡降级为「状态通道不可用」,设置表单仍可读写
compaction(该 agent 两边都没有)自动压缩停用并明确提示;手动 /handoff 不受影响

6.4 服务解析时机与 agent realm

插件对可选服务一律惰性读取:src/index.ts 的 serviceReader() 在每次使用时重新 ctx.get,绝不在 apply() 里快照。这不是风格问题——Loader 并发装配同层条目(cordis-plugin-loader/src/config/group.ts 用 Promise.allSettled 创建同层的所有条目),而任何 inject 了服务的行都会停在 PENDING,直到它的依赖全部就绪才真正 apply。sessionController 自己 inject 了 10 个服务,落在本插件之后是常态;装配时一次性取值会把健康的部署误报成「本部署未安装 sessionController」。

planMode 与 compaction 还多一层:Web 面(dsh-web-app/cordis.patch.yml)把宿主 plan-mode / compaction-basic 两行 disabled,改由每个 agent preset 挂载,并且包在 isolate realm 内。realm 对组外的行不可见——包括宿主平面的插件行——所以 ctx.get 永远拿不到。这两个服务因此按 agent 解析:

agentPresets.serviceFor(agent, 'planMode')   // preset realm 里的实例
  ?? ctx.get('planMode')                     // 宿主平面(TUI 等保留宿主行的场合)

迁移时源 agent 与新会话 agent 各自解析一次,两个实例可以不同——这正是 per-agent 语义。于是 /handoff status 与继承矩阵里的「Plan 模式」「自动压缩」反映的都是该会话真正能用的服务,而不是某个进程全局的猜测。

6.5 迁移后的可见性与自动跳转

一次真实 /handoff 暴露了三个各自独立的缺陷:会话确实建好了、简报也确实投递了,但用户「在工作区里看不到新会话,页面也没有跳过去」。三者都不是配置问题,修法也不在同一层:

缺陷 A(Host):按 cwd 建会话不会附加任何工作区。 浏览器严格按 Workspace.sessionIds 分项目组,而该字段只是过滤、从不按 cwd 补全:

get sessionIds() { return this.record.sessionIds.filter(id => this.host.sessionPath(id) === this.record.path) }

sessionController.create() 只在请求带 workspaceId 时调用 attachSession();只带 cwd 时谁都不记账。而 workspaceId 与 cwd 互斥(同时给会得到 gateway/bad-request),cwd 才是这里真正要继承的事实。因此迁移改为「按 cwd 创建 → 再 resolveByPath(cwd).attachSession(newId)」,与 dsh-api-session-controller 自己 fork 会话时的做法一致(它同样是先建、后 attach)。

这里有三条有意的设计决定:

  • 不把 workspaceId 传给 create():一旦 attach 失败,Host 会在会话已经创建成功之后抛错,我们就会把「已存在的新会话」误报成创建失败,并丢掉它的 sessionId;
  • 不在目录未注册时自动 registry.create():那会静默改动用户的侧边栏顺序;
  • attach 是 best-effort:失败了也只是继承行标记「未继承(原因)」,绝不把迁移报成失败——新会话的真实存在比报告好看更重要。

缺陷 B(Client):新会话被当成「空白行」隐藏。 会话在 session/created 时就以 api-session/added 广播,此刻 Host 侧 blank === true(ApiSessionList.init = { blank: true },只有 turn/start 才清掉)。客户端 mergeSummary 只写入这份 summary,之后再没有任何事件会清 blank:handleSessionStatus 只改 running,handleSessionActivity 只改 updatedAt,而 activity 只对 source.kind === 'user' 的消息发——迁移简报是 kind: 'plugin'。工作区分组里 sessionVisible = !blank || id === current,于是这张行除当前会话外一律隐藏。所以自动跳转前必须先 sessions.refresh() 重新拉一次列表(这是让 Host 重算 blank 的唯一手段),再 open();只 open 会跳到一个侧边栏仍拒绝显示的行上。

缺陷 C(Client):切换器不能寄生在设置卡里。 原先的切换逻辑写在设置卡的 effect 里,而卡片只在「设置 → 插件」标签挂载时才存在;/handoff 是在聊天里敲的,观察者根本没运行。现在由 apply() 启动常驻的 startHandoffFollower()(src/client/follow.ts),对整页有效,与卡片是否挂载无关。

follower 的两条规则值得单独写下来:

  • 基线规则:某个会话首次被观察到时,只记录它当时的 newSessionId、不动作。否则每次打开页面都会被劫持到历史上某次迁移的会话;只有 undefined → 有值 的跃迁才切换。
  • 重试规则:新行还没出现在列表里、或仍是 blank 时,本 tick 放弃、下个 tick 重试,最多 12 次(约 24 秒)后静默放弃——简报没能启动 turn 时行会一直是 blank,无限重试只会每 2 秒重拉一次整份列表。