dsh-session-rollover 1.1.1 → 1.2.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,15 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.2.0 — 2026-11-11
4
+
5
+ - 增加对 DSH `0.2.2-rc.1` 的确切版本支持,完成独立 home / profile 的真实宿主验收;保留旧版范围与原数据路径。
6
+ - 修复新版宿主重建 `uiConversation` 时交接卡插槽未随子作用域释放、重复占用 keyed slot 的问题。
7
+ - 集中宿主能力探测与读取结构预检,不兼容时在建卷和改名前暂停分卷,保留历史快照与只读拦截。
8
+ - 新版优先读取工作目录、活动 Agent 模型的领域服务,保留旧投影接口回退;可选服务热替换时避免旧清理回调误删新服务。
9
+ - 会话状态及面板补充兼容诊断,HTTP 建卷明确返回 503,缺失界面服务不直接中断渲染。
10
+ - 增加所有后续 npm DSH 版本的公开声明检查、Windows/Linux 与 Node 22/24 回归工作流和升级验收指南;不自动扩大兼容范围或发布。
11
+ - 增加实际 tgz 的隔离安装、稳定导出和面板资源校验;兼容策略及新模块随包分发。
12
+
3
13
  ## 1.1.1 — 2026-11-07
4
14
 
5
15
  - 公开包名改为 `dsh-session-rollover`,作者 Oak-liuhj;不改变运行数据路径、`sessionRollover` 服务和兼容导出。
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  适合持续编码、问题排查和多步骤任务。支持 Web 与桌面客户端,无需为插件单独配置 API Key。
10
10
 
11
- **版本 1.1.1** · **已验证 DSH 0.2.1-rc.5** · **维护者 Oak-liuhj**
11
+ **版本 1.2.0** · **支持 DSH 0.2.1-rc.5 / 0.2.2-rc.1** · **维护者 Oak-liuhj**
12
12
 
13
13
  [使用指南](./docs/DETAIL.zh-CN.md) · [English guide](./docs/DETAIL.md) · [更新记录](./CHANGELOG.md) · [问题反馈](https://github.com/Oak-liuhj/dsh-session-rollover/issues)
14
14
 
@@ -44,6 +44,8 @@
44
44
 
45
45
  需要已配置模型、可正常对话的 DSH 宿主。浏览器入口随包预构建,安装插件时无需自行构建。
46
46
 
47
+ `1.2.0` 支持 DSH `0.2.1-rc.5` 和已完成实机验收的 `0.2.2-rc.1`。旧版 `1.1.1` 只声明支持前者,在新版宿主中会被阻止启用;请更新兼容版本,保留原数据目录。后续版本通过逐版检查和真实宿主验收适配;关键接口变化时暂停分卷并提示。维护机制与证据边界见 [DSH 升级适配](docs/COMPATIBILITY.md)。
48
+
47
49
  可在 DSH 插件市场搜索 `dsh-session-rollover` 安装并启用,也可通过插件包名、GitHub 源或本地安装包安装。
48
50
 
49
51
  ### 通过 npm 包名安装
@@ -75,7 +77,7 @@ dsh plugin --profile desktop add github:Oak-liuhj/dsh-session-rollover
75
77
  将路径替换为实际 tgz 文件位置:
76
78
 
77
79
  ```powershell
78
- dsh plugin --profile desktop add "D:/release/dsh-session-rollover-1.1.1.tgz"
80
+ dsh plugin --profile desktop add "D:/release/dsh-session-rollover-1.2.0.tgz"
79
81
  ```
80
82
 
81
83
  桌面用户安装后,请通过桌面客户端重启宿主。已安装旧包 `@deepseek-ai/dsh-session-rollover` 时,应先替换旧依赖,避免同时加载新旧两份插件;升级前备份配置与插件数据。详细步骤见 [升级指南](./docs/PUBLISHING.md#从-scoped-旧包迁移)。
@@ -143,7 +145,7 @@ dsh plugin --profile desktop remove dsh-session-rollover
143
145
 
144
146
  ## 兼容性与开发
145
147
 
146
- 已验证 DSH `0.2.1-rc.5`,验证记录覆盖自动分卷、待办续接、来源导航、双向只读开关及浅深主题。旧宿主可能不显示插件长说明,可从会话 `⋯ → 使用说明` 查看内置指南。
148
+ 历史基线 DSH `0.2.1-rc.5` 的验证覆盖自动分卷、待办续接、来源导航、双向只读开关及浅深主题;`1.2.0` 对 DSH `0.2.2-rc.1` 的实机验收记录见[升级适配指南](docs/COMPATIBILITY.md)。旧宿主可能不显示插件长说明,可从会话 `⋯ → 使用说明` 查看内置指南。
147
149
 
148
150
  现有验证使用隔离宿主与本地模型夹具,完整 Bot 群端、生产模型和后续宿主版本仍需单独复验。详见 [功能验证](./docs/r3-validation.md) 与 [市场展示验证](./docs/marketplace-validation.md)。
149
151
 
package/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // Generated by npm run build; edit src/client/. Version 1.1.1.
1
+ // Generated by npm run build; edit src/client/. Version 1.2.0.
2
2
  // dsh-session-rollover · 浏览器半边(B6 一次性提示)。
3
3
  // 通道:server 半把「该提示什么」落盘,这里同源 GET /api-dsh-session-rollover/pending 取走渲染。
4
4
  // 不碰宿主 Toast 实现、不建 React hooks 面(D-36:bindInjectSources 会剥掉 hooks 字段),纯 DOM。
@@ -311,6 +311,18 @@ function createRolloverUI(React) {
311
311
  const entries = new Map();
312
312
  let navigate = null;
313
313
  let renderInline = false;
314
+ const clientWarnings = new Set();
315
+ function unavailable(feature, error) {
316
+ clientWarnings.add(feature);
317
+ console.warn('[dsh-session-rollover] client capability unavailable:', feature, error?.message ?? '');
318
+ }
319
+ function inject(ctx, names, setup) {
320
+ try {
321
+ ctx.inject(names, scope => {
322
+ try { return setup(scope); } catch (error) { unavailable(names.join(','), error); }
323
+ });
324
+ } catch (error) { unavailable(names.join(','), error); }
325
+ }
314
326
 
315
327
  async function request(sessionId, action) {
316
328
  const response = await fetch(action ? API : `${API}?session=${encodeURIComponent(sessionId)}`, action ? {
@@ -428,8 +440,8 @@ function createRolloverUI(React) {
428
440
  h('p', {className: 'dsr-help-credit'}, 'dsh-session-rollover · Oak-liuhj')) : null,
429
441
  h('div', {className: 'dsr-actions-body'},
430
442
  h('button', {type: 'button', 'aria-expanded': showHelp, onClick: () => setShowHelp(!showHelp)}, showHelp ? '收起使用说明' : '使用说明'),
431
- h('button', {type: 'button', disabled: busy || info.sealed || !navigate, onClick: () => action('rollover')}, '现在换会话'),
432
- h('button', {type: 'button', disabled: busy || !navigate, onClick: () => action('new')}, '新建独立会话'),
443
+ h('button', {type: 'button', disabled: busy || info.sealed || !navigate || info.compatibility?.compatible === false, onClick: () => action('rollover')}, '现在换会话'),
444
+ h('button', {type: 'button', disabled: busy || !navigate || info.compatibility?.compatible === false, onClick: () => action('new')}, '新建独立会话'),
433
445
  h('button', {type: 'button', onClick: () => dialog.current?.close()}, '关闭'))),
434
446
  error ? h('span', {role: 'alert'}, error) : null);
435
447
  }
@@ -489,6 +501,8 @@ function createRolloverUI(React) {
489
501
  if (!info) return null;
490
502
  return h('div', {className: 'dsr-dock'},
491
503
  info.error ? h('div', {role: 'alert'}, info.error) : null,
504
+ info.compatibility?.compatible === false ? h('div', {role: 'alert', className: 'dsr-warning'}, info.compatibility.message) : null,
505
+ clientWarnings.size ? h('div', {role: 'alert', className: 'dsr-warning'}, '部分会话界面功能与当前 DSH 不兼容,请更新插件。') : null,
492
506
  h(ReadonlyControl, {key: props.sessionId, info}),
493
507
  info.capped ? h('div', {role: 'status', className: 'dsr-warning'}, '同簇已达 9 卷上限(9/9),请通过会话操作新建独立会话。') : null,
494
508
  info.retryLimitReached ? h('div', {role: 'status', className: 'dsr-warning'}, '摘要生成失败,已停止自动重试。可在修正内容后手动换会话。') : null,
@@ -506,7 +520,9 @@ function createRolloverUI(React) {
506
520
 
507
521
  /** 释放时仅撤销自己的禁用;保留模型选择等其他插件后来设置的阻塞原因。 */
508
522
  function blockComposer(blocks, id, reason) {
523
+ if (typeof blocks?.storeFor !== 'function' || typeof blocks?.set !== 'function') throw new Error('composer blocks unavailable');
509
524
  const store = blocks.storeFor(id);
525
+ if (typeof store?.getSnapshot !== 'function' || typeof store?.subscribe !== 'function') throw new Error('composer store unavailable');
510
526
  let previous = store.getSnapshot();
511
527
  const own = {reason};
512
528
  let writing = false;
@@ -528,13 +544,15 @@ function createRolloverUI(React) {
528
544
  style.setAttribute('data-dsh-plugin', 'dsh-session-rollover-ui');
529
545
  document.head.appendChild(style);
530
546
  ctx.effect?.(() => () => { style.remove(); for (const item of entries.values()) item.stop?.(); entries.clear(); });
531
- ctx.inject(['uiWorkspace'], scope => {
547
+ inject(ctx, ['uiWorkspace'], scope => {
548
+ if (typeof scope.uiWorkspace?.openSession !== 'function') throw new Error('session navigation unavailable');
532
549
  navigate = id => scope.uiWorkspace.openSession(id);
533
550
  scope.effect?.(() => () => { navigate = null; });
534
551
  });
535
- ctx.inject(['slots'], scope => {
552
+ inject(ctx, ['slots'], scope => {
536
553
  const slots = scope.slots;
537
- const mount = (name, Component, options = {}) => scope.effect(() => slots.inject(name, () => slots.register({name,
554
+ if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') throw new Error('slots unavailable');
555
+ const mount = (name, Component, options = {}, owner = scope) => owner.effect(() => owner.slots.inject(name, () => owner.slots.register({name,
538
556
  id: 'dsh-session-rollover', order: 35, ...options}, Component)));
539
557
  mount('conversation.session.header.actions', Header);
540
558
  mount('conversation.input.dock', Dock);
@@ -543,20 +561,27 @@ function createRolloverUI(React) {
543
561
  mount('sidebar.session.row.leading', props => slots.snapshot?.('sidebar.session.row.trailing')?.length
544
562
  ? null : h(Badges, {...props, compact: true}));
545
563
  // 使用宿主公开 composer block 服务;覆盖输入组件会破坏其子槽授权。
546
- scope.inject(['conversation'], inputScope => {
564
+ inject(scope, ['conversation'], inputScope => {
565
+ if (typeof inputScope.conversation?.blocks?.storeFor !== 'function' || typeof inputScope.conversation?.blocks?.set !== 'function') {
566
+ throw new Error('composer blocks unavailable');
567
+ }
547
568
  function ComposerGuard(props) {
548
569
  const info = useInfo(props.sessionId);
549
570
  const reason = !info ? '正在读取会话状态…' : info.readonly
550
571
  ? '归档会话:不可输入,可通过上方开关恢复写入。' : info.error;
551
- useEffect(() => props.sessionId && reason
552
- ? blockComposer(inputScope.conversation.blocks, props.sessionId, reason) : undefined,
572
+ useEffect(() => {
573
+ if (props.sessionId && reason) {
574
+ try { return blockComposer(inputScope.conversation.blocks, props.sessionId, reason); }
575
+ catch (error) { unavailable('conversation.blocks', error); }
576
+ }
577
+ },
553
578
  [props.sessionId, reason]);
554
579
  return null;
555
580
  }
556
581
  inputScope.effect(() => slots.inject('conversation.input.dock', () => slots.register({
557
582
  name: 'conversation.input.dock', id: 'dsr-readonly-guard', order: 34}, ComposerGuard)));
558
583
  });
559
- scope.inject(['uiConversation'], textScope => {
584
+ inject(scope, ['uiConversation'], textScope => {
560
585
  if (!textScope.uiConversation.userText?.register) return;
561
586
  textScope.effect(() => textScope.uiConversation.userText.register({id: 'dsr-handoff', order: -100,
562
587
  match(text, from) {
@@ -564,8 +589,10 @@ function createRolloverUI(React) {
564
589
  const payload = parseHandoff(text);
565
590
  return payload ? {id: 'dsr-handoff', start: 0, end: text.length, data: payload} : undefined;
566
591
  }}));
592
+ // 插槽必须随 uiConversation 的注入子作用域释放,不能挂在较长寿命的 slots 父作用域。
593
+ // 新宿主重连/热替换领域服务时会重建该子作用域;挂在父级会重复占用同一个 keyed slot。
567
594
  mount('conversation.user.text.node', props => h(Card, {payload: props.node.data,
568
- source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'});
595
+ source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'}, textScope);
569
596
  renderInline = true;
570
597
  textScope.effect?.(() => () => { renderInline = false; });
571
598
  });
@@ -586,7 +613,7 @@ body[data-ds-dark-theme] :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges),ht
586
613
  @media(max-width:600px){.dsr-header{gap:3px;flex-wrap:nowrap;max-width:calc(100vw - 156px)}.dsr-header>.dsr-chip,.dsr-header>.dsr-source{display:none}.dsr-header>.dsr-chip:first-child{display:inline-flex;font-size:10px;padding:1px 3px}}
587
614
  @media(prefers-reduced-motion:reduce){.dsr-card *{animation:none!important;transition:none!important}}
588
615
  `;
589
- return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, CSS};
616
+ return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, clientWarnings, CSS};
590
617
  }
591
618
 
592
619
  function apply(ctx) {
@@ -0,0 +1,21 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "package": "@deepseek-ai/dsh",
4
+ "baseline": "0.2.1-rc.5",
5
+ "verifiedRuntimeVersions": ["0.2.1-rc.5", "0.2.2-rc.1"],
6
+ "registry": "https://registry.npmjs.org/",
7
+ "contracts": [
8
+ {"package": "@deepseek-ai/dsh-session", "methods": ["get", "list", "snapshotEvents"], "tokens": ["session/event"]},
9
+ {"package": "@deepseek-ai/dsh-session-title", "methods": ["get", "rename"]},
10
+ {"package": "@deepseek-ai/dsh-api-session-controller", "methods": ["create", "prompt", "selectModel"]},
11
+ {"package": "@deepseek-ai/dsh-session-projection", "methods": ["stateOf"]},
12
+ {"package": "@deepseek-ai/dsh-working-directory", "methods": ["get"]},
13
+ {"package": "@deepseek-ai/dsh-workspace", "methods": ["list"], "tokens": ["sessionIds"]},
14
+ {"package": "@deepseek-ai/dsh-agent", "methods": ["get", "current"], "tokens": ["agent/pre-step", "reject"],
15
+ "versionOverrides": {"0.2.1-rc.5": {"methods": ["get"]}}},
16
+ {"package": "@deepseek-ai/dsh-client-connection", "methods": ["admit"]},
17
+ {"package": "@deepseek-ai/dsh-client-ui-conversation", "methods": ["storeFor", "set"], "tokens": ["conversation.session.header.actions", "conversation.input.dock", "blocks"]},
18
+ {"package": "@deepseek-ai/dsh-client-ui-workspace", "methods": ["openSession"], "alternatives": [["sidebar.session.row.trailing", "sidebar.session.row.leading"]]},
19
+ {"package": "@deepseek-ai/dsh-client-ui-slots", "methods": ["register"]}
20
+ ]
21
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "passed": true,
3
+ "runtimeVerified": true,
4
+ "checkedAt": "2026-11-11T07:26:36.784Z",
5
+ "dsh": "0.2.2-rc.1",
6
+ "plugin": "1.2.0",
7
+ "platform": "Windows",
8
+ "environment": "installed DSH CLI with isolated home and profile",
9
+ "model": "localhost fixture only; no provider credentials or real model quality assessment",
10
+ "checks": {
11
+ "tenHumanMessagesOneRollover": true,
12
+ "workspaceAndDirectoryInherited": true,
13
+ "modelInherited": true,
14
+ "frozenTodosAndActiveStep": true,
15
+ "continuationTodoProgress": true,
16
+ "archivedReadonly": true,
17
+ "restoredWritePersistsAcrossRefresh": true,
18
+ "restoredVolumeDoesNotRolloverAgain": true,
19
+ "realHostRestartPreservesIndexAndSnapshot": true,
20
+ "anonymousHTTPStatus": 401,
21
+ "sourceNavigationAndCopyJSON": true,
22
+ "inlineSlotLifecycleFixedAndRechecked": true
23
+ },
24
+ "browser": "manual automation through computer-use; no plugin warnings after fix and real restart",
25
+ "limitation": "Host reconnect logged a native released-session-reference error; plugin UI recovered after page reload. No full Bot or production provider acceptance.",
26
+ "sourceDigests": {
27
+ "client.js": "25a484847caf9a2ec71b11817d3c147c83fd546f2f80582826df124d742c2b69",
28
+ "src/server/plugin.mjs": "6afd4b8d5e6c03082240ca2f7304bff9fd3ec0e28bfa32e1fe369458f4a48ae5",
29
+ "src/server/host-adapter.mjs": "d145284cfa392d64b00d45f40ab308b320d768dc23606bbdbb048c0db6b25dd6",
30
+ "src/server/compatibility.mjs": "5572940c2bb6fc16dd0393f369417bd9acc025ab65fd7df5f370ededf454ce38"
31
+ }
32
+ }
@@ -0,0 +1,70 @@
1
+ # DSH 升级适配与验收
2
+
3
+ 插件按“公开接口适配、运行前探测、逐版检查、真实宿主验收”维护。不能提前保证尚未发布的 DSH 一定兼容,也不根据版本号猜测新接口。
4
+
5
+ ## 当前范围
6
+
7
+ - 历史真实宿主验收基线:`0.2.1-rc.5`;`1.2.0` 已在本机安装的 `0.2.2-rc.1` 独立 home / profile 中完成实机验收。
8
+ - `compatibility/policy.json` 的 `verifiedRuntimeVersions` 决定允许安装的 DSH 版本;`engines.dsh` 必须与其逐项一致,发布检查禁止悄悄放宽范围。
9
+ - npm 声明扫描、夹具回归和真实宿主验收分别记录。扫描通过不自动修改版本约束,不自动发布 npm 包,也不自动改用户 profile。
10
+ - `1.2.0` 增加新版宿主支持;已安装 `1.1.1` 的用户需更新插件,npm 同一版本不会被覆盖更新。发布核验记录见 [投稿材料](../marketplace/SUBMISSION.md)。
11
+
12
+ ## 本次验证(2026-11-11)
13
+
14
+ | 检查 | 结果与范围 |
15
+ | --- | --- |
16
+ | 本地回归 | Windows、Node.js 24.22.1,100 项全部通过(含旧宿主有 agents、无 model.current 时的投影回退) |
17
+ | 公开声明 | `0.2.1-rc.5`、`0.2.2-alpha.3`、`0.2.2-rc.1` 的十一个组件包均通过 |
18
+ | 发布包 | `1.2.0` 实际 tgz 隔离安装、服务端导出、预构建客户端和展示资源通过 |
19
+ | 新版真实宿主 | `0.2.2-rc.1` 验收通过,保留旧基线并增加该确切版本;不放行其他未验收版本 |
20
+ | 远程 CI | 已编写工作流;本地提交不代表远程已启用,需推送默认分支后运行 |
21
+
22
+ 声明报告和安装包校验报告分别保存在 `out/dsh-compatibility.json`、`out/package-verification.json`;它们不是实际宿主验收报告。其他平台及 Node 版本由远程矩阵执行,本轮未在本机验证。
23
+
24
+ 真实宿主记录见 [runtime-0.2.2-rc.1.json](../compatibility/runtime-0.2.2-rc.1.json):十条用户消息仅分卷一次,继承工作区、目录及模型,待办快照保持不变,新卷实时待办可继续推进,来源跳转、复制 JSON、双向归档开关和宿主重启恢复正常,匿名 HTTP 请求返回 401。本轮使用本地模型夹具,不代表真实服务商模型或 Bot 端完整验收。
25
+
26
+ 重启复验发现并修复 `uiConversation` 子作用域卸载后交接卡 keyed slot 未释放的问题;插槽现在随该子作用域清理,重复注入回归与实机重启复验通过。宿主热重连期间仍观察到原生会话引用已释放的日志,刷新后恢复;这与插件重复插槽问题分开记录,不宣称宿主全局无错误。
27
+
28
+ ## 运行保护
29
+
30
+ 服务端集中在 `src/server/compatibility.mjs` 检查会话存储、标题、创建/提示/模型选择、投影、工作区接口,以及同步读取的返回结构。关键能力不满足时,自动与手动分卷在创建会话、改名、保存重试之前停止;HTTP 建卷操作返回 `503 host-incompatible`,面板显示更新提示。
31
+
32
+ 已有的交接快照与只读状态仍可读取;已注册的服务端归档写入拦截不因分卷暂停而关闭。方法存在不证明事件语义仍相同;如果升级导致插件本身无法加载,这些插件内保护也无法运行,所以仍需遵守宿主版本约束。
33
+
34
+ 新版宿主优先通过 `workingDirectory.get(session)` 和活动 Agent 的 `model.current()` 读取领域状态。缺少这些可选服务时,回退到已验证的 `sessionProjections.stateOf`。后者在较新源码中已弃用,仍纳入声明检查;将来移除时明确失败,由维护者适配,而不是静默换成空目录或默认模型。
35
+
36
+ 基线 `0.2.1-rc.5` 的 Agent 声明尚无 `current()`,检查策略对此确切版本保留显式例外,运行时使用投影回退;后续版本继续检查领域服务接口。未知模型结构会暂停分卷,合法的空选择保持可用。
37
+
38
+ 浏览器针对插槽、导航与输入阻塞服务做能力检查。缺少可选界面能力时记录带插件名的警告,并在可用的面板中提示;不让缺失服务直接导致 React 渲染错误。服务端诊断可通过 `ctx.sessionRollover.compatibility()` 获取;每个会话状态的 `compatibility` 还包含返回结构检查。
39
+
40
+ ## 自动检查
41
+
42
+ 仓库工作流 `.github/workflows/compatibility.yml` 包含:
43
+
44
+ 1. Windows / Linux、Node.js 22 / 24 上运行完整回归、发布格式检查和 tgz 隔离安装校验。
45
+ 2. 从官方 npm 注册表枚举基线发布时刻之后的每个 DSH 版本,包含 alpha、rc 与后续补丁;不只检查 `latest`,避免遗漏中间版本。
46
+ 3. 下载各版本的十一个公开组件包,核对 SHA512 后只读扫描类型声明,检查插件使用的方法、事件名及界面插槽,并保存报告。不会执行上游安装脚本。
47
+
48
+ 工作流在代码推送、PR、手动触发和每六小时的定时任务中运行;需要提交到 GitHub 默认分支并启用 Actions 才会生效。本地创建文件不等于已启用远程任务。注册表请求或归档校验失败均使任务失败,不视为兼容通过。版本数超过 200 时要求维护基线,不静默截断。
49
+
50
+ ```powershell
51
+ npm test
52
+ npm run check:release
53
+ npm run check:package
54
+ npm run check:dsh -- all
55
+ npm run check:dsh -- 0.2.2-rc.1
56
+ node scripts/check-dsh-compatibility.mjs --list
57
+ ```
58
+
59
+ 需要网络与系统 `tar`;Windows 自带的 tar 可用。报告默认写入忽略提交的 `out/dsh-compatibility.json`,也可用 `DSH_COMPATIBILITY_REPORT` 指定位置。`level: public-declaration-smoke`、`runtimeVerified: false` 明确说明证据边界。声明扫描不会证明方法签名、授权、事件顺序或视觉渲染完全兼容。
60
+
61
+ `check:package` 在自有临时目录安装实际 tgz,复核导出、预构建客户端和全部发布资源,结束后清理隔离目录;包与报告保存在 `out/`。此检查也不代表真实 DSH 宿主验收。
62
+
63
+ ## 每次 DSH 发布后的维护步骤
64
+
65
+ 1. 查看每个新版本的声明扫描和回归结果;发现方法、事件或插槽变化时更新集中适配层,保留旧宿主路径的测试。
66
+ 2. 用该版本的真实 DSH,在隔离 home / profile 和本地模型夹具中验证:插件装载、十条真实用户消息只分一次、模型与工作区继承、待办交接、来源导航、重启恢复、归档只读及恢复写入、HTTP owner 授权。不得使用生产会话验收。
67
+ 3. 记录 DSH 与插件版本、平台、结果及证据位置。通过后才向 `verifiedRuntimeVersions` 添加确切版本,并同步 `engines.dsh` 的 `||` 列表。旧基线仍用于回归,不能只因新版本号更大便放行。
68
+ 4. 更新插件版本、构建预生成客户端、完成打包与隔离安装验证,再发布新 npm 版本。用户通过正常插件升级获得适配;不得覆盖同一 npm 版本。
69
+
70
+ 宿主升级后遇到兼容提示时,应保留数据目录,使用已验收版本组合或更新插件;不要删除快照、绕过宿主版本检查或同时加载两份插件。
package/docs/DETAIL.md CHANGED
@@ -35,7 +35,7 @@ Default storage: `<DSH_HOME>/plugin-data/dsh-session-rollover`, or `~/.dsh/plugi
35
35
 
36
36
  Summaries use deterministic extraction, without another model call or API key. Conversation continuation uses the model configured in DSH. Background jobs are recorded as handles and states; processes are not moved, resumed or restarted. Summary text is limited to 1500 characters and the full handoff message to 16 KiB; oversized handoffs fail with a notice. Automatic failures get at most two further retries, triggered by subsequent user messages.
37
37
 
38
- Compatibility was verified against DSH `0.2.1-rc.5` using an isolated host and local model fixture. This is not full acceptance for production models, Bot group channels or future host versions. Older hosts may omit this long description; the conversation `⋯` menu includes a built-in usage guide.
38
+ Version `1.2.0` supports DSH `0.2.1-rc.5` and `0.2.2-rc.1`. The latter was tested in an isolated host with a local model fixture for ten-message rollover, model and workspace inheritance, todo continuation, archive controls and restart recovery. Version `1.1.1` excludes DSH `0.2.2-rc.1`; upgrade the plugin while retaining its data directory. This is not full acceptance for production models, Bot group channels or future host versions. Older hosts may omit this long description; the conversation `⋯` menu includes a built-in usage guide.
39
39
 
40
40
  Replace the old `@deepseek-ai/dsh-session-rollover` dependency and bundle rather than loading both versions. Back up profile configuration and plugin data before upgrading. See the Chinese [publishing and migration guide](./PUBLISHING.md).
41
41
 
@@ -38,7 +38,7 @@
38
38
 
39
39
  摘要是确定性摘录,不调用额外模型;继续对话仍使用 DSH 已配置的模型,无需额外 API Key。后台任务只继承句柄和状态,不迁移、恢复或重新启动进程。正文摘要上限 1500 字符、完整交接消息上限 16 KiB,超预算会拒绝交接并提示。自动失败最多再试两次,由后续用户轮次触发。
40
40
 
41
- 兼容声明基于 DSH `0.2.1-rc.5`。本地模型夹具已验证十轮分卷和工具待办续接;不代表所有模型、Bot 群端或后续宿主版本均已验收。旧宿主可能不显示本长说明,可从会话 `⋯ → 使用说明` 查看内置指南。
41
+ `1.2.0` 支持 DSH `0.2.1-rc.5` 与 `0.2.2-rc.1`;后者已在独立环境验证十轮分卷、模型和工作区继承、待办续接、归档开关及重启恢复。旧版 `1.1.1` 的版本约束不支持 `0.2.2-rc.1`,需升级插件并保留数据目录。不代表所有模型、Bot 群端或后续宿主版本均已验收。旧宿主可能不显示本长说明,可从会话 `⋯ → 使用说明` 查看内置指南。
42
42
 
43
43
  ## 升级与支持
44
44
 
@@ -29,6 +29,8 @@ R2 面板可继续调用原有 rollover/info 服务;无实时 `todo/write` 时
29
29
 
30
30
  `connection` 为显式必需注入;`jobs`、`agentDefaultModel` 通过独立可选注入获取,避免 Cordis 未声明服务访问报错。
31
31
 
32
+ `workingDirectory`、`agents` 同样通过可选注入获取,用于新版领域状态读取。`sessionRollover.compatibility()` 返回关键方法的能力报告;会话 `info().compatibility` 还检查读取结果形状。逐版检查、CI 与真实宿主验收流程见 [升级适配指南](./COMPATIBILITY.md)。
33
+
32
34
  ## 项目结构
33
35
 
34
36
  ```text
@@ -76,6 +78,7 @@ docs/r3-demo.html 固定数据交互原型
76
78
  ```powershell
77
79
  npm test
78
80
  npm run check:release
81
+ npm run check:package
79
82
  npm pack --dry-run
80
83
  npm pack --pack-destination <输出目录>
81
84
  ```
@@ -1,12 +1,13 @@
1
1
  # 发布与升级指南
2
2
 
3
- 公开包名为 `dsh-session-rollover`,版本 1.1.1,维护者 Oak-liuhj。仓库:[Oak-liuhj/dsh-session-rollover](https://github.com/Oak-liuhj/dsh-session-rollover)。支持在宿主中直接填写 npm 包名安装;维护者需将实际发布包上传到 npm 官方注册表,市场收录不代替 npm 发布。
3
+ 公开包名为 `dsh-session-rollover`,当前版本 1.2.0,维护者 Oak-liuhj。仓库:[Oak-liuhj/dsh-session-rollover](https://github.com/Oak-liuhj/dsh-session-rollover)。支持在宿主中直接填写 npm 包名安装;市场收录不代替 npm 发布。实际公开版本以 npm 官方源和 GitHub Release 为准,发布核验记录见 [投稿材料](../marketplace/SUBMISSION.md)。
4
4
 
5
5
  ## 构建与打包
6
6
 
7
7
  ```powershell
8
8
  npm test
9
9
  npm run check:release
10
+ npm run check:package
10
11
  npm pack --pack-destination D:/release
11
12
  ```
12
13
 
@@ -21,8 +22,8 @@ npm pack --pack-destination D:/release
21
22
  ```powershell
22
23
  npm login --auth-type=web --registry=https://registry.npmjs.org/
23
24
  npm whoami --registry=https://registry.npmjs.org/
24
- npm publish "D:/release/dsh-session-rollover-1.1.1.tgz" --access public --registry=https://registry.npmjs.org/
25
- npm view dsh-session-rollover@1.1.1 version dist.integrity --registry=https://registry.npmjs.org/
25
+ npm publish "D:/release/dsh-session-rollover-1.2.0.tgz" --access public --registry=https://registry.npmjs.org/
26
+ npm view dsh-session-rollover@1.2.0 version dist.integrity --registry=https://registry.npmjs.org/
26
27
  ```
27
28
 
28
29
  4. 在隔离目录从 npm 安装并核对包名、版本、挂载补丁、预构建客户端与模块导入;只有 npm 返回该版本且下载验证通过,才确认包名安装可用。
@@ -30,13 +31,29 @@ npm view dsh-session-rollover@1.1.1 version dist.integrity --registry=https://re
30
31
 
31
32
  `publishConfig.registry` 固定为 npm 官方源;公开包名为 `dsh-session-rollover`。npm 已发布的同一版本不能覆盖,后续发布需先提升版本号。认证和双重验证失败时保留待发布包,完成认证后继续发布,不绕过验证。
32
33
 
34
+ ### 已进入暂存区时
35
+
36
+ 使用支持 `npm stage` 的 CLI,先查询已有暂存记录,再确认同一个包:
37
+
38
+ ```powershell
39
+ npm stage list dsh-session-rollover --json --registry=https://registry.npmjs.org/
40
+ npm stage view <stage-id> --json --registry=https://registry.npmjs.org/
41
+ npm stage approve <stage-id> --registry=https://registry.npmjs.org/
42
+ ```
43
+
44
+ 核对版本、`latest` 标签和 shasum 与已审核 tgz 一致。`staged` 仅表示待审批,同一暂存版本不能再次普通发布;保留原 tgz 和 stage id,完成审批后再执行下载与隔离安装验证。首次暂存可能产生 `0.0.0-stage` 占位版本,不能以包名可查询作为正式版本发布成功的依据。
45
+
46
+ CLI 登录授权、注册安全密钥和本次发布的本人认证是不同步骤。账号已启用 2FA 但审批仍要求配置时,先核对账号状态并尝试全新登录,不反复注册密钥;认证 URL 失效也不代表 tgz 丢失。若全新登录后问题仍存在,保存不含凭据的错误、时间和 stage id,向 [npm 支持](https://www.npmjs.com/support)反馈。不要通过关闭 2FA 或创建绕过认证的令牌处理审批异常。
47
+
48
+ 认证请求有有效期,应在本人准备好设备验证后再启动命令,并让 npm 直接打开系统浏览器。不要先生成地址再等待聊天确认;手工打开时核对完整地址,避免回调参数混入中文标点或正文。有效期已过时重新运行原审批命令,不复用旧地址。
49
+
33
50
  ## 从 scoped 旧包迁移
34
51
 
35
52
  1. 记录真实运行的 profile,桌面通常是 `~/.dsh/profiles/desktop`。备份该 profile 的 package.json、锁文件、Cordis 配置和旧插件目录,并备份 `<DSH_HOME>/plugin-data/dsh-session-rollover`;自定义 dataDir 也需备份。
36
53
  2. 通过桌面插件管理器/宿主支持的依赖管理渠道,将 `@deepseek-ai/dsh-session-rollover` 替换为新包。CLI 可使用 `dsh plugin --profile desktop remove @deepseek-ai/dsh-session-rollover`,然后 `dsh plugin --profile desktop add <本地tgz或GitHub源>`。分步操作时先停止宿主,避免短暂缺包。
37
54
  3. profile 的 `dsh.profile.bundles` 只保留新包一次;显式 Cordis 节点的 `name` 改为新包名,原 `id: session-rollover`、用户配置值和相邻插件保留。不可同时加载两个包来注册同一个服务。不能只覆盖 node_modules 而保留过期依赖来源。
38
55
  4. 从桌面客户端重启实际宿主;不要用普通 Web profile 的重启操作接管桌面生命周期。
39
- 5. 核实已加载 1.1.1、面板图标/简介、会话 `⋯ → 使用说明`、旧卷快照和只读状态。不要为了安装验证向生产会话发送测试消息。
56
+ 5. 核实已加载目标版本、面板图标/简介、会话 `⋯ → 使用说明`、旧卷快照和只读状态。不要为了安装验证向生产会话发送测试消息。
40
57
 
41
58
  本次更名不迁移数据;数据目录、`sessionRollover`、HTTP 路由、Cordis id 和旧 `src/*` 导出保持兼容。卸载也不删除数据。回滚时停止宿主,恢复备份的 profile 配置、锁文件和依赖,再重启;不要覆盖更新后已产生的用户数据。
42
59
 
@@ -60,4 +77,4 @@ npm view dsh-session-rollover@1.1.1 version dist.integrity --registry=https://re
60
77
  3. 等仓库创建满一天并确保 topic 为 `dsh-plugin`。按 [投稿材料](../marketplace/SUBMISSION.md) 向官方插件目录发起 PR。
61
78
  4. 确认市场抓取后的简介、图标、截图及安装源正确;外部收录前不要宣称已上架。
62
79
 
63
- `engines.dsh` 保持已验证的 `0.2.1-rc.5`,不推测扩大兼容区间。许可证声明为 `(Apache-2.0 AND MIT)`,必须同时分发 LICENSE、LICENSE.upstream 和 NOTICE。
80
+ `engines.dsh` 由 `compatibility/policy.json` 中实际完成宿主验收的确切版本组成,不推测扩大兼容区间;声明扫描通过后仍需执行 [升级验收](./COMPATIBILITY.md)。许可证声明为 `(Apache-2.0 AND MIT)`,必须同时分发 LICENSE、LICENSE.upstream 和 NOTICE。
@@ -9,6 +9,17 @@
9
9
  - 唯一新增文件:`data/plugins/Oak-liuhj__dsh-session-rollover.yml`。
10
10
  - 校验与审核的实时状态以 PR 页面为准。提交 PR 不等于市场已收录;正式收录还需维护者合并及目录后续构建。
11
11
 
12
+ ## npm 发布记录
13
+
14
+ 2026-11-11 **13:51(北京时间)**,`dsh-session-rollover@1.1.1` 已正式发布到 [npm](https://www.npmjs.com/package/dsh-session-rollover/v/1.1.1)。npm 发布账号为 `oak-ai`,插件作者与 GitHub 仓库所有者为 `Oak-liuhj`。
15
+
16
+ - npm 官方源与腾讯镜像的 `latest` 均已确认为 `1.1.1`。
17
+ - 下载的 tgz 与审核通过的原包一致,SHA1 为 `cbe802fe44f8ae14da3eebc8d6a92e3468609ab5`,SHA512 integrity 也已核对。
18
+ - 两个源均已通过按包名隔离安装;已验证服务端入口导入、挂载补丁、预构建客户端、图标、双语资源及许可证文件。
19
+ - 用户可在「填入插件 npm 包名」中输入 `dsh-session-rollover`;CLI 桌面 profile 示例为 `dsh plugin --profile desktop add dsh-session-rollover`。
20
+
21
+ 本次验证未改动生产 DSH profile;正式版本发布与市场目录收录分别核实,npm 的 `0.0.0-stage` 占位版本不作为发布成功依据。
22
+
12
23
  ## 投稿规则与材料
13
24
 
14
25
  投稿目录为 [awesome-dsh-plugin/awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin),市场应用代码仓库 `dsh-market/dsh-market` 不是插件条目的投稿目标。规则来源:[contributing.md](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/blob/main/contributing.md)(核对于 2026-11-07)。
@@ -23,4 +34,4 @@
23
34
 
24
35
  提交前确认公开 main 分支或安装目标包含 `client.js`、图标和截图;仓库 topic 包含 `dsh-plugin`,创建满一天。仓库创建时间核对为 2026-11-06 06:45:34 UTC,最早满一天时间为北京时间 **2026-11-07 14:45:34**。npm 发布可选,未发布 npm 时使用 GitHub 安装源。
25
36
 
26
- 本目录的 YAML 已通过上述 PR 提交。市场收录、npm 发布和 GitHub Release 是独立状态;本次投稿不包含 npm 发布或创建 GitHub Release。
37
+ 本目录的 YAML 已通过上述 PR 提交。市场收录、npm 发布和 GitHub Release 是独立状态;首次市场投稿未包含 npm 发布,后续 npm 发布见上方记录。本记录不表示已创建 GitHub Release。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-rollover",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "DSH 会话分卷与续接:自动分卷、待办交接快照、来源回溯和归档只读,支持 Web 与桌面客户端。",
6
6
  "main": "index.js",
@@ -39,6 +39,8 @@
39
39
  "docs/screenshots/help-light.png",
40
40
  "docs/screenshots/help-dark.png",
41
41
  "marketplace/",
42
+ "compatibility/",
43
+ "docs/COMPATIBILITY.md",
42
44
  "docs/r3-demo.html",
43
45
  "docs/screenshots/prototype-fixed.png",
44
46
  "docs/live-host-validation.json",
@@ -51,7 +53,9 @@
51
53
  "pretest": "npm run build",
52
54
  "prepack": "npm run build && node scripts/check-release.mjs",
53
55
  "test": "node --test \"test/**/*.test.mjs\"",
54
- "check:release": "npm run build:check && node scripts/check-release.mjs"
56
+ "check:release": "npm run build:check && node scripts/check-release.mjs",
57
+ "check:dsh": "node scripts/check-dsh-compatibility.mjs",
58
+ "check:package": "npm run check:release && node scripts/check-package.mjs"
55
59
  },
56
60
  "dsh": {
57
61
  "manifestVersion": 1,
@@ -96,6 +100,6 @@
96
100
  "registry": "https://registry.npmjs.org/"
97
101
  },
98
102
  "engines": {
99
- "dsh": "0.2.1-rc.5"
103
+ "dsh": "0.2.1-rc.5 || 0.2.2-rc.1"
100
104
  }
101
105
  }
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {tmpdir} from 'node:os';
4
+ import {registryJSON, publishedTargets, checkPackage} from './dsh-contracts.mjs';
5
+ const policy = JSON.parse(await fs.readFile(new URL('../compatibility/policy.json', import.meta.url), 'utf8'));
6
+ const args = process.argv.slice(2);
7
+ const list = args.includes('--list');
8
+ const target = args.find(value => !value.startsWith('--')) ?? 'latest';
9
+ const metadata = await registryJSON(policy.registry + encodeURIComponent(policy.package));
10
+ const versions = publishedTargets(metadata, policy.baseline);
11
+ if (!versions.length || versions.length > 200) throw new Error('DSH compatibility matrix requires baseline maintenance');
12
+ if (list) {
13
+ // 超出 GitHub matrix 上限时明确失败,要求维护者调整基线,不静默漏掉新版本。
14
+ console.log(JSON.stringify({version: versions}));
15
+ } else {
16
+ const selected = target === 'all' ? versions : [metadata['dist-tags']?.[target] ?? target];
17
+ const directory = await fs.mkdtemp(path.join(tmpdir(), 'dsh-contracts-'));
18
+ const report = {level: 'public-declaration-smoke', runtimeVerified: false,
19
+ checkedAt: new Date().toISOString(), baseline: policy.baseline, targets: []};
20
+ try {
21
+ for (const version of selected) {
22
+ const results = [];
23
+ for (const contract of policy.contracts) {
24
+ try { results.push(await checkPackage(contract, version, policy, directory)); }
25
+ catch (error) { results.push({package: contract.package, version, error: String(error.message)}); }
26
+ }
27
+ const passed = results.every(result => !result.error && !result.missing?.length);
28
+ report.targets.push({version, passed, packages: results});
29
+ console.log(`${version}: ${passed ? 'PASS' : 'FAIL'} (public declarations; not runtime verification)`);
30
+ for (const result of results.filter(r => r.error || r.missing?.length)) console.error(JSON.stringify(result));
31
+ }
32
+ report.passed = report.targets.every(target => target.passed);
33
+ const output = process.env.DSH_COMPATIBILITY_REPORT ?? path.resolve('out/dsh-compatibility.json');
34
+ await fs.mkdir(path.dirname(output), {recursive: true});
35
+ await fs.writeFile(output, JSON.stringify(report, null, 2) + '\n');
36
+ if (!report.passed) process.exitCode = 1;
37
+ } finally {
38
+ // 只删除 mkdtemp 创建并持有的独立目录。
39
+ if (path.dirname(path.resolve(directory)) !== path.resolve(tmpdir()) || !path.basename(directory).startsWith('dsh-contracts-')) {
40
+ throw new Error('Refusing cleanup outside the owned temporary directory');
41
+ }
42
+ await fs.rm(directory, {recursive: true, force: true});
43
+ }
44
+ }
@@ -0,0 +1,51 @@
1
+ /** 校验真正交付的 tgz:隔离安装、稳定导出、清单和预构建浏览器入口;不接触 DSH profile。 */
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {tmpdir} from 'node:os';
6
+ import {execFile} from 'node:child_process';
7
+ import {promisify} from 'node:util';
8
+ import {pathToFileURL, fileURLToPath} from 'node:url';
9
+ import {createHash} from 'node:crypto';
10
+ const run = promisify(execFile);
11
+ const root = fileURLToPath(new URL('../', import.meta.url));
12
+ const npm = process.env.npm_execpath;
13
+ assert.ok(npm && path.basename(npm) === 'npm-cli.js', 'Run this check with npm run check:package');
14
+ const owned = await fs.mkdtemp(path.join(tmpdir(), 'dsh-rollover-package-'));
15
+ const output = path.join(root, 'out');
16
+ try {
17
+ // 调用方已完成 build:check 和发布检查;这里禁用生命周期,确保检查同一份构建产物。
18
+ const packed = JSON.parse((await run(process.execPath, [npm, 'pack', '--json', '--ignore-scripts', '--pack-destination', owned],
19
+ {cwd: root, windowsHide: true, maxBuffer: 4 * 1024 * 1024})).stdout)[0];
20
+ const archive = path.join(owned, path.basename(packed.filename));
21
+ const install = path.join(owned, 'install');
22
+ await fs.mkdir(install);
23
+ await fs.writeFile(path.join(install, 'package.json'), '{"name":"dsh-rollover-package-check","private":true}\n');
24
+ await run(process.execPath, [npm, 'install', archive, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false'],
25
+ {cwd: install, windowsHide: true, maxBuffer: 4 * 1024 * 1024});
26
+ const installed = path.join(install, 'node_modules', 'dsh-session-rollover');
27
+ const manifest = JSON.parse(await fs.readFile(path.join(installed, 'package.json'), 'utf8'));
28
+ const entry = await import(pathToFileURL(path.join(installed, manifest.main)).href);
29
+ assert.equal(entry.default, entry.SessionRolloverPlugin);
30
+ assert.equal(typeof entry.default, 'function');
31
+ const files = packed.files.map(file => file.path);
32
+ for (const required of ['client.js', 'src/server/compatibility.mjs', 'compatibility/policy.json', 'docs/COMPATIBILITY.md']) {
33
+ assert.ok(files.includes(required), `Missing packed resource: ${required}`);
34
+ }
35
+ // 从安装后的目录重新校验全部面板资源和 ModuleLoader,避免只检查源码目录。
36
+ await run(process.execPath, [path.join(installed, 'scripts/check-release.mjs')], {cwd: installed, windowsHide: true});
37
+ const bytes = await fs.readFile(archive);
38
+ await fs.mkdir(output, {recursive: true});
39
+ await fs.copyFile(archive, path.join(output, packed.filename));
40
+ const report = {passed: true, level: 'isolated-package-install', runtimeVerified: false,
41
+ checkedAt: new Date().toISOString(), platform: process.platform, node: process.version,
42
+ name: manifest.name, version: manifest.version, files: files.length,
43
+ sha256: createHash('sha256').update(bytes).digest('hex'), artifact: packed.filename};
44
+ await fs.writeFile(path.join(output, 'package-verification.json'), JSON.stringify(report, null, 2) + '\n');
45
+ console.log(`Package PASS: ${manifest.name}@${manifest.version}; isolated install, exports, client and resources (${files.length} files).`);
46
+ } finally {
47
+ if (path.dirname(path.resolve(owned)) !== path.resolve(tmpdir()) || !path.basename(owned).startsWith('dsh-rollover-package-')) {
48
+ throw new Error('Refusing cleanup outside the owned temporary directory');
49
+ }
50
+ await fs.rm(owned, {recursive: true, force: true});
51
+ }
@@ -25,7 +25,19 @@ assert.equal(pkg.bugs.url, repo + '/issues');
25
25
  assert.ok(pkg.keywords.includes('dsh-plugin'));
26
26
  assert.equal(pkg.dsh.manifestVersion, 1);
27
27
  assert.equal(pkg.dsh.client.platform, 'web');
28
- assert.equal(pkg.engines.dsh, pkg.dsh.verifiedDsh);
28
+ const compatibility = JSON.parse(await readFile(await localFile('compatibility/policy.json'), 'utf8'));
29
+ assert.equal(compatibility.baseline, pkg.dsh.verifiedDsh);
30
+ assert.ok(compatibility.verifiedRuntimeVersions.includes(compatibility.baseline));
31
+ assert.ok(compatibility.verifiedRuntimeVersions.every(version => /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)));
32
+ assert.equal(pkg.engines.dsh, compatibility.verifiedRuntimeVersions.join(' || '));
33
+ for (const version of compatibility.verifiedRuntimeVersions.filter(version => version !== compatibility.baseline)) {
34
+ const evidence = JSON.parse(await readFile(await localFile(`compatibility/runtime-${version}.json`), 'utf8'));
35
+ assert.equal(evidence.passed, true);
36
+ assert.equal(evidence.runtimeVerified, true);
37
+ assert.equal(evidence.dsh, version);
38
+ assert.equal(evidence.plugin, pkg.version);
39
+ }
40
+ await localFile('docs/COMPATIBILITY.md');
29
41
  assert.deepEqual(Object.keys(pkg.dependencies ?? {}), []);
30
42
  assert.equal(pkg.scripts.prepare, undefined);
31
43
  assert.equal(pkg.scripts.postinstall, undefined);
@@ -0,0 +1,69 @@
1
+ /** 检查 npm 公共声明中的依赖面;不执行上游包,不把声明扫描当作真实宿主验收。 */
2
+ import assert from 'node:assert/strict';
3
+ import {createHash} from 'node:crypto';
4
+ import {execFile} from 'node:child_process';
5
+ import {promisify} from 'node:util';
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ const run = promisify(execFile);
9
+
10
+ export async function registryJSON(url) {
11
+ const response = await fetch(url, {signal: AbortSignal.timeout(30000)});
12
+ assert.equal(response.status, 200, `Registry unavailable: ${url} (${response.status})`);
13
+ return response.json();
14
+ }
15
+
16
+ /** 按官方发布时刻枚举基线之后的每个版本,包含 alpha/rc 和后续补丁。 */
17
+ export function publishedTargets(metadata, baseline) {
18
+ const since = Date.parse(metadata.time?.[baseline]);
19
+ assert.ok(Number.isFinite(since), 'Missing baseline publication time');
20
+ const versions = Object.keys(metadata.versions ?? {});
21
+ assert.ok(versions.includes(baseline), 'Missing baseline package version');
22
+ for (const version of versions) {
23
+ assert.match(version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
24
+ assert.ok(Number.isFinite(Date.parse(metadata.time?.[version])), `Missing publication time: ${version}`);
25
+ }
26
+ return versions.filter(version => Date.parse(metadata.time?.[version]) >= since)
27
+ .sort((a, b) => Date.parse(metadata.time[a]) - Date.parse(metadata.time[b]));
28
+ }
29
+
30
+ export function inspectDeclarations(text, contract) {
31
+ // 去除注释,避免已删除的方法只在弃用说明中出现时误判通过。
32
+ const source = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, '');
33
+ const missing = [];
34
+ for (const method of contract.methods ?? []) {
35
+ assert.match(method, /^\w+$/);
36
+ if (!new RegExp(`\\b${method}\\s*(?:<[^;{}]*?>\\s*)?\\(`).test(source)) missing.push(method + '()');
37
+ }
38
+ for (const token of contract.tokens ?? []) if (!source.includes(token)) missing.push(token);
39
+ for (const group of contract.alternatives ?? []) {
40
+ if (!group.some(token => source.includes(token))) missing.push(group.join(' | '));
41
+ }
42
+ return missing;
43
+ }
44
+
45
+ export async function checkPackage(contract, version, policy, directory) {
46
+ const metadata = await registryJSON(policy.registry + encodeURIComponent(contract.package) + '/' + encodeURIComponent(version));
47
+ assert.equal(metadata.version, version);
48
+ assert.equal(new URL(metadata.dist.tarball).hostname, new URL(policy.registry).hostname);
49
+ assert.match(metadata.dist.integrity, /^sha512-[A-Za-z0-9+/=]+$/);
50
+ const response = await fetch(metadata.dist.tarball, {signal: AbortSignal.timeout(30000)});
51
+ assert.equal(response.status, 200);
52
+ const archive = Buffer.from(await response.arrayBuffer());
53
+ assert.equal('sha512-' + createHash('sha512').update(archive).digest('base64'), metadata.dist.integrity);
54
+ const file = path.join(directory, contract.package.replace(/[^\w-]/g, '_') + '.tgz');
55
+ await fs.writeFile(file, archive);
56
+ const {stdout} = await run('tar', ['-tzf', file], {windowsHide: true, maxBuffer: 4 * 1024 * 1024});
57
+ const declarations = stdout.split(/\r?\n/).filter(name => /^package\/lib\/.*\.d\.ts$/.test(name));
58
+ assert.ok(declarations.length > 0, 'Missing public type declarations');
59
+ let text = '';
60
+ for (let offset = 0; offset < declarations.length; offset += 40) {
61
+ const names = declarations.slice(offset, offset + 40);
62
+ assert.ok(names.every(name => !name.split('/').includes('..') && !name.includes('\\')));
63
+ // 只读 tar stdout,不把上游归档解压到任何宿主目录。
64
+ text += (await run('tar', ['-xOf', file, ...names], {windowsHide: true, maxBuffer: 16 * 1024 * 1024})).stdout + '\n';
65
+ }
66
+ return {package: contract.package, version, integrity: metadata.dist.integrity,
67
+ declarations: declarations.length,
68
+ missing: inspectDeclarations(text, {...contract, ...contract.versionOverrides?.[version]})};
69
+ }
@@ -5,6 +5,18 @@ export function createRolloverUI(React) {
5
5
  const entries = new Map();
6
6
  let navigate = null;
7
7
  let renderInline = false;
8
+ const clientWarnings = new Set();
9
+ function unavailable(feature, error) {
10
+ clientWarnings.add(feature);
11
+ console.warn('[dsh-session-rollover] client capability unavailable:', feature, error?.message ?? '');
12
+ }
13
+ function inject(ctx, names, setup) {
14
+ try {
15
+ ctx.inject(names, scope => {
16
+ try { return setup(scope); } catch (error) { unavailable(names.join(','), error); }
17
+ });
18
+ } catch (error) { unavailable(names.join(','), error); }
19
+ }
8
20
 
9
21
  async function request(sessionId, action) {
10
22
  const response = await fetch(action ? API : `${API}?session=${encodeURIComponent(sessionId)}`, action ? {
@@ -122,8 +134,8 @@ export function createRolloverUI(React) {
122
134
  h('p', {className: 'dsr-help-credit'}, 'dsh-session-rollover · Oak-liuhj')) : null,
123
135
  h('div', {className: 'dsr-actions-body'},
124
136
  h('button', {type: 'button', 'aria-expanded': showHelp, onClick: () => setShowHelp(!showHelp)}, showHelp ? '收起使用说明' : '使用说明'),
125
- h('button', {type: 'button', disabled: busy || info.sealed || !navigate, onClick: () => action('rollover')}, '现在换会话'),
126
- h('button', {type: 'button', disabled: busy || !navigate, onClick: () => action('new')}, '新建独立会话'),
137
+ h('button', {type: 'button', disabled: busy || info.sealed || !navigate || info.compatibility?.compatible === false, onClick: () => action('rollover')}, '现在换会话'),
138
+ h('button', {type: 'button', disabled: busy || !navigate || info.compatibility?.compatible === false, onClick: () => action('new')}, '新建独立会话'),
127
139
  h('button', {type: 'button', onClick: () => dialog.current?.close()}, '关闭'))),
128
140
  error ? h('span', {role: 'alert'}, error) : null);
129
141
  }
@@ -183,6 +195,8 @@ export function createRolloverUI(React) {
183
195
  if (!info) return null;
184
196
  return h('div', {className: 'dsr-dock'},
185
197
  info.error ? h('div', {role: 'alert'}, info.error) : null,
198
+ info.compatibility?.compatible === false ? h('div', {role: 'alert', className: 'dsr-warning'}, info.compatibility.message) : null,
199
+ clientWarnings.size ? h('div', {role: 'alert', className: 'dsr-warning'}, '部分会话界面功能与当前 DSH 不兼容,请更新插件。') : null,
186
200
  h(ReadonlyControl, {key: props.sessionId, info}),
187
201
  info.capped ? h('div', {role: 'status', className: 'dsr-warning'}, '同簇已达 9 卷上限(9/9),请通过会话操作新建独立会话。') : null,
188
202
  info.retryLimitReached ? h('div', {role: 'status', className: 'dsr-warning'}, '摘要生成失败,已停止自动重试。可在修正内容后手动换会话。') : null,
@@ -200,7 +214,9 @@ export function createRolloverUI(React) {
200
214
 
201
215
  /** 释放时仅撤销自己的禁用;保留模型选择等其他插件后来设置的阻塞原因。 */
202
216
  function blockComposer(blocks, id, reason) {
217
+ if (typeof blocks?.storeFor !== 'function' || typeof blocks?.set !== 'function') throw new Error('composer blocks unavailable');
203
218
  const store = blocks.storeFor(id);
219
+ if (typeof store?.getSnapshot !== 'function' || typeof store?.subscribe !== 'function') throw new Error('composer store unavailable');
204
220
  let previous = store.getSnapshot();
205
221
  const own = {reason};
206
222
  let writing = false;
@@ -222,13 +238,15 @@ export function createRolloverUI(React) {
222
238
  style.setAttribute('data-dsh-plugin', 'dsh-session-rollover-ui');
223
239
  document.head.appendChild(style);
224
240
  ctx.effect?.(() => () => { style.remove(); for (const item of entries.values()) item.stop?.(); entries.clear(); });
225
- ctx.inject(['uiWorkspace'], scope => {
241
+ inject(ctx, ['uiWorkspace'], scope => {
242
+ if (typeof scope.uiWorkspace?.openSession !== 'function') throw new Error('session navigation unavailable');
226
243
  navigate = id => scope.uiWorkspace.openSession(id);
227
244
  scope.effect?.(() => () => { navigate = null; });
228
245
  });
229
- ctx.inject(['slots'], scope => {
246
+ inject(ctx, ['slots'], scope => {
230
247
  const slots = scope.slots;
231
- const mount = (name, Component, options = {}) => scope.effect(() => slots.inject(name, () => slots.register({name,
248
+ if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') throw new Error('slots unavailable');
249
+ const mount = (name, Component, options = {}, owner = scope) => owner.effect(() => owner.slots.inject(name, () => owner.slots.register({name,
232
250
  id: 'dsh-session-rollover', order: 35, ...options}, Component)));
233
251
  mount('conversation.session.header.actions', Header);
234
252
  mount('conversation.input.dock', Dock);
@@ -237,20 +255,27 @@ export function createRolloverUI(React) {
237
255
  mount('sidebar.session.row.leading', props => slots.snapshot?.('sidebar.session.row.trailing')?.length
238
256
  ? null : h(Badges, {...props, compact: true}));
239
257
  // 使用宿主公开 composer block 服务;覆盖输入组件会破坏其子槽授权。
240
- scope.inject(['conversation'], inputScope => {
258
+ inject(scope, ['conversation'], inputScope => {
259
+ if (typeof inputScope.conversation?.blocks?.storeFor !== 'function' || typeof inputScope.conversation?.blocks?.set !== 'function') {
260
+ throw new Error('composer blocks unavailable');
261
+ }
241
262
  function ComposerGuard(props) {
242
263
  const info = useInfo(props.sessionId);
243
264
  const reason = !info ? '正在读取会话状态…' : info.readonly
244
265
  ? '归档会话:不可输入,可通过上方开关恢复写入。' : info.error;
245
- useEffect(() => props.sessionId && reason
246
- ? blockComposer(inputScope.conversation.blocks, props.sessionId, reason) : undefined,
266
+ useEffect(() => {
267
+ if (props.sessionId && reason) {
268
+ try { return blockComposer(inputScope.conversation.blocks, props.sessionId, reason); }
269
+ catch (error) { unavailable('conversation.blocks', error); }
270
+ }
271
+ },
247
272
  [props.sessionId, reason]);
248
273
  return null;
249
274
  }
250
275
  inputScope.effect(() => slots.inject('conversation.input.dock', () => slots.register({
251
276
  name: 'conversation.input.dock', id: 'dsr-readonly-guard', order: 34}, ComposerGuard)));
252
277
  });
253
- scope.inject(['uiConversation'], textScope => {
278
+ inject(scope, ['uiConversation'], textScope => {
254
279
  if (!textScope.uiConversation.userText?.register) return;
255
280
  textScope.effect(() => textScope.uiConversation.userText.register({id: 'dsr-handoff', order: -100,
256
281
  match(text, from) {
@@ -258,8 +283,10 @@ export function createRolloverUI(React) {
258
283
  const payload = parseHandoff(text);
259
284
  return payload ? {id: 'dsr-handoff', start: 0, end: text.length, data: payload} : undefined;
260
285
  }}));
286
+ // 插槽必须随 uiConversation 的注入子作用域释放,不能挂在较长寿命的 slots 父作用域。
287
+ // 新宿主重连/热替换领域服务时会重建该子作用域;挂在父级会重复占用同一个 keyed slot。
261
288
  mount('conversation.user.text.node', props => h(Card, {payload: props.node.data,
262
- source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'});
289
+ source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'}, textScope);
263
290
  renderInline = true;
264
291
  textScope.effect?.(() => () => { renderInline = false; });
265
292
  });
@@ -280,5 +307,5 @@ body[data-ds-dark-theme] :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges),ht
280
307
  @media(max-width:600px){.dsr-header{gap:3px;flex-wrap:nowrap;max-width:calc(100vw - 156px)}.dsr-header>.dsr-chip,.dsr-header>.dsr-source{display:none}.dsr-header>.dsr-chip:first-child{display:inline-flex;font-size:10px;padding:1px 3px}}
281
308
  @media(prefers-reduced-motion:reduce){.dsr-card *{animation:none!important;transition:none!important}}
282
309
  `;
283
- return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, CSS};
310
+ return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, clientWarnings, CSS};
284
311
  }
@@ -0,0 +1,84 @@
1
+ // 只探测已声明的宿主服务;不根据版本号猜测方法签名,也不在探测时写会话。
2
+ const REQUIRED = {
3
+ sessions: ['get', 'list'],
4
+ sessionTitle: ['get', 'rename'],
5
+ sessionController: ['create', 'prompt', 'selectModel'],
6
+ sessionProjections: ['stateOf'],
7
+ workspaceRegistry: ['list'],
8
+ };
9
+
10
+ export const INCOMPATIBLE_MESSAGE = '当前 DSH 的会话接口与插件不兼容,已暂停分卷。原会话与归档数据保留,请更新插件后重试。';
11
+
12
+ /** 方法存在只代表能力探测通过;事件语义、权限和界面仍需逐版本验收。 */
13
+ export function inspectHost(ctx, session) {
14
+ const missing = [];
15
+ for (const [service, methods] of Object.entries(REQUIRED)) {
16
+ for (const method of methods) {
17
+ try { if (typeof ctx[service]?.[method] !== 'function') missing.push(`${service}.${method}`); }
18
+ catch { missing.push(`${service}.${method}`); }
19
+ }
20
+ }
21
+ if (session && typeof session.snapshotEvents !== 'function') missing.push('session.snapshotEvents');
22
+ return {contractVersion: 1, compatible: missing.length === 0, missing,
23
+ message: missing.length ? INCOMPATIBLE_MESSAGE : null};
24
+ }
25
+
26
+ /** 在创建新卷之前检查同步读取面的形状;遇到变化时停止,不用空数据继续交接。 */
27
+ export function preflightHost(ctx, session, services = ctx) {
28
+ const report = inspectHost(ctx, session);
29
+ if (!report.compatible) return report;
30
+ try {
31
+ if (!Array.isArray(session.snapshotEvents())) throw new Error('session.snapshotEvents:array-required');
32
+ const sessions = ctx.sessions.list();
33
+ if (!Array.isArray(sessions) || sessions.some(s => !s || typeof s.id !== 'string' || !s.id)) {
34
+ throw new Error('sessions.list:session-id-required');
35
+ }
36
+ const workspaces = ctx.workspaceRegistry.list();
37
+ if (!Array.isArray(workspaces) || workspaces.some(w => !w || typeof w.id !== 'string' || !w.id ||
38
+ !Array.isArray(w.sessionIds) || w.sessionIds.some(id => typeof id !== 'string'))) {
39
+ throw new Error('workspaceRegistry.list:sessionIds-required');
40
+ }
41
+ const title = ctx.sessionTitle.get(session);
42
+ if (title != null && (typeof title !== 'object' || typeof title.title !== 'string')) {
43
+ throw new Error('sessionTitle.get:title-required');
44
+ }
45
+ const cwd = readWorkingDirectory(ctx, services, session);
46
+ if (cwd != null && typeof cwd !== 'string') throw new Error('workingDirectory:string-required');
47
+ readModelSelection(ctx, services, session);
48
+ } catch (error) {
49
+ return {...report, compatible: false, missing: [String(error?.message ?? error)], message: INCOMPATIBLE_MESSAGE};
50
+ }
51
+ return report;
52
+ }
53
+
54
+ /** 新版优先使用领域服务;旧宿主回退到已验证的公开投影入口。 */
55
+ export function readWorkingDirectory(ctx, services, session) {
56
+ return typeof services.workingDirectory?.get === 'function'
57
+ ? services.workingDirectory.get(session)
58
+ : ctx.sessionProjections.stateOf(session, 'workingDirectory');
59
+ }
60
+
61
+ function validateModel(selection) {
62
+ if (selection != null && (typeof selection.provider !== 'string' || typeof selection.model !== 'string' ||
63
+ !selection.provider || !selection.model ||
64
+ (selection.reasoningEffort != null && typeof selection.reasoningEffort !== 'string'))) {
65
+ throw new Error('modelSelection:provider-model-required');
66
+ }
67
+ return selection;
68
+ }
69
+
70
+ export function readModelSelection(ctx, services, session) {
71
+ const agent = services.agents?.get?.(session.id);
72
+ // 旧宿主也可能提供 agents,但尚无 model.current;此时仍走经过结构校验的投影入口。
73
+ if (typeof agent?.model?.current === 'function') {
74
+ const current = validateModel(agent.model.current());
75
+ if (current != null) return current;
76
+ }
77
+ const selection = ctx.sessionProjections.stateOf(session, 'modelSelection');
78
+ if (selection == null) return undefined;
79
+ if (typeof selection !== 'object' || Array.isArray(selection) ||
80
+ !('pending' in selection || 'lastUsed' in selection)) throw new Error('modelSelection:projection-shape-required');
81
+ validateModel(selection.pending);
82
+ validateModel(selection.lastUsed);
83
+ return selection.pending ?? selection.lastUsed;
84
+ }
@@ -2,6 +2,7 @@
2
2
  import {eventType} from '../domain/turns.mjs';
3
3
  import {openToolCalls} from '../domain/inflight.mjs';
4
4
  import {lastTodos} from '../domain/todos.mjs';
5
+ import {preflightHost, readWorkingDirectory, readModelSelection} from './compatibility.mjs';
5
6
 
6
7
  /**
7
8
  * 为一个来源会话创建适配器。新卷继承其工作区和模型选择。
@@ -14,6 +15,8 @@ import {lastTodos} from '../domain/todos.mjs';
14
15
  * @returns {object} performRollover 所需的异步宿主接口
15
16
  */
16
17
  export function createHostAdapter({ctx, session, state, sessionOf, log, services = ctx}) {
18
+ const compatibility = preflightHost(ctx, session, services);
19
+ if (!compatibility.compatible) throw new Error(`host-incompatible: ${compatibility.missing.join(', ')}`);
17
20
  const id = session.id;
18
21
  const sourceEvents = session.snapshotEvents().slice();
19
22
  const registry = services.jobs;
@@ -101,16 +104,17 @@ export function createHostAdapter({ctx, session, state, sessionOf, log, services
101
104
  },
102
105
  createSession: async () => {
103
106
  // 优先归入来源工作区;来源不属于工作区时才按其 cwd 建会话。
104
- const cwd = ctx.sessionProjections.stateOf(session, 'workingDirectory');
107
+ const cwd = readWorkingDirectory(ctx, services, session);
105
108
  const workspace = ctx.workspaceRegistry.list().find(w => w.sessionIds.includes(session.id));
106
- return ctx.sessionController.create(workspace ? {workspaceId: workspace.id} : typeof cwd === 'string' ? {cwd} : {});
109
+ const created = await ctx.sessionController.create(workspace ? {workspaceId: workspace.id} : typeof cwd === 'string' ? {cwd} : {});
110
+ if (!/^[\w-]{1,160}$/.test(created?.sessionId ?? '')) throw new Error('host-incompatible: create.sessionId');
111
+ return created;
107
112
  },
108
113
  injectFirstMessage: async (sessionId, text) => {
109
114
  // 宿主 create() 建的是空壳会话,没有 model selection,会落到默认 provider
110
115
  // (实测 "deepseek-official" 无 key → MISSING_CREDENTIAL)。先继承旧卷的模型,再注入。
111
116
  try {
112
- const sel = ctx.sessionProjections.stateOf(session, 'modelSelection');
113
- const pick = sel?.pending ?? sel?.lastUsed;
117
+ const pick = readModelSelection(ctx, services, session);
114
118
  if (pick?.provider && pick?.model) {
115
119
  await ctx.sessionController.selectModel({
116
120
  sessionId, provider: pick.provider, model: pick.model,
@@ -54,6 +54,12 @@ export async function handleSessionRequest(plugin, req, res) {
54
54
  try { input = JSON.parse(body); } catch { return send(400, {error: 'invalid-json'}); }
55
55
  if (!/^[\w-]{1,160}$/.test(input?.sessionId ?? '')) return send(400, {error: 'invalid-session'});
56
56
  if (!plugin.sessionOf(input.sessionId)) return send(404, {error: 'session-unavailable'});
57
+ if (['rollover', 'new'].includes(input.action)) {
58
+ const compatibility = plugin.handoffInfo(input.sessionId).compatibility;
59
+ if (compatibility && !compatibility.compatible) {
60
+ return send(503, {error: 'host-incompatible', message: compatibility.message, compatibility});
61
+ }
62
+ }
57
63
  if (input.action === 'readonly' && typeof input.readonly === 'boolean') {
58
64
  if (!plugin.handoffInfo(input.sessionId).sealed) return send(409, {error: 'session-not-archived'});
59
65
  return send(200, plugin.setReadonly(input.sessionId, input.readonly));
@@ -11,6 +11,7 @@ import {transitionInFlight} from '../domain/mode.mjs';
11
11
  import {createNoticeStore} from '../storage/notices.mjs';
12
12
  import {createHostAdapter} from './host-adapter.mjs';
13
13
  import {attachNoticeRoutes, handlePendingNotices} from './notice-route.mjs';
14
+ import {inspectHost, preflightHost, INCOMPATIBLE_MESSAGE} from './compatibility.mjs';
14
15
 
15
16
  const inflightKey = (id) => `rollover:${id}`;
16
17
 
@@ -27,6 +28,7 @@ const NOTICE_TEXT = {
27
28
  'cluster-cap': '同簇已达 9 卷上限,请新建独立会话继续。',
28
29
  'storage-unavailable': '分卷数据目录不可写,已保留原会话,请检查目录后重试。',
29
30
  'user-renamed': '已保留手动设置的会话名称。',
31
+ 'host-incompatible': INCOMPATIBLE_MESSAGE,
30
32
  };
31
33
 
32
34
  /**
@@ -52,9 +54,10 @@ export default class SessionRolloverPlugin {
52
54
  this.ctx = ctx;
53
55
  this.services = typeof ctx.inject === 'function' ? {} : ctx;
54
56
  if (ctx.inject) {
55
- for (const name of ['jobs', 'agentDefaultModel']) ctx.inject([name], scope => {
56
- this.services[name] = scope[name];
57
- scope.effect?.(() => () => { delete this.services[name]; });
57
+ for (const name of ['jobs', 'agentDefaultModel', 'workingDirectory', 'agents']) ctx.inject([name], scope => {
58
+ const service = scope[name];
59
+ this.services[name] = service;
60
+ scope.effect?.(() => () => { if (this.services[name] === service) delete this.services[name]; });
58
61
  });
59
62
  }
60
63
  this.config = {mode: 'auto', threshold: DEFAULT_THRESHOLD, ...config};
@@ -72,6 +75,7 @@ export default class SessionRolloverPlugin {
72
75
  rollover: (id) => this.rolloverNow(id),
73
76
  info: (id) => this.handoffInfo(id),
74
77
  setReadonly: (id, value) => this.setReadonly(id, value),
78
+ compatibility: () => inspectHost(this.ctx),
75
79
  });
76
80
  ctx.effect(() => () => {
77
81
  this.busy.clear();
@@ -87,6 +91,7 @@ export default class SessionRolloverPlugin {
87
91
  this.log('boot', {
88
92
  dataDir: this.dataDir, mode: this.config.mode, threshold: this.config.threshold,
89
93
  degraded: this.persistence.degraded, notices: this.notices.path, noticesDegraded: this.notices.degraded,
94
+ compatibility: inspectHost(ctx),
90
95
  });
91
96
  }
92
97
 
@@ -126,13 +131,21 @@ export default class SessionRolloverPlugin {
126
131
 
127
132
  /** 启动修复缓存缺项;失败注入产生的待清理卷不参与有效父链。 */
128
133
  reconcileIndex() {
134
+ if (!inspectHost(this.ctx).compatible) return;
135
+ let sessions;
136
+ try { sessions = this.ctx.sessions.list(); } catch { return; }
137
+ if (!Array.isArray(sessions)) return;
129
138
  let changed = false;
130
139
  const abandoned = new Set(this.state.pendingList().map(r => r.sessionId));
131
140
  const snapshots = new Map();
132
- for (const session of this.ctx.sessions.list()) {
141
+ for (const session of sessions) {
142
+ if (!session || typeof session.id !== 'string') continue;
133
143
  if (abandoned.has(session.id)) continue;
134
144
  let h;
135
- for (const e of session.snapshotEvents()) {
145
+ let events;
146
+ try { events = session.snapshotEvents(); } catch { continue; }
147
+ if (!Array.isArray(events)) continue;
148
+ for (const e of events) {
136
149
  const text = textOf(e);
137
150
  if (!text.startsWith('【分卷交接】')) continue;
138
151
  try { h = JSON.parse(/```json\s*([\s\S]*?)```/.exec(text)?.[1]); } catch { /* 非本插件快照 */ }
@@ -204,20 +217,24 @@ export default class SessionRolloverPlugin {
204
217
  const parent = handoff?.cluster?.independent ? null : handoff?.from_session_id ?? record?.parent_session_id;
205
218
  let title = handoff?.from_title || parent;
206
219
  const source = parent && this.sessionOf(parent);
207
- if (source) title = this.ctx.sessionTitle.get(source)?.title || title;
220
+ if (source) { try { title = this.ctx.sessionTitle.get(source)?.title || title; } catch {} }
208
221
  const session = this.sessionOf(id);
209
- const events = session?.snapshotEvents() ?? [];
222
+ let events = [];
223
+ try { const snapshot = session?.snapshotEvents?.(); if (Array.isArray(snapshot)) events = snapshot; } catch {}
224
+ let sessionTitle = '';
225
+ try { sessionTitle = session ? this.ctx.sessionTitle.get(session)?.title ?? '' : ''; } catch {}
210
226
  const sealed = record?.rollover_state === 'sealed';
211
227
  const turnCount = countTurns(events);
212
228
  const children = record ? this.index.cluster(record.cluster_id).filter(r => r.parent_session_id === id) : [];
213
229
  const retry = this.state.retryGet(id).attempts;
214
230
  return {source: parent ? {sessionId: parent, title} : null, handoff,
215
231
  readonly: this.isReadonly(id), sealed, sessionId: id,
216
- title: session ? this.ctx.sessionTitle.get(session)?.title ?? '' : '',
232
+ title: sessionTitle,
233
+ compatibility: session ? preflightHost(this.ctx, session, this.services) : inspectHost(this.ctx),
217
234
  turnCount, threshold: this.config.threshold, volume: record?.vol_no ?? null, cap: 9,
218
235
  capped: record?.vol_no >= 9, summaryRetry: retry,
219
236
  retryLimitReached: retry >= 3, degraded: this.persistence.degraded || !!this.stateDegraded?.has(id),
220
- previousTurnCount: parent && this.sessionOf(parent) ? countTurns(this.sessionOf(parent).snapshotEvents()) : null,
237
+ previousTurnCount: source ? (() => { try { const e = source.snapshotEvents(); return Array.isArray(e) ? countTurns(e) : null; } catch { return null; } })() : null,
221
238
  nextSessionId: children[0]?.sessionId ?? null};
222
239
  }
223
240
 
@@ -241,6 +258,8 @@ export default class SessionRolloverPlugin {
241
258
  async rolloverNow(id) {
242
259
  const session = this.sessionOf(id);
243
260
  if (!session) return {status: 'failed', reason: 'session-unavailable'};
261
+ const compatibility = preflightHost(this.ctx, session, this.services);
262
+ if (!compatibility.compatible) return {status: 'failed', reason: 'host-incompatible', compatibility};
244
263
  if (this.busy.has(inflightKey(id))) return {status: 'skipped', reason: 'busy'};
245
264
  if (this.index.get(id)?.rollover_state === 'sealed') return {status: 'skipped', reason: 'readonly'};
246
265
  this.busy.add(inflightKey(id));
@@ -300,6 +319,11 @@ export default class SessionRolloverPlugin {
300
319
  const session = agent?.session;
301
320
  if (!session?.id) return;
302
321
  const id = session.id;
322
+ const compatibility = preflightHost(this.ctx, session, this.services);
323
+ if (!compatibility.compatible) {
324
+ this.notifyOnce(session, {reason: 'host-incompatible'});
325
+ return;
326
+ }
303
327
  if (this.busy.has(inflightKey(id))) return;
304
328
  // 已封卷的 -N 永不再分卷:B9 的手动开关只放行「在旧卷继续写」,不等于再开新卷。
305
329
  try {