dsh-session-rollover 0.0.0-stage → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +201 -0
  3. package/LICENSE.upstream +21 -0
  4. package/NOTICE +9 -0
  5. package/README.md +157 -2
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +7 -0
  8. package/client.js +656 -0
  9. package/compatibility/policy.json +21 -0
  10. package/compatibility/runtime-0.2.2-rc.1.json +32 -0
  11. package/cordis.patch.yml +9 -0
  12. package/docs/COMPATIBILITY.md +70 -0
  13. package/docs/DETAIL.md +44 -0
  14. package/docs/DETAIL.zh-CN.md +49 -0
  15. package/docs/DEVELOPMENT.md +104 -0
  16. package/docs/PUBLISHING.md +80 -0
  17. package/docs/architecture.md +71 -0
  18. package/docs/live-host-validation.json +37 -0
  19. package/docs/marketplace-validation.md +22 -0
  20. package/docs/prototype-validation.json +10 -0
  21. package/docs/r3-demo.html +981 -0
  22. package/docs/r3-validation.md +59 -0
  23. package/docs/screenshots/help-dark.png +0 -0
  24. package/docs/screenshots/help-light.png +0 -0
  25. package/docs/screenshots/host-archived.png +0 -0
  26. package/docs/screenshots/host-dark.png +0 -0
  27. package/docs/screenshots/host-rollover.png +0 -0
  28. package/docs/screenshots/prototype-fixed.png +0 -0
  29. package/index.js +2 -0
  30. package/locale/en.json +7 -0
  31. package/locale/zh.json +7 -0
  32. package/marketplace/Oak-liuhj__dsh-session-rollover.yml +7 -0
  33. package/marketplace/SUBMISSION.md +37 -0
  34. package/package.json +103 -4
  35. package/screenshots.json +6 -0
  36. package/scripts/build-client.mjs +12 -0
  37. package/scripts/check-dsh-compatibility.mjs +44 -0
  38. package/scripts/check-package.mjs +51 -0
  39. package/scripts/check-release.mjs +76 -0
  40. package/scripts/dsh-contracts.mjs +69 -0
  41. package/src/application/handoff-message.mjs +53 -0
  42. package/src/application/rollover.mjs +236 -0
  43. package/src/assembly.mjs +4 -0
  44. package/src/client/rollover-ui.js +311 -0
  45. package/src/client/toast-client.js +342 -0
  46. package/src/cluster.mjs +3 -0
  47. package/src/domain/cluster-index.mjs +65 -0
  48. package/src/domain/handoff.mjs +67 -0
  49. package/src/domain/inflight.mjs +25 -0
  50. package/src/domain/mode.mjs +34 -0
  51. package/src/domain/naming.mjs +78 -0
  52. package/src/domain/summary.mjs +68 -0
  53. package/src/domain/todos.mjs +23 -0
  54. package/src/domain/turns.mjs +43 -0
  55. package/src/handoff.mjs +2 -0
  56. package/src/inflight.mjs +2 -0
  57. package/src/mode.mjs +2 -0
  58. package/src/naming.mjs +2 -0
  59. package/src/notices.mjs +2 -0
  60. package/src/server/compatibility.mjs +84 -0
  61. package/src/server/host-adapter.mjs +146 -0
  62. package/src/server/notice-route.mjs +104 -0
  63. package/src/server/plugin.mjs +447 -0
  64. package/src/storage/index-store.mjs +48 -0
  65. package/src/storage/notices.mjs +117 -0
  66. package/src/storage/rollover-state.mjs +75 -0
  67. package/src/summary.mjs +2 -0
  68. package/src/todos.mjs +2 -0
  69. package/src/turns.mjs +2 -0
@@ -0,0 +1,68 @@
1
+ // dsh-session-rollover · 八段摘要(无模型依赖的确定性版本;后续可换成模型生成的同结构输入,接口不变)
2
+ // 裁定依据:AC-R3-2 要求八段全非空;把「有据可查」的段从日志/投影直接摘出来,比让模型自由发挥更可复核。
3
+ import {eventType, isUserMessage} from './turns.mjs';
4
+
5
+ const FILE_HINT = /(?:^|[\s"'`((])([A-Za-z]:[\\/][^\s"'`]+|(?:\.{0,2}[\\/])?(?:[\w.@-]+[\\/])+[\w.@-]+\.[A-Za-z0-9]{1,8})/g;
6
+ const CONSTRAINT_HINT = /(不许|不要|禁止|必须|不得|硬约束|一票否决|不能|只能)/;
7
+
8
+ /** 一条消息事件的正文:宿主落盘形状是 data.content=[{type:'text',text}],不是 data.text
9
+ * application/handoff-message.mjs 复用此处,避免正文解析口径不一致。 */
10
+ export function textOf(e) {
11
+ const d = e?.data ?? {};
12
+ const c = d.content ?? e?.content;
13
+ if (typeof c === 'string') return c;
14
+ if (Array.isArray(c)) return c.map((p) => (typeof p === 'string' ? p : p?.text ?? '')).filter(Boolean).join('\n');
15
+ return String(e?.text ?? d.text ?? '');
16
+ }
17
+
18
+ function textsOf(events, kind) {
19
+ return events.filter((e) => kind === 'user/message' ? isUserMessage(e) : eventType(e) === kind).map(textOf).filter((t) => t.trim());
20
+ }
21
+
22
+ /** 从 tool/call 的 arguments 里捞文件路径(⑥ 改动文件;只报「碰过」不判改没改) */
23
+ export function changedFilesFrom(events) {
24
+ const seen = new Map();
25
+ for (const e of events) {
26
+ if (eventType(e) !== 'tool/call') continue;
27
+ const raw = typeof e.data?.arguments === 'string' ? e.data.arguments : JSON.stringify(e.data?.arguments ?? {});
28
+ for (const m of raw.matchAll(FILE_HINT)) {
29
+ const path = m[1].replace(/^[((]|["'`]$/g, '');
30
+ if (path.length < 3 || path.startsWith('http')) continue;
31
+ seen.set(path, {path, note: e.data?.name ?? 'tool', deleted: false});
32
+ }
33
+ }
34
+ return [...seen.values()];
35
+ }
36
+
37
+ export function constraintsFrom(events) {
38
+ const out = [];
39
+ for (const t of textsOf(events, 'user/message')) {
40
+ for (const line of t.split(/\r?\n/)) {
41
+ if (CONSTRAINT_HINT.test(line)) out.push(line.trim());
42
+ }
43
+ }
44
+ return [...new Set(out)];
45
+ }
46
+
47
+ /**
48
+ * 八段:[任务目标, 已完成, 进行中, 下一步, 约束, 已知问题, 在跑任务, 改动文件]
49
+ * @returns {Array<[string,string]>} 段名与文本,八段皆非空
50
+ */
51
+ export function buildSummarySegments({events = [], todos = [], jobs = [], currentStepIndex = null} = {}) {
52
+ const userMsgs = textsOf(events, 'user/message');
53
+ const goal = (userMsgs[0] ?? '').split(/\r?\n/)[0].slice(0, 300) || '未记录(旧卷无用户消息)';
54
+ const done = todos.filter((t) => t.status === 'completed').map((t) => t.content);
55
+ const doing = todos.filter((t) => t.status === 'in_progress').map((t) => t.content);
56
+ const pending = todos.filter((t) => t.status === 'pending').map((t) => t.content);
57
+ const failed = todos.filter((t) => t.status === 'failed').map((t) => t.content); // 数据态四态里的「中断」
58
+ const cur = currentStepIndex != null ? todos[currentStepIndex - 1] : null;
59
+ const files = changedFilesFrom(events);
60
+ const constraints = constraintsFrom(events);
61
+ const list = (a, empty) => (a.length ? a.map((x, i) => `${i + 1}. ${x}`).join('\n') : empty);
62
+ return [
63
+ ['上轮工作摘要', `${goal}\n已完成:\n${list(done, '尚无已完成项')}`],
64
+ ['当前进度与关键约束', `${currentStepIndex ? `第 ${currentStepIndex} 步进行中` : '尚未开始'}\n${list(doing, '无进行中项')}\n关键约束(原文):\n${list(constraints, '本轮未提供')}\n中断项:\n${list(failed, '无')}`],
65
+ ['文件、代码与未竟事项', `文件:\n${list(files.map(f => f.path), '本轮未提供')}\n未竟后台任务:\n${list(jobs.map(j => `${j.id} ${j.command ?? ''} status=${j.status}${j.recoverable === false ? '(需重跑)' : ''}`), '无后台任务')}`],
66
+ ['下一步与待办快照', list(pending, '本轮未提供')],
67
+ ];
68
+ }
@@ -0,0 +1,23 @@
1
+ // 旧卷待办快照:取日志里最后一次 todo/write。
2
+ // 不能读实时投影:宿主 todos 投影在 turn/start 归零(tool-todo: apply 遇 turn/start 返回 null),
3
+ // 而分卷判定恰好发生在第 N+1 轮 pre-step(turn/start 之后),此刻 stateOf 必为 null。
4
+ // 判定/交接要的是「上一轮结束时的清单」,日志最后一条 todo/write 才是它。
5
+
6
+ function typeOf(e) {
7
+ return e?.type ?? e?.kind;
8
+ }
9
+
10
+ export function lastTodos(events = []) {
11
+ let last = null;
12
+ for (const e of events) {
13
+ if (typeOf(e) !== 'todo/write') continue;
14
+ const todos = e?.data?.todos;
15
+ if (Array.isArray(todos)) last = todos;
16
+ }
17
+ return last ?? [];
18
+ }
19
+
20
+ export function activeIndex(todos = []) {
21
+ const i = todos.findIndex((t) => t?.status === 'in_progress');
22
+ return i < 0 ? null : i + 1;
23
+ }
@@ -0,0 +1,43 @@
1
+ // dsh-session-rollover · 轮次计数与触发(接口契约 §3.1;纯逻辑)
2
+ // 一条用户消息=一轮;内部 turn/step 不计。安全切点由 hasOpenTurn 独立判断。
3
+ // 事件判别字段:session.v4 落盘事件用 `type`,内存/夹具事件历史上用 `kind` —— 两种都认(实机踩坑:只认 kind 会数成 0)
4
+ export const DEFAULT_THRESHOLD = 10;
5
+ export const eventType = (e) => e?.type ?? e?.kind;
6
+
7
+ /** 宿主也用 user/message 记录时间、技能目录等上下文;它们不是用户发言。 */
8
+ export function isUserMessage(e) {
9
+ const source = e?.data?.source ?? e?.source;
10
+ return eventType(e) === 'user/message' && (!source?.kind || source.kind === 'user') &&
11
+ !e?.data?.rollover && !String(e?.data?.content?.[0]?.text ?? e?.text ?? '').startsWith('【分卷交接】');
12
+ }
13
+
14
+ /** 从完整日志重建用户轮次;插件生成的交接消息不计入。 */
15
+ export function countTurns(events) {
16
+ // B1:内部 turn/start、turn/end 的数量不代表用户发言轮数。
17
+ // 交接首条消息由插件生成,不占用新卷的用户轮次。
18
+ return events.filter(isUserMessage).length;
19
+ }
20
+
21
+ export function hasOpenTurn(events) {
22
+ return events.findLastIndex(e => eventType(e) === 'turn/start') >
23
+ events.findLastIndex(e => eventType(e) === 'turn/end');
24
+ }
25
+
26
+ /**
27
+ * 触发判定(agent/pre-step 旁挂调用)。
28
+ * @param turnCount 全 log 轮数
29
+ * @param opts { threshold, hasInFlightTool, isSubagent, clusterCapReached, rolloverPending }
30
+ * 后台 job 在跑不阻断(B2:job 句柄写入交接,不开新会话拆 job)
31
+ */
32
+ export function shouldTriggerRollover(turnCount, opts = {}) {
33
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
34
+ if (opts.isSubagent) return {trigger: false, reason: 'subagent-never'}; // B10
35
+ if (turnCount < threshold) return {trigger: false, reason: 'below-threshold'};
36
+ if (opts.hasInFlightTool) return {trigger: false, reason: 'unsafe-cut-point'}; // 安全切分点
37
+ if (opts.clusterCapReached) return {trigger: false, reason: 'cluster-cap'}; // B11 停自动
38
+ if (opts.rolloverPending) return {trigger: false, reason: 'pending'};
39
+ return {trigger: true, reason: 'threshold-reached'};
40
+ }
41
+
42
+ /** 从日志重建计数(重启一致性,AC-R3-8):纯函数天然满足,给出显式入口供接线层用 */
43
+ export const rebuildTurnCount = countTurns;
@@ -0,0 +1,2 @@
1
+ // 兼容旧导入路径;新代码请直接使用 src/domain/handoff.mjs。
2
+ export * from './domain/handoff.mjs';
@@ -0,0 +1,2 @@
1
+ // 兼容旧导入路径;新代码请直接使用 src/domain/inflight.mjs。
2
+ export * from './domain/inflight.mjs';
package/src/mode.mjs ADDED
@@ -0,0 +1,2 @@
1
+ // 兼容旧导入路径;新代码请直接使用 src/domain/mode.mjs。
2
+ export * from './domain/mode.mjs';
package/src/naming.mjs ADDED
@@ -0,0 +1,2 @@
1
+ // 兼容旧导入路径;新代码请直接使用 src/domain/naming.mjs。
2
+ export * from './domain/naming.mjs';
@@ -0,0 +1,2 @@
1
+ // 兼容旧导入路径;新代码请直接使用 src/storage/notices.mjs。
2
+ export * from './storage/notices.mjs';
@@ -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
+ }
@@ -0,0 +1,146 @@
1
+ // 将宿主公开服务转换为分卷事务接口;事件计数与正文等纯解析规则委托给 domain。
2
+ import {eventType} from '../domain/turns.mjs';
3
+ import {openToolCalls} from '../domain/inflight.mjs';
4
+ import {lastTodos} from '../domain/todos.mjs';
5
+ import {preflightHost, readWorkingDirectory, readModelSelection} from './compatibility.mjs';
6
+
7
+ /**
8
+ * 为一个来源会话创建适配器。新卷继承其工作区和模型选择。
9
+ * @param {object} deps
10
+ * @param {object} deps.ctx 宿主公开服务容器
11
+ * @param {object} deps.session 来源会话(id、snapshotEvents、header)
12
+ * @param {object} deps.state 插件旁路状态存储
13
+ * @param {(id: string) => object|null} deps.sessionOf 查询当前存活会话
14
+ * @param {(event: string, data?: object) => void} deps.log 诊断日志回调
15
+ * @returns {object} performRollover 所需的异步宿主接口
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(', ')}`);
20
+ const id = session.id;
21
+ const sourceEvents = session.snapshotEvents().slice();
22
+ const registry = services.jobs;
23
+ const registryJobs = registry?.list ? registry.list(id) : [];
24
+ const eventsOf = (sid) => {
25
+ const s = sid === id ? session : sessionOf(sid);
26
+ try {
27
+ return sid === id ? sourceEvents : s?.snapshotEvents?.() ?? [];
28
+ } catch {
29
+ return [];
30
+ }
31
+ };
32
+ return {
33
+ listEvents: async (sid) => eventsOf(sid),
34
+ getTitle: async (sid) => {
35
+ const s = sid === id ? session : sessionOf(sid);
36
+ try {
37
+ return ctx.sessionTitle.get(s)?.title ?? '';
38
+ } catch {
39
+ return '';
40
+ }
41
+ },
42
+ isUserRenamed: async (sid) => {
43
+ const target = sid === id ? session : sessionOf(sid);
44
+ const title = ctx.sessionTitle.get(target);
45
+ const inherited = state.handoffGet(sid);
46
+ return (title?.source?.kind === 'user' || title?.source === 'user') &&
47
+ title.title !== inherited?.to_session_title;
48
+ },
49
+ listTitles: async () => {
50
+ const out = [];
51
+ for (const s of ctx.sessions.list()) {
52
+ try {
53
+ out.push({sessionId: s.id, title: ctx.sessionTitle.get(s)?.title ?? ''});
54
+ } catch { /* 单条坏不影响整表 */
55
+ }
56
+ }
57
+ return out;
58
+ },
59
+ getTodos: async (sid) => {
60
+ const events = eventsOf(sid);
61
+ // 出现过实时待办事件即以日志为准;只有完全没有该事件时才继承交接快照。
62
+ // 空数组是用户清空清单,不能用继承值把它重新填回去。
63
+ if (events.some(e => eventType(e) === 'todo/write')) return lastTodos(events);
64
+ const inherited = state.handoffGet(sid)?.todos;
65
+ return Array.isArray(inherited) ? inherited : [];
66
+ },
67
+ inFlightTools: async (sid) => openToolCalls(eventsOf(sid)),
68
+ isSubagent: async (sid) => {
69
+ const s = sid === id ? session : sessionOf(sid);
70
+ try {
71
+ return s?.header?.parentSession !== undefined && s?.header?.parentSession !== null;
72
+ } catch {
73
+ return false;
74
+ }
75
+ },
76
+ listRunningJobs: async (sid) => {
77
+ const open = new Map();
78
+ for (const e of eventsOf(sid)) {
79
+ const runId = e?.data?.runId ?? e?.data?.id ?? e?.seq;
80
+ if (eventType(e) === 'tool-workflow/run-start') open.set(runId, {
81
+ id: String(runId),
82
+ command: e.data?.command ?? e.data?.name ?? 'workflow',
83
+ status: 'running',
84
+ recoverable: true
85
+ });
86
+ else if (eventType(e) === 'tool-workflow/run-end') open.delete(e.data?.runId ?? e.data?.id ?? e.seq);
87
+ else if (eventType(e) === 'job/registered' && e.data?.jobId) open.set(String(e.data.jobId), {
88
+ id: String(e.data.jobId), command: e.data.label ?? e.data.kind ?? '', status: 'running', recoverable: true,
89
+ });
90
+ else if (eventType(e) === 'job/settled') open.delete(String(e.data?.jobId));
91
+ }
92
+ // live registry 能识别重启后已结算但日志尚未补齐的任务。
93
+ if (registry?.list) {
94
+ for (const job of await registryJobs) {
95
+ if (job.owner && job.owner !== sid) continue;
96
+ if (['running', 'stopping', 'pending', 'starting'].includes(job.status)) open.set(String(job.id), {
97
+ id: String(job.id), command: job.label ?? job.command ?? job.kind ?? '',
98
+ status: job.status, recoverable: job.recoverable !== false,
99
+ });
100
+ else open.delete(String(job.id));
101
+ }
102
+ }
103
+ return [...open.values()]; // 同刻全量;切点后新起的 run 不在快照里
104
+ },
105
+ createSession: async () => {
106
+ // 优先归入来源工作区;来源不属于工作区时才按其 cwd 建会话。
107
+ const cwd = readWorkingDirectory(ctx, services, session);
108
+ const workspace = ctx.workspaceRegistry.list().find(w => w.sessionIds.includes(session.id));
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;
112
+ },
113
+ injectFirstMessage: async (sessionId, text) => {
114
+ // 宿主 create() 建的是空壳会话,没有 model selection,会落到默认 provider
115
+ // (实测 "deepseek-official" 无 key → MISSING_CREDENTIAL)。先继承旧卷的模型,再注入。
116
+ try {
117
+ const pick = readModelSelection(ctx, services, session);
118
+ if (pick?.provider && pick?.model) {
119
+ await ctx.sessionController.selectModel({
120
+ sessionId, provider: pick.provider, model: pick.model,
121
+ ...(pick.reasoningEffort ? {reasoningEffort: pick.reasoningEffort} : {}),
122
+ });
123
+ }
124
+ } catch (e) {
125
+ log('model-inherit-failed', {sessionId, error: String(e?.message ?? e)});
126
+ const defaults = services.agentDefaultModel;
127
+ const fallback = await defaults?.currentSelection?.();
128
+ if (!fallback?.provider || !fallback?.model) throw e;
129
+ await ctx.sessionController.selectModel({sessionId, ...fallback});
130
+ state.modelFallback?.(sessionId);
131
+ log('model-fallback', {sessionId});
132
+ }
133
+ // 宿主 prompt(request, signal):signal.throwIfAborted() 是第一行,必须给 AbortSignal
134
+ await ctx.sessionController.prompt({
135
+ requestId: `rollover-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
136
+ sessionId, mode: 'queue', content: [{type: 'text', text}],
137
+ }, new AbortController().signal);
138
+ },
139
+ renameSession: async (sessionId, title) => {
140
+ const target = sessionOf(sessionId);
141
+ if (!target) throw new Error(`session "${sessionId}" is not live in this store`);
142
+ ctx.sessionTitle.rename(target, title); // ds-bot roster 用的同一个调用
143
+ },
144
+ log: (event, data) => log(event, data),
145
+ };
146
+ }
@@ -0,0 +1,104 @@
1
+ // 一次性提示的同源 HTTP 入口;认证由宿主 webServer 统一处理。
2
+ /** client 半取件口:同源 GET,宿主 webServer 统一过登录校验(dsh-pick 同款)。
3
+ * 取走即删=「未发显」标记的投递;渲染由 client 半负责,server 不碰 DOM。 */
4
+ export function attachNoticeRoutes(ctx, {handlePending, log, plugin}) {
5
+ if (typeof ctx.inject !== 'function') return;
6
+ try {
7
+ ctx.inject(['webServer'], (scope) => {
8
+ const ws = scope.webServer ?? scope.get?.('webServer');
9
+ if (!ws || typeof ws.register !== 'function') {
10
+ log('route-unavailable');
11
+ return undefined;
12
+ }
13
+ const dispose = ws.register({
14
+ kind: 'exact',
15
+ path: '/api-dsh-session-rollover/pending',
16
+ handler: (req, res) => handlePending(req, res),
17
+ });
18
+ const control = plugin && ws.register({kind: 'exact', path: '/api-dsh-session-rollover/session',
19
+ handler: (req, res) => handleSessionRequest(plugin, req, res)});
20
+ log('route-registered', {path: '/api-dsh-session-rollover/pending'});
21
+ scope.effect?.(() => () => {
22
+ try {
23
+ dispose?.();
24
+ control?.();
25
+ } catch { /* 卸载容错 */
26
+ }
27
+ }, 'rollover: pending route');
28
+ return undefined;
29
+ });
30
+ } catch (e) {
31
+ log('route-failed', {error: String(e?.message ?? e)});
32
+ }
33
+ }
34
+
35
+ /** 与 R2 桥接相同的 owner 认证和同源约束;不接收浏览器传来的快照正文。 */
36
+ export async function handleSessionRequest(plugin, req, res) {
37
+ const send = (code, value) => { res.writeHead(code, {'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store'}); res.end(JSON.stringify(value)); };
38
+ try {
39
+ const admission = plugin.ctx.connection?.admit(req);
40
+ if (!admission || 'rejection' in admission) return send(admission?.rejection ?? 401, {error: 'unauthorized'});
41
+ if (admission.peer.principal?.kind !== 'owner') return send(403, {error: 'owner-required'});
42
+ if (req.method === 'GET') {
43
+ const id = new URL(req.url, 'http://localhost').searchParams.get('session');
44
+ if (!/^[\w-]{1,160}$/.test(id ?? '')) return send(400, {error: 'invalid-session'});
45
+ if (!plugin.sessionOf(id)) return send(404, {error: 'session-unavailable'});
46
+ return send(200, {...plugin.handoffInfo(id), completedSessionId: plugin.lastCompleted?.get(id) ?? null});
47
+ }
48
+ if (req.method !== 'POST') return send(405, {error: 'method-not-allowed'});
49
+ if (!/^application\/json(?:;|$)/i.test(req.headers?.['content-type'] ?? '')) return send(415, {error: 'json-required'});
50
+ if (req.headers.origin && new URL(req.headers.origin).host !== req.headers.host) return send(403, {error: 'origin-mismatch'});
51
+ let body = '';
52
+ for await (const chunk of req) { body += chunk; if (Buffer.byteLength(body) > 4096) return send(413, {error: 'too-large'}); }
53
+ let input;
54
+ try { input = JSON.parse(body); } catch { return send(400, {error: 'invalid-json'}); }
55
+ if (!/^[\w-]{1,160}$/.test(input?.sessionId ?? '')) return send(400, {error: 'invalid-session'});
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
+ }
63
+ if (input.action === 'readonly' && typeof input.readonly === 'boolean') {
64
+ if (!plugin.handoffInfo(input.sessionId).sealed) return send(409, {error: 'session-not-archived'});
65
+ return send(200, plugin.setReadonly(input.sessionId, input.readonly));
66
+ }
67
+ if (input.action === 'rollover') {
68
+ const result = await plugin.rolloverNow(input.sessionId);
69
+ return send(result.status === 'rolled-over' ? 200 : 409, result);
70
+ }
71
+ if (input.action === 'new') {
72
+ const result = await plugin.hostFor(plugin.sessionOf(input.sessionId)).createSession();
73
+ return send(200, {...result, status: 'created', independent: true});
74
+ }
75
+ return send(400, {error: 'invalid-action'});
76
+ } catch (e) { return send(500, {error: 'operation-failed', message: String(e.message)}); }
77
+ }
78
+
79
+ /** 按会话取走提示,成功取件后即从存储队列删除;此接口不确认浏览器已完成渲染。 */
80
+ export function handlePendingNotices({notices: store, log}, req, res) {
81
+ const send = (code, body) => {
82
+ try {
83
+ res.writeHead(code, {'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store'});
84
+ res.end(JSON.stringify(body));
85
+ } catch { /* 连接已断不阻断 */
86
+ }
87
+ };
88
+ try {
89
+ if (req.method !== 'GET') return send(405, {error: 'method-not-allowed'});
90
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
91
+ const sessionId = url.searchParams.get('session');
92
+ if (!sessionId) return send(400, {error: 'missing-session'});
93
+ const notices = store.takePending(sessionId);
94
+ if (notices.length) log('notice-delivered', {
95
+ sessionId,
96
+ count: notices.length,
97
+ reasons: notices.map((n) => n.reason)
98
+ });
99
+ return send(200, {version: 1, notices});
100
+ } catch (e) {
101
+ log('notice-deliver-failed', {error: String(e?.message ?? e)});
102
+ return send(500, {error: 'internal'});
103
+ }
104
+ }