dsh-session-rollover 1.1.1 → 1.2.1

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,21 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.2.1 — 2026-11-14
4
+
5
+ - 按维护策略将 DSH 安装兼容范围调整为 `0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0`,覆盖 `0.2.2-rc.3` 及范围内后续版本。
6
+ - 将安装范围与历史实机验收名单分离,发布检查改为核对显式范围;保留验收证据采集时的插件版本,不将历史记录视为当前版本实机验收。
7
+ - 同步中英文插件说明和升级文档;保留运行时关键能力探测、分卷暂停保护和既有数据路径。
8
+
9
+ ## 1.2.0 — 2026-11-11
10
+
11
+ - 增加对 DSH `0.2.2-rc.1` 的确切版本支持,完成独立 home / profile 的真实宿主验收;保留旧版范围与原数据路径。
12
+ - 修复新版宿主重建 `uiConversation` 时交接卡插槽未随子作用域释放、重复占用 keyed slot 的问题。
13
+ - 集中宿主能力探测与读取结构预检,不兼容时在建卷和改名前暂停分卷,保留历史快照与只读拦截。
14
+ - 新版优先读取工作目录、活动 Agent 模型的领域服务,保留旧投影接口回退;可选服务热替换时避免旧清理回调误删新服务。
15
+ - 会话状态及面板补充兼容诊断,HTTP 建卷明确返回 503,缺失界面服务不直接中断渲染。
16
+ - 增加所有后续 npm DSH 版本的公开声明检查、Windows/Linux 与 Node 22/24 回归工作流和升级验收指南;不自动扩大兼容范围或发布。
17
+ - 增加实际 tgz 的隔离安装、稳定导出和面板资源校验;兼容策略及新模块随包分发。
18
+
3
19
  ## 1.1.1 — 2026-11-07
4
20
 
5
21
  - 公开包名改为 `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.1** · **兼容范围:DSH 0.2.1-rc.5 或 ≥0.2.2-rc.1、<0.3.0-0** · **维护者 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.1` 的安装兼容范围为 `0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0`,包括 `0.2.2-rc.3` 及范围内后续预发布、正式和补丁版本,不包含 `0.3.0` 的任何预发布或正式版本。升级时保留原数据目录。历史实机验收版本为 `0.2.1-rc.5`、`0.2.2-rc.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.1.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.1.
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,22 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "package": "@deepseek-ai/dsh",
4
+ "baseline": "0.2.1-rc.5",
5
+ "supportedRuntimeRange": "0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0",
6
+ "verifiedRuntimeVersions": ["0.2.1-rc.5", "0.2.2-rc.1"],
7
+ "registry": "https://registry.npmjs.org/",
8
+ "contracts": [
9
+ {"package": "@deepseek-ai/dsh-session", "methods": ["get", "list", "snapshotEvents"], "tokens": ["session/event"]},
10
+ {"package": "@deepseek-ai/dsh-session-title", "methods": ["get", "rename"]},
11
+ {"package": "@deepseek-ai/dsh-api-session-controller", "methods": ["create", "prompt", "selectModel"]},
12
+ {"package": "@deepseek-ai/dsh-session-projection", "methods": ["stateOf"]},
13
+ {"package": "@deepseek-ai/dsh-working-directory", "methods": ["get"]},
14
+ {"package": "@deepseek-ai/dsh-workspace", "methods": ["list"], "tokens": ["sessionIds"]},
15
+ {"package": "@deepseek-ai/dsh-agent", "methods": ["get", "current"], "tokens": ["agent/pre-step", "reject"],
16
+ "versionOverrides": {"0.2.1-rc.5": {"methods": ["get"]}}},
17
+ {"package": "@deepseek-ai/dsh-client-connection", "methods": ["admit"]},
18
+ {"package": "@deepseek-ai/dsh-client-ui-conversation", "methods": ["storeFor", "set"], "tokens": ["conversation.session.header.actions", "conversation.input.dock", "blocks"]},
19
+ {"package": "@deepseek-ai/dsh-client-ui-workspace", "methods": ["openSession"], "alternatives": [["sidebar.session.row.trailing", "sidebar.session.row.leading"]]},
20
+ {"package": "@deepseek-ai/dsh-client-ui-slots", "methods": ["register"]}
21
+ ]
22
+ }
@@ -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,77 @@
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
+ - `1.2.1` 按维护者指定策略声明 `0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0`,允许范围内后续 RC、正式和补丁版本安装;排除 `0.3.0` 的全部预发布和正式版本。
9
+ - DSH 的版本判定使用 `includePrerelease: true`:该范围也包含 `0.2.3-alpha.1` 等后续 0.2.x 预发布版本;普通 SemVer 工具的默认预发布排除规则不同,复核时需使用与宿主相同的选项。`0.2.1` 分支仅保留确切的 `0.2.1-rc.5`。
10
+ - `compatibility/policy.json` 的 `supportedRuntimeRange` 决定安装范围,`engines.dsh` 必须与其一致;`verifiedRuntimeVersions` 独立记录已完成实机验收的版本,不再用于拼接安装白名单。
11
+ - `verifiedRuntimeVersions` 保留 `0.2.1-rc.5`、`0.2.2-rc.1`;历史证据保留采集时的插件版本,不因扩大范围而修改为新发行版或加入未实测宿主。
12
+ - npm 声明扫描、夹具回归和真实宿主验收分别记录。扫描通过不自动修改版本约束,不自动发布 npm 包,也不自动改用户 profile。
13
+ - 已安装旧版的用户需更新到包含新兼容声明的插件包;npm 同一版本不会被覆盖更新。历史发布核验记录见 [投稿材料](../marketplace/SUBMISSION.md)。
14
+
15
+ ## 1.2.1 范围调整(2026-11-14)
16
+
17
+ 本次调整版本元数据、发布检查与文档,不修改分卷运行逻辑、能力保护或数据路径。`0.2.2-rc.3` 的十一个组件公开声明检查通过;这不是新版本的完整实机分卷验收,因此没有将它加入 `verifiedRuntimeVersions`。
18
+
19
+ ## 本次验证(2026-11-11)
20
+
21
+ | 检查 | 结果与范围 |
22
+ | --- | --- |
23
+ | 本地回归 | Windows、Node.js 24.22.1,100 项全部通过(含旧宿主有 agents、无 model.current 时的投影回退) |
24
+ | 公开声明 | `0.2.1-rc.5`、`0.2.2-alpha.3`、`0.2.2-rc.1` 的十一个组件包均通过 |
25
+ | 发布包 | `1.2.0` 实际 tgz 隔离安装、服务端导出、预构建客户端和展示资源通过 |
26
+ | 新版真实宿主 | `1.2.0` 在 `0.2.2-rc.1` 验收通过,保留旧基线并增加该实测版本;当时的精确白名单已由 1.2.1 范围策略替代 |
27
+ | 远程 CI | Windows/Linux、Node 22/24 四组回归与打包、三个版本的声明检查均通过;见下方远程复验记录 |
28
+
29
+ 声明报告和安装包校验报告分别保存在 `out/dsh-compatibility.json`、`out/package-verification.json`;它们不是实际宿主验收报告。远程矩阵已通过 [运行 42981276082](https://github.com/Oak-liuhj/dsh-session-rollover/actions/runs/42981276082)。首次 Linux 运行发现 Windows 专用的不可写目录测试夹具不成立,已改为文件阻挡父目录并通过复验;该修复只影响测试,不改变发布包的运行代码。`v1.2.0` tag 原始失败记录保留,详见 [发布记录](../marketplace/SUBMISSION.md)。
30
+
31
+ 真实宿主记录见 [runtime-0.2.2-rc.1.json](../compatibility/runtime-0.2.2-rc.1.json):十条用户消息仅分卷一次,继承工作区、目录及模型,待办快照保持不变,新卷实时待办可继续推进,来源跳转、复制 JSON、双向归档开关和宿主重启恢复正常,匿名 HTTP 请求返回 401。本轮使用本地模型夹具,不代表真实服务商模型或 Bot 端完整验收。
32
+
33
+ 重启复验发现并修复 `uiConversation` 子作用域卸载后交接卡 keyed slot 未释放的问题;插槽现在随该子作用域清理,重复注入回归与实机重启复验通过。宿主热重连期间仍观察到原生会话引用已释放的日志,刷新后恢复;这与插件重复插槽问题分开记录,不宣称宿主全局无错误。
34
+
35
+ ## 运行保护
36
+
37
+ 服务端集中在 `src/server/compatibility.mjs` 检查会话存储、标题、创建/提示/模型选择、投影、工作区接口,以及同步读取的返回结构。关键能力不满足时,自动与手动分卷在创建会话、改名、保存重试之前停止;HTTP 建卷操作返回 `503 host-incompatible`,面板显示更新提示。
38
+
39
+ 已有的交接快照与只读状态仍可读取;已注册的服务端归档写入拦截不因分卷暂停而关闭。方法存在不证明事件语义仍相同;如果升级导致插件本身无法加载,这些插件内保护也无法运行,所以仍需遵守宿主版本约束。
40
+
41
+ 新版宿主优先通过 `workingDirectory.get(session)` 和活动 Agent 的 `model.current()` 读取领域状态。缺少这些可选服务时,回退到已验证的 `sessionProjections.stateOf`。后者在较新源码中已弃用,仍纳入声明检查;将来移除时明确失败,由维护者适配,而不是静默换成空目录或默认模型。
42
+
43
+ 基线 `0.2.1-rc.5` 的 Agent 声明尚无 `current()`,检查策略对此确切版本保留显式例外,运行时使用投影回退;后续版本继续检查领域服务接口。未知模型结构会暂停分卷,合法的空选择保持可用。
44
+
45
+ 浏览器针对插槽、导航与输入阻塞服务做能力检查。缺少可选界面能力时记录带插件名的警告,并在可用的面板中提示;不让缺失服务直接导致 React 渲染错误。服务端诊断可通过 `ctx.sessionRollover.compatibility()` 获取;每个会话状态的 `compatibility` 还包含返回结构检查。
46
+
47
+ ## 自动检查
48
+
49
+ 仓库工作流 `.github/workflows/compatibility.yml` 包含:
50
+
51
+ 1. Windows / Linux、Node.js 22 / 24 上运行完整回归、发布格式检查和 tgz 隔离安装校验。
52
+ 2. 从官方 npm 注册表枚举基线发布时刻之后的每个 DSH 版本,包含 alpha、rc 与后续补丁;不只检查 `latest`,避免遗漏中间版本。
53
+ 3. 下载各版本的十一个公开组件包,核对 SHA512 后只读扫描类型声明,检查插件使用的方法、事件名及界面插槽,并保存报告。不会执行上游安装脚本。
54
+
55
+ 工作流在代码推送、PR、手动触发和每六小时的定时任务中运行;需要提交到 GitHub 默认分支并启用 Actions 才会生效。本地创建文件不等于已启用远程任务。注册表请求或归档校验失败均使任务失败,不视为兼容通过。版本数超过 200 时要求维护基线,不静默截断。
56
+
57
+ ```powershell
58
+ npm test
59
+ npm run check:release
60
+ npm run check:package
61
+ npm run check:dsh -- all
62
+ npm run check:dsh -- 0.2.2-rc.1
63
+ node scripts/check-dsh-compatibility.mjs --list
64
+ ```
65
+
66
+ 需要网络与系统 `tar`;Windows 自带的 tar 可用。报告默认写入忽略提交的 `out/dsh-compatibility.json`,也可用 `DSH_COMPATIBILITY_REPORT` 指定位置。`level: public-declaration-smoke`、`runtimeVerified: false` 明确说明证据边界。声明扫描不会证明方法签名、授权、事件顺序或视觉渲染完全兼容。
67
+
68
+ `check:package` 在自有临时目录安装实际 tgz,复核导出、预构建客户端和全部发布资源,结束后清理隔离目录;包与报告保存在 `out/`。此检查也不代表真实 DSH 宿主验收。
69
+
70
+ ## 每次 DSH 发布后的维护步骤
71
+
72
+ 1. 查看每个新版本的声明扫描和回归结果;发现方法、事件或插槽变化时更新集中适配层,保留旧宿主路径的测试。
73
+ 2. 用该版本的真实 DSH,在隔离 home / profile 和本地模型夹具中验证:插件装载、十条真实用户消息只分一次、模型与工作区继承、待办交接、来源导航、重启恢复、归档只读及恢复写入、HTTP owner 授权。不得使用生产会话验收。
74
+ 3. 记录 DSH 与插件版本、平台、结果及证据位置。通过后才向 `verifiedRuntimeVersions` 添加确切版本及实机证据,保留证据采集时的插件版本。范围内新增实测版本无需修改 `engines.dsh`;超出范围时需单独审核并同步修改 `supportedRuntimeRange` 与清单。旧基线继续用于回归。
75
+ 4. 更新插件版本、构建预生成客户端、完成打包与隔离安装验证,再发布新 npm 版本。用户通过正常插件升级获得适配;不得覆盖同一 npm 版本。
76
+
77
+ 宿主升级后遇到兼容提示时,应保留数据目录,使用已验收版本组合或更新插件;不要删除快照、绕过宿主版本检查或同时加载两份插件。
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.1` declares the DSH installation range `0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0`. It includes `0.2.2-rc.3` and subsequent versions within the range, and excludes all `0.3.0` prereleases and stable releases. Historical runtime validation covers `0.2.1-rc.5` and `0.2.2-rc.1`; the latter used an isolated host and local model fixture for ten-message rollover, model and workspace inheritance, todo continuation, archive controls and restart recovery. The declared range and tested versions are maintained separately; the range does not claim runtime validation for every host, production model or Bot group channel. Retain the data directory when upgrading. 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.1` 的 DSH 安装兼容范围为 `0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0`,包括 `0.2.2-rc.3` 及范围内后续版本,排除 `0.3.0` 的预发布与正式版本。历史实机验收覆盖 `0.2.1-rc.5`、`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.1,维护者 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.1.tgz" --access public --registry=https://registry.npmjs.org/
26
+ npm view dsh-session-rollover@1.2.1 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` 的 `supportedRuntimeRange` 一致,当前为 `0.2.1-rc.5 || >=0.2.2-rc.1 <0.3.0-0`;`verifiedRuntimeVersions` 独立记录真实宿主验收,不决定安装范围,也不因包版本更新而改写历史证据中的插件版本。声明扫描通过后仍按 [升级验收](./COMPATIBILITY.md) 补充实机记录。许可证声明为 `(Apache-2.0 AND MIT)`,必须同时分发 LICENSE、LICENSE.upstream 和 NOTICE。
@@ -2,6 +2,6 @@ url: https://github.com/Oak-liuhj/dsh-session-rollover
2
2
  name: Oak-liuhj/dsh-session-rollover
3
3
  category: session
4
4
  description:
5
- en: 'Split long DSH conversations into volumes with todo handoff snapshots, source navigation and read-only archives.'
6
- zh: '长会话自动分卷,携带待办交接快照继续工作,支持来源回溯与归档只读。'
5
+ en: 'Split long DSH conversations every 10 user messages by default, with todo handoff snapshots, source links and read-only archives; declares DSH 0.2.1-rc.5 or >=0.2.2-rc.1 <0.3.0-0 compatibility.'
6
+ zh: '长会话默认每 10 条用户消息自动分卷,支持待办快照续接、来源回溯及归档只读;声明兼容 DSH 0.2.1-rc.5 或 >=0.2.2-rc.1 <0.3.0-0。'
7
7
  icon: https://raw.githubusercontent.com/Oak-liuhj/dsh-session-rollover/main/assets/icon.png
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## 投稿记录
4
4
 
5
+ 2026-11-11 已提交市场更新 PR:[awesome-dsh-plugin/awesome-dsh-plugin#14240](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/14240)。只修改现有条目的中英文简介,补充宿主兼容检查;正文说明 1.2.0 已发布及支持的 DSH 版本,并附真实宿主与远程 CI 证据。分支为 `Oak-liuhj:codex/update-dsh-session-rollover-1-2-0`,提交为 `5cd4d474f448a42fc2828978d6e215f9078447e8`。提交不代表已合并,npm/version 元数据仍由市场自动采集。
6
+
7
+ 2026-11-11 复核:首次收录 PR 已于 **2026-11-10 00:46:29 UTC** 合并,官方 main 的条目存在,在线 `plugins.json` 已包含本插件。市场条目指向本仓库,不锁定插件版本,后续发布无需重复投稿同名条目。
8
+
9
+ 本次发布后再次下载目录,条目的 `npm`、`version` 展示字段仍为 `null`,需等待市场重新抓取元数据;不据此宣称市场已显示 1.2.0。GitHub main 已包含新版代码,npm 官方源按包名可安装 1.2.0。
10
+
5
11
  2026-11-09 已提交首次上架 PR:[awesome-dsh-plugin/awesome-dsh-plugin#13453](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/13453)。
6
12
 
7
13
  - 插件版本:`1.1.1`,投稿时已发布源码提交为 `541321a91e77942ba23bcc19267b2d5a8a79ccbf`。
@@ -9,11 +15,36 @@
9
15
  - 唯一新增文件:`data/plugins/Oak-liuhj__dsh-session-rollover.yml`。
10
16
  - 校验与审核的实时状态以 PR 页面为准。提交 PR 不等于市场已收录;正式收录还需维护者合并及目录后续构建。
11
17
 
18
+ ## npm 发布记录
19
+
20
+ ### 1.2.0
21
+
22
+ 2026-11-11,`dsh-session-rollover@1.2.0` 已发布,npm 官方源 `latest` 为 `1.2.0`,声明支持 DSH `0.2.1-rc.5 || 0.2.2-rc.1`。
23
+
24
+ - [npm 1.2.0](https://www.npmjs.com/package/dsh-session-rollover/v/1.2.0) 与 [GitHub Release v1.2.0](https://github.com/Oak-liuhj/dsh-session-rollover/releases/tag/v1.2.0) 均已公开;tag 指向 `5bfde1e93ca4021fc7fcab59609925125bc2f740`。
25
+ - 两处实际下载的 tgz 均与审核包逐字节哈希一致;SHA256 为 `b35a9fa386d176e7dcc133e1a8bf36ac853ac7841786059bf302563c65c984a3`,SHA1 为 `7b1b69e17abfbc76a960f277a31092f70187f669`。GitHub 附有 `SHA256SUMS`。
26
+ - 已从 npm 官方源按 `dsh-session-rollover@1.2.0` 隔离安装,并核对版本、宿主范围、导出入口及全部发布资源。未改动生产 profile。
27
+ - 发布后的远程检查发现一个测试夹具将 Windows 的不存在盘符当作跨平台不可写路径;Linux 实际能创建该目录。提交 `0b526f057fdc1662ed3a7e7f2f973525578556c0` 改用文件阻挡父目录,不改变已发布的运行代码或安装包,也不移动既有 tag。
28
+ - [远程复验](https://github.com/Oak-liuhj/dsh-session-rollover/actions/runs/42981276082) 全部通过:Windows/Linux、Node 22/24 四组回归与打包检查,以及三个 DSH 版本的公开声明检查。`v1.2.0` tag 的原始 CI 仍保留测试夹具失败记录。
29
+
30
+ 桌面用户更新到 1.2.0 后重启 DSH;镜像尚未同步时,可使用 GitHub Release 附件或 GitHub 源。后续 DSH 版本仍需逐版真实宿主验收。
31
+
32
+ ### 1.1.1
33
+
34
+ 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`。
35
+
36
+ - npm 官方源与腾讯镜像的 `latest` 均已确认为 `1.1.1`。
37
+ - 下载的 tgz 与审核通过的原包一致,SHA1 为 `cbe802fe44f8ae14da3eebc8d6a92e3468609ab5`,SHA512 integrity 也已核对。
38
+ - 两个源均已通过按包名隔离安装;已验证服务端入口导入、挂载补丁、预构建客户端、图标、双语资源及许可证文件。
39
+ - 用户可在「填入插件 npm 包名」中输入 `dsh-session-rollover`;CLI 桌面 profile 示例为 `dsh plugin --profile desktop add dsh-session-rollover`。
40
+
41
+ 本次验证未改动生产 DSH profile;正式版本发布与市场目录收录分别核实,npm 的 `0.0.0-stage` 占位版本不作为发布成功依据。
42
+
12
43
  ## 投稿规则与材料
13
44
 
14
45
  投稿目录为 [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)。
15
46
 
16
- 仅新增 `data/plugins/Oak-liuhj__dsh-session-rollover.yml`,内容使用本目录同名文件;不手改自动生成的 README,不增加 npm/version/tags 等非目录字段。
47
+ 首次投稿仅新增 `data/plugins/Oak-liuhj__dsh-session-rollover.yml`,后续更新修改同一文件,内容使用本目录同名文件;不手改自动生成的 README,不增加 npm/version/tags 等非目录字段。投稿规则已于 2026-11-11 再次核对,npm 关联无需另行通知维护者。
17
48
 
18
49
  建议 PR 标题:`Add Oak-liuhj/dsh-session-rollover`
19
50
 
@@ -23,4 +54,4 @@
23
54
 
24
55
  提交前确认公开 main 分支或安装目标包含 `client.js`、图标和截图;仓库 topic 包含 `dsh-plugin`,创建满一天。仓库创建时间核对为 2026-11-06 06:45:34 UTC,最早满一天时间为北京时间 **2026-11-07 14:45:34**。npm 发布可选,未发布 npm 时使用 GitHub 安装源。
25
56
 
26
- 本目录的 YAML 已通过上述 PR 提交。市场收录、npm 发布和 GitHub Release 是独立状态;本次投稿不包含 npm 发布或创建 GitHub Release。
57
+ 本目录的 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.1",
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 <0.3.0-0"
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,24 @@ 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.schemaVersion, 2);
30
+ assert.equal(compatibility.baseline, pkg.dsh.verifiedDsh);
31
+ assert.ok(compatibility.verifiedRuntimeVersions.includes(compatibility.baseline));
32
+ assert.ok(compatibility.verifiedRuntimeVersions.every(version => /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)));
33
+ // 安装范围由显式策略决定;实机验收名单独立维护,新增验收记录无需收窄范围。
34
+ assert.equal(typeof compatibility.supportedRuntimeRange, 'string');
35
+ assert.ok(compatibility.supportedRuntimeRange.trim());
36
+ assert.equal(pkg.engines.dsh, compatibility.supportedRuntimeRange);
37
+ for (const version of compatibility.verifiedRuntimeVersions.filter(version => version !== compatibility.baseline)) {
38
+ const evidence = JSON.parse(await readFile(await localFile(`compatibility/runtime-${version}.json`), 'utf8'));
39
+ assert.equal(evidence.passed, true);
40
+ assert.equal(evidence.runtimeVerified, true);
41
+ assert.equal(evidence.dsh, version);
42
+ // 保留证据采集时真实的插件版本;旧验收记录不冒充当前发行版验收。
43
+ assert.match(evidence.plugin, /^\d+\.\d+\.\d+$/);
44
+ }
45
+ await localFile('docs/COMPATIBILITY.md');
29
46
  assert.deepEqual(Object.keys(pkg.dependencies ?? {}), []);
30
47
  assert.equal(pkg.scripts.prepare, undefined);
31
48
  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 {