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. 适合需要自动或手动将超长会话无缝迁移到新会话的重度用户。
インストール
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:qingyou002/dsh-auto-handoffドキュメント
README 全文を読む ↗5. Token 使用量与判定规则
5.1 来源
优先级从高到低(src/token-threshold.ts 的 readCumulativeUsage):
tokenUsage投影(ctx.sessionProjections.stateOf(session, 'tokenUsage'))。 数据来自 durable 的assistant/message.usage,由日志纯折叠得出,重启后按日志重放。ctx.tokenMeter.measure(session).totalTokens——适配器未上报用量时的固定启发式估算。- 两者都不可用。
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 会继承
| 配置 | 读取 | 写入 |
|---|---|---|
| 工作区 / cwd | session.header.cwd | sessionController.create({ cwd }) |
| 工作区归属 | workspaceRegistry.resolveByPath(cwd) | workspace.attachSession(newSessionId)(见 §6.5) |
| Agent preset | session.header.agentPreset,回退 agentPresets.composedPreset(ctx) | create({ agentPreset }) |
| 模型 | 日志最新 model/selection → requestHeader().config → agent.options → agentDefaultModel.currentSelection() | sessionController.selectModel(...) |
| 权限预设 | permissionPresets.current(session) | permissionPresets.set(newSession, name) |
权限为 custom | approval.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 | 摘要无法生成 → 放弃迁移,保留原会话 |
agentPresets | preset 行标记「未继承」,使用部署默认 |
workspaceRegistry | 工作区归属行标记「未继承」;新会话仍会创建,只是不进入任何项目分组(见 §6.5) |
permissionPresets / approval | 权限行标记「未继承」 |
planMode(两边都没有) | Plan 模式行标记「未继承」 |
tokenMeter / sessionProjections | Token 标注为「不可用」,自动功能停用 |
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 秒重拉一次整份列表。