@mzzsfy/dsh-session-manager 0.9.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.9.6",
4
+ "version": "0.10.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -10,6 +10,7 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "dsh": {
13
+ "hostMin": "0.2.1-rc.2",
13
14
  "bundle": {
14
15
  "patch": "./cordis.patch.yml"
15
16
  },
@@ -22,8 +23,11 @@
22
23
  "compatibility": {
23
24
  "dshReleases": {
24
25
  "0.2.0-rc.1": "unknown",
25
- "0.2.0-rc.2": "compatible",
26
- "0.2.1-alpha.1": "unknown"
26
+ "0.2.0-rc.2": "unknown",
27
+ "0.2.1-alpha.1": "unknown",
28
+ "0.2.1-rc.2": "compatible",
29
+ "0.2.1-rc.5": "compatible",
30
+ "0.2.2-rc.1": "compatible"
27
31
  }
28
32
  }
29
33
  },
@@ -31,6 +35,10 @@
31
35
  "test": "node --test \"test/*.test.mjs\"",
32
36
  "npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
33
37
  },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org"
41
+ },
34
42
  "license": "MIT",
35
43
  "repository": {
36
44
  "type": "git",
package/src/client.js CHANGED
@@ -123,6 +123,20 @@ const CSS = [
123
123
  ' border:1px solid var(--dsw-alias-border-l2);',
124
124
  ' background:transparent; color:inherit; font:inherit; }',
125
125
  '.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:0; }',
126
+ // 列表瘦身开关:原生 checkbox 保语义与可访问性,视觉由 track/thumb 呈现
127
+ '.sm-switch { display:inline-flex; align-items:center; gap:8px; min-height:28px; cursor:pointer;',
128
+ ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption); }',
129
+ '.sm-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
130
+ '.sm-switch__track { position:relative; flex-shrink:0; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
131
+ ' background:transparent; border:1px solid var(--dsw-alias-border-l2); transition:background 0.15s,border-color 0.15s; }',
132
+ '.sm-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
133
+ ' background:var(--dsw-alias-label-caption); transform:translateY(-50%); transition:left 0.15s,background 0.15s; }',
134
+ '.sm-switch:hover .sm-switch__track { border-color:var(--dsw-alias-state-business-primary); }',
135
+ '.sm-switch input[type="checkbox"]:checked + .sm-switch__track { background:var(--dsw-alias-state-business-primary); border-color:var(--dsw-alias-state-business-primary); }',
136
+ '.sm-switch input[type="checkbox"]:checked + .sm-switch__track .sm-switch__thumb { left:17px; background:#fff; }',
137
+ '.sm-switch input[type="checkbox"]:focus-visible + .sm-switch__track { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }',
138
+ '.sm-switch:has(input[type="checkbox"]:disabled) { opacity:0.5; cursor:not-allowed; }',
139
+ '@media (prefers-reduced-motion: reduce) { .sm-switch__track, .sm-switch__thumb { transition:none; } }',
126
140
  ].join('\n')
127
141
 
128
142
  const UNARCHIVE_URL = '/api/session-manager/unarchive'
@@ -136,6 +150,11 @@ const STATUS_URL = '/api/session-manager/status'
136
150
  // 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
137
151
  // 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
138
152
  const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
153
+ // 行操作按钮悬停说明:文案与行为同源维护,由源码契约测试锁定
154
+ const RESTORE_BUTTON_TITLE = '把该会话从归档移回会话列表;恢复后 10 分钟内暂停自动归档,保护「恢复 → 首条消息」窗口,期间发言即恢复正常活跃计时。'
155
+ const REMOUNT_BUTTON_TITLE = '把这条已删除记录对应的会话重新挂回列表:产物仍在原位置时直接挂载,产物已进系统回收站时先还原再挂载。'
156
+ const FORGET_BUTTON_TITLE = '只移除这条已删除记录,不动磁盘上的会话产物;用于产物已手动找回、或不再需要追踪的情况。'
157
+ const MORE_BUTTON_TITLE = '展开该工作区剩余的归档会话;只改变面板显示条数,不改变任何会话的归档状态。'
139
158
  const DELETE_DIALOG_TITLE = '确认删除该会话?'
140
159
  const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
141
160
  const DELETE_DIALOG_BODY_SUFFIX = '」。'
@@ -347,7 +366,7 @@ function ArchiveRow(props) {
347
366
  ]
348
367
  : [
349
368
  h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
350
- h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
369
+ h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, title: RESTORE_BUTTON_TITLE, onClick: props.onUnarchive }, '恢复'),
351
370
  ]
352
371
  return h('div', {
353
372
  className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
@@ -402,8 +421,8 @@ function DeletedRow(props) {
402
421
  h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
403
422
  h('span', { className: 'sm-row__title', title: row.title }, row.title),
404
423
  h('div', { className: 'sm-row__actions' },
405
- h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
406
- h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
424
+ h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, title: REMOUNT_BUTTON_TITLE, onClick: props.onRemount }, '重新挂载'),
425
+ h('button', { key: 'forget', className: 'sm-btn', disabled: busy, title: FORGET_BUTTON_TITLE, onClick: props.onForget }, '移除记录'),
407
426
  ),
408
427
  h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
409
428
  )
@@ -554,7 +573,7 @@ function SessionManagerApp(props) {
554
573
  // 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
555
574
  const expandMore = (remaining, onMore) => remaining <= 0
556
575
  ? null
557
- : h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
576
+ : h('button', { key: 'more', className: 'sm-more', title: MORE_BUTTON_TITLE, onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
558
577
 
559
578
  // 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
560
579
  const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
@@ -668,11 +687,13 @@ function SessionManagerApp(props) {
668
687
  const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除;恢复后 10 分钟内暂停自动归档,期间发言即恢复正常活跃计时。'
669
688
  const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
670
689
  const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
690
+ const LIST_SLIM_TITLE = '会话列表瘦身:列表接口原本把每个会话的完整投影快照(上下文请求头、回合大纲等)一并下发,会话多时单次响应数十 MB、服务端重建要数十秒,每次打开页面都重来一遍。开启后只下发列表真正用到的字段并缓存结果,重复打开不再重建,响应体积下降九成以上;被裁掉的字段在会话详情里照旧完整返回。关闭即恢复原始行为,不影响归档、删除等其它功能。'
671
691
 
672
692
  // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
673
693
  // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
674
694
  // 服务端失败后回读生效值回滚显示
675
695
  const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
696
+ const LIST_SLIM_URL = '/api/session-manager/list-slim'
676
697
 
677
698
  // 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
678
699
  // invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
@@ -685,29 +706,30 @@ function classifyAutoArchiveInput(committedText, text) {
685
706
  return { action: 'post', value }
686
707
  }
687
708
 
709
+ // 面板状态:数值项取字符串供受控输入,开关项取布尔
710
+ const configStateOf = (archive, slim) => ({
711
+ days: String(archive.days),
712
+ intervalHours: String(archive.intervalHours),
713
+ enabled: slim.enabled !== false,
714
+ })
715
+
688
716
  function AutoArchiveConfig() {
689
717
  const [state, setState] = useState(null)
690
718
  // 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
691
719
  const committedRef = React.useRef(null)
692
720
  useEffect(() => {
693
- api(AUTO_ARCHIVE_URL)
694
- .then((payload) => {
695
- if (!payload) return
696
- const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
697
- committedRef.current = next
698
- setState(next)
699
- })
700
- .catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
721
+ void refresh()
701
722
  }, [])
723
+ // 两个接口合并成一个面板状态:数值项与开关项一次读齐,提交后各自回读
702
724
  function refresh() {
703
- return api(AUTO_ARCHIVE_URL)
704
- .then((payload) => {
705
- if (!payload) return
706
- const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
725
+ return Promise.all([api(AUTO_ARCHIVE_URL), api(LIST_SLIM_URL)])
726
+ .then(([archive, slim]) => {
727
+ if (!archive || !slim) return
728
+ const next = configStateOf(archive, slim)
707
729
  committedRef.current = next
708
730
  setState(next)
709
731
  })
710
- .catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
732
+ .catch((error) => { console.warn('[session-manager] 面板配置回读失败', error) })
711
733
  }
712
734
  function commit(field, text) {
713
735
  const committed = committedRef.current
@@ -735,6 +757,20 @@ function AutoArchiveConfig() {
735
757
  void refresh()
736
758
  })
737
759
  }
760
+ // 开关提交:布尔值直接落 settings,成功后按回显值收敛,失败回读生效值
761
+ function commitEnabled(next) {
762
+ api(LIST_SLIM_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
763
+ .then((payload) => {
764
+ const saved = payload.enabled !== false
765
+ committedRef.current = { ...committedRef.current, enabled: saved }
766
+ setState((prev) => (prev === null ? prev : { ...prev, enabled: saved }))
767
+ toast(saved ? '会话列表瘦身已开启' : '会话列表瘦身已关闭')
768
+ })
769
+ .catch(() => {
770
+ toast('会话列表瘦身开关保存失败', { kind: 'error' })
771
+ void refresh()
772
+ })
773
+ }
738
774
  if (state === null) return null
739
775
  const numberInput = (field) => h('input', {
740
776
  type: 'number', min: 0, step: 1, value: state[field],
@@ -747,6 +783,16 @@ function AutoArchiveConfig() {
747
783
  h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
748
784
  h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
749
785
  h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
786
+ h('label', { className: 'sm-switch', title: LIST_SLIM_TITLE },
787
+ h('input', {
788
+ type: 'checkbox',
789
+ checked: state.enabled,
790
+ 'aria-label': '会话列表瘦身',
791
+ onChange: (event) => commitEnabled(event.target.checked),
792
+ }),
793
+ h('span', { className: 'sm-switch__track' }, h('span', { className: 'sm-switch__thumb' })),
794
+ h('span', { title: LIST_SLIM_TITLE }, '会话列表瘦身'),
795
+ ),
750
796
  )
751
797
  }
752
798
 
package/src/index.js CHANGED
@@ -31,13 +31,13 @@ import {
31
31
  updatedAtOf,
32
32
  } from './core.mjs'
33
33
  import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
34
+ import { installListSlim } from './list-slim.mjs'
34
35
 
35
36
  export const name = 'dsh-session-manager'
36
37
 
37
38
  export const inject = ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']
38
39
 
39
40
  const NAMESPACE = 'session-manager'
40
- const WORKSPACE_DOMAIN_NAME = 'workspace'
41
41
 
42
42
  // 已删除台账域:global 单列表,条目为回收站还原后重挂载所需的最小信息;
43
43
  // heldPath 仅回收区降级条目携带(暂存路径,重挂载时移回 path 原位置)
@@ -98,6 +98,8 @@ const SETTINGS_SCHEMA = schemastery.object({
98
98
  .description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
99
99
  autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
100
100
  .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
101
+ listSlimEnabled: schemastery.boolean().default(true)
102
+ .description('会话列表瘦身:裁剪 session/list 的投影并缓存,降低列表响应体积与重建耗时;上游结构变更时自动降级为原行为'),
101
103
  })
102
104
 
103
105
  // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
@@ -142,6 +144,7 @@ function readSettings(ctx, config) {
142
144
  return {
143
145
  days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
144
146
  intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
147
+ listSlimEnabled: value?.listSlimEnabled !== false,
145
148
  }
146
149
  }
147
150
 
@@ -161,6 +164,21 @@ function readAutoArchivePatch(body) {
161
164
  return patch
162
165
  }
163
166
 
167
+ // settings 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
168
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
169
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
170
+ async function persistSettings(ctx, patch) {
171
+ const settings = ctx.get('settings')
172
+ if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
173
+ await settings.update(NAMESPACE, patch)
174
+ return
175
+ }
176
+ const editor = ctx.get('configEditor')
177
+ const entry = ctx.fiber?.entry
178
+ if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
179
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
180
+ }
181
+
164
182
  function readBody(req) {
165
183
  const BODY_MAX_BYTES = 64 * 1024
166
184
  return new Promise((resolve, reject) => {
@@ -216,9 +234,44 @@ function rejectMethod(req, res, method) {
216
234
  return false
217
235
  }
218
236
 
237
+ // 写路由守卫(全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
238
+ // 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
239
+ // 无 Origin 的非浏览器调用放行(本地 CLI/脚本);无 headers 的直调桩同样放行。
240
+ function rejectCrossOrigin(req, res) {
241
+ const origin = req.headers ? req.headers.origin : undefined
242
+ if (!origin) return false
243
+ let sameOrigin = false
244
+ try {
245
+ sameOrigin = new URL(origin).host === req.headers.host
246
+ } catch {
247
+ sameOrigin = false
248
+ }
249
+ if (sameOrigin) return false
250
+ sendJson(res, 403, { error: '跨源请求被拒绝' })
251
+ return true
252
+ }
253
+
254
+ // 内容类型守卫:浏览器发起的跨站请求(表单/fetch 字符串体)必带 text/plain 等类型,
255
+ // 借此阻断 drive-by 写;完全不带 content-type 的调用(本地 CLI/无头脚本)放行,
256
+ // 与"无 Origin 放行"同口径
257
+ function rejectNonJson(req, res) {
258
+ const type = req.headers ? req.headers['content-type'] : undefined
259
+ if (typeof type !== 'string' || type.trim() === '') return false
260
+ if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
261
+ sendJson(res, 415, { error: '仅接受 application/json 请求体' })
262
+ return true
263
+ }
264
+
265
+ function guardWrite(req, res) {
266
+ return rejectCrossOrigin(req, res) || rejectNonJson(req, res)
267
+ }
268
+
219
269
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
220
270
  export function apply(ctx, config) {
221
271
  let evaluating = false
272
+ // 列表瘦身:开关从设置实时读取,设置页改动即时生效;装载失败只关闭该能力,
273
+ // 归档与删除等其它功能不受影响
274
+ installListSlim(ctx, { isEnabled: () => readSettings(ctx, config).listSlimEnabled })
222
275
  // 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
223
276
  // 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
224
277
  let unarchivedAtMs = 0
@@ -226,9 +279,9 @@ export function apply(ctx, config) {
226
279
  const inFlightDeletes = new Set()
227
280
 
228
281
  // 插件回收区:系统回收站不可用环境的删除降级暂存地,重挂载时移回原位置。
229
- // 测试经 env DSH_QUARANTINE_DIR 注入临时目录
282
+ // 测试经 env DSH_QUARANTINE_DIR 注入临时目录;缺省从宿主 home 派生(DSH_HOME 即 .dsh 根)
230
283
  const quarantineDir = process.env.DSH_QUARANTINE_DIR
231
- || join(homedir(), '.dsh', 'trash', 'session-manager')
284
+ || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'trash', 'session-manager')
232
285
 
233
286
  // header 查找缓存:归档/删除/重挂载路由共用(inputs 浮层已迁 dsh-context-manager)
234
287
  const headerCache = new Map()
@@ -332,27 +385,11 @@ export function apply(ctx, config) {
332
385
  evaluateArchives(cwd)
333
386
  })
334
387
 
335
- // 取消归档:直写 workspace 域 global,无官方 API 可用。注册表把域 global 快照到
336
- // 进程内 state 且只在自身写路径同步,直写后必须把快照一并改掉:否则注册表后续
337
- // 全量写回(含自动归档)与 workspace.list 重连基线都会复活该 id,官方归档的幂等
338
- // 判定也会因旧快照静默跳过。合并 durable 与快照再移除,可自愈历史失同步与清理
339
- // 半失败(经取消归档重试即补全),代价是快照中的陈旧 id 会随任一次清理重新落盘,
340
- // 可经再次取消归档清除。inject 保证注册表就绪,快照缺失即上游形态变更,loud fail;
341
- // 域 global 仅支持整体写回,读改写窗口若与注册表两阶段变更交错,理论上可回退其
342
- // pendingMutation/workspaceIds 中间态,下次启动 validateStoredState 将 loud fail,
343
- // 窗口极窄且域 API 无原子原语可用,属已知取舍(详见 README)。
388
+ // 取消归档:官方 workspaceRegistry.unarchiveSession(0.2.1-rc.2+ 串行实现,
389
+ // 未归档 id 幂等不写)写穿 state,无私有直写的读改写窗口与快照失同步问题;
390
+ // 合并自愈语义随私有写一并退役(开发守则:移除被宿主能力取代的实现)
344
391
  async function removeArchivedId(sessionId) {
345
- const domain = ctx.storageDomain.get(WORKSPACE_DOMAIN_NAME)
346
- if (domain === undefined) throw new Error('workspace 域未打开')
347
- const durable = domain.global.get()
348
- const cached = ctx.workspaceRegistry.state
349
- if (cached === undefined) throw new Error('workspace 注册表快照不可用')
350
- const merged = new Set(durable.archivedSessionIds.map(String))
351
- for (const id of cached.archivedSessionIds) merged.add(String(id))
352
- if (!merged.delete(String(sessionId))) return
353
- const next = { ...durable, archivedSessionIds: [...merged] }
354
- await domain.global.set(next)
355
- ctx.workspaceRegistry.state = next
392
+ await ctx.workspaceRegistry.unarchiveSession(sessionId)
356
393
  }
357
394
 
358
395
  // 台账记账:trash 成功即记录,路径为回收站「原位置」还原所需。
@@ -462,6 +499,7 @@ export function apply(ctx, config) {
462
499
  path: '/api/session-manager/unarchive',
463
500
  handler: async (req, res) => {
464
501
  if (!rejectMethod(req, res, 'POST')) return
502
+ if (guardWrite(req, res)) return
465
503
  try {
466
504
  const sessionId = await requireSessionId(req)
467
505
  await removeArchivedId(sessionId)
@@ -476,6 +514,7 @@ export function apply(ctx, config) {
476
514
  path: '/api/session-manager/info',
477
515
  handler: async (req, res) => {
478
516
  if (!rejectMethod(req, res, 'POST')) return
517
+ if (guardWrite(req, res)) return
479
518
  try {
480
519
  const sessionId = await requireSessionId(req)
481
520
  const header = await findHeader(ctx, sessionId, headerCache)
@@ -510,6 +549,7 @@ export function apply(ctx, config) {
510
549
  path: '/api/session-manager/delete',
511
550
  handler: async (req, res) => {
512
551
  if (!rejectMethod(req, res, 'POST')) return
552
+ if (guardWrite(req, res)) return
513
553
  const sessionId = await requireSessionId(req).catch((error) => {
514
554
  respondError(ctx, res, error)
515
555
  return undefined
@@ -661,6 +701,7 @@ export function apply(ctx, config) {
661
701
  return
662
702
  }
663
703
  if (!rejectMethod(req, res, 'POST')) return
704
+ if (guardWrite(req, res)) return
664
705
  let body
665
706
  try {
666
707
  body = JSON.parse(await readBody(req))
@@ -668,19 +709,7 @@ export function apply(ctx, config) {
668
709
  throw new Error(MESSAGES.badJsonBody)
669
710
  }
670
711
  const patch = readAutoArchivePatch(body)
671
- // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
672
- // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
673
- // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
674
- // await 保证回显为新值,persist 失败走 respondError
675
- const settings = ctx.get('settings')
676
- if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
677
- await settings.update(NAMESPACE, patch)
678
- } else {
679
- const editor = ctx.get('configEditor')
680
- const entry = ctx.fiber?.entry
681
- if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
682
- await editor.edit(entry, (current) => ({ ...current, ...patch }))
683
- }
712
+ await persistSettings(ctx, patch)
684
713
  const current = readSettings(ctx, config)
685
714
  sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
686
715
  } catch (error) {
@@ -688,6 +717,32 @@ export function apply(ctx, config) {
688
717
  }
689
718
  },
690
719
  },
720
+ {
721
+ // 会话列表瘦身开关:读回生效值,写经 settings 落 profile 条目;
722
+ // 关闭即恢复原始列表语义(不裁剪、不缓存),无需卸载插件
723
+ path: '/api/session-manager/list-slim',
724
+ handler: async (req, res) => {
725
+ try {
726
+ if (req.method === 'GET') {
727
+ sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
728
+ return
729
+ }
730
+ if (!rejectMethod(req, res, 'POST')) return
731
+ if (guardWrite(req, res)) return
732
+ let body
733
+ try {
734
+ body = JSON.parse(await readBody(req))
735
+ } catch {
736
+ throw new Error(MESSAGES.badJsonBody)
737
+ }
738
+ if (typeof body?.enabled !== 'boolean') throw new Error('列表瘦身开关须为布尔值')
739
+ await persistSettings(ctx, { listSlimEnabled: body.enabled })
740
+ sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
741
+ } catch (error) {
742
+ respondError(ctx, res, error)
743
+ }
744
+ },
745
+ },
691
746
  {
692
747
  path: '/api/session-manager/deleted',
693
748
  handler: async (req, res) => {
@@ -704,6 +759,7 @@ export function apply(ctx, config) {
704
759
  path: '/api/session-manager/remount',
705
760
  handler: async (req, res) => {
706
761
  if (!rejectMethod(req, res, 'POST')) return
762
+ if (guardWrite(req, res)) return
707
763
  try {
708
764
  const sessionId = await requireSessionId(req)
709
765
  // 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
@@ -747,6 +803,7 @@ export function apply(ctx, config) {
747
803
  path: '/api/session-manager/forget',
748
804
  handler: async (req, res) => {
749
805
  if (!rejectMethod(req, res, 'POST')) return
806
+ if (guardWrite(req, res)) return
750
807
  try {
751
808
  await removeLedgerEntry(await requireSessionId(req))
752
809
  sendJson(res, 200, { ok: true })
@@ -0,0 +1,106 @@
1
+ // 会话列表瘦身:session/list 为每个会话下发完整投影快照,列表行只消费其中少数几项。
2
+ // 本模块在 remote 方法返回前按白名单裁剪并缓存结果。任何失败路径都降级为原行为:
3
+ // 未装载、形状不符、原方法抛错一律透传,不影响本插件的其它功能。
4
+ // sessionController 经 ctx.inject 可选依赖取得,宿主缺该服务时本能力静默关闭。
5
+
6
+ // 列表行消费的投影;其余键不下发。被裁掉的投影仍由会话详情接口完整返回。
7
+ export const KEEP_PROJECTIONS = ['title', 'sessionListMetadata', 'inbox', 'modelSelection', 'tokenUsage']
8
+
9
+ // 缓存有效期:会话增删立即失效,TTL 只兜底标题与运行时状态的漂移。
10
+ export const TTL_MS = 15 * 1000
11
+
12
+ // 启动后预热延迟:让宿主先完成装载,再让出一次列表重建。
13
+ export const WARMUP_DELAY_MS = 10 * 1000
14
+
15
+ /**
16
+ * 裁剪一条列表项的投影,只保留白名单里的键。
17
+ * @param item - 宿主返回的列表项。
18
+ * @param keep - 保留的投影键。
19
+ * @returns 投影已裁剪的列表项;形状不符时原样返回。
20
+ */
21
+ export function slimItem(item, keep = KEEP_PROJECTIONS) {
22
+ const values = item?.projections?.values
23
+ if (values === null || typeof values !== 'object' || Array.isArray(values)) return item
24
+ const picked = {}
25
+ for (const key of keep) if (key in values) picked[key] = values[key]
26
+ return { ...item, projections: { ...item.projections, values: picked } }
27
+ }
28
+
29
+ /**
30
+ * 给 sessionController.list 装上裁剪与缓存。
31
+ * @param ctx - 宿主上下文。
32
+ * @param options - 开关、白名单、缓存有效期与预热延迟。
33
+ * @returns 是否提交了装载回调;sessionController 缺席时回调不执行,宿主行为不变。
34
+ */
35
+ export function installListSlim(ctx, options = {}) {
36
+ const {
37
+ enabled = true,
38
+ isEnabled,
39
+ keep = KEEP_PROJECTIONS,
40
+ ttlMs = TTL_MS,
41
+ warmupDelayMs = WARMUP_DELAY_MS,
42
+ } = options
43
+ if (!enabled) return false
44
+ const warn = (message) => ctx.logger?.warn?.('session-manager: 列表瘦身 ' + message)
45
+ try {
46
+ ctx.inject(['sessionController'], (scope) => {
47
+ try {
48
+ const controller = scope.sessionController
49
+ const original = controller?.list
50
+ if (typeof original !== 'function') return
51
+ let cache
52
+ let cachedAt = 0
53
+ let inflight
54
+
55
+ const fresh = () => cache !== undefined && Date.now() - cachedAt < ttlMs
56
+ const invalidate = () => {
57
+ cachedAt = 0
58
+ }
59
+ scope.on('api-session/added', invalidate)
60
+ scope.on('api-session/removed', invalidate)
61
+
62
+ // 只在调用方这一次请求上构建并缓存:不额外探测一次原方法,形状不符仅本次透传
63
+ controller.list = async (request, signal) => {
64
+ // 开关每次读取:设置页改动即时生效,无需重载插件
65
+ if (isEnabled !== undefined && !isEnabled()) return original.call(controller, request, signal)
66
+ if (fresh()) return cache
67
+ if (inflight !== undefined) return inflight
68
+ inflight = Promise.resolve(original.call(controller, request, signal))
69
+ .then((value) => {
70
+ if (!Array.isArray(value?.items)) return value
71
+ cache = { items: value.items.map((item) => slimItem(item, keep)) }
72
+ cachedAt = Date.now()
73
+ return cache
74
+ })
75
+ .finally(() => {
76
+ inflight = undefined
77
+ })
78
+ return inflight
79
+ }
80
+
81
+ let timer
82
+ if (warmupDelayMs >= 0) {
83
+ timer = setTimeout(() => {
84
+ if (isEnabled !== undefined && !isEnabled()) return
85
+ if (fresh() || inflight !== undefined) return
86
+ void controller.list({}, undefined).catch(() => {})
87
+ }, warmupDelayMs)
88
+ timer.unref?.()
89
+ }
90
+
91
+ // 卸载恢复原方法:本能力退出不改变宿主的列表语义
92
+ scope.effect(() => () => {
93
+ if (timer !== undefined) clearTimeout(timer)
94
+ controller.list = original
95
+ })
96
+ ctx.logger?.info?.(`session-manager: 列表瘦身已启用 (keep=${keep.join(',')} ttl=${ttlMs}ms)`)
97
+ } catch (error) {
98
+ warn('装载失败,保持原行为: ' + String(error?.message ?? error))
99
+ }
100
+ })
101
+ return true
102
+ } catch (error) {
103
+ warn('未启用: ' + String(error?.message ?? error))
104
+ return false
105
+ }
106
+ }
@@ -44,3 +44,23 @@ test('源码契约:已删除区保持悬停显现且减弱动效覆盖新选择
44
44
  assert.ok(/prefers-reduced-motion: reduce.*\{ \.sm-btn--danger, \.sm-deleted \.sm-row__actions \{ transition:none/.test(CLIENT_SRC),
45
45
  '减弱动效未覆盖删除按钮与已删除区过渡')
46
46
  })
47
+
48
+ // Given 归档行与已删除行的操作按钮, When 渲染, Then 每个按钮都带 title 悬停说明
49
+ test('源码契约:行操作按钮都带 title 悬停说明', () => {
50
+ const buttons = [
51
+ ['restore', 'RESTORE_BUTTON_TITLE'],
52
+ ['remount', 'REMOUNT_BUTTON_TITLE'],
53
+ ['forget', 'FORGET_BUTTON_TITLE'],
54
+ ['delete', 'DELETE_BUTTON_TITLE'],
55
+ ['more', 'MORE_BUTTON_TITLE'],
56
+ ]
57
+ for (const [key, constant] of buttons) {
58
+ const pattern = new RegExp("key: '" + key + "'[^\\n]*title: " + constant)
59
+ assert.ok(pattern.test(CLIENT_SRC), key + ' 按钮缺少 title(' + constant + ')')
60
+ }
61
+ for (const constant of ['RESTORE_BUTTON_TITLE', 'REMOUNT_BUTTON_TITLE', 'FORGET_BUTTON_TITLE', 'MORE_BUTTON_TITLE']) {
62
+ const matched = CLIENT_SRC.match(new RegExp('const ' + constant + " = '([^']+)'"))
63
+ assert.ok(matched, constant + ' 未定义')
64
+ assert.ok(matched[1].length >= 20, constant + ' 文案须讲清用途')
65
+ }
66
+ })
@@ -0,0 +1,49 @@
1
+ // 开关形态守卫:布尔偏好一律 switch(track+thumb),原生 checkbox 视觉隐藏保语义与
2
+ // 可访问性,状态选择器以 input[type="checkbox"] 锚定,防止组合进含其他 input 的 label 时误伤。
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
10
+
11
+ test('开关结构:label 包原生 checkbox + track + thumb', () => {
12
+ assert.match(CLIENT_SOURCE, /className: 'sm-switch'/, '开关 label 存在')
13
+ assert.match(CLIENT_SOURCE, /type: 'checkbox'/, '原生 checkbox 保语义')
14
+ assert.match(CLIENT_SOURCE, /className: 'sm-switch__track'/, 'track 存在')
15
+ assert.match(CLIENT_SOURCE, /className: 'sm-switch__thumb'/, 'thumb 存在')
16
+ })
17
+
18
+ test('原生 input 被视觉隐藏,不以裸 checkbox 呈现', () => {
19
+ assert.match(CLIENT_SOURCE, /\.sm-switch input\[type="checkbox"\] \{ position:absolute; opacity:0;/, 'input 视觉隐藏')
20
+ })
21
+
22
+ test('开关状态选择器以 input[type="checkbox"] 锚定', () => {
23
+ for (const state of [':checked', ':focus-visible', ':disabled']) {
24
+ const pattern = new RegExp('\\.sm-switch[^{]*\\' + state + '[^{]*\\{', 'g')
25
+ const matches = [...CLIENT_SOURCE.matchAll(pattern)].map((match) => match[0])
26
+ assert.ok(matches.length > 0, state + ' 选择器存在')
27
+ for (const selector of matches) {
28
+ assert.match(selector, /input\[type="checkbox"\]/, state + ' 必须以 input[type="checkbox"] 锚定: ' + selector)
29
+ }
30
+ }
31
+ })
32
+
33
+ test('开关样式的 :hover 锚定 label 而非隐藏的 input', () => {
34
+ assert.match(CLIENT_SOURCE, /\.sm-switch:hover \.sm-switch__track/, 'hover 锚定 label')
35
+ })
36
+
37
+ test('开关带悬停说明与无障碍标签', () => {
38
+ assert.match(CLIENT_SOURCE, /className: 'sm-switch', title: LIST_SLIM_TITLE/, 'label 带 title 悬停说明')
39
+ assert.match(CLIENT_SOURCE, /'aria-label': '会话列表瘦身'/, 'input 带 aria-label')
40
+ const matched = CLIENT_SOURCE.match(/const LIST_SLIM_TITLE = '([^']+)'/)
41
+ assert.ok(matched, '说明文案常量存在')
42
+ assert.ok(matched[1].length >= 40, '说明文案须讲清用途,不少于 40 字')
43
+ })
44
+
45
+ test('样式类名带本包前缀 sm-', () => {
46
+ for (const selector of ['.sm-switch {', '.sm-switch__track', '.sm-switch__thumb']) {
47
+ assert.ok(CLIENT_SOURCE.includes(selector), selector + ' 存在')
48
+ }
49
+ })
@@ -39,17 +39,26 @@ const GHOST_DIR = path.join(tmpdir(), 'sm-ghost-fixture', 's1')
39
39
  const GHOST_PATH = path.join(GHOST_DIR, 'session.jsonl.zstd')
40
40
 
41
41
  function makeRegistry(initialIds) {
42
- // 真实注册表契约:archivedSessionIds 是进程内快照的 getter,官方读路径全部经快照;
43
- // archiveSession 归档即入集合(官方幂等判定依据),桩保持同语义
42
+ // 真实注册表契约(0.2.1-rc.2+):archivedSessionIds 是进程内快照的 getter;
43
+ // archiveSession 归档即入集合,unarchiveSession 串行移除(未归档 id 幂等不写),
44
+ // 两个官方入口都写穿 state——快照与持久集由注册表自身保证一致
44
45
  const registry = {
45
46
  state: { initialized: true, workspaceIds: [], archivedSessionIds: initialIds },
46
47
  list: () => [],
47
48
  archiveCalls: [],
49
+ unarchiveCalls: [],
48
50
  archiveSession: async (id) => {
49
51
  const sid = String(id)
50
52
  registry.archiveCalls.push(sid)
51
53
  registry.state.archivedSessionIds = [...registry.state.archivedSessionIds, sid]
52
54
  },
55
+ unarchiveSession: async (id) => {
56
+ const sid = String(id)
57
+ const next = registry.state.archivedSessionIds.filter((existing) => String(existing) !== sid)
58
+ if (next.length === registry.state.archivedSessionIds.length) return
59
+ registry.unarchiveCalls.push(sid)
60
+ registry.state.archivedSessionIds = next
61
+ },
53
62
  }
54
63
  Object.defineProperty(registry, 'archivedSessionIds', {
55
64
  get: () => registry.state.archivedSessionIds,
@@ -399,17 +408,17 @@ test('多工作区 detach:首个失败不终止后续,全部失败进日志', sk
399
408
  }
400
409
  })
401
410
 
402
- test('取消归档:域领先快照时反向合并清理', skipMissingDeps, async () => {
403
- const domain = makeDomain(['s1'])
404
- const { handlers, registry } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map(), domain })
411
+ test('取消归档路由:走官方 unarchiveSession 写穿注册表', skipMissingDeps, async () => {
412
+ const domain = makeDomain(['s1', 's2'])
413
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1', 's2'], headers: [HEADER], agents: new Map(), domain })
405
414
  const res = response()
406
415
  await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
407
416
  assert.equal(res.status, 200)
408
417
  assert.deepEqual(res.body, { ok: true })
409
- // durable 有而快照无:合并后仍完成移除,防漏清理
410
- assert.deepEqual(domain.state.archivedSessionIds, [])
411
- assert.equal(domain.writes, 1)
412
- assert.deepEqual(registry.state.archivedSessionIds, [])
418
+ // 官方 API 写穿:注册表快照移除,无私有直写路径
419
+ assert.deepEqual(registry.unarchiveCalls, ['s1'])
420
+ assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
421
+ assert.deepEqual(registry.archivedSessionIds, ['s2'])
413
422
  })
414
423
 
415
424
  test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissingDeps, async () => {
@@ -435,52 +444,16 @@ test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissin
435
444
  }
436
445
  })
437
446
 
438
- test('取消归档路由:从域集合移除目标 id 并写回', skipMissingDeps, async () => {
439
- const domain = makeDomain(['s1', 's2'])
440
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
441
- const res = response()
442
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
443
- assert.equal(res.status, 200)
444
- assert.deepEqual(res.body, { ok: true })
445
- assert.deepEqual(domain.state.archivedSessionIds, ['s2'])
446
- assert.equal(domain.writes, 1)
447
- // 复活守卫:注册表进程内快照必须同步移除,否则官方全量写回与 list 基线会恢复该 id
448
- assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
449
- assert.deepEqual(registry.archivedSessionIds, ['s2'])
450
- })
451
-
452
- test('取消归档路由:workspace 域未打开时拒绝', skipMissingDeps, async () => {
453
- const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain: null })
454
- const res = response()
455
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
456
- assert.equal(res.status, 400)
457
- assert.equal(res.body.error, 'workspace 域未打开')
458
- })
459
-
460
- test('取消归档路由:未归档 id 幂等跳过,不产生写回', skipMissingDeps, async () => {
461
- const domain = makeDomain(['s1'])
462
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
447
+ test('取消归档路由:未归档 id 幂等,注册表零写', skipMissingDeps, async () => {
448
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
463
449
  const res = response()
464
450
  await handlers.get('/api/session-manager/unarchive')(request('s9'), res)
465
451
  assert.equal(res.status, 200)
466
452
  assert.deepEqual(res.body, { ok: true })
467
- assert.deepEqual(domain.state.archivedSessionIds, ['s1'])
468
- assert.equal(domain.writes, 0)
453
+ assert.deepEqual(registry.unarchiveCalls, [])
469
454
  assert.deepEqual(registry.state.archivedSessionIds, ['s1'])
470
455
  })
471
456
 
472
- test('取消归档路由:快照领先域时补全清理(重试路径)', skipMissingDeps, async () => {
473
- const domain = makeDomain([])
474
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
475
- const res = response()
476
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
477
- assert.equal(res.status, 200)
478
- assert.deepEqual(res.body, { ok: true })
479
- assert.deepEqual(domain.state.archivedSessionIds, [])
480
- assert.equal(domain.writes, 1)
481
- assert.deepEqual(registry.state.archivedSessionIds, [])
482
- })
483
-
484
457
  test('取消归档宽限:恢复后 graceMs 内同进程评估暂停,不秒归档', skipMissingDeps, async () => {
485
458
  const dir = await mkdtemp(path.join(tmpdir(), 'sm-grace-'))
486
459
  try {
@@ -1077,7 +1050,7 @@ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMi
1077
1050
  try {
1078
1051
  const domain = makeDomain(['s1'])
1079
1052
  const workspace = makeWorkspace('C:\\x', ['s1'])
1080
- const { handlers, ledger } = makeCtx({
1053
+ const { handlers, ledger, registry } = makeCtx({
1081
1054
  archivedIds: ['s1'],
1082
1055
  headers: [HEADER],
1083
1056
  agents: IDLE_S1,
@@ -1097,7 +1070,8 @@ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMi
1097
1070
  assert.equal(ledger.state.deleted.length, 1)
1098
1071
  assert.equal(ledger.state.deleted[0].sessionId, 's1')
1099
1072
  assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
1100
- assert.deepEqual(domain.state.archivedSessionIds, [])
1073
+ // 取消归档走官方 unarchiveSession:注册表快照为事实源,域不再被插件直写
1074
+ assert.deepEqual(registry.state.archivedSessionIds, [])
1101
1075
  assert.deepEqual(workspace.sessionIds, [])
1102
1076
  } finally {
1103
1077
  await artifact.cleanup()
@@ -1251,17 +1225,18 @@ test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, as
1251
1225
  assert.equal(res.body.error, MESSAGES.running)
1252
1226
  })
1253
1227
 
1254
- test('删除:产物已缺失且清理半失败时聚合失败点', skipMissingDeps, async () => {
1228
+ test('删除:产物已缺失且归档清理半失败时聚合失败点', skipMissingDeps, async () => {
1255
1229
  const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
1256
- const { handlers, logger } = makeCtx({
1257
- archivedIds: [],
1230
+ const { handlers, logger, registry } = makeCtx({
1231
+ archivedIds: ['s1'],
1258
1232
  headers: [HEADER],
1259
1233
  agents: IDLE_S1,
1260
- domain: null,
1261
1234
  sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1262
1235
  ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
1263
1236
  workspaces: [workspace],
1264
1237
  })
1238
+ // 官方取消归档半失败:注入 unarchiveSession 抛错
1239
+ registry.unarchiveSession = async () => { throw new Error('unarchive boom') }
1265
1240
  const res = response()
1266
1241
  await withTrashStub(async () => {}, async () => {
1267
1242
  await handlers.get('/api/session-manager/delete')(request('s1'), res)
@@ -1323,16 +1298,16 @@ test('归档评估:超期运行中会话零产物 IO(早退,core 过滤为第二
1323
1298
  assert.deepEqual(registry.archiveCalls, [])
1324
1299
  })
1325
1300
 
1326
- test('删除:workspace 域未打开时归档清理失败,响应 partial 且台账已记录', skipMissingDeps, async () => {
1301
+ test('删除:取消归档半失败时响应 partial 且台账已记录', skipMissingDeps, async () => {
1327
1302
  const artifact = await makeLocatedArtifact()
1328
1303
  try {
1329
- const { handlers, ledger } = makeCtx({
1304
+ const { handlers, ledger, registry } = makeCtx({
1330
1305
  archivedIds: ['s1'],
1331
1306
  headers: [HEADER],
1332
1307
  agents: IDLE_S1,
1333
- domain: null,
1334
1308
  sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1335
1309
  })
1310
+ registry.unarchiveSession = async () => { throw new Error('unarchive boom') }
1336
1311
  const res = response()
1337
1312
  await withTrashStub(async () => {}, async () => {
1338
1313
  await handlers.get('/api/session-manager/delete')(request('s1'), res)
@@ -1383,12 +1358,10 @@ test('删除:detach 与台账同时失败时,partial 消息聚合两个失败点
1383
1358
  test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
1384
1359
  const artifact = await makeLocatedArtifact()
1385
1360
  try {
1386
- const domain = makeDomain(['s1'])
1387
- const { handlers } = makeCtx({
1361
+ const { handlers, registry } = makeCtx({
1388
1362
  archivedIds: ['s1'],
1389
1363
  headers: [HEADER],
1390
1364
  agents: IDLE_S1,
1391
- domain,
1392
1365
  sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1393
1366
  openRejected: new Error('ledger down'),
1394
1367
  })
@@ -1399,8 +1372,8 @@ test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDep
1399
1372
  assert.equal(res.status, 200)
1400
1373
  assert.equal(res.body.partial, true)
1401
1374
  assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
1402
- // 台账失败不阻断删除主链路:归档清理照常完成
1403
- assert.deepEqual(domain.state.archivedSessionIds, [])
1375
+ // 台账失败不阻断删除主链路:归档清理经官方 API 照常完成
1376
+ assert.deepEqual(registry.state.archivedSessionIds, [])
1404
1377
  } finally {
1405
1378
  await artifact.cleanup()
1406
1379
  }
@@ -1915,3 +1888,82 @@ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissi
1915
1888
  assert.equal(configureCalls.length, 1)
1916
1889
  assert.deepEqual(configureCalls[0].presentation, { auto: false })
1917
1890
  })
1891
+
1892
+ // ── 写路由同源 + JSON 守卫回归 ──
1893
+
1894
+ // POST 夹具:带 headers 的请求体走 readBody 的 data/end 事件流
1895
+ function guardedRequest(routePath, headers, body = { sessionId: 's1' }) {
1896
+ const req = new EventEmitter()
1897
+ req.method = 'POST'
1898
+ req.url = routePath
1899
+ req.headers = headers
1900
+ process.nextTick(() => {
1901
+ req.emit('data', Buffer.from(JSON.stringify(body)))
1902
+ req.emit('end')
1903
+ })
1904
+ return req
1905
+ }
1906
+
1907
+ test('写路由守卫:跨源 POST 403 / 非 JSON 内容类型 415 / 同源 JSON 正常受理', skipMissingDeps, async () => {
1908
+ // Given 全部写路由(浏览器表单可发 text/plain 且载荷为合法 JSON,仅方法守卫不足)
1909
+ const POST_ROUTES = [
1910
+ '/api/session-manager/unarchive',
1911
+ '/api/session-manager/info',
1912
+ '/api/session-manager/delete',
1913
+ '/api/session-manager/auto-archive',
1914
+ '/api/session-manager/remount',
1915
+ '/api/session-manager/forget',
1916
+ ]
1917
+ const ORIGIN = 'http://127.0.0.1:9291'
1918
+ const sameOrigin = {
1919
+ origin: ORIGIN,
1920
+ host: '127.0.0.1:9291',
1921
+ 'content-type': 'application/json; charset=utf-8',
1922
+ }
1923
+
1924
+ for (const routePath of POST_ROUTES) {
1925
+ const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
1926
+
1927
+ // When Origin 与 Host 不同 Then 403,且响应为统一拒绝文案
1928
+ const crossOrigin = response()
1929
+ await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, origin: 'http://evil.example' }), crossOrigin)
1930
+ assert.equal(crossOrigin.status, 403, `${routePath} 跨源 POST 必须 403`)
1931
+
1932
+ // When 同源但 content-type: text/plain Then 415(drive-by 表单形态)
1933
+ const textPlain = response()
1934
+ await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, 'content-type': 'text/plain' }), textPlain)
1935
+ assert.equal(textPlain.status, 415, `${routePath} text/plain POST 必须 415`)
1936
+
1937
+ // When 无 Origin(本地 CLI)但带 text/plain Then 仍 415(内容类型守卫独立生效)
1938
+ const cliTextPlain = response()
1939
+ await handlers.get(routePath)(guardedRequest(routePath, { host: '127.0.0.1:9291', 'content-type': 'text/plain' }), cliTextPlain)
1940
+ assert.equal(cliTextPlain.status, 415, `${routePath} 无 Origin 的 text/plain 仍必须 415`)
1941
+ }
1942
+
1943
+ // When 同源 application/json Then 正常受理(unarchive 可幂等成功)
1944
+ const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
1945
+ const accepted = response()
1946
+ await handlers.get('/api/session-manager/unarchive')(guardedRequest('/api/session-manager/unarchive', sameOrigin), accepted)
1947
+ assert.equal(accepted.status, 200)
1948
+ assert.deepEqual(accepted.body, { ok: true })
1949
+ })
1950
+
1951
+ test('列表瘦身开关:GET 回显默认开启,POST 持久,GET 反映新值', skipMissingDeps, async () => {
1952
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1953
+ const initial = response()
1954
+ await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), initial)
1955
+ assert.deepEqual(initial.body, { enabled: true }, '未配置时默认开启')
1956
+ const off = await postJson(handlers, '/api/session-manager/list-slim', { enabled: false })
1957
+ assert.deepEqual(off.body, { enabled: false })
1958
+ const reread = response()
1959
+ await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), reread)
1960
+ assert.deepEqual(reread.body, { enabled: false }, 'settings 持久化开关')
1961
+ })
1962
+
1963
+ test('列表瘦身开关:非布尔载荷一律 400', skipMissingDeps, async () => {
1964
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1965
+ for (const payload of [{ enabled: 'yes' }, { enabled: 1 }, {}, { enabled: null }]) {
1966
+ const rejected = await postJson(handlers, '/api/session-manager/list-slim', payload)
1967
+ assert.equal(rejected.status, 400, JSON.stringify(payload))
1968
+ }
1969
+ })
@@ -0,0 +1,174 @@
1
+ // 列表瘦身的契约:只裁白名单、命中缓存不重建、增删事件失效、任何异常回落到原行为。
2
+ import { test } from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+ import { installListSlim, slimItem } from '../src/list-slim.mjs'
5
+
6
+ /** 一条带完整投影的列表项。 */
7
+ const item = (id, values) => ({ sessionId: id, updatedAt: 1, projections: { kind: 'cached', asOfSeq: 1, values } })
8
+
9
+ /** 造一个只实现本模块用到表面的上下文(inject 立即回调,等价于服务已就绪)。 */
10
+ function makeContext(list, { withController = true } = {}) {
11
+ const handlers = new Map()
12
+ const disposers = []
13
+ const controller = withController ? { list } : undefined
14
+ const ctx = {
15
+ get: (name) => (name === 'sessionController' ? controller : undefined),
16
+ on: (event, handler) => handlers.set(event, handler),
17
+ logger: { info() {}, warn() {} },
18
+ inject: (deps, callback) => {
19
+ callback({
20
+ sessionController: controller,
21
+ on: (event, handler) => handlers.set(event, handler),
22
+ effect: (factory) => {
23
+ disposers.push(factory())
24
+ },
25
+ })
26
+ },
27
+ }
28
+ return { ctx, controller, handlers, disposers }
29
+ }
30
+
31
+ const sample = () => [
32
+ item('a', { title: 'A', sessionListMetadata: { blank: false }, contextHeaders: [{ pad: 'x'.repeat(64) }] }),
33
+ item('b', { title: 'B', inbox: { 'next-turn': [], 'next-step': [] } }),
34
+ ]
35
+
36
+ test('裁剪只保留白名单里的投影', () => {
37
+ const slim = slimItem(sample()[0], ['title'])
38
+ assert.deepEqual(Object.keys(slim.projections.values), ['title'])
39
+ assert.equal(slim.projections.kind, 'cached')
40
+ assert.equal(slim.sessionId, 'a')
41
+ })
42
+
43
+ test('形状不符的条目原样返回', () => {
44
+ const bare = { sessionId: 'c' }
45
+ assert.equal(slimItem(bare, ['title']), bare)
46
+ assert.equal(slimItem({ projections: { values: null } }, ['title']).projections.values, null)
47
+ const array = { projections: { values: ['x'] } }
48
+ assert.equal(slimItem(array, ['title']), array)
49
+ })
50
+
51
+ test('开关关闭时不接管 controller.list', async () => {
52
+ const original = async () => ({ items: sample() })
53
+ const { ctx, controller } = makeContext(original)
54
+ assert.equal(installListSlim(ctx, { enabled: false }), false)
55
+ assert.equal(controller.list, original)
56
+ })
57
+
58
+ test('宿主没有 sessionController 时不接管任何方法', () => {
59
+ const { ctx } = makeContext(undefined, { withController: false })
60
+ installListSlim(ctx)
61
+ assert.equal(ctx.get('sessionController'), undefined)
62
+ })
63
+
64
+ test('controller 缺 list 方法时静默放弃', () => {
65
+ const { ctx, controller } = makeContext(undefined)
66
+ installListSlim(ctx)
67
+ assert.equal(controller.list, undefined)
68
+ })
69
+
70
+ test('第二次调用命中缓存,不重建', async () => {
71
+ let built = 0
72
+ const { ctx, controller } = makeContext(async () => {
73
+ built += 1
74
+ return { items: sample() }
75
+ })
76
+ installListSlim(ctx, { warmupDelayMs: -1 })
77
+ await controller.list({}, undefined)
78
+ const value = await controller.list({}, undefined)
79
+ assert.equal(built, 1)
80
+ assert.equal(value.items[0].projections.values.contextHeaders, undefined)
81
+ })
82
+
83
+ test('过期后重建', async () => {
84
+ let built = 0
85
+ const { ctx, controller } = makeContext(async () => {
86
+ built += 1
87
+ return { items: sample() }
88
+ })
89
+ installListSlim(ctx, { ttlMs: 0, warmupDelayMs: -1 })
90
+ await controller.list({}, undefined)
91
+ await controller.list({}, undefined)
92
+ assert.equal(built, 2)
93
+ })
94
+
95
+ test('会话增删让缓存失效', async () => {
96
+ let built = 0
97
+ const { ctx, controller, handlers } = makeContext(async () => {
98
+ built += 1
99
+ return { items: sample() }
100
+ })
101
+ installListSlim(ctx, { warmupDelayMs: -1 })
102
+ await controller.list({}, undefined)
103
+ handlers.get('api-session/added')()
104
+ await controller.list({}, undefined)
105
+ assert.equal(built, 2)
106
+ handlers.get('api-session/removed')()
107
+ await controller.list({}, undefined)
108
+ assert.equal(built, 3)
109
+ })
110
+
111
+ test('并发请求只触发一次重建', async () => {
112
+ let built = 0
113
+ const { ctx, controller } = makeContext(async () => {
114
+ built += 1
115
+ return { items: sample() }
116
+ })
117
+ installListSlim(ctx, { warmupDelayMs: -1 })
118
+ await Promise.all([controller.list({}, undefined), controller.list({}, undefined), controller.list({}, undefined)])
119
+ assert.equal(built, 1)
120
+ })
121
+
122
+ test('原方法抛错时错误照旧透传给调用方', async () => {
123
+ const { ctx, controller } = makeContext(async () => {
124
+ throw new Error('宿主故障')
125
+ })
126
+ installListSlim(ctx, { warmupDelayMs: -1 })
127
+ await assert.rejects(() => controller.list({}, undefined), /宿主故障/)
128
+ })
129
+
130
+ test('响应形状不符时原样透传,不缓存', async () => {
131
+ let built = 0
132
+ const { ctx, controller } = makeContext(async () => {
133
+ built += 1
134
+ return { sessions: sample() }
135
+ })
136
+ installListSlim(ctx, { warmupDelayMs: -1 })
137
+ const first = await controller.list({}, undefined)
138
+ const second = await controller.list({}, undefined)
139
+ assert.deepEqual(Object.keys(first), ['sessions'])
140
+ assert.equal(built, 2)
141
+ assert.notEqual(second, first)
142
+ })
143
+
144
+ test('把调用方 request 原样交给原方法', async () => {
145
+ const seen = []
146
+ const { ctx, controller } = makeContext(async (request) => {
147
+ seen.push(request)
148
+ return { items: sample() }
149
+ })
150
+ installListSlim(ctx, { warmupDelayMs: -1 })
151
+ const request = { cursor: 'abc' }
152
+ await controller.list(request, undefined)
153
+ assert.equal(seen[0], request)
154
+ })
155
+
156
+ test('预热在延迟后构建一次', async () => {
157
+ let built = 0
158
+ const { ctx } = makeContext(async () => {
159
+ built += 1
160
+ return { items: sample() }
161
+ })
162
+ installListSlim(ctx, { warmupDelayMs: 0 })
163
+ await new Promise((resolve) => setTimeout(resolve, 20))
164
+ assert.equal(built, 1)
165
+ })
166
+
167
+ test('卸载时恢复原方法', async () => {
168
+ const original = async () => ({ items: sample() })
169
+ const { ctx, controller, disposers } = makeContext(original)
170
+ installListSlim(ctx, { warmupDelayMs: -1 })
171
+ assert.notEqual(controller.list, original)
172
+ for (const dispose of disposers) dispose()
173
+ assert.equal(controller.list, original)
174
+ })