@gehennawu/dsh-service 1.1.2 → 1.4.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/index.js CHANGED
@@ -15,6 +15,8 @@ import { promisify } from 'node:util'
15
15
  import { ServerResponse as NodeServerResponse } from 'node:http'
16
16
  import z from '@deepseek-ai/schemastery'
17
17
  import { createBackupIntegrity } from './backup-integrity.js'
18
+ import { collectPluginCompat, pluginCompatCheckItem } from './plugin-compat.js'
19
+ import { collectPluginHealth, pluginCheckItem, restartPluginEntry } from './plugin-health.js'
18
20
  import {
19
21
  buildCliproxyAccountPlan as buildCliproxyAccountPlanAdapter,
20
22
  cliproxyFetchGuard as cliproxyFetchGuardAdapter,
@@ -131,6 +133,16 @@ const SESSIONS_BYTES_MAX_IDS = 200
131
133
  const SESSIONS_BYTES_MAX_ENTRIES = 1000
132
134
  // 详情快照缓存(v0.36 查看渲染优化):live 会话 30s 后重读保新鲜,冷会话长期复用至槽位失效/删除。
133
135
  const SESSIONS_VIEW_LIVE_TTL_MS = 30 * 1000
136
+ // 会话标题缓存(v1.1.3 列表加载优化):0.1.2-alpha.3 的 readTitleSnapshots 经 SessionCorpus.
137
+ // projectMany → persistence.inspect 对每个冷会话全量解析整份 JSONL 日志且零官方缓存——会话一多,
138
+ // 每次 sessions-list 都是整库日志重读,远贵于体积 stat。标题按 revision(stat 指纹,跨重启稳定)
139
+ // 键控缓存:冷会话文件不变标题不变,只对新增/revision 变更/live 超时条目重读;缓存持久化到
140
+ // DSH_HOME(仅标题+revision,不含内容),宿主重启后首个列表加载同样命中。
141
+ const SESSIONS_TITLE_FILE = 'dsh-service-session-titles.json'
142
+ const SESSIONS_TITLE_VERSION = 1
143
+ const SESSIONS_TITLE_MAX_ENTRIES = 2000
144
+ const SESSIONS_TITLE_LIVE_TTL_MS = 30 * 1000
145
+ const SESSIONS_TITLE_COLD_TTL_MS = 5 * 60 * 1000
134
146
  // 详情/检索里视为「机制性噪声」的事件类型:折叠展示计数,用户可展开。
135
147
  const SESSION_NOISE_TYPES = new Set([
136
148
  'turn/start', 'step/start', 'step/end', 'assistant/chunk', 'request/header',
@@ -1733,12 +1745,37 @@ function pruneSessionErrors(session, cutoff) {
1733
1745
  }
1734
1746
  }
1735
1747
 
1736
- function foldUsageEvents(ctx, record, previous, events) {
1748
+ function validSessionOffset(value) {
1749
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0) ? value : undefined
1750
+ }
1751
+
1752
+ // alpha.4 moves the fork cut out of SessionHeader and into body-bearing reads;
1753
+ // older DSH releases expose it as header/meta.seedLength. Keep this normalization
1754
+ // at the persistence seam so the usage fold never has to know which runtime spoke.
1755
+ function inheritedEventCountFor(record, read) {
1756
+ const fromRead = validSessionOffset(read?.inheritedEventCount)
1757
+ if (fromRead !== undefined) return fromRead
1758
+ const fromMeta = validSessionOffset(read?.meta?.seedLength)
1759
+ if (fromMeta !== undefined) return fromMeta
1760
+ const fromHeader = validSessionOffset(record?.header?.seedLength)
1761
+ return fromHeader ?? 0
1762
+ }
1763
+
1764
+ function usageReadStart(previous) {
1765
+ return previous === undefined ? 0 : Math.max(0, previous.lastSeq + 1)
1766
+ }
1767
+
1768
+ function usageReadEvents(read) {
1769
+ return Array.isArray(read?.events) ? read.events : []
1770
+ }
1771
+
1772
+ function foldUsageEvents(ctx, record, previous, events, inheritedEventCount = 0) {
1737
1773
  const project = projectForCwd(ctx, record.header.cwd)
1738
- const session = previous || { revision: '', lastSeq: Math.max(0, record.header.seedLength || 0) - 1, project, currentModel: null, hours: {} }
1774
+ const cut = validSessionOffset(inheritedEventCount) ?? 0
1775
+ const session = previous || { revision: '', lastSeq: cut - 1, project, currentModel: null, hours: {} }
1739
1776
  session.project = project
1740
1777
  for (const event of events) {
1741
- if (event.seq < (record.header.seedLength || 0)) continue
1778
+ if (event.seq < cut) continue
1742
1779
  session.lastSeq = Math.max(session.lastSeq, event.seq)
1743
1780
  if (event.type === 'request/header') {
1744
1781
  const provider = event.data?.header?.config?.provider
@@ -1836,9 +1873,10 @@ async function refreshUsageIndex(ctx, dshHome, index) {
1836
1873
  const revision = revisionKey(record.revision)
1837
1874
  const previous = index.sessions[id]
1838
1875
  if (previous?.revision === revision) continue
1839
- const fromSeq = previous === undefined ? Math.max(0, record.header.seedLength || 0) : previous.lastSeq + 1
1876
+ const fromSeq = usageReadStart(previous)
1840
1877
  const read = await persistence.readFrom(record.header.id, fromSeq)
1841
- const next = foldUsageEvents(ctx, record, previous, read.events)
1878
+ const inheritedEventCount = inheritedEventCountFor(record, read)
1879
+ const next = foldUsageEvents(ctx, record, previous, usageReadEvents(read), inheritedEventCount)
1842
1880
  next.revision = revision
1843
1881
  index.sessions[id] = next
1844
1882
  }
@@ -2253,11 +2291,46 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2253
2291
  const nodeMajor = parseInt(process.versions.node, 10)
2254
2292
  add('node-version', Number.isFinite(nodeMajor) && nodeMajor >= requiredNodeMajor ? 'ok' : 'warning', `${process.version}:${requiredNodeMajor}`)
2255
2293
 
2294
+ // v1.3 插件健康检查:只检查异常——loader 缺席降级为 info 检查项、不带异常行数组;
2295
+ // 停用条目(无论内置/自定义)一律不算异常,正常插件不下发(官方设置页已有完整清单与开关)。
2296
+ let pluginReport
2297
+ try {
2298
+ pluginReport = await collectPluginHealth(ctx)
2299
+ } catch (error) {
2300
+ pluginReport = { available: false, total: 0, issues: [] }
2301
+ }
2302
+ if (!pluginReport.available) add('plugins', 'info', 'unavailable')
2303
+ else {
2304
+ const pluginCheck = pluginCheckItem(pluginReport)
2305
+ if (pluginCheck.status === 'info') pluginCheck.advisory = true
2306
+ checks.push(pluginCheck)
2307
+ }
2308
+
2309
+ // v1.3 插件兼容性:对照已核实的 alpha 破坏面清单(client-runtime 供应商移除、SQLite
2310
+ // persistence 移除、聊天/统计条 CSS 哈希漂移、data-time-hover-root 删除)扫描启用插件的
2311
+ // 清单与入口代码;loader 缺席降级为 info 检查项、不带扫描结果。
2312
+ let compatReport
2313
+ try {
2314
+ compatReport = await collectPluginCompat(ctx)
2315
+ } catch (error) {
2316
+ compatReport = { available: false, scanned: 0, issues: [], declaredOnly: [], unknown: [] }
2317
+ }
2318
+ if (!compatReport.available) add('plugin-compat', 'info', 'unavailable')
2319
+ else checks.push(pluginCompatCheckItem(compatReport))
2320
+
2256
2321
  let status = 'ok'
2257
2322
  // advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
2258
2323
  if (checks.some((check) => check.status === 'error')) status = 'error'
2259
2324
  else if (checks.some((check) => check.status === 'warning' && check.advisory !== true)) status = 'warning'
2260
- return { status, checkedAt: Date.now(), checks }
2325
+ return {
2326
+ status,
2327
+ checkedAt: Date.now(),
2328
+ checks,
2329
+ ...(pluginReport.available ? { pluginIssues: pluginReport.issues } : {}),
2330
+ ...(compatReport.available
2331
+ ? { pluginCompat: { scanned: compatReport.scanned, issues: compatReport.issues, declaredOnly: compatReport.declaredOnly, unknown: compatReport.unknown } }
2332
+ : {}),
2333
+ }
2261
2334
  }
2262
2335
 
2263
2336
  async function collectHealth(ctx) {
@@ -3144,7 +3217,15 @@ function buildSubagentDispatchRecord(agent, parent, dispatch, options = {}) {
3144
3217
 
3145
3218
  /** 父会话事件尾向上扫最近一条带数字 `data.turn` 的事件(≤scanLimit 条);无则 undefined。 */
3146
3219
  function lastSubagentTurn(parent, scanLimit = 50) {
3147
- const events = parent?.session?.events
3220
+ let events
3221
+ try {
3222
+ if (typeof parent?.session?.snapshotEvents === 'function') events = parent.session.snapshotEvents()
3223
+ } catch (_) {
3224
+ events = undefined
3225
+ }
3226
+ if (!Array.isArray(events)) {
3227
+ try { events = parent?.session?.events } catch (_) { events = undefined }
3228
+ }
3148
3229
  if (!Array.isArray(events)) return undefined
3149
3230
  const limit = Number.isFinite(scanLimit) && scanLimit > 0 ? scanLimit : 50
3150
3231
  const from = Math.max(0, events.length - limit)
@@ -3256,6 +3337,46 @@ async function saveDeletedSessions(dshHome, data) {
3256
3337
  }
3257
3338
  }
3258
3339
 
3340
+ // 标题缓存磁盘持久化:{version, items: {id: {title, revision?, live}}}——只存标题与指纹,
3341
+ // 不含任何会话内容;损坏/版本不符整体丢弃(回退全量拉取,宁慢勿错)。at 不落盘:
3342
+ // 加载后按 0 处理,revision 对不上或缺失时自然判定过期重读。
3343
+ function createEmptySessionTitles() {
3344
+ return { version: SESSIONS_TITLE_VERSION, items: {} }
3345
+ }
3346
+
3347
+ async function loadSessionTitles(dshHome) {
3348
+ try {
3349
+ const parsed = JSON.parse(await readFile(join(dshHome, SESSIONS_TITLE_FILE), 'utf8'))
3350
+ if (parsed?.version !== SESSIONS_TITLE_VERSION || typeof parsed.items !== 'object' || parsed.items === null) return new Map()
3351
+ const entries = new Map()
3352
+ for (const [id, entry] of Object.entries(parsed.items)) {
3353
+ if (typeof id !== 'string' || id === '') continue
3354
+ if (typeof entry?.title !== 'string' || typeof entry.revision !== 'string') continue
3355
+ entries.set(id, { title: entry.title, revision: entry.revision, live: entry.live === true, at: 0 })
3356
+ }
3357
+ return entries
3358
+ } catch (_) {
3359
+ return new Map()
3360
+ }
3361
+ }
3362
+
3363
+ async function saveSessionTitles(dshHome, cache) {
3364
+ const items = {}
3365
+ for (const [id, entry] of cache) {
3366
+ if (typeof entry?.title !== 'string') continue
3367
+ items[id] = { title: entry.title, ...(typeof entry.revision === 'string' ? { revision: entry.revision } : {}), live: entry.live === true }
3368
+ }
3369
+ await mkdir(dshHome, { recursive: true })
3370
+ const target = join(dshHome, SESSIONS_TITLE_FILE)
3371
+ const temporary = `${target}.${randomUUID()}.tmp`
3372
+ try {
3373
+ await writeFile(temporary, JSON.stringify({ version: SESSIONS_TITLE_VERSION, items }), { mode: 0o600 })
3374
+ await rename(temporary, target)
3375
+ } finally {
3376
+ await rm(temporary, { force: true })
3377
+ }
3378
+ }
3379
+
3259
3380
  /** 单个会话的事件文本(与官方 extractSessionEventText 语义一致,不依赖官方内部包)。 */
3260
3381
  function sessionEventText(event) {
3261
3382
  if (typeof event !== 'object' || event === null) return ''
@@ -3320,9 +3441,26 @@ async function sessionDirectoryBytes(ctx, header) {
3320
3441
  }
3321
3442
  }
3322
3443
 
3444
+ // 标题缓存条目是否仍新鲜:有 revision 源(sessionPersistence.listSnapshots 可用)时按指纹
3445
+ // 精确判定——文件没变标题就不变,冷会话永久命中;快照清单里缺席的 id(纯内存 live 会话)
3446
+ // 及 revision 源不可用时按 TTL 兜底。宁重读不展示旧标题。
3447
+ function sessionTitleFresh(entry, record, revisions, now) {
3448
+ if (entry === undefined) return false
3449
+ if (revisions !== null) {
3450
+ const revision = revisions.get(record.header.id)
3451
+ if (revision !== undefined) return entry.revision === revision
3452
+ return now - entry.at < SESSIONS_TITLE_LIVE_TTL_MS
3453
+ }
3454
+ const ttl = record.live === true ? SESSIONS_TITLE_LIVE_TTL_MS : SESSIONS_TITLE_COLD_TTL_MS
3455
+ return now - entry.at < ttl
3456
+ }
3457
+
3323
3458
  // 会话管理列表:live + 冷会话合并,标注归档/已删除,补标题。列表不携带体积——
3324
3459
  // 体积走 sessions-bytes 懒加载(v0.36:全量下发意味着打开/切换就要逐个 readdir+stat)。
3325
- async function listSessionsForManage(ctx, dshHome, scope = 'all') {
3460
+ // v1.1.3:readTitleSnapshots 对每个冷会话全量解析整份日志(官方零缓存),改为 revision
3461
+ // 键控缓存——列表加载只对新增/变更条目重读,重复加载/面板重开零整库扫描;listing 与
3462
+ // 已删除清单并行获取。
3463
+ async function listSessionsForManage(ctx, dshHome, scope = 'all', titleCache = null) {
3326
3464
  const sessionQuery = ctx.get('sessionQuery')
3327
3465
  const workspaceRegistry = ctx.get('workspaceRegistry')
3328
3466
  const available = sessionQuery !== undefined && typeof sessionQuery.listSessions === 'function'
@@ -3332,27 +3470,72 @@ async function listSessionsForManage(ctx, dshHome, scope = 'all') {
3332
3470
  if (scope === 'deleted') {
3333
3471
  return { available: true, items: [], archivedIds: [], deleted: (await loadDeletedSessions(dshHome)).items }
3334
3472
  }
3335
- const records = await sessionQuery.listSessions()
3473
+ const now = Date.now()
3474
+ // revision 源:缓存启用时尽早并行拉取(一次 header-only 目录遍历 + 每会话一次 stat,
3475
+ // 与 listSessions 内部同量级);不可用/失败回落 null → TTL 兜底。
3476
+ const sessionPersistence = titleCache !== null ? ctx.get('sessionPersistence') : undefined
3477
+ const revisionsPromise = sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function'
3478
+ ? sessionPersistence.listSnapshots().then((snapshots) => {
3479
+ const revisions = new Map()
3480
+ for (const snapshot of Array.isArray(snapshots) ? snapshots : []) {
3481
+ if (typeof snapshot?.header?.id === 'string' && typeof snapshot.revision === 'string') revisions.set(snapshot.header.id, snapshot.revision)
3482
+ }
3483
+ return revisions
3484
+ }).catch(() => null)
3485
+ : Promise.resolve(null)
3486
+ const [records, deletedSnapshot] = await Promise.all([
3487
+ sessionQuery.listSessions(),
3488
+ loadDeletedSessions(dshHome),
3489
+ ])
3490
+ const deleted = deletedSnapshot.items
3491
+ const revisions = await revisionsPromise
3336
3492
  const archivedIds = workspaceRegistry !== undefined && Array.isArray(workspaceRegistry.archivedSessionIds)
3337
3493
  ? [...workspaceRegistry.archivedSessionIds]
3338
3494
  : []
3339
3495
  const archivedSet = new Set(archivedIds)
3340
- const deleted = (await loadDeletedSessions(dshHome)).items
3341
3496
  const deletedSet = new Set(deleted.map((item) => item.id))
3342
3497
  // scope=archived 只保留归档条目(含 live 会话被归档的情况);此时仍需标题。
3343
3498
  const recordsInScope = scope === 'archived'
3344
3499
  ? records.filter((record) => archivedSet.has(record.header.id))
3345
3500
  : records
3346
- // 标题批量折叠:只对 scope 内的 id 取(按请求序);缺席时逐个回落为默认。
3347
- const ids = recordsInScope.map((record) => record.header.id)
3501
+ // 标题折叠(缓存优先):只重读新增/revision 变更/live 超时的条目;fulfilled 才入缓存
3502
+ //(rejected 留空且不缓存,下次自动重试)。缓存条目按插入序 LRU 式淘汰封顶。
3503
+ const staleRecords = []
3504
+ if (titleCache !== null) {
3505
+ for (const record of recordsInScope) {
3506
+ if (deletedSet.has(record.header.id)) continue
3507
+ if (!sessionTitleFresh(titleCache.get(record.header.id), record, revisions, now)) staleRecords.push(record)
3508
+ }
3509
+ } else {
3510
+ for (const record of recordsInScope) {
3511
+ if (!deletedSet.has(record.header.id)) staleRecords.push(record)
3512
+ }
3513
+ }
3348
3514
  const titles = new Map()
3349
- if (typeof sessionQuery.readTitleSnapshots === 'function' && ids.length > 0) {
3350
- const observed = await sessionQuery.readTitleSnapshots(ids)
3515
+ if (typeof sessionQuery.readTitleSnapshots === 'function' && staleRecords.length > 0) {
3516
+ const observed = await sessionQuery.readTitleSnapshots(staleRecords.map((record) => record.header.id))
3351
3517
  for (const entry of observed) {
3352
3518
  if (entry?.sessionId !== undefined && entry?.status === 'fulfilled' && entry.value?.title?.title !== undefined) {
3353
3519
  titles.set(entry.sessionId, entry.value.title.title)
3354
3520
  }
3355
3521
  }
3522
+ if (titleCache !== null) {
3523
+ const recordById = new Map(staleRecords.map((record) => [record.header.id, record]))
3524
+ for (const [id, title] of titles) {
3525
+ const record = recordById.get(id)
3526
+ if (record === undefined) continue
3527
+ titleCache.set(id, { title, revision: revisions !== null ? revisions.get(id) : undefined, at: now, live: record.live === true })
3528
+ }
3529
+ while (titleCache.size > SESSIONS_TITLE_MAX_ENTRIES) {
3530
+ const oldest = titleCache.keys().next().value
3531
+ if (oldest === undefined) break
3532
+ titleCache.delete(oldest)
3533
+ }
3534
+ // 落盘随 RPC 收敛(不留在 Fiber 外):仅本次确实重读过标题才写,原子替换 0600。
3535
+ if (titles.size > 0) {
3536
+ try { await saveSessionTitles(dshHome, titleCache) } catch (_) {}
3537
+ }
3538
+ }
3356
3539
  }
3357
3540
  const items = []
3358
3541
  for (const record of recordsInScope) {
@@ -3360,7 +3543,7 @@ async function listSessionsForManage(ctx, dshHome, scope = 'all') {
3360
3543
  if (deletedSet.has(id)) continue
3361
3544
  items.push({
3362
3545
  id,
3363
- title: titles.get(id) ?? '',
3546
+ title: titles.get(id) ?? (titleCache !== null ? titleCache.get(id)?.title : undefined) ?? '',
3364
3547
  cwd: record.header.cwd ?? null,
3365
3548
  createdAt: record.header.createdAt ?? 0,
3366
3549
  live: record.live === true,
@@ -3397,20 +3580,27 @@ async function resolveSessionBytesForIds(ctx, ids, cache) {
3397
3580
  headerById = null
3398
3581
  }
3399
3582
  }
3400
- for (const id of misses) {
3401
- const header = headerById === null ? undefined : headerById.get(id)
3402
- if (header === undefined) {
3403
- bytes[id] = null
3404
- continue
3405
- }
3406
- const value = await sessionDirectoryBytes(ctx, header)
3407
- cache.set(id, { bytes: value, at: Date.now() })
3408
- if (cache.size > SESSIONS_BYTES_MAX_ENTRIES) {
3409
- const oldest = cache.keys().next().value
3410
- if (oldest !== undefined) cache.delete(oldest)
3583
+ // 未命中目录 stat 并发执行(8 路封顶):首个列表加载的 stat 风暴从串行排队压成小批并行。
3584
+ const SESSIONS_BYTES_STAT_CONCURRENCY = 8
3585
+ let statCursor = 0
3586
+ const statWorkers = Array.from({ length: Math.min(SESSIONS_BYTES_STAT_CONCURRENCY, misses.length) }, async () => {
3587
+ while (statCursor < misses.length) {
3588
+ const id = misses[statCursor++]
3589
+ const header = headerById === null ? undefined : headerById.get(id)
3590
+ if (header === undefined) {
3591
+ bytes[id] = null
3592
+ continue
3593
+ }
3594
+ const value = await sessionDirectoryBytes(ctx, header)
3595
+ cache.set(id, { bytes: value, at: Date.now() })
3596
+ if (cache.size > SESSIONS_BYTES_MAX_ENTRIES) {
3597
+ const oldest = cache.keys().next().value
3598
+ if (oldest !== undefined) cache.delete(oldest)
3599
+ }
3600
+ bytes[id] = value
3411
3601
  }
3412
- bytes[id] = value
3413
- }
3602
+ })
3603
+ await Promise.all(statWorkers)
3414
3604
  return bytes
3415
3605
  }
3416
3606
 
@@ -3489,13 +3679,13 @@ async function viewSessionPage(ctx, id, cursor, cacheRef, limit = SESSIONS_VIEW_
3489
3679
  }
3490
3680
 
3491
3681
  // 内容搜索:逐会话 filterEvents 文本谓词(语义、大小写不敏感、空白灵活),带预算约束。
3492
- async function searchSessionsContent(ctx, dshHome, query, scope = 'all') {
3682
+ async function searchSessionsContent(ctx, dshHome, query, scope = 'all', titleCache = null) {
3493
3683
  const sessionQuery = ctx.get('sessionQuery')
3494
3684
  const result = { available: sessionQuery !== undefined && typeof sessionQuery.filterEvents === 'function', query, scope, hits: [] }
3495
3685
  if (!result.available) return result
3496
3686
  const q = typeof query === 'string' ? query.trim() : ''
3497
3687
  if (q === '') return result
3498
- const listed = await listSessionsForManage(ctx, dshHome)
3688
+ const listed = await listSessionsForManage(ctx, dshHome, 'all', titleCache)
3499
3689
  if (!listed.available) return result
3500
3690
  // scope=all 搜全部会话(含 live,readSession/filterEvents 均支持 live 快照);archived 只搜归档冷会话。
3501
3691
  const targets = scope === 'archived'
@@ -3858,6 +4048,12 @@ function apply(ctx) {
3858
4048
  // 会话体积懒加载缓存(v0.36):宿主进程不重启就一直在——浏览器刷新/面板重开直接命中,
3859
4049
  // 不用重新 readdir+stat(归档冷会话体积不变;live 会话靠 TTL 兜底、删除时主动失效)。
3860
4050
  const sessionBytesCache = new Map()
4051
+ // 会话标题缓存(v1.1.3):revision 键控 + 磁盘持久化,宿主启动即异步预加载,
4052
+ // 首个 sessions-list 前等待填充完成(失败静默回落全量拉取)。
4053
+ const sessionTitleCache = new Map()
4054
+ const sessionTitlesReady = loadSessionTitles(dshHome).then((entries) => {
4055
+ for (const [id, entry] of entries) sessionTitleCache.set(id, entry)
4056
+ }).catch(() => {})
3861
4057
  // 会话详情快照缓存(v0.36):单槽位只留最近打开的会话,翻页零重复 readSession。
3862
4058
  const sessionViewCache = { id: null, snapshot: null, at: 0, live: false }
3863
4059
  let usageIndexPromise = loadUsageIndex(dshHome)
@@ -4214,6 +4410,7 @@ function apply(ctx) {
4214
4410
  ctx.effect(() => () => { backupIntegrity.dispose().catch(() => {}) }, 'dsh-service backup restore plans')
4215
4411
  ctx.effect(() => () => sessionDeletePlans.clear(), 'dsh-service session delete plans')
4216
4412
  ctx.effect(() => () => sessionBytesCache.clear(), 'dsh-service session bytes cache')
4413
+ ctx.effect(() => () => sessionTitleCache.clear(), 'dsh-service session title cache')
4217
4414
  ctx.effect(() => { sessionViewCache.id = null; sessionViewCache.snapshot = null }, 'dsh-service session view cache')
4218
4415
  ctx.effect(() => () => {
4219
4416
  quotaDisposed = true
@@ -4367,6 +4564,19 @@ function apply(ctx) {
4367
4564
  'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
4368
4565
  return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
4369
4566
 
4567
+ } },
4568
+ 'plugin-restart': { feature: 'healthDiagnostics', audit: true, handle: async (payload, rpcEndpoint) => {
4569
+ try {
4570
+ const result = await restartPluginEntry(ctx, payload?.entryId)
4571
+ if (result.ok) return { ok: true, value: {} }
4572
+ // 稳定业务码(unknown-plugin / loader-unavailable / plugin-disabled / not-failed /
4573
+ // restart-failed)走严格 RPC 归一(strictRpcResult),客户端词典映射;restart-failed
4574
+ // 的底层错误原文并入 message(码: 详情 后缀语义)。
4575
+ return rpcFailure(new Error(result.code === 'restart-failed' ? `restart-failed: ${result.error}` : result.code))
4576
+ } catch (error) {
4577
+ return rpcTechnicalFailure(error)
4578
+ }
4579
+
4370
4580
  } },
4371
4581
  'usage': { feature: 'modelUsage', handle: async (payload, rpcEndpoint) => {
4372
4582
  return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
@@ -5071,7 +5281,8 @@ function apply(ctx) {
5071
5281
  'sessions-list': { feature: 'sessionManager', handle: async (payload, rpcEndpoint) => {
5072
5282
  const scope = payload?.scope === 'archived' || payload?.scope === 'deleted' ? payload.scope : 'all'
5073
5283
  try {
5074
- const value = await listSessionsForManage(ctx, dshHome, scope)
5284
+ await sessionTitlesReady
5285
+ const value = await listSessionsForManage(ctx, dshHome, scope, sessionTitleCache)
5075
5286
  // 已删除记录独立下发(供「已删除」筛选):字段只为展示,绝不包含内容。
5076
5287
  return { ok: true, value }
5077
5288
  } catch (error) {
@@ -5115,7 +5326,8 @@ function apply(ctx) {
5115
5326
  const query = typeof payload?.query === 'string' ? payload.query : ''
5116
5327
  const scope = payload?.scope === 'archived' ? 'archived' : 'all'
5117
5328
  try {
5118
- return { ok: true, value: await searchSessionsContent(ctx, dshHome, query, scope) }
5329
+ await sessionTitlesReady
5330
+ return { ok: true, value: await searchSessionsContent(ctx, dshHome, query, scope, sessionTitleCache) }
5119
5331
  } catch (error) {
5120
5332
  return rpcTechnicalFailure(error)
5121
5333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "1.1.2",
3
+ "version": "1.4.0",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -19,6 +19,8 @@
19
19
  "index.js",
20
20
  "quota-adapters.js",
21
21
  "backup-integrity.js",
22
+ "plugin-health.js",
23
+ "plugin-compat.js",
22
24
  "client.js",
23
25
  "cordis.patch.yml",
24
26
  "README.md",