dsh-compact-relay 0.5.0 → 0.5.2

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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 0.5.2 L 项:**强制归档旧会话**(显式用户确认后才停止后台任务)。
3
+ *
4
+ * 背景:自动归档(`dsh/handoff-archive.js:archiveHandoffSource`)在旧会话还有
5
+ * turn/subagent/job/schedule 时**故意不归档**(`status:'active'`)—— 静默杀后台任务
6
+ * 比「旧会话多留一行」破坏性大得多。但这也意味着「旧会话一直有任务在跑」时用户
7
+ * 没有清理出路(0.5.x 补丁计划 P4)。这里补的就是那条**显式**出路:
8
+ *
9
+ * - 只有 `confirmStop === true`(用户在二次确认里点过「强制归档(停止任务)」)
10
+ * 才会调用 `workspaceRegistry.archiveSession(sessionId, { stopActivity: true })`;
11
+ * - 没有确认 → 直接拒绝(不静默降级成普通归档,也不静默停止任务);
12
+ * - 旧会话在侧栏置顶时要求 `confirmUnpin === true`(宿主归档会在同一次写盘里
13
+ * 取消置顶),否则返回 `pinned` + 现有置顶文案,**不归档**;
14
+ * - 失败抛可读错误(`code:'force-archive'`),不吞、不假装成功。
15
+ *
16
+ * 宿主实证(`@deepseek-ai/dsh-workspace/lib/index.js`):
17
+ * - `archiveSession(id)` 先跑 `workspace/session-activity` waterfall,有 provider
18
+ * 报活就抛 `WorkspaceActiveSessionError`(带 `activity` 数组);
19
+ * - `archiveSession(id, { stopActivity:true })` 跳过活动检查、先写归档集合,再
20
+ * `workspace/session-stop` 停止旧会话的 turn/subagent/job/schedule;
21
+ * - 归档与置顶互斥:同一笔写盘会把该 id 从 `pinnedSessionIds` 移除。
22
+ */
23
+ import { actionError } from '../core/errors.js';
24
+ import {
25
+ archivedSessionIdsOf,
26
+ handoffArchiveNoteOf,
27
+ isSessionArchived,
28
+ isSessionPinned,
29
+ workspaceRegistryOf,
30
+ } from '../dsh/handoff-archive.js';
31
+
32
+ function errorMessageOf(error) {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+
36
+ /**
37
+ * 强制归档旧会话(会停止它上面正在跑的 turn / subagent / job / schedule)。
38
+ *
39
+ * 返回值(全部 `ok:true`;失败一律抛 `actionError('force-archive', …)`):
40
+ * - `status:'archived'` 刚写入归档集合(已停止任务);
41
+ * - `status:'already'` 之前就已经归档(幂等,不再写盘、不再停任务);
42
+ * - `status:'pinned'` 已置顶且未确认取消置顶 —— **没有归档**,note 是现有置顶文案。
43
+ * 三种情况的 `note` 都复用 `handoffArchiveNoteOf`(与交接自动归档同一套告知文案)。
44
+ *
45
+ * @param {{ctx:object, log?:{warn?:Function}}} deps
46
+ * @param {{sessionId:string, confirmStop:boolean, confirmUnpin?:boolean}} input
47
+ * sessionId 是**旧会话** id(不是当前会话);confirmStop 必须是 true(二次确认的凭证)。
48
+ * @returns {Promise<{ok:true, status:'archived'|'already'|'pinned', archived:boolean, note:string, archivedSessionIds:string[]|null}>}
49
+ */
50
+ export async function forceArchiveSession(deps, { sessionId, confirmStop, confirmUnpin } = {}) {
51
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
52
+ throw actionError('force-archive', '缺少要强制归档的会话 id');
53
+ }
54
+ // 防御性:HTTP 层与客户端都要求显式确认;动作层再挡一次,避免任何调用方误传。
55
+ if (confirmStop !== true) {
56
+ throw actionError('force-archive', '强制归档会停止旧会话上正在跑的任务,需要显式确认(confirmStop:true)');
57
+ }
58
+
59
+ const registry = workspaceRegistryOf(deps.ctx);
60
+ if (registry === null || typeof registry.archiveSession !== 'function') {
61
+ throw actionError('force-archive', '当前 DSH 宿主没有 Workspace 归档接口,无法强制归档旧会话');
62
+ }
63
+
64
+ // 幂等:已在归档集合里就什么都不做(尤其不能再 stopActivity 一次)。
65
+ if (isSessionArchived(deps.ctx, sessionId) === true) {
66
+ return {
67
+ ok: true,
68
+ status: 'already',
69
+ archived: true,
70
+ note: handoffArchiveNoteOf({ status: 'already', archived: true }),
71
+ archivedSessionIds: archivedSessionIdsOf(deps.ctx),
72
+ };
73
+ }
74
+
75
+ // 置顶保护:归档会取消置顶(宿主在同一笔写盘里做),必须用户单独确认;
76
+ // 读不到置顶集合(null)时不猜 —— 按「未置顶」继续,由宿主自己决定。
77
+ if (confirmUnpin !== true && isSessionPinned(deps.ctx, sessionId) === true) {
78
+ deps.log?.warn?.(
79
+ `compact-relay: 旧会话 ${sessionId} 在侧栏置顶,未确认取消置顶前不强制归档(归档会取消置顶)`,
80
+ );
81
+ return {
82
+ ok: true,
83
+ status: 'pinned',
84
+ archived: false,
85
+ // 0.5.2 审查 P1-3:本场景没有「交接确认」可点,不能复用交接流程的 pinned 文案。
86
+ note: '旧会话已置顶,本次未归档(归档会取消置顶);确认「仍然归档(取消置顶)」后才会归档并可恢复。',
87
+ archivedSessionIds: archivedSessionIdsOf(deps.ctx),
88
+ };
89
+ }
90
+
91
+ try {
92
+ // 唯一允许传 stopActivity 的地方:用户已确认「会停止旧会话上正在跑的任务」。
93
+ await registry.archiveSession(sessionId, { stopActivity: true });
94
+ } catch (error) {
95
+ deps.log?.warn?.(`compact-relay: 强制归档旧会话 ${sessionId} 失败:${errorMessageOf(error)}`);
96
+ const unknown = error !== null && typeof error === 'object' && error.name === 'WorkspaceUnknownSessionError';
97
+ throw actionError(
98
+ 'force-archive',
99
+ unknown
100
+ ? `强制归档失败:宿主里找不到这个会话(${errorMessageOf(error)})`
101
+ : `强制归档失败:${errorMessageOf(error)}`,
102
+ error,
103
+ );
104
+ }
105
+
106
+ return {
107
+ ok: true,
108
+ status: 'archived',
109
+ archived: true,
110
+ note: handoffArchiveNoteOf({ status: 'archived', archived: true }),
111
+ archivedSessionIds: archivedSessionIdsOf(deps.ctx),
112
+ };
113
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
+ import { clipCodeUnits } from '../core/clip.js';
20
21
  import { actionError } from '../core/errors.js';
21
22
  import { pinAnchorOf } from '../core/pin-anchors.js';
22
23
  import { handoffFileName, renderHandoffDoc } from '../core/handoff-doc.js';
@@ -29,12 +30,13 @@ import { buildSiteSnapshot } from '../dsh/site-info.js';
29
30
  import { generateHandoffDoc } from '../dsh/handoff-writer.js';
30
31
  import { buildHandoffProgramSections } from '../core/handoff-facts.js';
31
32
  import { checkHandoffReadiness } from '../core/readiness.js';
33
+ import { parentRelayRecordOf } from '../core/relay-record.js';
32
34
  import { changedFilesOf } from '../dsh/session-files.js';
33
35
  import { errorCodeOf, localIso } from '../dsh/diag-counters.js';
34
36
  import { surfaceMessages } from '../dsh/prefix.js';
35
37
  import { handoffTitlePlanOf, createHandoffSession, renameHistoryOf, sourceTitleOf } from '../dsh/sessions.js';
36
38
  import { writeRelayRecord } from '../dsh/relay.js';
37
- import { archiveHandoffSource, handoffArchiveNoteOf } from '../dsh/handoff-archive.js';
39
+ import { archiveHandoffSource, handoffArchiveNoteOf, isSessionPinned } from '../dsh/handoff-archive.js';
38
40
  import { withLock } from './lock.js';
39
41
  import { beginInflight, cancelErrorOf, combinedSignal, finishInflight } from './support.js';
40
42
 
@@ -97,13 +99,15 @@ async function collectHandoffFacts(deps, agent) {
97
99
  * 不记 message(message 可能带交接单路径/会话内容)。行为与文案不变。
98
100
  *
99
101
  * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object, diag?:object}} deps
100
- * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>}} input
102
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:Record<string,string>, archive?:boolean, confirmUnpin?:boolean}} input
101
103
  * edits 只允许 `{约束?:string, 下一步?:string}`(P2-1;非法字段抛 code='edits')
104
+ * archive 默认 true(0.5.0 行为);false = 只交接不归档
105
+ * confirmUnpin 默认 false:旧会话在侧栏置顶时,必须显式确认才归档(归档会取消置顶)
102
106
  */
103
- export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
107
+ export async function handoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin } = {}) {
104
108
  const startedAt = Date.now();
105
109
  try {
106
- const outcome = await runHandoff(deps, { agent, signal, commandId, edits });
110
+ const outcome = await runHandoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin });
107
111
  deps.diag?.bumpAction('handoff', true);
108
112
  return outcome;
109
113
  } catch (error) {
@@ -118,9 +122,9 @@ export async function handoff(deps, { agent, signal, commandId, edits } = {}) {
118
122
  /**
119
123
  * 交接流程本体。
120
124
  * @param {{ctx:object, cfg:object, lock:object, engine:object, log?:object}} deps
121
- * @param {{agent:object, signal?:AbortSignal, commandId?:string}} input
125
+ * @param {{agent:object, signal?:AbortSignal, commandId?:string, edits?:object, archive?:boolean, confirmUnpin?:boolean}} input
122
126
  */
123
- async function runHandoff(deps, { agent, signal, commandId, edits }) {
127
+ async function runHandoff(deps, { agent, signal, commandId, edits, archive, confirmUnpin }) {
124
128
  if (agent === undefined || agent === null) throw actionError('handoff', '当前没有打开的会话,无法交接');
125
129
  const session = agent.session;
126
130
  if (session === undefined) throw actionError('handoff', '没有活动会话,无法交接');
@@ -260,29 +264,32 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
260
264
  });
261
265
 
262
266
  // A5:把接力链写成父会话里的结构化记录;写失败不阻断交接(标题解析仍可兜底)。
263
- // 0.4 P2-2:canonical 字段 parentId/childId/ordinal/at/atIso 补齐;旧字段 parentSessionId/index 保留兼容。
264
- // 0.5.0:父/子记录都写 scheme(本次链的命名方案)+ titleSeq(各自 rename 的 eventSeq);
265
- // 新链父记录再写 historyTitle(旧会话改名后的标题),供审计/幂等核对。
267
+ // 0.5.2 S2:形状(含 0.3 旧字段双写、planId、条件写入的 historyTitle、**键顺序**)
268
+ // 全部由 core/relay-record 的唯一构造器负责,这里只传语义参数。
269
+ // historyTitle 只在改名真的生效(renamed / already)时传;失败/服务不可用时
270
+ // 不能记一个不存在的标题 —— 构造器对空/非字符串一律不写这个键。
266
271
  const relayAt = Date.now();
267
- const relayWrote = writeRelayRecord(deps.engine, session, {
268
- parentId: session.id,
269
- childId: created.childId,
270
- ordinal: titlePlan.index,
271
- at: relayAt,
272
- atIso: localIso(new Date(relayAt)),
273
- parentSessionId: session.id,
274
- index: titlePlan.index,
275
- path: file,
276
- planId: compactOutcome?.planId ?? null,
277
- title: titlePlan.title,
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
- : {}),
285
- });
272
+ const relayWrote = writeRelayRecord(
273
+ deps.engine,
274
+ session,
275
+ parentRelayRecordOf({
276
+ parentId: session.id,
277
+ childId: created.childId,
278
+ ordinal: titlePlan.index,
279
+ at: relayAt,
280
+ atIso: localIso(new Date(relayAt)),
281
+ path: file,
282
+ planId: compactOutcome?.planId,
283
+ title: titlePlan.title,
284
+ baseTitle: titlePlan.baseTitle,
285
+ scheme: titlePlan.scheme,
286
+ titleSeq: historyRename.titleSeq,
287
+ historyTitle:
288
+ titlePlan.scheme === 'history-n' && (historyRename.renamed || historyRename.reason === 'already')
289
+ ? historyRename.title
290
+ : undefined,
291
+ }),
292
+ );
286
293
  if (!relayWrote) deps.log?.warn?.('compact-relay: 接力链记录写入失败,下次交接将回退到标题解析');
287
294
  // renameHistoryOf 的 flush 发生在父记录之前;这里再 flush 一次,保证旧会话的
288
295
  // 「历史 N」标题与这条父记录一起落盘(失败只 warn,不影响交接结果)。
@@ -293,28 +300,36 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
293
300
  }
294
301
 
295
302
  // 0.5.0:交接成功后自动归档旧会话(让列表只剩新会话)。
303
+ // 0.5.1 批 2:archive:false = 只交接不归档;旧会话在侧栏置顶时必须显式
304
+ // confirmUnpin 才归档(宿主归档会在同一次写盘里取消置顶 —— dsh-workspace 源码)。
296
305
  // 顺序要求:子会话已建好、旧会话已改名、父记录已 flush —— 全部完成之后才归档;
297
306
  // 归档失败只 warn + 在返回文案里说明,绝不回滚/阻断交接。
298
307
  // 不传 stopActivity:自动流程不替用户取消旧会话里仍在跑的 turn/subagent/job/schedule;
299
308
  // 只对收尾中的 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: [] };
309
+ const archiveRequested = archive !== false;
310
+ let archiveResult;
311
+ if (!archiveRequested) {
312
+ archiveResult = { status: 'skipped', archived: false, activityKinds: [] };
313
+ } else if (isSessionPinned(deps.ctx, session.id) === true && confirmUnpin !== true) {
314
+ archiveResult = { status: 'pinned', archived: false, activityKinds: [] };
315
+ deps.log?.warn?.(
316
+ `compact-relay: 旧会话 ${session.id} 在侧栏置顶,未确认前不自动归档(归档会取消置顶;交接已完成)`,
317
+ );
318
+ } else {
319
+ try {
320
+ archiveResult = await archiveHandoffSource(deps.ctx, session.id, { log: deps.log });
321
+ } catch (error) {
322
+ // archiveHandoffSource 设计上不抛;这里再兜一层,任何替身/内核异常都不影响交接结果。
323
+ deps.log?.warn?.(`compact-relay: 旧会话归档异常:${error instanceof Error ? error.message : String(error)}(交接已完成)`);
324
+ archiveResult = { status: 'failed', archived: false, activityKinds: [] };
325
+ }
307
326
  }
308
- const archiveNote = handoffArchiveNoteOf(archive);
327
+ const archiveNote = handoffArchiveNoteOf(archiveResult);
309
328
 
310
329
  // BUG-004:失败原因可能是「摘要模型调用了工具」这类可操作信息,以前只写「日志有记录」
311
330
  // (用户看不到 host 日志);这里带上短原因 + 可手动 /bc 的建议。
312
- const briefWarning =
313
- compactWarning === null
314
- ? ''
315
- : compactWarning.length > 120
316
- ? `${compactWarning.slice(0, 119)}…`
317
- : compactWarning;
331
+ // 0.5.1 D03:截断收口到 core/clip(原手写 120 字预算,文案不变)。
332
+ const briefWarning = compactWarning === null ? '' : clipCodeUnits(compactWarning, 120);
318
333
  const compactNote =
319
334
  compactOutcome !== null && compactOutcome.compacted === true
320
335
  ? `已将 ${compactOutcome.shadowedSeqs.length} 条旧会话压成 1 条摘要;`
@@ -347,7 +362,7 @@ async function runHandoff(deps, { agent, signal, commandId, edits }) {
347
362
  readiness,
348
363
  started: created.started,
349
364
  // 0.5.0:归档结果结构化返回(HTTP 暂不下发;客户端看 message/接力条状态即可)。
350
- archive: { status: archive.status, archived: archive.archived === true },
365
+ archive: { status: archiveResult.status, archived: archiveResult.archived === true },
351
366
  message:
352
367
  `交接完成:${compactNote}` +
353
368
  `交接单已存至 ${file};` +
@@ -176,16 +176,17 @@ export function listPins(deps, { agent } = {}) {
176
176
  }
177
177
 
178
178
  /**
179
- * 菜单用:有锁定就清空,没有就锁最近一轮。
179
+ * 菜单用:有锁定(含全部已失效的快照)就清空,没有就锁最近一轮。
180
180
  *
181
- * P3-03:判断必须和 readState 的展示同口径(`livePinnedSeqs`)。只看日志里的最新
182
- * 快照时,快照 seq 已被压缩/清屏带走的场景会让菜单显示「锁定重要消息」(未锁),
183
- * 点下去却走 unpinAll → 用户看到的是「明明没锁却被解锁」。
181
+ * 0.5.1 批 2:菜单在「全部 pin 已被原生压缩带走」时显示「解除失效锁定(N 条)」,
182
+ * 所以这里必须按**快照**判定:快照非空 → unpinAll,不能因为 live=0 就反过来新锁
183
+ * 一条(按钮文案与动作会完全相反)。`pinLastTurn`/`pinByIndex` 内部仍只带 live
184
+ * 集合进新快照,顺手清掉过期 seq。
184
185
  * @param {{engine:object}} deps
185
186
  * @param {{agent:object}} input
186
187
  */
187
188
  export function togglePin(deps, { agent } = {}) {
188
189
  const session = agent?.session;
189
- if (session === null || session === undefined) throw actionError('pin', '没有活动会话');
190
- return livePinnedSeqs(session).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
190
+ if (session === null || session === undefined) throw actionError('pin', '当前没有活动会话');
191
+ return latestPinnedSeqs(sessionLog(session)).length > 0 ? unpinAll(deps, { agent }) : pinLastTurn(deps, { agent });
191
192
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 完整交接链(只读)动作层 —— HTTP 适配层与 dsh 读取器之间的薄包装。
3
+ *
4
+ * 0.5.1 DCR-05:客户端点「完整交接链」时一次取回整条链;服务端按需只读持久化日志,
5
+ * **不把祖先会话加载进 ctx.sessions**(宿主对未加载会话的 readState 返回 sessionId:null,
6
+ * 逐级 GET 会停在断点)。读取细节见 dsh/relay-chain.js。
7
+ */
8
+ import { readRelayChain } from '../dsh/relay-chain.js';
9
+
10
+ /**
11
+ * @param {{ctx:object}} deps
12
+ * @param {{sessionId?:string}} [input]
13
+ * @returns {ReturnType<typeof readRelayChain>}
14
+ */
15
+ export function readCompactRelayChain(deps, { sessionId } = {}) {
16
+ return readRelayChain(deps?.ctx, { sessionId });
17
+ }
@@ -6,15 +6,19 @@
6
6
  */
7
7
  import { paletteForBothSchemes } from '../core/colors.js';
8
8
  import { adviceFor, ratioOf, thresholdsForWindow } from '../core/policy.js';
9
+ import { nativePinnedRiskOf } from '../core/native-pin-risk.js';
10
+ import { pinHealthOf } from '../core/pin-health.js';
11
+ import { latestPinnedSeqs } from '../core/pins.js';
9
12
  import { tokenOf } from '../core/tokens.js';
10
13
  import { measurePressure, routeBudgetOf } from '../dsh/pressure.js';
11
14
  import { selectCompactableRange } from '../dsh/select-range.js';
12
- import { compressHint as compressHintOf, countSummaries, lastCheckpointSurfaceSeq, livePinnedSeqs, liveSessionOf, pickAgent, surfaceSeqs } from '../dsh/session-view.js';
15
+ import { compressHint as compressHintOf, countSummaries, firstCompactableIdx, lastCheckpointSurfaceSeq, liveSessionOf, pickAgent, sessionLog, surfaceSeqs } from '../dsh/session-view.js';
13
16
  import { compressHintIsActive } from '../core/compress-hint.js';
14
17
  import { readNativeCompaction, resolveNativePolicy } from '../dsh/native-compaction.js';
15
- import { nativeLineOf } from '../core/native-line.js';
16
- import { relayChainOf, relayRecordOf } from '../dsh/relay.js';
17
- import { isSessionArchived } from '../dsh/handoff-archive.js';
18
+ import { nativeLineOf, nativeRetainTokensOf } from '../core/native-line.js';
19
+ import { legacyChainParentIdOf, legacyRelayChainOf, relayChainOf, relayInboundRecordOf, relayOutboundRecordOf, relayRecordOf } from '../dsh/relay.js';
20
+ import { isSessionArchived, isSessionPinned, workspaceRegistryOf } from '../dsh/handoff-archive.js';
21
+ import { sourceTitleOf } from '../dsh/sessions.js';
18
22
 
19
23
  /**
20
24
  * 动作层统一解析「这次动作作用在哪个 agent」:只认显式 sessionId,
@@ -96,10 +100,20 @@ export async function readState(deps, { sessionId, agent } = {}) {
96
100
  const compactRangeReason = compactRange !== null && !hasLiveAgent ? 'no_agent' : null;
97
101
 
98
102
  // M6:钉住集合(菜单显示「清除钉住(N 条)」,也能在无 agent 时读 live session)。
99
- const pinnedSeqs = target === null ? [] : livePinnedSeqs(target.session);
103
+ // 0.5.1 批 2(C 方案):pin 快照 vs 当前 surface ——
104
+ // live = 还在 surface 上(环/计数/解锁仍按这些);
105
+ // stale = 快照里有、surface 上没了(原生压缩/其它压缩/历史变化带走)——
106
+ // 单独下发,界面标「已失效」,既不假装还在,也不凭空消失。
107
+ const surface = target === null ? [] : surfaceSeqs(target.session);
108
+ const events = target === null ? [] : sessionLog(target.session);
109
+ const pinHealth =
110
+ target === null || surface.length === 0
111
+ ? // 没有 surface(会话刚打开/尚未回放)时不能把全部 pin 判成失效:那是「还不知道」。
112
+ { live: [], stale: [], cause: null, causes: { native: 0, other: 0, unknown: 0 } }
113
+ : pinHealthOf({ pinnedSeqs: latestPinnedSeqs(events), surfaceSeqs: surface, log: events });
114
+ const pinnedSeqs = pinHealth.live;
100
115
  // 0.3.1:锁定消息在 surface 里的相对位置(0–1,方案 A:消息在历史中的位置),
101
116
  // 指示灯环上按位置画锁定块;多个锁定按位置从早到晚排序。
102
- const surface = target === null ? [] : surfaceSeqs(target.session);
103
117
  const surfaceIndex = new Map(surface.map((seq, index) => [seq, index]));
104
118
  const pinMarks = pinnedSeqs
105
119
  .filter((seq) => surfaceIndex.has(seq))
@@ -145,16 +159,96 @@ export async function readState(deps, { sessionId, agent } = {}) {
145
159
  }
146
160
  }
147
161
 
148
- // A5 / 0.5.0 F4:接力链记录只读一次。`relay` 是旧字段(原样下发,一个不改);
149
- // `chain` 是它的只读投影,链号/原题/父子/scheme/path 直接给出,客户端不再自己解析。
150
- // 没有记录 → 两者都是 null;旧记录没有的字段 → chain 里为 null(不猜)。
162
+ // 0.5.1 批 2:预判「下一次原生自动压缩会覆盖哪些锁定消息」——事前提示。
163
+ //
164
+ // 宿主信号:原生自动压缩只在 step boundary 触发(pressure ≥ 真实触发线后),
165
+ // 选段 = `selectCompactableRange(session, measurement, retainTokens)`:从第一个非
166
+ // system 节点起,逐字保留尾部 retainTokens,再按工具配对平衡回退。这里用同一份
167
+ // 定价节点(measured.nodes)+ 同一套保留预算复刻该范围,只读、不触发任何压缩。
168
+ //
169
+ // 只在「自动压缩开着 + 已到真实触发线 + 节点定价与 surface 对齐」时预警;
170
+ // 算不出/对不齐就返回 null(宁可不报,也不误报会覆盖)。
171
+ let pinRisk = null;
172
+ if (
173
+ target !== null &&
174
+ pinnedSeqs.length > 0 &&
175
+ nativeCfg.autoEnabled !== false &&
176
+ nativeLine !== null &&
177
+ nativeLine.ok === true &&
178
+ Number.isFinite(measured.tokens) &&
179
+ measured.tokens >= nativeLine.thresholdTokens &&
180
+ nativeBudget !== null &&
181
+ Array.isArray(measured.nodes) &&
182
+ measured.nodes.length === surface.length &&
183
+ measured.nodes.every((node, index) => node?.seq === surface[index])
184
+ ) {
185
+ const retainTokens = nativeRetainTokensOf({
186
+ contextWindow: nativeBudget.contextWindow,
187
+ reservedCompletionTokens: nativeBudget.reservedCompletionTokens,
188
+ retainRatio: nativePolicy.retainRatio,
189
+ retainTokens: nativePolicy.retainTokens,
190
+ });
191
+ if (retainTokens !== null) {
192
+ const risk = nativePinnedRiskOf({
193
+ pinnedSeqs,
194
+ nodes: measured.nodes,
195
+ firstIdx: firstCompactableIdx(target.session),
196
+ retainTokens,
197
+ isBalancedBefore: (seq) => {
198
+ try {
199
+ return deps.engine?.toolPairingBalancedBefore?.(target.session, seq) === true;
200
+ } catch {
201
+ return true; // 判定失败时按「可切」处理:只影响预警范围,不会真的压
202
+ }
203
+ },
204
+ });
205
+ if (risk !== null) pinRisk = { count: risk.count, seqs: risk.seqs };
206
+ }
207
+ }
208
+
209
+ //
210
+ // `relay` 是旧字段:日志里**最近一条**记录(出站/入站都可能)——语义一个字节不改,
211
+ // 下一次交接的链号递增(handoffTitlePlanOf)也依赖它。
212
+ // `chain` 改成「本会话入站记录」(childId===本会话)的只读投影:祖先会话再次交接后,
213
+ // 最近的记录变成出站(childId=下一代),旧实现照它下发 → 客户端「是不是本会话入站」
214
+ // 判定不成立 → 接力条消失(DCR-01)。没有入站记录时(DCR-02)用会话头
215
+ // header.parentSession + 标题兜底合成只读链(排除 subagent 会话与 fork 出来的会话),
216
+ // 让 0.5.0 之前生成、子会话没有记录的老链也能显示接力条。
151
217
  const relay = target === null ? null : relayRecordOf(target.session);
152
- const chain = relay === null ? null : relayChainOf(relay);
218
+ const inbound = target === null ? null : relayInboundRecordOf(target.session);
219
+ const chain =
220
+ target === null
221
+ ? null
222
+ : inbound !== null
223
+ ? relayChainOf(inbound)
224
+ : // DCR-02:只有会话头看起来像老链时才去读标题(普通会话不付这次查找开销)。
225
+ legacyChainParentIdOf(target.session) === null
226
+ ? null
227
+ : legacyRelayChainOf({ session: target.session, title: sourceTitleOf(deps.ctx, target.session) });
228
+ // DCR-01:`next` = 本会话最近一条**出站**记录的只读投影(只加不改)。接力条不看它,
229
+ // 但「父→子入口」与「子会话向父会话补交接单 path」需要出站那一跳的信息。
230
+ const next = target === null ? null : relayChainOf(relayOutboundRecordOf(target.session));
153
231
  // 0.5.0:旧会话是否已被自动归档(接力条据此显示「恢复旧会话」)。这是宿主
154
- // registry 的独立状态,不塞进 chain(chain 仍然只是 relay 记录的纯投影)。
232
+ // registry 的独立状态,不塞进 chain(chain 仍然只是链投影)。
233
+ // 0.5.1 DCR-01:用**入站** parentId 查(出站记录里的父会话是本会话自己,会查错)。
155
234
  // 拿不到 registry/归档集合 → null(不知道,客户端按「可尝试恢复」处理)。
156
235
  const parentArchived =
157
236
  chain === null || chain.parentId === null ? null : isSessionArchived(deps.ctx, chain.parentId);
237
+ // 0.5.1 批 3:出站记录的目标会话(下一代)是否已在归档集合里。父会话侧的「去新会话」
238
+ // 用它决定要不要先 unarchive(宿主拒绝打开归档会话;unarchive 幂等)。
239
+ // 和 parentArchived 完全对称:各查各的 id,读不到 → null(不猜 false)。
240
+ const nextArchived = next === null || next.childId === null ? null : isSessionArchived(deps.ctx, next.childId);
241
+ // 0.5.2 L 项:链上父会话(旧会话)是否在侧栏置顶 —— 强制归档的二次确认要提前说明
242
+ // 「归档会取消置顶」(宿主在同一笔写盘里 unpin)。没有父会话/读不到 → null(不猜)。
243
+ const parentPinned = chain === null || chain.parentId === null ? null : isSessionPinned(deps.ctx, chain.parentId);
244
+ // 0.5.1 DCR-03:宿主有没有「恢复归档会话」的接缝(HTTP recover 动作调的就是它)。
245
+ // 客户端拿不到 ctx.uiWorkspace.unarchiveSession 时据此隐藏「恢复旧会话」死入口。
246
+ // 0.5.2 L 项:`forceArchive` = 宿主有没有 archiveSession(接力条据此显示「强制归档旧会话」;
247
+ // 缺这个接缝时隐藏入口——点了必然失败)。
248
+ const capabilities = {
249
+ unarchive: typeof workspaceRegistryOf(deps.ctx)?.unarchiveSession === 'function',
250
+ forceArchive: typeof workspaceRegistryOf(deps.ctx)?.archiveSession === 'function',
251
+ };
158
252
 
159
253
  return {
160
254
  revision: ++stateRevision,
@@ -166,7 +260,19 @@ export async function readState(deps, { sessionId, agent } = {}) {
166
260
  summaries,
167
261
  advice,
168
262
  level: advice === 'none' ? 'idle' : advice,
169
- pins: { count: pinnedSeqs.length, seqs: pinnedSeqs.slice(-20), marks: pinMarks },
263
+ // 0.5.1 批 2:stale = 快照里有、surface 上已没有的 pin(被压缩/历史变化带走)。
264
+ // `count` 仍是 live 集合(老客户端语义不变);`stale` / `atRisk` 只加不改。
265
+ pins: {
266
+ count: pinnedSeqs.length,
267
+ seqs: pinnedSeqs.slice(-20),
268
+ marks: pinMarks,
269
+ stale: {
270
+ count: pinHealth.stale.length,
271
+ seqs: pinHealth.stale.slice(-20),
272
+ ...pinHealth.causes,
273
+ },
274
+ atRisk: pinRisk,
275
+ },
170
276
  compactRange,
171
277
  // 0.3.5:compactRange 非 null 但无活 agent 时为 'no_agent',其它情况 null。
172
278
  compactRangeReason,
@@ -178,12 +284,28 @@ export async function readState(deps, { sessionId, agent } = {}) {
178
284
  settingsError: deps.settings?.error ?? null,
179
285
  // A5:最近一次交接的接力链记录(没有就是 null)。0.5.0 F4:旧字段原样保留。
180
286
  relay,
181
- // 0.5.0 F4:同一条记录的只读链投影 {ordinal, baseTitle, parentId, childId, scheme, path?};
182
- // 只加不改:老客户端忽略它,新客户端不再解析标题。没有记录 → null。
287
+ // 0.5.0 F4 / 0.5.1 DCR-01:本会话**入站**记录的只读链投影
288
+ // {ordinal, baseTitle, parentId, childId, scheme, path?};老链(无记录)可能是
289
+ // header 兜底合成的(多一个 synthesized:true)。没有链信息 → null。
183
290
  chain,
184
- // 0.5.0:链上父会话当前是否在 workspace 归档集合里(true/false);没有父会话或
185
- // 宿主没有可读的归档状态 → null。只加不改。
291
+ // 0.5.1 DCR-01:最近一条**出站**记录的只读投影(本会话交接给下一代那一跳)。
292
+ // 只加不改:老客户端忽略;接力条不用它。没有出站记录 → null。
293
+ next,
294
+ // 0.5.0:链上父会话(入站 parentId)当前是否在 workspace 归档集合里(true/false);
295
+ // 没有父会话或宿主没有可读的归档状态 → null。只加不改。
186
296
  parentArchived,
297
+ // 0.5.1 批 3:出站目标(next.childId)是否在同一归档集合里。父会话侧「去新会话」
298
+ // 据此先恢复再切;没有出站记录或读不到归档集合 → null。只加不改。
299
+ nextArchived,
300
+ // 0.5.2 L 项:入站父会话(旧会话)是否在侧栏置顶。强制归档的二次确认用它在
301
+ // 「会停止后台任务」之外再说明「归档会取消置顶」;没有父会话/读不到 → null(不猜)。
302
+ parentPinned,
303
+ // 0.5.1 批 2:本会话(agent.session,不是 agent.id)是否在侧栏置顶。
304
+ // 交接前客户端据此弹「归档会取消置顶」确认;读不到置顶集合 → null(不猜)。
305
+ sessionPinned: target === null || target.session === undefined ? null : isSessionPinned(deps.ctx, target.session.id),
306
+ // 0.5.1 DCR-03:宿主能力位(能不能恢复归档会话 / 能不能强制归档)。老客户端忽略;
307
+ // 新客户端在缺 `unarchive` 能力时隐藏恢复入口,缺 `forceArchive` 时隐藏强制归档入口。
308
+ capabilities,
187
309
  // 0.3.1:模型判断的「适合压缩」断点位置(没有或已失效为 null)。
188
310
  compressHint: activeHint,
189
311
  // 0.3.1:DSH 原生自动压缩(环上的报警最高阈值 / 设置页开关的数据源)。