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,236 @@
1
+ // dsh-session-rollover · 装配层(事务 T0–T8,细化稿 §3 方案 A:注入前置为第一方案)
2
+ // 顺序:T0 轮次 → T1 mode 拦截 → T2 硬闸+快照+命名预演 → T3 八段摘要 → T4 建未定名新会话
3
+ // → T5 注入摘要(首条 user/message)→ T6 旧卷改名 → T7 新卷改名 → T8 提交(簇索引+turn_counts+-1 只读)
4
+ // T0–T3 不过时不创建会话、不改名;注入失败登记待清理,新卷改名失败则尝试回滚旧卷标题。
5
+ // 依赖注入:host(宿主适配器)与 state/index/persistence(存储)都由调用方给,夹具可全内存替换真跑。
6
+ import {countTurns, hasOpenTurn, DEFAULT_THRESHOLD} from '../domain/turns.mjs';
7
+ import {parseVolume, planVolumeTitles} from '../domain/naming.mjs';
8
+ import {effectiveRolloverMode, markersFound, transitionInFlight} from '../domain/mode.mjs';
9
+ import {buildHandoff, MAX_BYTES} from '../domain/handoff.mjs';
10
+ import {eventsToMessages, renderHandoffMessage} from './handoff-message.mjs';
11
+ import {buildSummarySegments, changedFilesFrom, constraintsFrom} from '../domain/summary.mjs';
12
+
13
+ /**
14
+ * 跑一次分卷事务。host 接口(全部 async,夹具可内存实现):
15
+ * listEvents(sessionId) / getTitle(sessionId) / listTitles() / getTodos(sessionId)
16
+ * inFlightTools(sessionId) / isSubagent(sessionId) / listRunningJobs(sessionId)
17
+ * createSession({cwd}) -> {sessionId} / injectFirstMessage(sessionId, text) / renameSession(sessionId, title)
18
+ * log(event, data)
19
+ * manual=true 仅绕过 T0/T1 自动门槛;只读、子会话、未收尾工具和完整消息字节预算仍生效。
20
+ * T8 的各文件分别提交,不是跨文件数据库事务;索引存储失败通过 degraded 显式返回。
21
+ */
22
+ export async function performRollover({
23
+ host,
24
+ sessionId,
25
+ index,
26
+ persistence,
27
+ state,
28
+ config = {},
29
+ summarySegments,
30
+ currentStepIndex = null,
31
+ activeIndexes = [],
32
+ constraints,
33
+ changedFiles,
34
+ cwd,
35
+ userRenamed = false,
36
+ manual = false,
37
+ now = () => new Date().toISOString(),
38
+ }) {
39
+ const trace = [];
40
+ const mark = (step, data = {}) => {
41
+ const rec = {step, at: now(), ...data};
42
+ trace.push(rec);
43
+ host.log?.(`rollover:${step}`, rec);
44
+ return rec;
45
+ };
46
+ const done = (status, reason, extra = {}) => ({status, reason, sessionId, trace, ...extra});
47
+
48
+ // 只读卷(-1)一律不动:连触发都不许(AC-R3-7 的服务端闸)
49
+ if (state?.isReadonly(sessionId)) return done('skipped', 'readonly');
50
+
51
+ // T0 轮次口径由 domain/turns.mjs 统一定义,统计真实用户消息,执行安全由 open-turn 单独判断。
52
+ const events = await host.listEvents(sessionId);
53
+ if (transitionInFlight(events)) return done('skipped', 'transition-in-flight');
54
+ if (hasOpenTurn(events)) return done('skipped', 'turn-in-flight');
55
+ const turnCount = countTurns(events);
56
+ const threshold = config.threshold ?? DEFAULT_THRESHOLD;
57
+ mark('T0', {turnCount, threshold});
58
+ if (!manual && turnCount < threshold) return done('skipped', 'below-threshold', {turnCount});
59
+
60
+ // T1 mode 拦截(零副作用):off 直接返回;manual 只提示;auto 检出外部分段特征降 manual
61
+ const markers = markersFound(eventsToMessages(events));
62
+ const {mode, downgraded} = effectiveRolloverMode(config.mode ?? 'auto', {
63
+ forceAuto: !!config.forceAuto,
64
+ hasMarkers: markers
65
+ });
66
+ mark('T1', {mode, downgraded, markers});
67
+ if (!manual && mode === 'off') return done('skipped', 'mode-off');
68
+ if (!manual && mode === 'manual') return done('skipped', 'manual-pending', {notice: true, downgraded});
69
+
70
+ // T2 硬闸 + 快照(同刻取 running job 全集,切点后新起的不入交接)
71
+ if (await host.isSubagent(sessionId)) return done('skipped', 'subagent-never');
72
+ const inFlight = await host.inFlightTools(sessionId);
73
+ mark('T2', {inFlight: inFlight.length, tools: inFlight.map((t) => t?.name ?? t?.callId ?? '?')});
74
+ if (inFlight.length > 0) return done('skipped', 'unsafe-cut-point', {inFlight: inFlight.length});
75
+
76
+ // 命名预演放在建会话之前:cluster-cap / user-renamed 也必须是零副作用
77
+ const currentTitle = await host.getTitle(sessionId);
78
+ const titles = (await host.listTitles()).map((t) => t.title);
79
+ const previous = index?.get(sessionId);
80
+ const renamed = userRenamed || await host.isUserRenamed?.(sessionId) || false;
81
+ const oldVolume = previous?.vol_no ?? (!renamed && parseVolume(currentTitle).k === 9 ? 9 : 1);
82
+ let plan = planVolumeTitles(currentTitle, titles, {preserveTitle: renamed, nameSuffix: !renamed ? previous?.name_suffix : undefined,
83
+ volumeNo: oldVolume});
84
+ // 达到卷号上限后,显式手动分卷允许新建独立接续系列,保留来源卷的标题与索引。
85
+ if (manual && !plan.ok && plan.reason === 'cluster-cap') {
86
+ const base = parseVolume(currentTitle).base;
87
+ let n = 1;
88
+ while (titles.includes(`${base}(接续 ${n})`)) n++;
89
+ plan = {ok: true, independent: true, base: `${base}(接续 ${n})`, oldTarget: null,
90
+ oldK: parseVolume(currentTitle).k, newK: 1, newTarget: `${base}(接续 ${n})`};
91
+ }
92
+ mark('T2:plan', {currentTitle, ...plan});
93
+ if (!plan.ok) return done('skipped', plan.reason, {currentTitle, notice: true});
94
+
95
+ const jobs = await host.listRunningJobs(sessionId);
96
+ const todos = await host.getTodos(sessionId);
97
+ // 单次事务只捕获一次待办/jobs;四段正文、机器快照和页面八块都消费它。
98
+ activeIndexes = todos.flatMap((t, i) => t.status === 'in_progress' ? [i + 1] : []);
99
+ currentStepIndex = activeIndexes[0] ?? null;
100
+ constraints ??= constraintsFrom(events);
101
+ changedFiles ??= changedFilesFrom(events);
102
+ summarySegments ??= buildSummarySegments({events, todos, jobs, currentStepIndex});
103
+ mark('T2:snapshot', {jobs: jobs.length, todos: todos.length});
104
+
105
+ // T3 八段摘要(失败=不分卷,旧卷零动作;retry 计数由调用方按 index.summary_retry 维护)
106
+ if (!Array.isArray(summarySegments) || summarySegments.length === 0) return done('failed', 'summary-missing');
107
+ const clusterId = (!plan.independent && index?.get(sessionId)?.cluster_id) || `cluster:${plan.base}`;
108
+ const built = buildHandoff({
109
+ summarySegments, todos, activeIndexes, currentStepIndex, openJobs: jobs,
110
+ constraints, changedFiles, cluster: {cluster_id: clusterId, parent_session_id: plan.independent ? null : sessionId,
111
+ vol_no: plan.independent ? 1 : oldVolume + 1, independent: !!plan.independent}, fromSessionId: sessionId,
112
+ toSessionTitle: plan.newTarget,
113
+ });
114
+ mark('T3', {ok: built.ok, bytes: built.bytes, reason: built.reason});
115
+ if (!built.ok) return done('failed', built.reason, {bytes: built.bytes});
116
+ built.payload.from_title = plan.oldTarget ?? currentTitle;
117
+ built.payload.created_at = now();
118
+ built.payload.health = {summary_retry: state?.retryGet?.(sessionId)?.attempts ?? 0, snapshot_consistent: true};
119
+ const text = renderHandoffMessage(built.payload, {volume: built.payload.cluster.vol_no, at: now()});
120
+ const messageBytes = new TextEncoder().encode(text).length;
121
+ if (messageBytes > MAX_BYTES) return done('failed', 'oversize', {bytes: messageBytes});
122
+ try { state?.preflight?.(); } catch (e) {
123
+ return done('failed', 'storage-unavailable', {error: String(e.message)});
124
+ }
125
+ const pending = (rec) => { try { state?.pendingAdd(rec); } catch (e) { mark('cleanup-degraded', {error: String(e.message), sessionId: rec.sessionId}); } };
126
+
127
+ // T4 新建会话(暂不定名:宿主 create 本身没有标题参数)
128
+ let newSessionId;
129
+ try {
130
+ const created = await host.createSession({cwd});
131
+ newSessionId = created.sessionId;
132
+ } catch (e) {
133
+ mark('T4', {error: String(e?.message ?? e)});
134
+ return done('failed', 'create-failed', {error: String(e?.message ?? e)}); // 四无:无注入/无改名/无索引/无只读
135
+ }
136
+ mark('T4', {newSessionId});
137
+
138
+ // T5 注入摘要(首条 user/message)。炸了绝不改名:弃未定名卷、旧卷一字未动,挂待清理
139
+ try {
140
+ state?.handoffSave?.(newSessionId, {...built.payload, from_title: plan.oldTarget ?? currentTitle});
141
+ await host.injectFirstMessage(newSessionId, text);
142
+ } catch (e) {
143
+ mark('T5', {error: String(e?.message ?? e)});
144
+ pending({
145
+ sessionId: newSessionId,
146
+ kind: 'abandoned-untitled',
147
+ reason: 'inject-failed',
148
+ at: now(),
149
+ error: String(e?.message ?? e)
150
+ });
151
+ return done('failed', 'inject-failed', {newSessionId, abandoned: newSessionId, error: String(e?.message ?? e)});
152
+ }
153
+ mark('T5', {bytes: text.length});
154
+
155
+ // T6 旧卷改名(仅当旧卷还是原始标题;已是 -k 的卷不动)
156
+ const renamedOld = [];
157
+ if (plan.oldTarget && plan.oldTarget !== currentTitle) {
158
+ try {
159
+ await host.renameSession(sessionId, plan.oldTarget);
160
+ renamedOld.push({sessionId, from: currentTitle, to: plan.oldTarget});
161
+ } catch (e) {
162
+ mark('T6', {error: String(e?.message ?? e)});
163
+ pending({
164
+ sessionId: newSessionId,
165
+ kind: 'abandoned-untitled',
166
+ reason: 'rename-old-failed',
167
+ at: now(),
168
+ error: String(e?.message ?? e)
169
+ });
170
+ return done('failed', 'rename-old-failed', {newSessionId, abandoned: newSessionId});
171
+ }
172
+ mark('T6', {to: plan.oldTarget, bumped: plan.bumped});
173
+ } else {
174
+ mark('T6', {skipped: true, reason: 'already-volume'});
175
+ }
176
+
177
+ // T7 新卷改名。炸了回滚旧卷改名(保原名),新卷仍未定名挂待清理
178
+ try {
179
+ await host.renameSession(newSessionId, plan.newTarget);
180
+ mark('T7', {to: plan.newTarget, bumped: plan.bumped});
181
+ } catch (e) {
182
+ mark('T7', {error: String(e?.message ?? e)});
183
+ const rolledBack = [];
184
+ let rollbackFailed = false;
185
+ for (const r of renamedOld) {
186
+ try {
187
+ await host.renameSession(r.sessionId, r.from);
188
+ rolledBack.push(r);
189
+ } catch (e2) {
190
+ rollbackFailed = true;
191
+ mark('T7:rollback-failed', {sessionId: r.sessionId, error: String(e2?.message ?? e2)});
192
+ }
193
+ }
194
+ pending({
195
+ sessionId: newSessionId,
196
+ kind: 'abandoned-untitled',
197
+ reason: 'rename-new-failed',
198
+ at: now(),
199
+ error: String(e?.message ?? e)
200
+ });
201
+ // rollbackFailed 决定告警文案:回滚失败时「已保留原名」不成立(B6 第二句的前提,已核定)
202
+ return done('failed', 'rename-new-failed', {newSessionId, abandoned: newSessionId, rolledBack, rollbackFailed});
203
+ }
204
+
205
+ // T8 提交:簇索引(七字段)+ turn_counts 同文件同原子提交,旧卷置只读标记
206
+ const oldRec = plan.independent ? index.get(sessionId) : index.record({
207
+ sessionId, clusterId, parentSessionId: index.get(sessionId)?.parent_session_id ?? null,
208
+ volNo: oldVolume,
209
+ nameSuffix: plan.oldTarget ? `-${plan.oldTarget.match(/-(\d+)$/)?.[1]}` : plan.oldK ? `-${plan.oldK}` : '',
210
+ rolloverState: 'sealed', readonlyByRollover: true,
211
+ });
212
+ const newRec = index.record({
213
+ sessionId: newSessionId, clusterId, parentSessionId: plan.independent ? null : sessionId,
214
+ volNo: plan.independent ? 1 : oldVolume + 1, nameSuffix: `-${plan.newK}`, rolloverState: 'active', readonlyByRollover: false,
215
+ });
216
+ index.setTurnCounts?.({...index.turnCounts?.(), [sessionId]: turnCount, [newSessionId]: 0});
217
+ const persisted = persistence?.persist?.() ?? null; // null = 显式降级(L3),不静默丢:调用方看 persistence.degraded
218
+ let ro = null, readonlyDegraded = false;
219
+ try { ro = (!plan.independent && state?.markReadonly(sessionId, {
220
+ clusterId,
221
+ volNo: oldRec.vol_no,
222
+ rolledAt: now(),
223
+ rolledInto: newSessionId
224
+ })) || null; } catch (e) { readonlyDegraded = true; mark('readonly-degraded', {error: String(e.message)}); }
225
+ try { state?.pendingRemove(newSessionId); } catch (e) { mark('cleanup-degraded', {error: String(e.message)}); }
226
+ mark('T8', {clusterId, persisted, readonly: !!ro, degraded: !!persistence?.degraded || readonlyDegraded});
227
+
228
+ return {
229
+ status: 'rolled-over', reason: manual ? 'manual' : 'threshold-reached', sessionId, newSessionId, trace, turnCount,
230
+ clusterId,
231
+ from: {sessionId, title: plan.oldTarget ?? currentTitle, record: oldRec},
232
+ to: {sessionId: newSessionId, title: plan.newTarget, record: newRec},
233
+ persisted, degraded: persistence?.degraded ?? false,
234
+ handoffBytes: built.bytes,
235
+ };
236
+ }
@@ -0,0 +1,4 @@
1
+ // 兼容旧装配入口;实现已按事务、消息排版、磁盘状态分别归类。
2
+ export {performRollover} from './application/rollover.mjs';
3
+ export {eventsToMessages, renderHandoffMessage} from './application/handoff-message.mjs';
4
+ export {PENDING_FILE, READONLY_FILE, readonlyFileOf, createRolloverState} from './storage/rollover-state.mjs';
@@ -0,0 +1,311 @@
1
+ /** R3 只承担会话状态与交接卡;待办进度复用 R2,不另建任务状态。 */
2
+ export function createRolloverUI(React) {
3
+ const {createElement: h, useState, useEffect, useRef} = React;
4
+ const API = '/api-dsh-session-rollover/session';
5
+ const entries = new Map();
6
+ let navigate = null;
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
+ }
20
+
21
+ async function request(sessionId, action) {
22
+ const response = await fetch(action ? API : `${API}?session=${encodeURIComponent(sessionId)}`, action ? {
23
+ method: 'POST', credentials: 'same-origin', headers: {'content-type': 'application/json'},
24
+ body: JSON.stringify({sessionId, ...action}),
25
+ } : {credentials: 'same-origin', cache: 'no-store'});
26
+ const result = await response.json();
27
+ if (!response.ok) throw new Error(result.message ?? result.error ?? '会话状态读取失败');
28
+ return result;
29
+ }
30
+
31
+ function entry(id) {
32
+ if (!entries.has(id)) entries.set(id, {value: null, listeners: new Set(), stop: null});
33
+ return entries.get(id);
34
+ }
35
+
36
+ function publish(id, value) {
37
+ const item = entry(id);
38
+ item.value = value;
39
+ for (const listener of item.listeners) listener(value);
40
+ }
41
+
42
+ function useInfo(id) {
43
+ const [state, setState] = useState(() => entry(id).value);
44
+ useEffect(() => {
45
+ if (!id) return;
46
+ const item = entry(id);
47
+ let alive = true;
48
+ const receive = value => { if (alive) setState(value); };
49
+ item.listeners.add(receive);
50
+ receive(item.value);
51
+ if (!item.stop) {
52
+ let stopped = false, timer;
53
+ const poll = async () => {
54
+ try {
55
+ const value = await request(id);
56
+ if (!stopped) publish(id, value);
57
+ } catch (e) {
58
+ if (!stopped) publish(id, {...item.value, sessionId: id, error: '暂时无法读取会话状态,请稍后重试。'});
59
+ }
60
+ if (!stopped) timer = setTimeout(poll, 1000);
61
+ };
62
+ item.stop = () => { stopped = true; clearTimeout(timer); };
63
+ void poll();
64
+ }
65
+ return () => {
66
+ alive = false;
67
+ item.listeners.delete(receive);
68
+ if (!item.listeners.size) { item.stop?.(); item.stop = null; entries.delete(id); }
69
+ };
70
+ }, [id]);
71
+ // 会话切换当帧不借用上一会话的只读状态。
72
+ return state?.sessionId === id ? state : null;
73
+ }
74
+
75
+ function Source({source}) {
76
+ return source ? h('button', {type: 'button', className: 'dsr-link',
77
+ disabled: !navigate, onClick: () => navigate?.(source.sessionId),
78
+ 'aria-label': `跳转到上一卷 ${source.title}`}, `承自 ${source.title}`) : null;
79
+ }
80
+
81
+ function ReadonlyControl({info}) {
82
+ const [busy, setBusy] = useState(false);
83
+ const [error, setError] = useState('');
84
+ if (!info?.sealed) return null;
85
+ return h('div', {className: 'dsr-archive', role: 'status'},
86
+ h('span', null, info.readonly ? '此会话已归档,默认只读。历史可回看。' : '此会话已归档,已允许再次写入。'),
87
+ h('button', {type: 'button', role: 'switch', 'aria-checked': !info.readonly,
88
+ disabled: busy, onClick: async () => {
89
+ setBusy(true); setError('');
90
+ try { publish(info.sessionId, await request(info.sessionId, {action: 'readonly', readonly: !info.readonly})); }
91
+ catch { setError('保存失败,请重试;写入权限未改变。'); }
92
+ finally { setBusy(false); }
93
+ }}, busy ? '正在保存…' : info.readonly ? '允许再次写入' : '恢复只读'),
94
+ error ? h('span', {role: 'alert'}, error) : null);
95
+ }
96
+
97
+ function Header(props) {
98
+ const info = useInfo(props.sessionId);
99
+ const [busy, setBusy] = useState(false);
100
+ const [error, setError] = useState('');
101
+ const dialog = useRef(null);
102
+ const [showHelp, setShowHelp] = useState(false);
103
+ const action = async type => {
104
+ setBusy(true); setError('');
105
+ try {
106
+ const result = await request(props.sessionId, {action: type});
107
+ if (result.newSessionId ?? result.sessionId) navigate?.(result.newSessionId ?? result.sessionId);
108
+ dialog.current?.close();
109
+ } catch { setError('操作未完成,请稍后重试或检查会话状态。'); }
110
+ finally { setBusy(false); }
111
+ };
112
+ if (!info) return null;
113
+ return h('div', {className: 'dsr-header'},
114
+ h('span', {className: 'dsr-chip'}, `本卷第 ${info.turnCount} 轮 / 阈值 ${info.threshold}`),
115
+ info.volume ? h('span', {className: 'dsr-chip'}, `卷 ${info.volume}/${info.cap}`) : null,
116
+ info.sealed ? h('span', {className: 'dsr-chip'}, info.readonly ? '归档只读' : '归档 · 已恢复写入') : null,
117
+ info.source ? h('span', {className: 'dsr-source'}, h(Source, {source: info.source}),
118
+ info.previousTurnCount != null ? ` · 第 ${info.previousTurnCount} 轮` : '') : null,
119
+ h('button', {type: 'button', 'aria-label': '会话分卷操作', onClick: () => { setShowHelp(false); dialog.current?.showModal(); }}, '⋯'),
120
+ h('dialog', {ref: dialog, className: 'dsr-actions', 'aria-label': '会话操作'},
121
+ showHelp ? h('section', {className: 'dsr-help', 'aria-label': '会话分卷使用说明'},
122
+ h('h3', null, '会话分卷与续接 · 使用说明'),
123
+ h('p', null, '长会话达到阈值后,在本轮执行结束且没有未完成工具调用或压缩时创建下一卷。默认每 10 条真实用户消息分卷,同一系列最多 9 卷。系统上下文和交接消息不计数。'),
124
+ h('h4', null, '从哪里开始'),
125
+ h('p', null, '标题栏查看本卷轮数、阈值和卷序。菜单中的“现在换会话”可提前交接;“新建独立会话”会携带快照另开一个系列。操作需等待当前执行结束。'),
126
+ h('h4', null, '交接卡与任务进度'),
127
+ h('p', null, '新卷首条接续摘要保留切卷时的待办、当前步骤、约束、文件线索及后台任务状态。可展开摘要、查看或复制完整 JSON,并点击来源回看上一卷。右侧实时任务进度由可选的 dsh-todo-progress 插件提供,历史快照不会随任务进度变化。'),
128
+ h('h4', null, '回看与恢复写入'),
129
+ h('p', null, '旧卷默认归档只读,可在输入区域上方切换“允许再次写入 / 恢复只读”。开关会保存;恢复写入不会让旧卷再次自动分卷。达到 9 卷后请新建独立会话。'),
130
+ h('h4', null, '配置与数据'),
131
+ h('p', null, '在宿主插件配置中调整 mode(auto / manual / off)、threshold 和 dataDir。manual 达阈值只提示,off 关闭自动判定;显式手动操作仍可使用。数据默认存于 DSH_HOME/plugin-data/dsh-session-rollover,卸载不会清除。'),
132
+ h('h4', null, '能力边界'),
133
+ h('p', null, '摘要为确定性摘录,不额外调用模型;继续对话使用 DSH 已配置的模型,无需额外 API Key。后台任务仅记录句柄和状态,不迁移或重启进程。摘要或存储异常会提示,复杂内容超预算时可能无法交接。'),
134
+ h('p', {className: 'dsr-help-credit'}, 'dsh-session-rollover · Oak-liuhj')) : null,
135
+ h('div', {className: 'dsr-actions-body'},
136
+ h('button', {type: 'button', 'aria-expanded': showHelp, onClick: () => setShowHelp(!showHelp)}, showHelp ? '收起使用说明' : '使用说明'),
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')}, '新建独立会话'),
139
+ h('button', {type: 'button', onClick: () => dialog.current?.close()}, '关闭'))),
140
+ error ? h('span', {role: 'alert'}, error) : null);
141
+ }
142
+
143
+ function Badges(props) {
144
+ const info = useInfo(props.sessionId);
145
+ if (!info?.volume) return null;
146
+ return h('span', {className: `dsr-row-badges${props.compact ? ' dsr-row-compact' : ''}`, title: `卷 ${info.volume}/${info.cap}${info.readonly ? ' · 归档只读' : ''}`},
147
+ info.readonly ? h('span', null, '只读') : null, h('span', null, `${info.volume}/${info.cap}`));
148
+ }
149
+
150
+ function Card({payload, source}) {
151
+ const [expanded, setExpanded] = useState(false);
152
+ const [jsonOpen, setJsonOpen] = useState(false);
153
+ const [message, setMessage] = useState('');
154
+ if (!payload) return null;
155
+ const todos = Array.isArray(payload.todos) ? payload.todos : [];
156
+ const summary = payload.summary ?? {};
157
+ const active = payload.current_step?.active_items ?? [];
158
+ const text = Object.entries(summary).map(([k, v]) => `${k}\n${v}`).join('\n');
159
+ const chunks = [
160
+ ['上轮工作摘要', summary['上轮工作摘要'] ?? summary['任务目标'] ?? '本轮未提供'],
161
+ ['当前工作', active.length ? active.map(n => `第 ${n} 步进行中:${todos[n - 1]?.content ?? ''}`).join('\n') : '尚未开始'],
162
+ ['关键约束(原文)', (payload.constraints ?? []).join('\n') || '本轮未提供'],
163
+ ['文件与代码', (payload.changed_files ?? []).map(f => `${f.path}${f.deleted ? '(已删除)' : ''}`).join('\n') || '本轮未提供'],
164
+ ['未竟后台任务', (payload.open_jobs ?? []).map(j => `${j.id} · ${j.command ?? ''} · ${j.status}${j.recoverable === false ? '(需重跑)' : ''}`).join('\n') || '无后台任务'],
165
+ ['待办快照', todos.length ? todos.map((t, i) => `${i + 1}. ${t.content} · ${{completed: '已完成', in_progress: '进行中', pending: '待办', failed: '中断'}[t.status] ?? t.status}`).join('\n') : '无结构化待办'],
166
+ ];
167
+ return h('section', {className: 'dsr-card', 'aria-label': '接续摘要'},
168
+ h('div', {className: 'dsr-card-head'}, h('strong', null, '接续摘要'), h(Source, {source}),
169
+ payload.cluster?.vol_no ? h('span', {className: 'dsr-chip'}, `卷 ${payload.cluster.vol_no}/9`) : null,
170
+ payload.health ? h('span', {className: 'dsr-chip'}, `摘要重试 ${payload.health.summary_retry}/2`) : null),
171
+ h('div', {className: expanded ? 'dsr-card-content' : 'dsr-card-preview'},
172
+ expanded ? chunks.map(([name, value]) => h('div', {className: 'dsr-section', key: name},
173
+ h('h4', null, name), h('div', {className: 'dsr-prewrap'}, value))) : text),
174
+ h('button', {type: 'button', className: 'dsr-link dsr-expand', 'aria-expanded': expanded,
175
+ onClick: () => setExpanded(!expanded)}, expanded ? '收起摘要' : '展开全部'),
176
+ h('div', {className: 'dsr-json-controls'},
177
+ h('button', {type: 'button', 'aria-expanded': jsonOpen, onClick: () => setJsonOpen(!jsonOpen)}, jsonOpen ? '收起快照 JSON' : '展开快照 JSON'),
178
+ h('button', {type: 'button', onClick: async () => {
179
+ try { await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); setMessage('快照已复制'); }
180
+ catch { setJsonOpen(true); setMessage('复制失败,可选择下方 JSON 手动复制。'); }
181
+ }}, '复制快照')),
182
+ jsonOpen ? h('pre', {className: 'dsr-json', tabIndex: 0, 'aria-label': '完整交接快照 JSON'}, JSON.stringify(payload, null, 2)) : null,
183
+ message ? h('div', {role: 'status'}, message) : null);
184
+ }
185
+
186
+ function Dock(props) {
187
+ const info = useInfo(props.sessionId);
188
+ const previous = useRef(null);
189
+ useEffect(() => {
190
+ const next = info?.completedSessionId;
191
+ // 只自动跳转浏览器亲历的“活动→封卷”;回看历史旧卷时不能被再次弹回新卷。
192
+ if (next && previous.current?.sessionId === props.sessionId && previous.current.sealed === false && info.sealed && navigate) navigate(next);
193
+ if (info) previous.current = info;
194
+ }, [info, props.sessionId]);
195
+ if (!info) return null;
196
+ return h('div', {className: 'dsr-dock'},
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,
200
+ h(ReadonlyControl, {key: props.sessionId, info}),
201
+ info.capped ? h('div', {role: 'status', className: 'dsr-warning'}, '同簇已达 9 卷上限(9/9),请通过会话操作新建独立会话。') : null,
202
+ info.retryLimitReached ? h('div', {role: 'status', className: 'dsr-warning'}, '摘要生成失败,已停止自动重试。可在修正内容后手动换会话。') : null,
203
+ info.degraded ? h('div', {role: 'alert'}, '分卷索引保存失败,重启前请检查数据目录权限。') : null,
204
+ !renderInline && info.handoff ? h(Card, {key: props.sessionId, payload: info.handoff, source: info.source}) : null);
205
+ }
206
+
207
+ function parseHandoff(text) {
208
+ if (!text.startsWith('【分卷交接】')) return null;
209
+ try {
210
+ const payload = JSON.parse(/```json\s*([\s\S]*?)```/.exec(text)?.[1]);
211
+ return payload.rollover === 'v1' ? payload : null;
212
+ } catch { return null; }
213
+ }
214
+
215
+ /** 释放时仅撤销自己的禁用;保留模型选择等其他插件后来设置的阻塞原因。 */
216
+ function blockComposer(blocks, id, reason) {
217
+ if (typeof blocks?.storeFor !== 'function' || typeof blocks?.set !== 'function') throw new Error('composer blocks unavailable');
218
+ const store = blocks.storeFor(id);
219
+ if (typeof store?.getSnapshot !== 'function' || typeof store?.subscribe !== 'function') throw new Error('composer store unavailable');
220
+ let previous = store.getSnapshot();
221
+ const own = {reason};
222
+ let writing = false;
223
+ const enforce = () => {
224
+ if (writing || store.getSnapshot() === own) return;
225
+ previous = store.getSnapshot();
226
+ writing = true;
227
+ try { blocks.set(id, own); } finally { writing = false; }
228
+ };
229
+ const off = store.subscribe(enforce);
230
+ enforce();
231
+ return () => { off(); if (store.getSnapshot() === own) blocks.set(id, previous); };
232
+ }
233
+
234
+ function apply(ctx) {
235
+ if (!ctx?.inject) return;
236
+ const style = document.createElement('style');
237
+ style.textContent = CSS;
238
+ style.setAttribute('data-dsh-plugin', 'dsh-session-rollover-ui');
239
+ document.head.appendChild(style);
240
+ ctx.effect?.(() => () => { style.remove(); for (const item of entries.values()) item.stop?.(); entries.clear(); });
241
+ inject(ctx, ['uiWorkspace'], scope => {
242
+ if (typeof scope.uiWorkspace?.openSession !== 'function') throw new Error('session navigation unavailable');
243
+ navigate = id => scope.uiWorkspace.openSession(id);
244
+ scope.effect?.(() => () => { navigate = null; });
245
+ });
246
+ inject(ctx, ['slots'], scope => {
247
+ const slots = scope.slots;
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,
250
+ id: 'dsh-session-rollover', order: 35, ...options}, Component)));
251
+ mount('conversation.session.header.actions', Header);
252
+ mount('conversation.input.dock', Dock);
253
+ mount('sidebar.session.row.trailing', Badges);
254
+ // 已安装的较旧宿主只开放 leading;新宿主优先 trailing,避免重复徽标。
255
+ mount('sidebar.session.row.leading', props => slots.snapshot?.('sidebar.session.row.trailing')?.length
256
+ ? null : h(Badges, {...props, compact: true}));
257
+ // 使用宿主公开 composer block 服务;覆盖输入组件会破坏其子槽授权。
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
+ }
262
+ function ComposerGuard(props) {
263
+ const info = useInfo(props.sessionId);
264
+ const reason = !info ? '正在读取会话状态…' : info.readonly
265
+ ? '归档会话:不可输入,可通过上方开关恢复写入。' : info.error;
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
+ },
272
+ [props.sessionId, reason]);
273
+ return null;
274
+ }
275
+ inputScope.effect(() => slots.inject('conversation.input.dock', () => slots.register({
276
+ name: 'conversation.input.dock', id: 'dsr-readonly-guard', order: 34}, ComposerGuard)));
277
+ });
278
+ inject(scope, ['uiConversation'], textScope => {
279
+ if (!textScope.uiConversation.userText?.register) return;
280
+ textScope.effect(() => textScope.uiConversation.userText.register({id: 'dsr-handoff', order: -100,
281
+ match(text, from) {
282
+ if (from !== 0) return;
283
+ const payload = parseHandoff(text);
284
+ return payload ? {id: 'dsr-handoff', start: 0, end: text.length, data: payload} : undefined;
285
+ }}));
286
+ // 插槽必须随 uiConversation 的注入子作用域释放,不能挂在较长寿命的 slots 父作用域。
287
+ // 新宿主重连/热替换领域服务时会重建该子作用域;挂在父级会重复占用同一个 keyed slot。
288
+ mount('conversation.user.text.node', props => h(Card, {payload: props.node.data,
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);
290
+ renderInline = true;
291
+ textScope.effect?.(() => () => { renderInline = false; });
292
+ });
293
+ });
294
+ }
295
+
296
+ const CSS = `
297
+ .dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges{--dsr-bg:var(--dsw-alias-bg-base, #fff);--dsr-fg:var(--dsw-alias-label-primary, #111827);--dsr-muted:#61666b;--dsr-line:rgba(0,0,0,.12);--dsr-accent:#3d56d6;--dsr-weak:rgba(77,107,254,.09);color:var(--dsr-fg);font:13px/20px -apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;box-sizing:border-box}
298
+ body[data-ds-dark-theme] :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges),html[data-theme=dark] :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges),html.dark :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges){--dsr-bg:#171a1f;--dsr-fg:#f9fafb;--dsr-muted:#cfd3d6;--dsr-line:rgba(255,255,255,.16);--dsr-accent:#95a8ff;--dsr-weak:rgba(107,133,255,.13)}
299
+ @media(prefers-color-scheme:dark){html:not([data-theme=light]):not([data-ds-theme-source=light]) :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges){--dsr-bg:#171a1f;--dsr-fg:#f9fafb;--dsr-muted:#cfd3d6;--dsr-line:rgba(255,255,255,.16);--dsr-accent:#95a8ff}}
300
+ .dsr-header{display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-width:0}.dsr-chip{display:inline-flex;align-items:center;border:1px solid var(--dsr-line);border-radius:99px;padding:1px 7px;font-size:11.5px;white-space:nowrap;color:var(--dsr-muted)}
301
+ .dsr-source{font-size:12px;color:var(--dsr-muted)}.dsr-link{color:var(--dsr-accent)!important;background:none!important}.dsr-header button,.dsr-dock button,.dsr-card button,.dsr-actions summary{font:inherit;cursor:pointer;min-height:28px;min-width:28px;border:1px solid var(--dsr-line);background:var(--dsr-bg);color:inherit;border-radius:6px;padding:3px 8px}.dsr-link{border:0!important}.dsr-header button:disabled,.dsr-dock button:disabled{opacity:.6;cursor:default}
302
+ .dsr-dock{width:100%;min-width:0;display:flex;flex-direction:column;gap:8px}.dsr-archive{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--dsr-line);border-radius:8px;background:var(--dsr-weak)}.dsr-archive button{margin-left:auto}.dsr-warning{padding:8px 10px;border:1px solid var(--dsr-line);border-radius:8px}
303
+ .dsr-card{display:block;flex:0 0 auto;min-width:0;max-width:100%;border:1px solid var(--dsr-line);border-radius:10px;background:var(--dsr-bg);overflow-wrap:anywhere;white-space:normal;margin:8px 0}.dsr-card-head{display:flex;align-items:center;flex-wrap:wrap;gap:6px;background:var(--dsr-weak);border-radius:10px 10px 0 0;padding:10px 12px}.dsr-card-preview{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;margin:10px 12px;white-space:pre-wrap}.dsr-card-content{display:block}.dsr-section{padding:8px 12px;border-top:1px solid var(--dsr-line)}.dsr-section h4{font-size:12px;line-height:20px;color:var(--dsr-muted);margin:0 0 4px;font-weight:500}.dsr-prewrap{white-space:pre-wrap}.dsr-expand{margin:0 4px 6px}.dsr-json-controls{display:flex;gap:8px;flex-wrap:wrap;padding:8px 12px;border-top:1px solid var(--dsr-line)}.dsr-json{font:12px/18px Consolas,monospace;max-height:240px;overflow:auto;background:var(--dsr-weak);padding:10px;margin:0 12px 12px;white-space:pre-wrap;overflow-wrap:anywhere}.dsr-row-badges{display:inline-flex;gap:3px;font-size:10px;white-space:nowrap}.dsr-row-badges span{background:var(--dsr-weak);padding:0 4px;border-radius:4px}
304
+ .dsr-actions{border:1px solid var(--dsr-line);border-radius:10px;background:var(--dsr-bg);color:var(--dsr-fg);max-width:calc(100vw - 32px)}.dsr-actions::backdrop{background:rgba(0,0,0,.25)}.dsr-actions-body{display:flex;gap:6px;flex-wrap:wrap;padding:6px 0}.dsr-header :focus-visible,.dsr-dock :focus-visible,.dsr-card :focus-visible{outline:2px solid var(--dsr-accent);outline-offset:2px}.dsr-card [role=status]{padding:4px 12px;color:var(--dsr-muted)}
305
+ .dsr-row-compact{flex-direction:column;align-items:center;justify-content:center;gap:0;width:20px;max-width:20px;font-size:9px;line-height:11px}.dsr-row-compact span{padding:0 1px}
306
+ .dsr-actions:has(.dsr-help){width:560px;max-height:calc(100dvh - 48px);overflow:auto}.dsr-help{max-width:100%;line-height:1.7;overflow-wrap:anywhere}.dsr-help h3{margin:4px 0 12px;font-size:17px}.dsr-help h4{margin:14px 0 4px;font-size:13px}.dsr-help p{margin:4px 0 10px}.dsr-help-credit{font-size:11px;color:var(--dsr-muted)}
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}}
308
+ @media(prefers-reduced-motion:reduce){.dsr-card *{animation:none!important;transition:none!important}}
309
+ `;
310
+ return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, clientWarnings, CSS};
311
+ }