dsh-sessions-manager 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,1158 @@
1
+ // dsh-sessions-manager — host half.
2
+ //
3
+ // Serves /archived-sessions/* JSON routes (list / restore / restore-many /
4
+ // delete / delete-many / sessions / workspaces / move) over the host
5
+ // `webServer`. The browser Settings sections ("归档会话" & "移动会话") talk to
6
+ // these. Reads/writes the durable workspace archive set
7
+ // (workspaceRegistry + storageDomain), folds titles/dates/workspace tags from
8
+ // session persistence, physically removes a session's log file on delete, and
9
+ // relocates a conversation (session) between workspaces on move.
10
+ import { mkdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
11
+ import { basename, dirname, isAbsolute, join } from 'node:path'
12
+ import { readFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { rewriteFrame0Cwd } from './zstd-frame.js'
15
+
16
+ export const name = 'dsh-sessions-manager'
17
+ export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
18
+
19
+ const MAX_TITLE = 80
20
+ // Recycle bin (回收站): normal deletes land here instead of being erased.
21
+ const TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join(homedir(), '.dsh', 'sessions-manager-trash')
22
+ const TRASH_INDEX = join(TRASH_DIR, 'index.json')
23
+ const TRASH_SCHEMA_VERSION = 2
24
+ const DEFAULT_TRASH_SETTINGS = Object.freeze({ retentionDays: 0 })
25
+ // -- per-session detail aggregation (v2.0: 取 Zephyr-vibe buildDetails 精华) --
26
+ // 识别“搜索/抓取”类工具,用来收集 fetch 记录。
27
+ const FETCH_TOOL_RE = /search|fetch|download|browse/i
28
+ const MAX_FETCHES = 12 // fetch 记录上限(防响应过大)
29
+ const MAX_FILES = 20 // write/edit 文件列表上限
30
+
31
+ function json(res, value, status = 200) {
32
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
33
+ res.end(JSON.stringify(value))
34
+ }
35
+
36
+ function errorStatus(error) {
37
+ return error && Number.isInteger(error.status) ? error.status : 500
38
+ }
39
+
40
+ async function readJsonBody(req) {
41
+ const chunks = []
42
+ let total = 0
43
+ for await (const chunk of req) {
44
+ chunks.push(chunk)
45
+ total += chunk.length
46
+ if (total > 1 << 20) return null
47
+ }
48
+ try {
49
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ function parseIds(body) {
56
+ const raw = body && body.sessionIds
57
+ if (!Array.isArray(raw)) return null
58
+ const ids = []
59
+ for (const v of raw) if (typeof v === 'string' && isSafeSessionId(v)) ids.push(v)
60
+ return ids
61
+ }
62
+
63
+ function isSafeSessionId(value) {
64
+ return typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\\/\0]/.test(value) && value !== '.' && value !== '..'
65
+ }
66
+
67
+ function requireSessionId(value) {
68
+ if (!isSafeSessionId(value)) {
69
+ const error = new Error('无效的 sessionId')
70
+ error.status = 400
71
+ throw error
72
+ }
73
+ return value
74
+ }
75
+
76
+ // Best-effort: figure out which conversation is the host's *currently active*
77
+ // one. DSH's in-memory session store (ctx.sessions) keeps EVERY instantiated
78
+ // session alive even after you switch away in the UI, so "is it in
79
+ // ctx.sessions" is NOT the same as "is it the active conversation". We probe a
80
+ // few known accessors for the active id; if none is available we return null
81
+ // and callers should treat the session as movable (the move path is
82
+ // crash-safe via backup+rollback and re-syncs the live object afterwards).
83
+ function getActiveSessionId(context) {
84
+ try {
85
+ const a = context.get('activeSession')
86
+ if (a != null) return (a && a.id != null) ? a.id : (typeof a === 'string' ? a : null)
87
+ } catch (e) { /* no such key */ }
88
+ try {
89
+ const c = context.get('currentSession')
90
+ if (c != null) return (c && c.id != null) ? c.id : (typeof c === 'string' ? c : null)
91
+ } catch (e) { /* no such key */ }
92
+ try {
93
+ const store = context.get('sessions')
94
+ if (store && store.active && store.active.id != null) return store.active.id
95
+ } catch (e) { /* no such key */ }
96
+ return null
97
+ }
98
+
99
+ function foldTitle(events) {
100
+ let found = null
101
+ let firstUser = null
102
+ for (const ev of events) {
103
+ if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {
104
+ found = ev.data.title
105
+ }
106
+ if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {
107
+ const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()
108
+ if (txt) firstUser = txt
109
+ }
110
+ }
111
+ return found || firstUser || null
112
+ }
113
+
114
+ export function apply(ctx) {
115
+ const w = ctx.workspaceRegistry
116
+ const sp = ctx.sessionPersistence
117
+ const sq = ctx.sessionQuery
118
+ const dom = () => ctx.storageDomain.get('workspace')
119
+ const authorityTitleCache = new Map()
120
+ let authorityTitlesLoaded = false
121
+
122
+ async function archivedState() {
123
+ const d = dom()
124
+ if (!d) throw new Error('workspace domain is not open')
125
+ return d.global.get()
126
+ }
127
+
128
+ async function writeArchived(nextIds) {
129
+ const d = dom()
130
+ if (!d) throw new Error('workspace domain is not open')
131
+ const cur = d.global.get()
132
+ const next = Object.assign({}, cur, { archivedSessionIds: nextIds })
133
+ await d.global.set(next)
134
+ // Keep the registry's in-memory cache in sync so the live sidebar refreshes.
135
+ if (w && 'state' in w) { try { w.state = next } catch (e) { /* best-effort */ } }
136
+ return next
137
+ }
138
+
139
+ let archiveMutation = Promise.resolve()
140
+ function mutateArchived(mutator) {
141
+ const operation = archiveMutation.then(async () => {
142
+ const state = await archivedState()
143
+ const list = (state.archivedSessionIds || []).map(String)
144
+ const result = await mutator(list)
145
+ if (result.next) await writeArchived(result.next)
146
+ return result.value
147
+ })
148
+ archiveMutation = operation.catch(() => {})
149
+ return operation
150
+ }
151
+
152
+ let wsByPath = {}
153
+
154
+ async function resolveOne(id) {
155
+ let title = null, createdAt = null, cwd = null
156
+ try {
157
+ const o = await sq.readTitleSnapshot(id)
158
+ if (o) {
159
+ if (o.title && o.title.title) title = String(o.title.title)
160
+ if (o.session) { cwd = o.session.cwd || null; createdAt = o.session.createdAt || null }
161
+ }
162
+ } catch (e) { /* fall back to raw log */ }
163
+ if (!title || !cwd) {
164
+ try {
165
+ const r = await sp.readFrom(id, 0)
166
+ if (r.meta) {
167
+ if (!cwd) cwd = r.meta.cwd || null
168
+ if (!createdAt) createdAt = r.meta.createdAt || null
169
+ }
170
+ if (!title && Array.isArray(r.events)) title = foldTitle(r.events)
171
+ } catch (e2) { /* keep what we have */ }
172
+ }
173
+ const ws = cwd ? wsByPath[cwd] : undefined
174
+ const workspaceGone = !!(cwd && !ws)
175
+ const display = title ? (String(title).length > MAX_TITLE ? String(title).slice(0, MAX_TITLE) + '…' : String(title)) : null
176
+ return {
177
+ sessionId: id,
178
+ title: display,
179
+ createdAt: createdAt || null,
180
+ workspacePath: cwd || null,
181
+ workspaceTitle: (ws && ws.title) ? ws.title : null,
182
+ workspaceGone: workspaceGone ? true : false,
183
+ hasWorkspace: !!cwd,
184
+ }
185
+ }
186
+
187
+ // Restore (unarchive) one session; throws on failure.
188
+ async function restoreOne(sid) {
189
+ requireSessionId(sid)
190
+ return mutateArchived((list) => list.includes(sid)
191
+ ? { next: list.filter((x) => x !== sid), value: { ok: true, restored: true } }
192
+ : { next: null, value: { ok: true, restored: false } })
193
+ }
194
+
195
+ // ---- Recycle bin (回收站) helpers ----------------------------------------
196
+ let trashMutation = Promise.resolve()
197
+ function normalizeTrashStore(raw) {
198
+ if (Array.isArray(raw)) return { schemaVersion: TRASH_SCHEMA_VERSION, settings: { ...DEFAULT_TRASH_SETTINGS }, items: raw, purgedSessionIds: [] }
199
+ const settings = raw && typeof raw.settings === 'object' ? raw.settings : {}
200
+ const retentionDays = Number.isInteger(settings.retentionDays) && settings.retentionDays >= 0 ? settings.retentionDays : 0
201
+ return {
202
+ schemaVersion: TRASH_SCHEMA_VERSION,
203
+ settings: { retentionDays },
204
+ items: raw && Array.isArray(raw.items) ? raw.items : [],
205
+ purgedSessionIds: raw && Array.isArray(raw.purgedSessionIds) ? [...new Set(raw.purgedSessionIds.filter(isSafeSessionId).map(String))] : [],
206
+ }
207
+ }
208
+ async function readTrashStore() {
209
+ try { return normalizeTrashStore(JSON.parse(readFileSync(TRASH_INDEX, 'utf8'))) } catch (e) { return normalizeTrashStore(null) }
210
+ }
211
+ async function readTrash() { return (await readTrashStore()).items }
212
+ async function writeTrashStore(store) {
213
+ await mkdir(TRASH_DIR, { recursive: true })
214
+ const tmp = join(TRASH_DIR, `.index-${process.pid}-${Date.now()}.tmp`)
215
+ await writeFile(tmp, JSON.stringify(normalizeTrashStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
216
+ await rename(tmp, TRASH_INDEX)
217
+ }
218
+ function mutateTrash(mutator) {
219
+ const operation = trashMutation.then(async () => {
220
+ const store = await readTrashStore()
221
+ const result = await mutator(store)
222
+ await writeTrashStore(store)
223
+ return result
224
+ })
225
+ trashMutation = operation.catch(() => {})
226
+ return operation
227
+ }
228
+
229
+ // Soft-delete one session: record it in the recycle-bin index but KEEP its
230
+ // log in the original workspace directory. Moving the file out (and detaching
231
+ // it from the workspace) orphaned the session into DSH's "未分组" group and
232
+ // made restore land in 未分组 instead of the original workspace — so we leave
233
+ // the file where it is and let the sidebar DOM shim hide the row instead.
234
+ async function deleteOne(sid) {
235
+ requireSessionId(sid)
236
+ // Soft-delete is always allowed — including the currently-active conversation.
237
+ // The log file stays in its original workspace dir (recorded in the 回收站
238
+ // index below), so the live session is unaffected and the entry stays
239
+ // recoverable from 回收站. (Move, by contrast, physically relocates the file
240
+ // and still guards the active session in moveTargetWorkspace.)
241
+ let header = null
242
+ let cwd = null
243
+ let title = null
244
+ let removedPath = null
245
+ try {
246
+ const headers = await sp.list()
247
+ header = headers.find((h) => String(h.id) === sid) || null
248
+ if (header) {
249
+ const loc = sp.locate(header)
250
+ if (loc && typeof loc.path === 'string') removedPath = loc.path
251
+ cwd = header.cwd || null
252
+ title = header.title || (header.meta && header.meta.title) || null
253
+ }
254
+ if (!title) {
255
+ const r = await sp.readFrom(sid, 0)
256
+ if (r && r.meta) { if (!cwd) cwd = r.meta.cwd; title = foldTitle(r.events) }
257
+ }
258
+ } catch (e) { /* best-effort */ }
259
+ // Record in the trash index only — the log stays in its workspace dir.
260
+ if (!header && !removedPath) {
261
+ const error = new Error('找不到该会话')
262
+ error.status = 404
263
+ throw error
264
+ }
265
+ const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
266
+ await mutateTrash((store) => {
267
+ const entry = {
268
+ sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
269
+ header: header || null, originalPath: removedPath || null,
270
+ sizeBytes: header && typeof header.size === 'number' ? header.size : null,
271
+ wasArchived: archived, deletedAt: Date.now(),
272
+ }
273
+ const at = store.items.findIndex((t) => String(t.sessionId) === sid)
274
+ if (at >= 0) store.items[at] = entry
275
+ else store.items.push(entry)
276
+ store.purgedSessionIds = store.purgedSessionIds.filter((id) => id !== sid)
277
+ })
278
+ return { ok: true, trashed: true }
279
+ }
280
+
281
+ // Restore a trashed session: the log never left its original workspace dir,
282
+ // so we just drop it from the recycle-bin index and the sidebar reveals it in
283
+ // its original workspace (no move / no re-attach needed).
284
+ async function restoreFromTrash(sid) {
285
+ requireSessionId(sid)
286
+ await mutateTrash(async (store) => {
287
+ const entry = store.items.find((t) => String(t.sessionId) === sid)
288
+ if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
289
+ // Restore the pre-delete archive state before removing the durable trash
290
+ // entry. If this fails, mutateTrash does not write and the item remains
291
+ // recoverable instead of disappearing into an inconsistent state.
292
+ if (entry.wasArchived === false) await restoreOne(sid)
293
+ store.items = store.items.filter((t) => String(t.sessionId) !== sid)
294
+ store.purgedSessionIds = store.purgedSessionIds.filter((id) => id !== sid)
295
+ })
296
+ return { ok: true, restored: true }
297
+ }
298
+
299
+ // Permanently erase a trashed session: physically delete its log (still in
300
+ // the original workspace dir) and detach it from any workspace so DSH drops it.
301
+ async function purgeFromTrash(sid) {
302
+ requireSessionId(sid)
303
+ let purged = false
304
+ await mutateTrash(async (store) => {
305
+ const entry = store.items.find((t) => String(t.sessionId) === sid)
306
+ if (!entry) { const error = new Error('回收站中找不到该会话'); error.status = 404; throw error }
307
+ let target = null
308
+ try {
309
+ const headers = await sp.list()
310
+ const current = headers.find((h) => String(h.id) === sid)
311
+ const located = current && sp.locate(current)
312
+ if (located && typeof located.path === 'string') target = located.path
313
+ } catch (e) {}
314
+ if (!target && typeof entry.originalPath === 'string') target = entry.originalPath
315
+ // JSONL persistence stores logs as
316
+ // .../<sessionId>/session.jsonl.zstd
317
+ // Older backends may instead include the id in the filename itself.
318
+ // Accept both layouts, but reject every unrelated path before unlink.
319
+ const targetOwnsSession = target && (basename(dirname(target)) === sid || basename(target).includes(sid))
320
+ if (target && !targetOwnsSession) {
321
+ const error = new Error('日志路径与会话 ID 不匹配,已停止永久删除')
322
+ error.status = 409
323
+ throw error
324
+ }
325
+ // Persist the tombstone before any irreversible work. A crash after this
326
+ // point may leave the trash item retryable, but can never resurrect the
327
+ // session in a later list baseline.
328
+ if (!store.purgedSessionIds.includes(sid)) store.purgedSessionIds.push(sid)
329
+ await writeTrashStore(store)
330
+ // A freshly-created or recently-opened Session can remain resident after
331
+ // its file is unlinked. Flush once, then use SessionStore's entered-record
332
+ // detach capability so DSH emits host/session-removed and the client list
333
+ // drops the row instead of resurrecting it from live memory.
334
+ try {
335
+ const sessions = ctx.get('sessions')
336
+ const liveSession = sessions && sessions.get && sessions.get(sid)
337
+ if (liveSession && typeof sessions.flush === 'function') await sessions.flush(liveSession)
338
+ const entered = sessions && sessions.store && sessions.store.get && sessions.store.get(sid)
339
+ if (liveSession && (!entered || typeof entered.detach !== 'function')) throw new Error('宿主未提供 live Session detach 能力')
340
+ if (entered && typeof entered.detach === 'function') entered.detach()
341
+ // session/disposed starts an asynchronous persistence retirement. Wait
342
+ // for it before unlinking, otherwise its final drain can race the file
343
+ // deletion and briefly (or permanently) republish an orphan that the
344
+ // official sidebar groups under “未分组”.
345
+ const retirement = sp && sp.retirements && sp.retirements.get && sp.retirements.get(sid)
346
+ if (retirement && typeof retirement.then === 'function') await retirement
347
+ } catch (e) {
348
+ const error = new Error('无法从宿主内存移除会话,已停止永久删除:' + String((e && e.message) || e))
349
+ error.status = 409
350
+ throw error
351
+ }
352
+ if (target) {
353
+ try { await unlink(target) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('删除文件失败:' + String((e && e.message) || e)) }
354
+ }
355
+ try { for (const ent of w.list()) { if (ent.sessionIds.includes(sid)) { try { await ent.detachSession(sid) } catch (e) {} } } } catch (e) {}
356
+ try { if (w.sessionPaths && w.sessionPaths.delete) w.sessionPaths.delete(sid) } catch (e) {}
357
+ try { if (w.headers && w.headers.delete) w.headers.delete(sid) } catch (e) {}
358
+ await restoreOne(sid)
359
+ // Rebuild from the post-unlink disk baseline before reporting success.
360
+ // Merely deleting the two Maps above does not notify/rebuild Workspace
361
+ // entities, leaving the client with an orphaned “未分组” snapshot.
362
+ try { await reindexRegistry() } catch (e) { /* tombstone still prevents resurrection */ }
363
+ store.items = store.items.filter((t) => String(t.sessionId) !== sid)
364
+ purged = true
365
+ })
366
+ if (!purged) throw new Error('彻底删除失败')
367
+ return { ok: true, purged: true }
368
+ }
369
+
370
+ async function trashSettings(next) {
371
+ if (next === undefined) return (await readTrashStore()).settings
372
+ const days = Number(next.retentionDays)
373
+ if (!Number.isInteger(days) || ![0, 7, 30, 90].includes(days)) {
374
+ const error = new Error('retentionDays 仅支持 0、7、30、90')
375
+ error.status = 400
376
+ throw error
377
+ }
378
+ await mutateTrash((store) => { store.settings = { retentionDays: days } })
379
+ return (await readTrashStore()).settings
380
+ }
381
+
382
+ async function cleanupExpiredTrash() {
383
+ const store = await readTrashStore()
384
+ const days = store.settings.retentionDays
385
+ if (!days) return 0
386
+ const cutoff = Date.now() - days * 86400000
387
+ const ids = store.items.filter((item) => Number(item.deletedAt) > 0 && Number(item.deletedAt) < cutoff).map((item) => String(item.sessionId))
388
+ let count = 0
389
+ for (const sid of ids) { try { await purgeFromTrash(sid); count++ } catch (e) {} }
390
+ return count
391
+ }
392
+
393
+ // ---- "move conversation between workspaces" helper -----------------------
394
+ // DSH binds a conversation to the workspace whose canonical directory path
395
+ // equals the session's stored cwd. Moving it therefore means: (1) adopt the
396
+ // target path as a workspace (create if needed), (2) durably relocate the
397
+ // session's log so its header carries the new cwd, and (3) reassign the
398
+ // workspace membership (detach everywhere, attach to target). The log
399
+ // relocation goes through the persistence service's own encoder (handles the
400
+ // zstd artifact encoding) with a backup + rollback so a failure never leaves
401
+ // the session half-moved.
402
+
403
+ async function moveTargetWorkspace(rawPath) {
404
+ if (typeof rawPath !== 'string' || !rawPath.trim()) throw new Error('缺少目标工作区路径')
405
+ let p = String(rawPath).trim()
406
+ if (p.startsWith('~/')) p = join(homedir(), p.slice(2))
407
+ if (!isAbsolute(p)) p = join(homedir(), p)
408
+ let canonical = null
409
+ try { canonical = await realpath(p) } catch (e) { canonical = null }
410
+ if (canonical === null) {
411
+ await mkdir(p, { recursive: true })
412
+ canonical = await realpath(p)
413
+ }
414
+ return { canonical, entity: await w.create(canonical, basename(canonical) || 'workspace') }
415
+ }
416
+
417
+ async function moveOne(sid, targetPath) {
418
+ // Only block the *active* conversation. ctx.sessions keeps instantiated
419
+ // sessions alive after you switch away, so the old check (sessions.get(sid))
420
+ // wrongly rejected every opened session — you could never move one you'd
421
+ // merely looked at. When the host exposes no active-session accessor we
422
+ // can't prove activeness, so we allow the move; the relocation below is
423
+ // crash-safe (backup + rollback) and re-syncs the live object.
424
+ const activeId = getActiveSessionId(ctx)
425
+ if (activeId != null && String(activeId) === String(sid)) {
426
+ throw new Error('该会话当前处于打开状态,请先切换到别的会话再移动。')
427
+ }
428
+ const r = await sp.readFrom(sid, 0)
429
+ if (!r || !r.meta) throw new Error('无法读取该会话的日志')
430
+ const meta = r.meta
431
+ const events = r.events
432
+ const oldCwd = meta.cwd || null
433
+
434
+ const { canonical, entity: target } = await moveTargetWorkspace(targetPath)
435
+
436
+ if (oldCwd) {
437
+ let oldCanon = null
438
+ try { oldCanon = await realpath(oldCwd) } catch (e) { oldCanon = null }
439
+ if (oldCanon === canonical) {
440
+ return { ok: true, already: true, workspaceId: target.id, workspaceTitle: target.title }
441
+ }
442
+ }
443
+
444
+ const newHeader = Object.assign({}, meta, { cwd: canonical })
445
+
446
+ // 1) Decide relocation strategy. `sessionPersistence.create()` rejects
447
+ // ("already exists in this backend") for ANY session the host has
448
+ // instantiated into its in-memory `states` — and DSH instantiates *every*
449
+ // session it can find on disk at startup, including ARCHIVED ones. So a
450
+ // supposedly "closed" archived session is NOT safe for the create()+append()
451
+ // path; create() will throw. The only universally safe move is to physically
452
+ // relocate the on-disk log (rewriting frame0's cwd) and redirect the live
453
+ // object + persistence state. We still attempt create()+append() as the
454
+ // fast path for genuinely-virgin session ids, but on an already-exists
455
+ // collision we fall back to the relocate path. That covers live, archived,
456
+ // and restored sessions alike.
457
+ const live = ctx.get('sessions')
458
+ const liveObj = live && live.get && live.get(sid)
459
+ const isOpen = !!liveObj
460
+
461
+ const ALREADY_EXISTS_RE = /already exists in this backend/i
462
+
463
+ // Physically relocate a session's on-disk log to `newHeader`'s cwd,
464
+ // rewriting frame0's cwd so sp.list()/reindex attribute it correctly.
465
+ // Returns true if a relocation actually happened.
466
+ const relocateLog = async (header, newHeaderObj) => {
467
+ const oldPath = locatePath(header)
468
+ const newPath = locatePath(newHeaderObj)
469
+ if (!oldPath || !newPath || oldPath === newPath) return false
470
+ const backupPath = `${oldPath}.move-backup-${Date.now()}`
471
+ try {
472
+ // Ensure the destination project directory exists (rename does not
473
+ // create it). Without this, the rename silently no-ops on ENOENT and
474
+ // the log stays put while workspace.json is wrongly updated.
475
+ await mkdir(dirname(newPath), { recursive: true })
476
+ await rename(oldPath, backupPath) // current log to safety
477
+ await rewriteFrame0Cwd(backupPath, canonical) // frame0 cwd -> newPath
478
+ await rename(backupPath, newPath) // relocate to the new workspace dir
479
+ } catch (e) {
480
+ try { await rename(backupPath, oldPath) } catch (_) {}
481
+ if (e && e.code !== 'ENOENT') throw e
482
+ return false
483
+ }
484
+ return true
485
+ }
486
+
487
+ const locatePath = (header) => {
488
+ let fn = null
489
+ try { if (typeof sp.locate === 'function') fn = sp.locate.bind(sp) } catch (e) {}
490
+ if (!fn && sp.backend && typeof sp.backend.locate === 'function') fn = sp.backend.locate.bind(sp.backend)
491
+ if (!fn) return null
492
+ try {
493
+ const loc = fn(header)
494
+ if (loc && typeof loc.path === 'string') return loc.path
495
+ if (typeof loc === 'string') return loc
496
+ } catch (e) {}
497
+ return null
498
+ }
499
+
500
+ // Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
501
+ // regression-tested directly. See that module for why frame boundaries are
502
+ // validated by decompression and why a non-session frame0 is rejected
503
+ // instead of rewritten.
504
+
505
+ if (isOpen) {
506
+ // Live session: relocate the on-disk log (rewriting frame0's cwd to the
507
+ // new path) and redirect the live object + persistence state. We must
508
+ // rewrite frame0, not just rename: sp.list() reads frame0's cwd from
509
+ // disk, and WorkspaceEntity.sessionIds filters by that exact cwd. A bare
510
+ // rename would leave frame0 pointing at the old workspace, so reindex /
511
+ // restart would keep attributing the session to the wrong workspace.
512
+ await relocateLog(meta, newHeader)
513
+ // Redirect the persistence state's cwd so future appends land in newPath.
514
+ try {
515
+ const st = sp.states && sp.states.get && sp.states.get(sid)
516
+ if (st && st.meta) st.meta = Object.assign({}, st.meta, { cwd: canonical })
517
+ } catch (e) { /* best-effort */ }
518
+ } else {
519
+ // Closed session: try the fast create()+append() path first. But DSH
520
+ // instantiates *all* on-disk sessions (including archived ones) into its
521
+ // in-memory states at startup, so create() usually throws
522
+ // "already exists in this backend". On that collision we fall back to a
523
+ // physical relocate of the existing log (rewriting frame0's cwd), which
524
+ // is safe and needs no create().
525
+ let oldPath = null
526
+ try {
527
+ const loc = locatePath(meta)
528
+ if (loc && typeof loc === 'string') oldPath = loc
529
+ else if (loc && loc.path) oldPath = loc.path
530
+ } catch (e) { oldPath = null }
531
+
532
+ if (typeof sp.create !== 'function' || typeof sp.append !== 'function') {
533
+ // No create primitive: must relocate the existing log directly.
534
+ await relocateLog(meta, newHeader)
535
+ } else {
536
+ const backupPath = oldPath ? `${oldPath}.move-backup-${Date.now()}` : null
537
+ if (backupPath) { try { await rename(oldPath, backupPath) } catch (e) { if (e && e.code !== 'ENOENT') throw new Error('移动失败:无法备份旧的会话日志') } }
538
+ const restore = async () => { if (backupPath) { try { await rename(backupPath, oldPath) } catch (_) {} } }
539
+ try {
540
+ await sp.create(newHeader)
541
+ await sp.append(sid, events)
542
+ const check = await sp.readFrom(sid, 0)
543
+ if (!check || !check.meta || check.meta.cwd !== canonical) {
544
+ throw new Error('移动后校验失败:会话工作目录未正确更新')
545
+ }
546
+ if (backupPath) { try { await unlink(backupPath) } catch (e) {} }
547
+ } catch (e) {
548
+ if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
549
+ // Collision: the session is already materialized in states (archived
550
+ // or previously opened). Fall back to physically relocating the log.
551
+ await restore()
552
+ await relocateLog(meta, newHeader)
553
+ } else {
554
+ await restore()
555
+ throw new Error('移动会话日志失败:' + String((e && e.message) || e))
556
+ }
557
+ }
558
+ }
559
+ }
560
+
561
+ // Keep the live (in-memory) session object consistent with the relocated
562
+ // log so the host doesn't keep appending to the old path. This MUST happen
563
+ // before attachSession(): WorkspaceEntity.attachSession() validates the
564
+ // session by reading live.header first, and if it still carries the old cwd
565
+ // the realpath check will fail on the old (now missing) directory.
566
+ try {
567
+ if (liveObj) {
568
+ if ('header' in liveObj) liveObj.header = newHeader
569
+ if ('cwd' in liveObj) liveObj.cwd = canonical
570
+ if ('meta' in liveObj) liveObj.meta = newHeader
571
+ }
572
+ } catch (e) { /* best-effort */ }
573
+
574
+ // 2) Reassign workspace membership (durable records + in-memory index).
575
+ for (const ent of w.list()) {
576
+ try { await ent.detachSession(sid) } catch (e) { /* ignore */ }
577
+ }
578
+ if (w.headers && typeof w.headers.set === 'function') w.headers.set(sid, newHeader)
579
+ if (w.sessionPaths && typeof w.sessionPaths.set === 'function') w.sessionPaths.set(sid, canonical)
580
+ await target.attachSession(sid)
581
+
582
+ // Verify the membership actually landed on the target workspace. DSH's
583
+ // WorkspaceEntity.attachSession persists asynchronously; if it silently
584
+ // no-ops (e.g. the session's durable cwd still points elsewhere) the UI
585
+ // would show "moved" while the sidebar keeps the old grouping. Fail loud
586
+ // instead of returning a fake success.
587
+ const verified = (() => {
588
+ try { return target.sessionIds.includes(sid) } catch (e) { return false }
589
+ })()
590
+ if (!verified) {
591
+ throw new Error('移动后校验失败:会话未出现在目标工作区,请重试或重启 DSH。')
592
+ }
593
+
594
+ return {
595
+ ok: true,
596
+ moved: true,
597
+ workspaceId: target.id,
598
+ workspaceTitle: target.title,
599
+ workspacePath: canonical,
600
+ }
601
+ }
602
+
603
+ // Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
604
+ // index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
605
+ // is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
606
+ // record.path`; that sessionPath Map is only repopulated at startup (bootstrap
607
+ // + indexHeaders). So even after a successful move writes the durable cwd,
608
+ // the running process keeps attributing the session to its OLD workspace until
609
+ // a restart — unless we reindex here. Calling this right after move makes the
610
+ // sidebar reflect the new grouping with NO restart required.
611
+ async function reindexRegistry() {
612
+ const reg = w
613
+ if (!reg || typeof reg.replaceHeaderIndex !== 'function') return false
614
+ let headers = null
615
+ try { headers = await sp.list() } catch (e) { headers = null }
616
+ if (!headers || !Array.isArray(headers)) return false
617
+ await reg.replaceHeaderIndex(headers)
618
+ if (typeof reg.rebuildEntities === 'function') reg.rebuildEntities()
619
+ return true
620
+ }
621
+
622
+ async function listWorkspaces() {
623
+ const out = []
624
+ try {
625
+ for (const ent of w.list()) out.push({ workspaceId: ent.id, title: ent.title, path: ent.path })
626
+ } catch (e) { /* ignore */ }
627
+ return out
628
+ }
629
+
630
+ // Archive (hide) one session: adds its id to the durable archive set so it
631
+ // is dropped out of the sidebar. DSH requires the session to exist (live or
632
+ // persisted) — a genuine miss surfaces as an error.
633
+ async function archiveOne(sid) {
634
+ requireSessionId(sid)
635
+ return mutateArchived(async (list) => {
636
+ if (list.includes(sid)) return { next: null, value: { ok: true, archived: false } }
637
+ await w.archiveSession(sid)
638
+ // archiveSession owns the durable write; keep this operation serialized
639
+ // with restoreOne so two requests cannot overwrite each other's state.
640
+ return { next: null, value: { ok: true, archived: true } }
641
+ })
642
+ }
643
+
644
+ async function allSessionItems() {
645
+ let materialized = new Set()
646
+ let live = ctx.get('sessions')
647
+ try {
648
+ const headers = await sp.list()
649
+ materialized = new Set(headers.map((h) => String(h.id)))
650
+ } catch (e) { /* best-effort */ }
651
+ const ids = []
652
+ try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
653
+ if (live) { try { live.list().forEach((s) => { if (!ids.includes(String(s.id))) ids.push(String(s.id)) }) } catch (e) { /* ignore */ } }
654
+ // Exclude sessions already moved to the recycle bin (软删除): they live in
655
+ // 回收站, not in 会话管理, so the panel won't re-list them after a delete.
656
+ let hiddenIds = new Set()
657
+ try {
658
+ const store = await readTrashStore()
659
+ hiddenIds = new Set([...store.items.map((t) => String(t.sessionId)), ...store.purgedSessionIds.map(String)])
660
+ } catch (e) {}
661
+ const visibleIds = ids.filter((id) => !hiddenIds.has(id))
662
+ wsByPath = {}
663
+ try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
664
+ const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
665
+ const items = []
666
+ const CHUNK = 6
667
+ for (let i = 0; i < visibleIds.length; i += CHUNK) {
668
+ const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne))
669
+ for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
670
+ }
671
+ return items
672
+ }
673
+
674
+ async function sidebarAuthority() {
675
+ const ids = []
676
+ try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) {}
677
+ const sessions = ctx.get('sessions')
678
+ try { if (sessions) sessions.list().forEach((session) => { if (!ids.includes(String(session.id))) ids.push(String(session.id)) }) } catch (e) {}
679
+ const store = await readTrashStore()
680
+ if (!authorityTitlesLoaded && ids.length && typeof sq.readTitleSnapshots === 'function') {
681
+ const results = await sq.readTitleSnapshots(ids)
682
+ results.forEach((result, index) => {
683
+ if (result && result.status === 'fulfilled' && result.value && result.value.title && typeof result.value.title.title === 'string') {
684
+ authorityTitleCache.set(ids[index], result.value.title.title)
685
+ }
686
+ })
687
+ authorityTitlesLoaded = true
688
+ } else if (!authorityTitlesLoaded) {
689
+ for (const sid of ids) {
690
+ try {
691
+ const snapshot = await sq.readTitleSnapshot(sid)
692
+ if (snapshot && snapshot.title && typeof snapshot.title.title === 'string') authorityTitleCache.set(sid, snapshot.title.title)
693
+ } catch (e) {}
694
+ }
695
+ authorityTitlesLoaded = true
696
+ }
697
+ return {
698
+ titles: Object.fromEntries(authorityTitleCache),
699
+ trashedSessionIds: store.items.map((item) => String(item.sessionId)),
700
+ purgedSessionIds: store.purgedSessionIds.map(String),
701
+ }
702
+ }
703
+
704
+ // 聚合一条会话的详情(磁盘占用 / 轮次·步数·消息数 / 工具统计 / fetch /
705
+ // write/edit 文件 / 血统 parent/children/subagents)。live 与持久化会话都可读。
706
+ // 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
707
+ // 磁盘上已不存在的路径,避免详情面板列出已删除文件。
708
+ async function buildDetails(sid) {
709
+ const sessions = ctx.get('sessions')
710
+ const live = sessions && sessions.get(sid)
711
+ let meta = null
712
+ let events = []
713
+ if (live !== void 0) {
714
+ meta = (live && live.header) || null
715
+ try { events = Array.isArray(live.events) ? [...live.events] : [] } catch (e) { events = [] }
716
+ } else {
717
+ const r = await sp.readFrom(sid, 0)
718
+ if (!r || !r.meta) throw new Error('找不到该会话的记录(会话不存在)')
719
+ meta = r.meta
720
+ events = Array.isArray(r.events) ? r.events : []
721
+ }
722
+ let sizeBytes = null
723
+ try {
724
+ // rc.8 的 sessionPersistence 后端没有 artifactInfo;用 locate(meta) 拿日志
725
+ // 文件真实路径后 stat 出字节数(磁盘占用)。
726
+ const loc = sp.locate(meta)
727
+ if (loc && typeof loc.path === 'string' && loc.path) {
728
+ const st = await stat(loc.path)
729
+ if (st && typeof st.size === 'number') sizeBytes = st.size
730
+ }
731
+ } catch (e) { sizeBytes = null }
732
+ let lastTime = typeof meta && typeof meta.createdAt === 'number' ? meta.createdAt : 0
733
+ const fileSet = new Map()
734
+ const stats = {
735
+ turns: 0, steps: 0, userMessages: 0, assistantMessages: 0,
736
+ toolCalls: 0, attachments: 0, toolCounts: {}, fetches: [],
737
+ }
738
+ const turnSeen = new Set()
739
+ const stepSeen = new Set()
740
+ for (const ev of events) {
741
+ if (ev && typeof ev.time === 'number' && ev.time > lastTime) lastTime = ev.time
742
+ const d = (ev && ev.data && typeof ev.data === 'object') ? ev.data : {}
743
+ const type = ev && ev.type
744
+ switch (type) {
745
+ case 'turn/start':
746
+ if (typeof d.turn === 'number') turnSeen.add(d.turn)
747
+ break
748
+ case 'step/start':
749
+ if (typeof d.step === 'number') stepSeen.add(d.step)
750
+ break
751
+ case 'user/message':
752
+ stats.userMessages++
753
+ if (Array.isArray(d.content)) for (const b of d.content) if (b && b.type === 'image') stats.attachments++
754
+ break
755
+ case 'assistant/message':
756
+ stats.assistantMessages++
757
+ break
758
+ case 'tool/call': {
759
+ stats.toolCalls++
760
+ const tn = typeof d.name === 'string' && d.name ? d.name : 'tool'
761
+ stats.toolCounts[tn] = (stats.toolCounts[tn] || 0) + 1
762
+ if (FETCH_TOOL_RE.test(tn)) {
763
+ let query
764
+ try {
765
+ const a = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments
766
+ query = typeof a?.query === 'string' ? a.query : typeof a?.url === 'string' ? a.url : typeof a?.q === 'string' ? a.q : undefined
767
+ } catch (e) { query = undefined }
768
+ stats.fetches.push({ tool: tn, ...(query && query !== '' ? { query } : {}) })
769
+ }
770
+ if (tn === 'write' || tn === 'edit') {
771
+ let argsJ
772
+ try { argsJ = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments } catch (e) { break }
773
+ const fp = argsJ && typeof argsJ.file_path === 'string' && argsJ.file_path ? argsJ.file_path : undefined
774
+ if (fp !== undefined && !fileSet.has(fp)) fileSet.set(fp, tn)
775
+ }
776
+ break
777
+ }
778
+ }
779
+ }
780
+ stats.turns = turnSeen.size
781
+ stats.steps = stepSeen.size
782
+ if (stats.fetches.length > MAX_FETCHES) stats.fetches = stats.fetches.slice(0, MAX_FETCHES)
783
+ const fileEntries = [...fileSet.entries()].slice(0, MAX_FILES * 2)
784
+ const exists = await Promise.all(fileEntries.map(([p]) => stat(p).then(() => true).catch(() => false)))
785
+ const files = fileEntries
786
+ .filter((_, i) => exists[i])
787
+ .map(([path, tool]) => ({ path, tool }))
788
+ .slice(0, MAX_FILES)
789
+ // lineage:分叉子会话(非 subagent)与子代理(origin==='subagent'),source 去重。
790
+ const lineage = {
791
+ parentSessionId: (meta && typeof meta.parentSession === 'string') ? meta.parentSession : null,
792
+ children: [],
793
+ subagents: [],
794
+ }
795
+ const childrenSet = new Set()
796
+ const subagentSet = new Set()
797
+ try {
798
+ if (typeof sp.list === 'function') {
799
+ for (const h of await sp.list()) {
800
+ if (String(h.parentSession) !== String(sid)) continue
801
+ if (h.origin === 'subagent') subagentSet.add(h.id); else childrenSet.add(h.id)
802
+ }
803
+ }
804
+ } catch (e) { /* best-effort */ }
805
+ if (sessions) {
806
+ try {
807
+ sessions.list().forEach((s) => {
808
+ if (String(s.header.parentSession) !== String(sid)) return
809
+ if (s.header.origin === 'subagent') subagentSet.add(s.id); else childrenSet.add(s.id)
810
+ })
811
+ } catch (e) { /* best-effort */ }
812
+ }
813
+ lineage.children = [...childrenSet]
814
+ lineage.subagents = [...subagentSet]
815
+ return {
816
+ sessionId: sid,
817
+ sizeBytes,
818
+ createdAt: (meta && typeof meta.createdAt === 'number') ? meta.createdAt : null,
819
+ updatedAt: lastTime || null,
820
+ files,
821
+ stats,
822
+ lineage,
823
+ }
824
+ }
825
+
826
+ ctx.effect(() => {
827
+ const disposers = []
828
+
829
+ if (typeof ctx.on === 'function') disposers.push(ctx.on('session/event', (session, event) => {
830
+ if (event && event.type === 'session/title' && event.data && typeof event.data.title === 'string') {
831
+ authorityTitleCache.set(String(session.id), event.data.title)
832
+ }
833
+ }))
834
+
835
+ disposers.push(ctx.webServer.register({
836
+ kind: 'exact',
837
+ path: '/archived-sessions/list',
838
+ handler: async (req, res) => {
839
+ try {
840
+ const state = await archivedState()
841
+ const ids = state.archivedSessionIds || []
842
+ // Only surface archived ids that still exist (materialized log or live).
843
+ // Deleted sessions keep a hidden archive id but no log, so they drop out here.
844
+ let materialized = new Set()
845
+ let live = ctx.get('sessions')
846
+ try {
847
+ const headers = await sp.list()
848
+ materialized = new Set(headers.map((h) => String(h.id)))
849
+ } catch (e) { /* best-effort */ }
850
+ const trashStore = await readTrashStore()
851
+ const hidden = new Set([...trashStore.items.map((item) => String(item.sessionId)), ...trashStore.purgedSessionIds.map(String)])
852
+ const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
853
+ wsByPath = {}
854
+ try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
855
+ const items = []
856
+ const CHUNK = 6
857
+ for (let i = 0; i < idStrs.length; i += CHUNK) {
858
+ const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map(resolveOne))
859
+ items.push.apply(items, res2)
860
+ }
861
+ json(res, { items })
862
+ } catch (e) {
863
+ json(res, { error: String((e && e.message) || e) }, 500)
864
+ }
865
+ },
866
+ }))
867
+
868
+ disposers.push(ctx.webServer.register({
869
+ kind: 'exact',
870
+ path: '/archived-sessions/restore',
871
+ handler: async (req, res) => {
872
+ try {
873
+ const body = await readJsonBody(req)
874
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
875
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
876
+ json(res, await restoreOne(sid))
877
+ } catch (e) {
878
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
879
+ }
880
+ },
881
+ }))
882
+
883
+ disposers.push(ctx.webServer.register({
884
+ kind: 'exact',
885
+ path: '/archived-sessions/restore-many',
886
+ handler: async (req, res) => {
887
+ try {
888
+ const body = await readJsonBody(req)
889
+ const ids = parseIds(body)
890
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
891
+ const results = []
892
+ for (const sid of ids) {
893
+ try { results.push({ sessionId: sid, ok: true, ...(await restoreOne(sid)) }) }
894
+ catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
895
+ }
896
+ json(res, { ok: true, restored: results.filter((r) => r.ok).length, results })
897
+ } catch (e) {
898
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
899
+ }
900
+ },
901
+ }))
902
+
903
+ disposers.push(ctx.webServer.register({
904
+ kind: 'exact',
905
+ path: '/archived-sessions/delete',
906
+ handler: async (req, res) => {
907
+ try {
908
+ const body = await readJsonBody(req)
909
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
910
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
911
+ json(res, await deleteOne(sid))
912
+ } catch (e) {
913
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
914
+ }
915
+ },
916
+ }))
917
+
918
+ disposers.push(ctx.webServer.register({
919
+ kind: 'exact',
920
+ path: '/archived-sessions/delete-many',
921
+ handler: async (req, res) => {
922
+ try {
923
+ const body = await readJsonBody(req)
924
+ const ids = parseIds(body)
925
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
926
+ const results = []
927
+ for (const sid of ids) {
928
+ try { results.push({ sessionId: sid, ok: true, ...(await deleteOne(sid)) }) }
929
+ catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
930
+ }
931
+ json(res, { ok: true, deleted: results.filter((r) => r.ok).length, results })
932
+ } catch (e) {
933
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
934
+ }
935
+ },
936
+ }))
937
+
938
+ // ---- Recycle bin (回收站) routes ----------------------------------------
939
+ disposers.push(ctx.webServer.register({
940
+ kind: 'exact',
941
+ path: '/archived-sessions/trash/list',
942
+ handler: async (req, res) => {
943
+ try {
944
+ await cleanupExpiredTrash()
945
+ const list = await readTrash()
946
+ list.sort((a, b) => (b.deletedAt || 0) - (a.deletedAt || 0))
947
+ const store = await readTrashStore()
948
+ json(res, { schemaVersion: store.schemaVersion, settings: store.settings, purgedSessionIds: store.purgedSessionIds, items: list })
949
+ } catch (e) {
950
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
951
+ }
952
+ },
953
+ }))
954
+
955
+ disposers.push(ctx.webServer.register({
956
+ kind: 'exact',
957
+ path: '/archived-sessions/trash/settings',
958
+ handler: async (req, res) => {
959
+ try {
960
+ const body = await readJsonBody(req)
961
+ const settings = body && Object.prototype.hasOwnProperty.call(body, 'retentionDays')
962
+ ? await trashSettings({ retentionDays: body.retentionDays })
963
+ : await trashSettings()
964
+ json(res, { ok: true, settings })
965
+ } catch (e) {
966
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
967
+ }
968
+ },
969
+ }))
970
+
971
+ disposers.push(ctx.webServer.register({
972
+ kind: 'exact',
973
+ path: '/archived-sessions/trash/verify',
974
+ handler: async (req, res) => {
975
+ try {
976
+ const items = await readTrash()
977
+ const results = await Promise.all(items.map(async (item) => {
978
+ let exists = false
979
+ if (typeof item.originalPath === 'string') exists = await stat(item.originalPath).then(() => true).catch(() => false)
980
+ return { sessionId: item.sessionId, status: exists ? 'ok' : 'missing', originalPath: item.originalPath || null }
981
+ }))
982
+ json(res, { ok: true, healthy: results.filter((r) => r.status === 'ok').length, missing: results.filter((r) => r.status === 'missing').length, results })
983
+ } catch (e) {
984
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
985
+ }
986
+ },
987
+ }))
988
+
989
+ disposers.push(ctx.webServer.register({
990
+ kind: 'exact',
991
+ path: '/archived-sessions/trash/restore',
992
+ handler: async (req, res) => {
993
+ try {
994
+ const body = await readJsonBody(req)
995
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
996
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
997
+ json(res, await restoreFromTrash(sid))
998
+ } catch (e) {
999
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1000
+ }
1001
+ },
1002
+ }))
1003
+
1004
+ disposers.push(ctx.webServer.register({
1005
+ kind: 'exact',
1006
+ path: '/archived-sessions/trash/purge',
1007
+ handler: async (req, res) => {
1008
+ try {
1009
+ const body = await readJsonBody(req)
1010
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
1011
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1012
+ json(res, await purgeFromTrash(sid))
1013
+ } catch (e) {
1014
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1015
+ }
1016
+ },
1017
+ }))
1018
+
1019
+ disposers.push(ctx.webServer.register({
1020
+ kind: 'exact',
1021
+ path: '/archived-sessions/trash/purge-many',
1022
+ handler: async (req, res) => {
1023
+ try {
1024
+ const body = await readJsonBody(req)
1025
+ const ids = parseIds(body)
1026
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1027
+ const results = []
1028
+ for (const sid of ids) {
1029
+ try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }) }
1030
+ catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1031
+ }
1032
+ json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
1033
+ } catch (e) {
1034
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1035
+ }
1036
+ },
1037
+ }))
1038
+
1039
+ // All conversations (for the "移动会话" panel).
1040
+ disposers.push(ctx.webServer.register({
1041
+ kind: 'exact',
1042
+ path: '/archived-sessions/sessions',
1043
+ handler: async (req, res) => {
1044
+ try {
1045
+ json(res, { items: await allSessionItems() })
1046
+ } catch (e) {
1047
+ json(res, { error: String((e && e.message) || e) }, 500)
1048
+ }
1049
+ },
1050
+ }))
1051
+
1052
+ disposers.push(ctx.webServer.register({
1053
+ kind: 'exact',
1054
+ path: '/archived-sessions/sidebar-state',
1055
+ handler: async (req, res) => {
1056
+ try {
1057
+ json(res, await sidebarAuthority())
1058
+ } catch (e) {
1059
+ json(res, { error: String((e && e.message) || e) }, errorStatus(e))
1060
+ }
1061
+ },
1062
+ }))
1063
+
1064
+ // Available target workspaces (for the move picker).
1065
+ disposers.push(ctx.webServer.register({
1066
+ kind: 'exact',
1067
+ path: '/archived-sessions/workspaces',
1068
+ handler: async (req, res) => {
1069
+ try {
1070
+ json(res, { items: await listWorkspaces() })
1071
+ } catch (e) {
1072
+ json(res, { error: String((e && e.message) || e) }, 500)
1073
+ }
1074
+ },
1075
+ }))
1076
+
1077
+ // Move one conversation to a target workspace (existing path or a new one).
1078
+ disposers.push(ctx.webServer.register({
1079
+ kind: 'exact',
1080
+ path: '/archived-sessions/move',
1081
+ handler: async (req, res) => {
1082
+ try {
1083
+ const body = await readJsonBody(req)
1084
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
1085
+ const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
1086
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1087
+ if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
1088
+ const moved = await moveOne(sid, target)
1089
+ // Reindex the host's in-memory sessionPath index so the sidebar
1090
+ // reflects the new grouping immediately (no DSH restart needed).
1091
+ // Do this before replying: drag/drop and menu clients treat a 2xx
1092
+ // response as the commit point and must never announce success while
1093
+ // the sidebar still holds the old workspace index.
1094
+ try { await reindexRegistry() } catch (e) { /* best-effort */ }
1095
+ json(res, { sessionId: sid, ...moved })
1096
+ } catch (e) {
1097
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1098
+ }
1099
+ },
1100
+ }))
1101
+
1102
+ // Archive (hide) one session.
1103
+ disposers.push(ctx.webServer.register({
1104
+ kind: 'exact',
1105
+ path: '/archived-sessions/archive',
1106
+ handler: async (req, res) => {
1107
+ try {
1108
+ const body = await readJsonBody(req)
1109
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
1110
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1111
+ json(res, { sessionId: sid, ...(await archiveOne(sid)) })
1112
+ } catch (e) {
1113
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1114
+ }
1115
+ },
1116
+ }))
1117
+
1118
+ // Archive (hide) many sessions.
1119
+ disposers.push(ctx.webServer.register({
1120
+ kind: 'exact',
1121
+ path: '/archived-sessions/archive-many',
1122
+ handler: async (req, res) => {
1123
+ try {
1124
+ const body = await readJsonBody(req)
1125
+ const ids = parseIds(body)
1126
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1127
+ const results = []
1128
+ for (const sid of ids) {
1129
+ try { results.push({ sessionId: sid, ok: true, ...(await archiveOne(sid)) }) }
1130
+ catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1131
+ }
1132
+ json(res, { ok: true, archived: results.filter((r) => r.ok).length, results })
1133
+ } catch (e) {
1134
+ json(res, { ok: false, error: String((e && e.message) || e) }, 500)
1135
+ }
1136
+ },
1137
+ }))
1138
+
1139
+ // Per-session details (v2.0): disk usage, turn/step/message counts, tool
1140
+ // usage, fetch records, write/edit files, and lineage.
1141
+ disposers.push(ctx.webServer.register({
1142
+ kind: 'exact',
1143
+ path: '/archived-sessions/details',
1144
+ handler: async (req, res) => {
1145
+ try {
1146
+ const body = await readJsonBody(req)
1147
+ const sid = body && typeof body.sessionId === 'string' ? body.sessionId : null
1148
+ if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1149
+ json(res, await buildDetails(sid))
1150
+ } catch (e) {
1151
+ json(res, { error: String((e && e.message) || e) }, (e && e.status) ? e.status : 500)
1152
+ }
1153
+ },
1154
+ }))
1155
+
1156
+ return () => { for (const d of disposers) d() }
1157
+ }, 'dsh-sessions-manager: routes')
1158
+ }