dsh-compact-relay 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0(2026-10-26)
4
+
5
+ > 0.5.0 功能批次:地基 F1–F6 + **命名反转 + 接力条 + 交接后自动归档旧会话/恢复入口**。双向跳转的「父会话 → 子会话」入口与里程碑面板尚未实现;其余已提交并发布(2026-10-26)。
6
+
7
+ ### F1+F2:历史前缀纯函数 + relay record 白名单(commit `5397863`)
8
+
9
+ - **F1(`lib/core/relay.js`)**:新增 `HISTORY_PREFIX='历史'`、`historyTitleOf(n, base)`(生成「历史 N:<原名>」,空格/全角冒号/序号写法与 `relayTitleOf` 同一套规则,原名先 trim,空题回落「会话」);`splitHandoffTitle` 返回值新增 `scheme`——`handoff-n`(交接第 N 次)/ `history-n`(历史 N)/ `legacy`(旧「交接:」「接棒:」堆叠、无前缀、空/脏输入)。`baseTitle` / `handedOff` 的字段名与语义不变,旧标题解析结果逐字节不变。
10
+ - **F2(`lib/dsh/relay.js`)**:`normalizeRelayRecord` 白名单放行可选 `scheme` / `titleSeq` / `historyTitle`;旧记录缺失/非法一律读成 `null`(不猜、不补默认值),其余字段与旧记录读取语义不变。父会话写出的 relay record 形状不变(不写这三个字段)。
11
+ - **测试**:新增 `test/relay-title-scheme.mjs`(生成/解析/往返/scheme 不混用/空题与脏输入/>80 字节不截断)。
12
+
13
+ ### F3:子会话自带 relay record(commit `b4878b8`)
14
+
15
+ - **`lib/dsh/sessions.js`**:`createHandoffSession` 在 `followup` 之后、`flush` 之前,向**子会话**写一条 `plugin:compact-relay/relay`:`parentId` / `childId` / `ordinal` / `baseTitle` / `title` / `scheme='handoff-n'` / `titleSeq` / `at` / `atIso`。走 plugin record 通道(`ignorable`,不进模型上下文、不算 token、压缩碰不到)。
16
+ - **为什么**:0.5.0 把旧会话改名「历史 N:<原名>」、新会话继承原名后,子会话标题里不再有「交接第 N 次」;链号若仍靠标题解析会算成 0,第 2 次交接会重复「第 1 次」。record 让链号自带。
17
+ - **父记录不动**:父会话的 relay record 仍由 `actions/handoff.js` 写 0.3/0.4 的现场形状(canonical + 旧字段 + `path`/`title`/`baseTitle`),0.5.0 起父/子记录都带 `path`(交接单路径);本次只在子会话多一条记录,标题/文档/交接流程不变。
18
+ - **测试**:`test/handoff.mjs` 增子会话 record 字段、`titleSeq`、两跳链式(第 1 次 → 第 2 次不重号)断言。
19
+
20
+ ### F4:`readState` 增只读 `chain` 投影(commit `cc6f86d`)
21
+
22
+ - **`lib/dsh/relay.js`**:新增 `relayChainOf(record)`,把最近一条 relay record 投影成 `{ordinal, baseTitle, parentId, childId, scheme, path?}`——字段全部取自记录本身,不解析标题、不补默认序号;记录里没有的字段给 `null`,没有 `path` 就不出现这个键;不是对象返回 `null`。
23
+ - **`lib/actions/state.js`**:`readState` 新增 `chain`(没有记录为 `null`),旧字段 `relay` 原样保留(0.3 旧名 `parentSessionId`/`index` 仍可读)。纯 additive:老客户端忽略新字段;SSE 帧 +~110B。
24
+ - **测试**:`test/state-passive.mjs` 增「新记录全字段 / 0.3 旧记录缺字段 null / 无记录 chain=null / 旧字段不变」四组断言。
25
+
26
+ ### 命名反转(第一个用户可见功能,commit d340dff)
27
+
28
+ - **行为**:新链交接后,旧会话改名「历史 N:<原名>」,新会话继承干净原名(不带序号);第 2 次交接为「历史 2:X / 历史 1:X / X」,列表读起来是一条对话。旧链(已存在的「交接第 N 次:」链、0.3 旧 record、旧「交接:/接棒:」堆叠)继续旧命名,旧会话标题一个字符不改(链级 cutover,不迁移、不混用)。
29
+ - **`lib/core/relay.js`**:新增 `MAX_TITLE_BYTES=80` 与 `truncateTitleUtf8`(与宿主 `session-title` 同口径的 UTF-8 安全截断);`historyTitleOf(index, base, maxBytes?)` 支持预算——前缀先计入、超长只缩 base,不传时保持 0.4.3 纯函数行为。
30
+ - **`lib/dsh/sessions.js`**:
31
+ - `handoffTitlePlanOf` 返回新增 `scheme`(`history-n`/`handoff-n`)与 `historyTitle`;有 record 时方案以 record 为准(旧记录 scheme=null → 旧链),无 record 时干净标题/「历史 N」→ 新链,「交接第 N 次」/旧前缀 → 旧链,读不到标题 → 保守旧链。
32
+ - `createHandoffSession` 接受可选 `plan`(文档/双写同一份);子会话 record 的 `scheme` 改为本次链实际方案(新链 `history-n`)。
33
+ - 新增 `renameHistoryOf`:子会话建好后把旧会话改名「历史 N:<原名>」,成功后 `await sessions.flush`;改名失败只 warn 不阻断;当前标题已是目标(含宿主按更小预算截断)时跳过,同一链重复执行不重复加前缀。scheme=`handoff-n` 时不读标题服务、不改名。
34
+ - **`lib/actions/handoff.js`**:交接编排在 `createHandoffSession` 之后调用 `renameHistoryOf`;父 record 写出新增 `scheme` + `titleSeq`(旧会话 rename 的 eventSeq),新链再写 `historyTitle`;父记录写完后再次 flush 旧会话,保证标题与 record 一起落盘。
35
+ - **有意行为变化**:F2/F3 时期「父记录不写 scheme/titleSeq/historyTitle」的约束随本功能更新——父/子记录现在都写 `scheme` + `titleSeq`;F5 链不变量测试已改为断言新形状。
36
+ - **测试**:新增 `test/handoff-naming.mjs`(第 1/2 次新链标题与序号、父/子 titleSeq 各取自己 rename、旧链 cutover 不改旧标题、0.3 旧记录不迁移、80 字节预算、幂等 guard、rename 失败降级含端到端不阻断);更新 `test/handoff.mjs` 与 `test/relay-chain.mjs` 的父记录/标题期望;`test/harness/host.mjs` 支持 `sessionTitleText` 与函数式 `renameEventSeq`。
37
+
38
+ ### 交接后自动归档旧会话 + 恢复入口(commit 369a235)
39
+
40
+ - **宿主接缝(0.2.1-rc.3 app.asar 实测)**:
41
+ - 服务端 `@deepseek-ai/dsh-workspace/lib/index.js` 的 `WorkspaceRegistry.archiveSession(sessionId, { stopActivity })`;插件通过 `ctx.get('workspaceRegistry')` 调用(建子会话已在用同一服务)。
42
+ - 不带 `stopActivity` 时先跑 `workspace/session-activity` waterfall,任何 provider(运行中的 turn / subagent / job / schedule)报活都会在写盘前抛 `WorkspaceActiveSessionError`;带 `stopActivity: true` 会先写归档集合、再停旧会话的工作。**自动交接不传 stopActivity**——静默取消用户后台任务比多留一行破坏性大。
43
+ - `unarchiveSession(id)` 幂等且不做存在性探测;归档集合是 registry 全局状态,不改变 workspace 成员/排序,恢复后旧会话回到原位。
44
+ - 客户端 `ctx.uiWorkspace.unarchiveSession(id)` 与侧栏「取消归档」是同一接缝(`dsh-client-ui-workspace` 的 `UiWorkspaceService`)。
45
+ - **`lib/dsh/handoff-archive.js`**:自动归档只传 `sessionId`;已归档 → `already` 幂等;旧会话有活 → 等 150ms 重试一次后降级 `active`,保留旧会话可见并只 warn;无 registry / 找不到会话 / 写盘失败分别降级 `unavailable` / `unknown` / `failed`;任何分支都不抛错。`handoffArchiveNoteOf` 统一生成用户文案(失败固定含「归档失败,不影响交接」)。
46
+ - **`lib/actions/handoff.js`**:归档顺序在**子会话建好、旧会话改名、父 record flush**之后;成功 message 追加「旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏「全部会话」也能取消归档)」,失败追加原因且不影响 `ok/childId/path`。
47
+ - **恢复入口**:
48
+ - 服务端 `lib/actions/recover.js` + HTTP `action: 'recover'`:直接调 `workspaceRegistry.unarchiveSession`,跳过 `resolveTarget`(归档会话通常没有活 agent),幂等并回报剩余归档集合。
49
+ - `readState` 新增 additive 字段 `parentArchived`(true/false;没有父会话或宿主不给归档状态为 null),`chain` 仍是 relay record 的纯投影。
50
+ - 客户端接力条下拉新增「恢复旧会话」:已归档或宿主未给状态时显示;优先 `ctx.uiWorkspace.unarchiveSession`,老宿主回退 HTTP recover;成功后更新本地归档状态并自动切回旧会话。「回到原会话」在已归档时也先恢复,避免用户撞「归档会话不可打开」。
51
+ - **兼容/边界**:旧链(`handoff-n`)与 0.3/0.4 旧 record 同样归档,命名逻辑一个字符不改;宿主接缝缺失/失败一律降级为「不归档」并 warn;不改交接单正文、压缩行为、命名反转逻辑。
52
+ - **测试**:新增 `test/archive.mjs` 16 组(归档参数/幂等/活跃重试/失败降级/旧链/恢复动作/HTTP 路由/产物静态断言);`test/relay-bar.mjs` 新增 5 组恢复交互;`test/state-passive.mjs` 增 `parentArchived` 断言。`npm test` **257 PASS / 0 FAIL**。
53
+ - **未真机验证**:本环境未启动真实 DSH 会话点验「交接 → 自动归档 → 接力条恢复」全链路;宿主证据来自 app.asar 源码与同形 fake ctx。真机需确认:①交接完成后侧栏旧会话消失、新会话自动打开;②「恢复旧会话」后旧会话回原位且可打开;③旧会话有后台任务时保留可见并提示任务族。
54
+
55
+ ### 验证
56
+
57
+ - `npm test` 全绿:**257 PASS / 0 FAIL**(历史基线:F1+F2 后 204 → F3 后 206 → F4 后 207 → F5 后 213 → 命名反转后 220 → 接力条后 236;本次自动归档 + 恢复入口新增 21)。
58
+ - 未 commit、未发布、未打 tag;0.5.0 剩余:双向跳转的父会话→子会话入口、里程碑面板。
59
+
3
60
  ## 0.4.2(2026-10-26 · 交接线保命补丁)
4
61
 
5
62
  ### 修复:交接提醒可能晚于 DSH 原生压缩(0.4.1 的三个洞)
package/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 压缩接力 · 目标与路线图
2
2
 
3
- > 版本基准:0.4.2(2026-10-25)|状态:0.3 已发布(npm + GitHub),0.3.6 已发布,0.4.0 已发布(npm latest + GitHub tag/Release),0.4.1 已发布;**0.4.2 交接线保命补丁已实现、待验收与发布**
3
+ > 版本基准:0.4.2(2026-10-25)|状态:0.3 已发布(npm + GitHub),0.3.6 已发布,0.4.0 已发布(npm latest + GitHub tag/Release),0.4.1 已发布;**0.4.2 交接线保命补丁已实现、待验收与发布**;**0.5.0 命名反转 + 接力条 + 交接后自动归档/恢复已实现(未提交、未发布);双向跳转的父→子入口与里程碑面板待实现(见 §三 0.5.0)**
4
4
  > 维护方式:每次发布前更新"当前状态"与勾选进度;方向变化必须同时改本文件和 memory 项目页。
5
5
 
6
6
  ## 一、最终目标(North Star)
@@ -68,7 +68,7 @@
68
68
  - ✅ **P1 换模型跟随**:状态刷新即重判;观察路由变化后首次违规弹一次 toast;算不出实际线不告警、不阻断。
69
69
  - ✅ **P2 文档三线**:README / README.en / TUTORIAL 写清 40% / 60% / 67.8%,删掉「原生线 −1%」旧文案;CHANGELOG 0.4.2 段同步。
70
70
  - ⏳ 待真机点验:设置页 max=62 与提示、环上警示、菜单 ⚠、换模型 toast、截图存档(本仓库自动化无 Chrome/CDP)。
71
- - 明确不做(0.4.2 边界):历史前缀 / 接力条(0.5.0)、自动触发 / 自动归档(1.0)、改宿主触发逻辑、环上 A/B/C 画法。
71
+ - 明确不做(0.4.2 边界):历史前缀 / 接力条(0.5.0)、自动触发(1.0)、改宿主触发逻辑、环上 A/B/C 画法。**自动归档旧会话已从 1.0 挪入 0.5.0**(见 §三 0.5.0 第 4 条)。
72
72
 
73
73
  1. ✅ **交接单六段 → 九段**(0.3 已提前交付):总体目标 / 约束 / 已完成 / 当前进度 / 改过的文件 / 关键决策 / 已知问题 / 未完成 / 下一步。
74
74
  - 文件改动、关键决策由程序收集(`core/handoff-facts.js`),模型只写 7 段、编不了。
@@ -79,8 +79,49 @@
79
79
 
80
80
  验收:真实长任务交接后新会话**无需补充说明**即可继续;交接单一次通过率 ≥90%。
81
81
 
82
+ ### 0.5.0 无痕交接(命名反转 + 接力条 + 自动归档/恢复已实现;父→子跳转/里程碑面板待实现)
83
+
84
+ > 范围口径:`default-workspace/dcr-版本路线图-2026-10-26.md` + `dcr-无痕handoff-运行目标与地基层-2026-10-25.md`。0.5.0 是用户可见的第一次大更新;**地基本身不单独发版**,随 0.5.0 一起发。
85
+
86
+ **用户能看到的变化(0.5.0 范围):**
87
+
88
+ 1. **命名反转**:交接后旧会话改名「历史 N:<原名>」,新会话继承原名;旧链(交接第 N 次)一个字符不动;列表读起来是一条对话。
89
+ 2. **接力条**:新会话输入框上方一行「已接力自 历史 1 ▾」,下拉含查看交接单 / 回到原会话 / 恢复旧会话 / 路线图占位;落位 `conversation.input.dock`(输入框单行时间线;早期审查记录的 `conversation.composer.dock` 同属输入框区域,宿主两个槽都在)。
90
+ 3. **双向跳转**:接力条回父会话(已实现,已归档时先自动恢复);父会话侧回子会话(待实现,需要父会话接力记录/入口)。
91
+ 4. **交接后自动归档旧会话**:交接成功(子会话已建、旧会话已改名、record 已落盘)即归档旧会话;交接完成提示 + 接力条「恢复旧会话」两处告知,一键恢复(正式 unarchive API),恢复后回原位;旧会话仍有 turn/subagent/job/schedule 时**不自动停止任务**,有限等待后保留可见并提示;失败不阻断交接。
92
+
93
+ **命名反转(第一个用户可见功能,已实现、未提交):**
94
+
95
+ - 新链:旧会话「历史 N:<原名>」,新会话继承干净原名;第 2 次为「历史 2:X / 历史 1:X / X」。
96
+ - 旧链:交接第 N 次 / 0.3 旧 record / 旧堆叠前缀继续旧命名,旧会话标题一个字符不改(链级 cutover)。
97
+ - 数据面:父/子 relay record 都写 `scheme` + `titleSeq`,新链父记录带 `historyTitle`;链号只从 record 续。
98
+ - 80 字节:前缀先计入、超长只缩 base;`renameHistoryOf` 幂等 + flush,失败只 warn。
99
+ - 测试:`test/handoff-naming.mjs`(7 组)+ F5 期望更新;接力条 `test/relay-bar.mjs`;自动归档/恢复 `test/archive.mjs`。`npm test` **257 PASS / 0 FAIL**。
100
+
101
+ **地基进度(数据面 / 纯函数 / 测试):**
102
+
103
+ | # | 内容 | 状态 |
104
+ |---|---|---|
105
+ | F1 | 历史前缀纯函数:`HISTORY_PREFIX` / `historyTitleOf` / `splitHandoffTitle` 返回 `scheme`(`handoff-n` / `history-n` / `legacy`) | ✅ 已提交 `5397863` |
106
+ | F2 | relay record 白名单放行 `scheme` / `titleSeq` / `historyTitle`(旧记录读成 null;父会话记录写出形状不变) | ✅ 已提交 `5397863` |
107
+ | F3 | 建子会话时往**子会话**写 relay record(ordinal/baseTitle/scheme/titleSeq)——不做这步第 2 次交接序号会重复 | ✅ 已提交 `b4878b8` |
108
+ | F4 | `readState.relay` 扩成只读 `chain`(ordinal/baseTitle/parentId/childId/scheme/path),旧字段原样保留 | ✅ 已提交 `cc6f86d` |
109
+ | F5 | 链不变量测试 `test/relay-chain.mjs`(三跳 1→2→3、双写形状、scheme 不混、旧链逐字节、chain 投影),并入 `npm test` | ✅ 已完成(未提交) |
110
+ | F6 | ROADMAP / CHANGELOG / ARCHITECTURE 登记 0.5.0 地基 | ✅ 本次(未提交) |
111
+ | 命名反转 | 旧会话「历史 N」/新会话继承原名/旧链 cutover(`dsh/sessions.js` + `actions/handoff.js`) | ✅ 已实现(未提交) |
112
+ | 自动归档/恢复 | 交接成功后 `workspaceRegistry.archiveSession`(不 stopActivity)+ `recover` 动作 + 接力条「恢复旧会话」+ `readState.parentArchived` | ✅ 已实现(未提交) |
113
+
114
+ **宿主侧没有接缝(0.5.0 不追):**
115
+
116
+ - **消息流拼接不可行**:`conversation.chat.flow` 是 `single` 槽且已被宿主 ChatFlow 注册,`conversation.chat.node` 只按宿主 entryKey 渲染 —— 旧消息拼不进新会话,只能「接力条 + 跳旧会话」。
117
+ - **通栏接力条不可行**:宿主 header 没有通栏 band,接力条落在输入框内的 `conversation.input.dock`(单行;早期审查记录的 `conversation.composer.dock` 同区域)。
118
+
119
+ **验收:** 交接 2 次后列表为 `历史 2:X / 历史 1:X / X`;接力条「查看交接单 / 回到原会话 / 恢复旧会话 / 路线图占位」可用;旧会话自动归档且能从接力条恢复;旧链标题不变;`npm test` 全绿 + 真机截图。
120
+
82
121
  ### 0.5 项目记忆(1–2 月)
83
122
 
123
+ > 0.5.0 实际范围是上面的「无痕交接」;本节为早期规划,不随 0.5.0 交付。
124
+
84
125
  1. **项目上下文包**:目标 / 关键决策 / 待办 / 改过的文件,新会话自动带上。
85
126
  2. **接力链视图**:父子会话树/时间线,一键跳转。
86
127
  3. **Memory 双向**:读取项目页作为上下文,写回交接单(先跑稳单向再开)。
@@ -164,6 +205,7 @@
164
205
  - ✅ 0.3.6 已发布(BUG-012):原生压缩线解析只认 `config:` 直接子键(按缩进判断),修复 `modelPolicies` 嵌套 `thresholdRatio` 被误报为全局线(Windows 真实会话误显示 50%);真实触发线按模型 route 计算留 0.4。
165
206
  - ✅ 0.4.0 判断机制与诊断包(2026-10-24 完成):P0-2 诊断包最小版已交付(commit `41699c8`,`npm test` 全绿 + 隔离 e2e 真机验证);P0-1 判断机制加强(cue 文案 v2 + 注入门控 10%–40%/≥3 轮 + 工具描述 v2 + cue/mark 计量)代码已落地、测试全绿,标准 B 已达标(3/3)并随 0.4.0 发布。
166
207
  - ✅ 真实压缩 e2e:`test/e2e/real-compact.mjs`(真实模型 + 真实压缩事务 + `maxSummaries=1` 合并旧摘要分支)6 项断言通过。
208
+ - ✅ 0.5.0 功能已落地(**未发布**):F1–F6 地基 + 命名反转 + 接力条 + 交接后自动归档/恢复入口;`test/relay-chain.mjs`、`test/relay-bar.mjs`、`test/archive.mjs` 并入 `npm test`(当前 **257 PASS / 0 FAIL**)。剩余:父→子跳转入口、里程碑面板。
167
209
  - ✅ P3-01~14(13 修/1 确认无需修)+ BUG-004 + P2-T1 已收口。
168
210
  - ⏳ 市场收录 PR:仓库满 1 天(2026-10-21 19:09 后)提,yml 与说明已备。
169
211
  - ⏳ 陌生人安装路径验证(npm 安装 → 重启 → 设置/指示灯)—— 发布后第一次外部用户安装时一并确认。
@@ -32,8 +32,9 @@ import { checkHandoffReadiness } from '../core/readiness.js';
32
32
  import { changedFilesOf } from '../dsh/session-files.js';
33
33
  import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
34
34
  import { surfaceMessages } from '../dsh/prefix.js';
35
- import { handoffTitlePlanOf, createHandoffSession, sourceTitleOf } from '../dsh/sessions.js';
35
+ import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
36
36
  import { writeRelayRecord } from '../dsh/relay.js';
37
+ import { archiveHandoffSource, handoffArchiveNoteOf } from '../dsh/handoff-archive.js';
37
38
  import { withLock } from './lock.js';
38
39
  import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
39
40
 
@@ -231,6 +232,10 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
231
232
  agent,
232
233
  doc,
233
234
  title: titlePlan.title,
235
+ // 0.5.0:交接单已经落盘,把同一个路径写进子会话 relay 记录(接力条
236
+ // 「查看交接单」不再依赖回查父会话);父记录仍写同一份 path。
237
+ path: file,
238
+ plan: titlePlan,
234
239
  log: deps.log,
235
240
  });
236
241
  } catch (error) {
@@ -243,8 +248,21 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
243
248
  throw error;
244
249
  }
245
250
 
251
+ // 0.5.0 命名反转:新链在**子会话建好之后**把旧会话改名「历史 N:<原名>」,
252
+ // 新会话已经继承干净原名(createHandoffSession 里改名)。旧链(handoff-n)不动,
253
+ // 保持「交接第 N 次:」一个字符不改(链级 cutover)。
254
+ // 改名是可见副作用:失败只 warn,交接照常成功;成功后 renameHistoryOf 内部 flush。
255
+ const historyRename = await renameHistoryOf(deps.ctx, session, {
256
+ index: titlePlan.index,
257
+ baseTitle: titlePlan.baseTitle,
258
+ scheme: titlePlan.scheme,
259
+ log: deps.log,
260
+ });
261
+
246
262
  // A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
247
263
  // 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
264
+ // 0.5.0:父/子记录都写 scheme(本次链的命名方案)+ titleSeq(各自 rename 的 eventSeq);
265
+ // 新链父记录再写 historyTitle(旧会话改名后的标题),供审计/幂等核对。
248
266
  const relayAt = Date.now();
249
267
  const relayWrote = writeRelayRecord(deps.engine, session, {
250
268
  parentId: session.id,
@@ -258,8 +276,36 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
258
276
  planId: compactOutcome?.planId ?? null,
259
277
  title: titlePlan.title,
260
278
  baseTitle: titlePlan.baseTitle,
279
+ scheme: titlePlan.scheme,
280
+ titleSeq: historyRename.titleSeq,
281
+ // historyTitle 只在改名真的生效时写;失败/服务不可用时不能记一个不存在的标题。
282
+ ...(titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
283
+ ? { historyTitle: historyRename.title }
284
+ : {}),
261
285
  });
262
286
  if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
287
+ // renameHistoryOf 的 flush 发生在父记录之前;这里再 flush 一次,保证旧会话的
288
+ // 「历史 N」标题与这条父记录一起落盘(失败只 warn,不影响交接结果)。
289
+ try {
290
+ await deps.ctx.sessions?.flush?.(session);
291
+ } catch (error) {
292
+ deps.log?.warn?.(`compact-relay: 旧会话接力链记录落盘失败:${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+
295
+ // 0.5.0:交接成功后自动归档旧会话(让列表只剩新会话)。
296
+ // 顺序要求:子会话已建好、旧会话已改名、父记录已 flush —— 全部完成之后才归档;
297
+ // 归档失败只 warn + 在返回文案里说明,绝不回滚/阻断交接。
298
+ // 不传 stopActivity:自动流程不替用户取消旧会话里仍在跑的 turn/subagent/job/schedule;
299
+ // 只对收尾中的 turn 做有限等待重试,仍活跃就保留可见并提示(宁可多一行,不静默杀任务)。
300
+ let archive;
301
+ try {
302
+ archive = await archiveHandoffSource(deps.ctx, session.id, { log: deps.log });
303
+ } catch (error) {
304
+ // archiveHandoffSource 设计上不抛;这里再兜一层,任何替身/内核异常都不影响交接结果。
305
+ deps.log?.warn?.(`compact-relay: 旧会话归档异常:${error instanceof Error ? error.message : String(error)}(交接已完成)`);
306
+ archive = { status: 'failed', archived: false, activityKinds: [] };
307
+ }
308
+ const archiveNote = handoffArchiveNoteOf(archive);
263
309
 
264
310
  // BUG-004:失败原因可能是「摘要模型调用了工具」这类可操作信息,以前只写「日志有记录」
265
311
  // (用户看不到 host 日志);这里带上短原因 + 可手动 /bc 的建议。
@@ -300,11 +346,14 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
300
346
  path: file,
301
347
  readiness,
302
348
  started: created.started,
349
+ // 0.5.0:归档结果结构化返回(HTTP 暂不下发;客户端看 message/接力条状态即可)。
350
+ archive: { status: archive.status, archived: archive.archived === true },
303
351
  message:
304
352
  `交接完成:${compactNote}` +
305
353
  `交接单已存至 ${file};` +
306
354
  `新会话(${created.childId})已就绪` +
307
355
  (created.started ? ',正在切换。' : ',正在切换(首轮未自动启动,可在新会话里发一条消息继续)。') +
356
+ archiveNote +
308
357
  // A5 兜底:结构化 childId 没传下来时,客户端可从文本里提取。
309
358
  `\n#child=${created.childId}`,
310
359
  };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 0.5.0:恢复归档的旧会话(交接后的恢复入口,HTTP `action: 'recover'`)。
3
+ *
4
+ * 这是客户端接力条恢复入口的**兜底**:新版客户端优先直接调
5
+ * `ctx.uiWorkspace.unarchiveSession(sessionId)`(宿主 Client 侧同一条接缝,
6
+ * 会同时更新本地归档集合);老客户端/客户端拿不到 `unarchiveSession` 时,
7
+ * 走本插件自己的 HTTP 动作,服务端调 `workspaceRegistry.unarchiveSession`。
8
+ *
9
+ * 幂等:宿主 unarchiveSession 对不在归档集合里的 id 直接 no-op,所以重复点
10
+ * 「恢复旧会话」不会报错、也不会改 workspace 位置(归档从不改成员关系)。
11
+ */
12
+ import { actionError } from '../core/errors.js';
13
+ import { archivedSessionIdsOf, isSessionArchived, workspaceRegistryOf } from '../dsh/handoff-archive.js';
14
+
15
+ function errorMessageOf(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+
19
+ /**
20
+ * 把旧会话从归档集合里恢复。
21
+ * @param {{ctx:object, log?:{warn?:Function}}} deps
22
+ * @param {{sessionId:string}} input 要恢复的会话 id(不是当前会话)
23
+ * @returns {Promise<{ok:true, recovered:boolean, message:string, archivedSessionIds:string[]|null}>}
24
+ */
25
+ export async function recoverArchivedSession(deps, { sessionId } = {}) {
26
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
27
+ throw actionError('recover', '缺少要恢复的会话 id');
28
+ }
29
+ const registry = workspaceRegistryOf(deps.ctx);
30
+ if (registry === null || typeof registry.unarchiveSession !== 'function') {
31
+ throw actionError('recover', '当前 DSH 宿主没有 Workspace 归档恢复接口,无法恢复旧会话');
32
+ }
33
+
34
+ const wasArchived = isSessionArchived(deps.ctx, sessionId);
35
+ try {
36
+ await registry.unarchiveSession(sessionId);
37
+ } catch (error) {
38
+ deps.log?.warn?.(`compact-relay: 恢复归档会话 ${sessionId} 失败:${errorMessageOf(error)}`);
39
+ throw actionError('recover', `恢复旧会话失败:${errorMessageOf(error)}`, error);
40
+ }
41
+
42
+ return {
43
+ ok: true,
44
+ // false 表示本来就没归档(幂等 no-op),仍然算「恢复成功」——调用方可以直接打开。
45
+ recovered: wasArchived !== false,
46
+ message: wasArchived === false ? '这个会话本来就没有归档,可直接打开。' : '已恢复旧会话,可从侧栏重新打开。',
47
+ archivedSessionIds: archivedSessionIdsOf(deps.ctx),
48
+ };
49
+ }
@@ -13,7 +13,8 @@ import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSe
13
13
  import { compressHintIsActive } from '../core/compress-hint.js';
14
14
  import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
15
15
  import { nativeLineOf } from '../core/native-line.js';
16
- import { relayRecordOf } from '../dsh/relay.js';
16
+ import { relayChainOf, relayRecordOf } from '../dsh/relay.js';
17
+ import { isSessionArchived } from '../dsh/handoff-archive.js';
17
18
 
18
19
  /**
19
20
  * 动作层统一解析「这次动作作用在哪个 agent」:只认显式 sessionId,
@@ -144,6 +145,17 @@ export async function readState(deps, { sessionId, agent } = {}) {
144
145
  }
145
146
  }
146
147
 
148
+ // A5 / 0.5.0 F4:接力链记录只读一次。`relay` 是旧字段(原样下发,一个不改);
149
+ // `chain` 是它的只读投影,链号/原题/父子/scheme/path 直接给出,客户端不再自己解析。
150
+ // 没有记录 → 两者都是 null;旧记录没有的字段 → chain 里为 null(不猜)。
151
+ const relay = target === null ? null : relayRecordOf(target.session);
152
+ const chain = relay === null ? null : relayChainOf(relay);
153
+ // 0.5.0:旧会话是否已被自动归档(接力条据此显示「恢复旧会话」)。这是宿主
154
+ // registry 的独立状态,不塞进 chain(chain 仍然只是 relay 记录的纯投影)。
155
+ // 拿不到 registry/归档集合 → null(不知道,客户端按「可尝试恢复」处理)。
156
+ const parentArchived =
157
+ chain === null || chain.parentId === null ? null : isSessionArchived(deps.ctx, chain.parentId);
158
+
147
159
  return {
148
160
  revision: ++stateRevision,
149
161
  sessionId: target?.id ?? null,
@@ -164,8 +176,14 @@ export async function readState(deps, { sessionId, agent } = {}) {
164
176
  colors: { ...deps.cfg.colors, palette: paletteForBothSchemes(deps.cfg.colors) },
165
177
  // A4:设置文件有误时随状态下发,设置页/客户端可提示(正常为 null)。
166
178
  settingsError: deps.settings?.error ?? null,
167
- // A5:最近一次交接的接力链记录(没有就是 null)。
168
- relay: target === null ? null : relayRecordOf(target.session),
179
+ // A5:最近一次交接的接力链记录(没有就是 null)。0.5.0 F4:旧字段原样保留。
180
+ relay,
181
+ // 0.5.0 F4:同一条记录的只读链投影 {ordinal, baseTitle, parentId, childId, scheme, path?};
182
+ // 只加不改:老客户端忽略它,新客户端不再解析标题。没有记录 → null。
183
+ chain,
184
+ // 0.5.0:链上父会话当前是否在 workspace 归档集合里(true/false);没有父会话或
185
+ // 宿主没有可读的归档状态 → null。只加不改。
186
+ parentArchived,
169
187
  // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
170
188
  compressHint: activeHint,
171
189
  // 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。
@@ -8,6 +8,7 @@ import { readCompactDiffs } from '../actions/compact-diff.js';
8
8
  import { readDiagnostics } from '../actions/diagnostics.js';
9
9
  import { handoff, previewHandoff } from '../actions/handoff.js';
10
10
  import { listHandoffDocs, readHandoffDoc } from '../actions/handoff-docs.js';
11
+ import { recoverArchivedSession } from '../actions/recover.js';
11
12
  import { togglePin } from '../actions/pin.js';
12
13
  import { setNativeAutoCompactionEnabled } from '../actions/native-compaction.js';
13
14
  import { readState, resolveTarget } from '../actions/state.js';
@@ -384,11 +385,14 @@ export function registerHttp(webCtx, deps) {
384
385
  }
385
386
  // 多数动作作用在具体会话上:缺 sessionId 或类型错时绝不能退化成「最后一个 agent」
386
387
  // (2026-10-19 debug:旧实现会压错会话)。
387
- // 例外:native-auto 改的是 DSH profile 配置(全局开关),不属于任何会话——
388
- // UI-003(2026-10-20):以前这里对所有 action 都先要 sessionId,设置页没带 →
389
- // 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
388
+ // 例外:
389
+ // - native-auto 改的是 DSH profile 配置(全局开关),不属于任何会话——
390
+ // UI-003(2026-10-20):以前这里对所有 action 都先要 sessionId,设置页没带 →
391
+ // 恒 400「缺少 sessionId」,于是「关闭自动压缩」永远写不进去、也看不到反馈。
392
+ // - recover 作用在**已归档**的旧会话上,它通常没有活 agent;不能走 resolveTarget
393
+ // (否则恒 409「没有活动会话」)。sessionId 由 recover 分支自己校验。
390
394
  let agent = null;
391
- if (parsed.action !== 'native-auto') {
395
+ if (parsed.action !== 'native-auto' && parsed.action !== 'recover') {
392
396
  if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
393
397
  return send(res, 400, { ok: false, message: '缺少 sessionId' });
394
398
  }
@@ -396,6 +400,15 @@ export function registerHttp(webCtx, deps) {
396
400
  if (agent === null) return send(res, 409, { ok: false, message: '没有活动会话' });
397
401
  }
398
402
 
403
+ if (parsed.action === 'recover') {
404
+ // 0.5.0:恢复被自动归档的旧会话(接力条恢复入口的 HTTP 兜底)。
405
+ if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) {
406
+ return send(res, 400, { ok: false, message: '缺少要恢复的会话 sessionId' });
407
+ }
408
+ const outcome = await recoverArchivedSession(deps, { sessionId: parsed.sessionId });
409
+ return send(res, 200, outcome);
410
+ }
411
+
399
412
  if (parsed.action === 'preview') {
400
413
  // M6:只算不写——先告诉用户「要压哪段、多少 token」,再让他确认。
401
414
  // A3:返回 planId,confirm 会带着它回来校验「同一段范围」。