@device1999/dsh-session-archive-delete 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,830 @@
1
+ /**
2
+ * dsh-session-archive-delete · 宿主半
3
+ *
4
+ * 职责:
5
+ * 1. 暴露 HTTP 接口给客户端:
6
+ * GET /dsh-session-archive-delete/config 读取设置
7
+ * POST /dsh-session-archive-delete/settings 写入设置
8
+ * POST /dsh-session-archive-delete/session 对单个会话执行「备份删除」或「直接删除」
9
+ * GET /dsh-session-archive-delete/browse 列出目录(页面内兜底浏览)
10
+ * POST /dsh-session-archive-delete/pick-folder 弹系统文件夹对话框,返回选中路径
11
+ * 2. 客户端渲染失败时,把 /dsh-session-archive-delete/client.js 作为普通 <script> 兜底注入
12
+ *
13
+ * 主路径是标准 dsh.client 声明;webserver 注入只作兜底。
14
+ *
15
+ * 安全约束:
16
+ * - 会话目录必须解析在 ~/.dsh/sessions 之下,且目录名与会话 id 一致(delete.js 里再校验一次)
17
+ * - 备份根目录不能落在 ~/.dsh 里,否则 DSH 会把它当成数据
18
+ * - 备份校验不通过就中止,原会话保留
19
+ */
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import os from 'node:os'
23
+ import { spawn } from 'node:child_process'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { dshHome, isInside } from './paths.js'
26
+ import { readSettings, writeSettings, normalize } from './store.js'
27
+ import { backupSession } from './backup.js'
28
+ import { deleteSession, isValidSessionId } from './delete.js'
29
+ import { collectSubtree } from './subtree.js'
30
+
31
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
32
+ const ROOT = path.join(HERE, '..')
33
+ const CLIENT_FILE = path.join(ROOT, 'assets', 'client.js')
34
+ const PKG_FILE = path.join(ROOT, 'package.json')
35
+
36
+ const NS = '/dsh-session-archive-delete'
37
+ const CLIENT_PATH = NS + '/client.js'
38
+ const CONFIG_PATH = NS + '/config'
39
+ const SETTINGS_PATH = NS + '/settings'
40
+ const SESSION_PATH = NS + '/session'
41
+ const BROWSE_PATH = NS + '/browse'
42
+ const PICK_PATH = NS + '/pick-folder'
43
+ const CHECK_PATH = NS + '/check-path'
44
+
45
+ const PKG_VERSION = (() => {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(PKG_FILE, 'utf8')).version || '0.0.0'
48
+ } catch {
49
+ return '0.0.0'
50
+ }
51
+ })()
52
+
53
+ // 日志只打到宿主控制台,不落盘。
54
+ function log(...args) {
55
+ try {
56
+ console.log('[dsh-session-archive-delete]', ...args)
57
+ } catch {}
58
+ }
59
+
60
+ function json(res, obj, code) {
61
+ const body = JSON.stringify(obj)
62
+ res.writeHead(code || 200, {
63
+ 'Content-Type': 'application/json; charset=utf-8',
64
+ 'Content-Length': Buffer.byteLength(body),
65
+ 'Cache-Control': 'no-store',
66
+ })
67
+ res.end(body)
68
+ }
69
+
70
+ function readBody(req, limit = 1e6) {
71
+ return new Promise((resolve, reject) => {
72
+ let data = ''
73
+ req.on('data', (d) => {
74
+ data += d
75
+ if (data.length > limit) {
76
+ req.destroy()
77
+ reject(new Error('body too large'))
78
+ }
79
+ })
80
+ req.on('end', () => resolve(data))
81
+ req.on('error', reject)
82
+ req.on('aborted', () => reject(new Error('aborted')))
83
+ })
84
+ }
85
+
86
+ /** 会话 id 的两种写法:裸 uuid 与 session- 前缀 */
87
+ function idVariants(id) {
88
+ const leaf = String(id).replace(/^session-/, '')
89
+ return [...new Set([String(id), leaf, 'session-' + leaf])]
90
+ }
91
+
92
+ /**
93
+ * 找到会话目录与缓存文件。
94
+ * 目录扫描方式与 ps1 一致:遍历 sessions/<工作区>/<id>,不自己去推导工作区目录名的编码。
95
+ */
96
+ function locateSession(id) {
97
+ const home = dshHome()
98
+ const sessionsRoot = path.join(home, 'sessions')
99
+ const cacheRoot = path.join(home, 'storages', 'session_projcache', 'sessions')
100
+ const variants = idVariants(id)
101
+
102
+ let sessionDir = null
103
+ let workspace = null
104
+ let entries = []
105
+ try {
106
+ entries = fs.readdirSync(sessionsRoot, { withFileTypes: true })
107
+ } catch {}
108
+ for (const e of entries) {
109
+ if (!e.isDirectory()) continue
110
+ for (const v of variants) {
111
+ const candidate = path.join(sessionsRoot, e.name, v)
112
+ try {
113
+ if (fs.statSync(candidate).isDirectory()) {
114
+ sessionDir = candidate
115
+ workspace = e.name
116
+ break
117
+ }
118
+ } catch {}
119
+ }
120
+ if (sessionDir) break
121
+ }
122
+
123
+ let cacheFile = null
124
+ for (const v of variants) {
125
+ const candidate = path.join(cacheRoot, v + '.json')
126
+ if (fs.existsSync(candidate)) {
127
+ cacheFile = candidate
128
+ break
129
+ }
130
+ }
131
+
132
+ return {
133
+ sessionDir,
134
+ workspace: workspace || '_unknown',
135
+ cacheFile,
136
+ sessionsRoot,
137
+ snapshots: {
138
+ workspaceJson: path.join(home, 'storages', 'workspace.json'),
139
+ projcacheJson: path.join(home, 'storages', 'session_projcache.json'),
140
+ },
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 默认备份根 = 工作区根目录下的「DSH-归档会话」。
146
+ *
147
+ * 注意:不能用 process.cwd()!DSH 宿主进程的工作目录通常落在 ~/.dsh/profiles 下,
148
+ * 会命中「备份不能放在 ~/.dsh 里」的安全校验。
149
+ *
150
+ * 优先级:
151
+ * 1. 用户设置 backupRoot
152
+ * 2. 环境变量 DSH_ARCHIVE_ROOT
153
+ * 3. 插件 config.workspaceRoot
154
+ * 4. 从当前会话的工作区目录推断(用户的真实项目目录)
155
+ * 5. 最后回退到用户主目录下的 DSH-归档会话
156
+ */
157
+ const ARCHIVE_FOLDER = 'DSH-归档会话'
158
+
159
+ /** 从会话列表里挑一个工作区目录作为默认存放位置 */
160
+ async function detectWorkspaceRoot(ctx) {
161
+ try {
162
+ const sq = ctx && ctx.get ? ctx.get('sessionQuery') : null
163
+ if (!sq || typeof sq.listSessions !== 'function') return ''
164
+ const records = await sq.listSessions()
165
+ const counts = new Map()
166
+ for (const r of records || []) {
167
+ const cwd = r && r.header && typeof r.header.cwd === 'string' ? r.header.cwd.trim() : ''
168
+ if (!cwd) continue
169
+ // 工作区目录不能落在 ~/.dsh 里
170
+ const home = path.resolve(dshHome())
171
+ const resolved = path.resolve(cwd)
172
+ if (resolved === home || isInside(home, resolved)) continue
173
+ counts.set(cwd, (counts.get(cwd) || 0) + 1)
174
+ }
175
+ // 取会话最多的那个目录
176
+ let best = ''
177
+ let bestN = 0
178
+ for (const [cwd, n] of counts) {
179
+ if (n > bestN) {
180
+ best = cwd
181
+ bestN = n
182
+ }
183
+ }
184
+ return best
185
+ } catch {
186
+ return ''
187
+ }
188
+ }
189
+
190
+ function resolveBackupRoot(settings, workspaceRoot) {
191
+ const raw = settings && settings.backupRoot ? String(settings.backupRoot).trim() : ''
192
+ if (raw) return path.resolve(raw)
193
+ const envRoot = process.env.DSH_ARCHIVE_ROOT
194
+ if (envRoot && envRoot.trim()) return path.resolve(envRoot.trim())
195
+ const ws = workspaceRoot && String(workspaceRoot).trim() ? String(workspaceRoot).trim() : ''
196
+ if (ws) return path.join(path.resolve(ws), ARCHIVE_FOLDER)
197
+ // 兜底:主目录下(既不属于 ~/.dsh,也不写死任何盘符)
198
+ return path.join(os.homedir(), ARCHIVE_FOLDER)
199
+ }
200
+
201
+ function checkBackupRoot(backupRoot) {
202
+ const home = dshHome()
203
+ const root = path.resolve(backupRoot)
204
+ if (root === path.resolve(home) || isInside(home, root)) {
205
+ return '备份目录不能放在 ~/.dsh 里面,否则 DSH 会重新看到它'
206
+ }
207
+ return null
208
+ }
209
+
210
+ /**
211
+ * 校验用户填写的备份目录。
212
+ *
213
+ * 规则:
214
+ * - 空字符串 = 使用默认(始终合法)
215
+ * - 必须在 ~/.dsh 之外
216
+ * - 已存在的路径必须是目录
217
+ * - 不存在时,最近的已存在祖先必须是目录,且可写(这样才能自动建目录)
218
+ *
219
+ * @returns {{ok:boolean, input:string, resolved:string, isDefault:boolean, exists:boolean, error?:string}}
220
+ */
221
+ function validateBackupPath(input, workspaceRoot) {
222
+ const raw = typeof input === 'string' ? input.trim() : ''
223
+ const isDefault = raw === ''
224
+ const fallback = resolveBackupRoot({ backupRoot: '' }, workspaceRoot)
225
+
226
+ if (isDefault) {
227
+ return { ok: true, input: '', resolved: fallback, isDefault: true, exists: fs.existsSync(fallback) }
228
+ }
229
+
230
+ let resolved
231
+ try {
232
+ resolved = path.resolve(raw)
233
+ } catch {
234
+ return { ok: false, input: raw, resolved: raw, isDefault: false, exists: false, error: '路径格式无效' }
235
+ }
236
+
237
+ // 必须是非空盘符路径
238
+ if (!path.isAbsolute(resolved)) {
239
+ return { ok: false, input: raw, resolved, isDefault: false, exists: false, error: '请填写完整路径,例如 E:\\备份\\DSH' }
240
+ }
241
+
242
+ const homeProblem = checkBackupRoot(resolved)
243
+ if (homeProblem) {
244
+ return { ok: false, input: raw, resolved, isDefault: false, exists: false, error: homeProblem }
245
+ }
246
+
247
+ const exists = fs.existsSync(resolved)
248
+ if (exists) {
249
+ let st
250
+ try {
251
+ st = fs.statSync(resolved)
252
+ } catch (e) {
253
+ return { ok: false, input: raw, resolved, isDefault: false, exists: true, error: '无法读取该路径:' + String((e && e.message) || e) }
254
+ }
255
+ if (!st.isDirectory()) {
256
+ return { ok: false, input: raw, resolved, isDefault: false, exists: true, error: '这个路径是一个文件,不是文件夹' }
257
+ }
258
+ // 探测可写:在目标目录里建一个临时文件再删掉
259
+ try {
260
+ const probe = path.join(resolved, '.dsh-write-probe-' + Date.now())
261
+ fs.writeFileSync(probe, '')
262
+ fs.rmSync(probe, { force: true })
263
+ } catch {
264
+ return { ok: false, input: raw, resolved, isDefault: false, exists: true, error: '该文件夹没有写入权限' }
265
+ }
266
+ return { ok: true, input: raw, resolved, isDefault: false, exists: true }
267
+ }
268
+
269
+ // 不存在:找最近的已存在祖先,要求它可写(届时会自动逐级创建)
270
+ let probe = resolved
271
+ for (let i = 0; i < 32 && probe; i++) {
272
+ const parent = path.dirname(probe)
273
+ if (parent === probe) break
274
+ probe = parent
275
+ if (!fs.existsSync(probe)) continue
276
+ try {
277
+ const st = fs.statSync(probe)
278
+ if (!st.isDirectory()) {
279
+ return { ok: false, input: raw, resolved, isDefault: false, exists: false, error: '上层路径不是文件夹:' + probe }
280
+ }
281
+ const t = path.join(probe, '.dsh-write-probe-' + Date.now())
282
+ fs.writeFileSync(t, '')
283
+ fs.rmSync(t, { force: true })
284
+ } catch {
285
+ return { ok: false, input: raw, resolved, isDefault: false, exists: false, error: '没有权限在 ' + probe + ' 下创建文件夹' }
286
+ }
287
+ return { ok: true, input: raw, resolved, isDefault: false, exists: false }
288
+ }
289
+
290
+ return { ok: false, input: raw, resolved, isDefault: false, exists: false, error: '找不到可用的上层目录,请检查盘符是否存在' }
291
+ }
292
+
293
+ // ---- 标题:与 delete-archived-sessions.ps1 同一套来源 ----
294
+ // record.rows.title.val → record.rows.titleInput.val.first.text → 会话首条用户输入 → 「未命名」
295
+ function titleFromProjCache(cacheFile) {
296
+ const out = { title: '', first: '' }
297
+ if (!cacheFile || !fs.existsSync(cacheFile)) return out
298
+ try {
299
+ const doc = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
300
+ const rows = doc && doc.record && doc.record.rows
301
+ if (!rows || typeof rows !== 'object') return out
302
+ if (rows.title && typeof rows.title.val === 'string') out.title = rows.title.val
303
+ const ti = rows.titleInput
304
+ if (ti && ti.val && ti.val.first && typeof ti.val.first.text === 'string') out.first = ti.val.first.text
305
+ } catch {}
306
+ return out
307
+ }
308
+
309
+ /** 从会话目录里的明文 .jsonl 取首条用户输入(主文件是 zstd,读不了就返回空) */
310
+ function firstUserText(sessionDir) {
311
+ if (!sessionDir || !fs.existsSync(sessionDir)) return ''
312
+ try {
313
+ for (const e of fs.readdirSync(sessionDir, { withFileTypes: true })) {
314
+ if (!e.isFile() || !e.name.endsWith('.jsonl')) continue
315
+ const lines = fs.readFileSync(path.join(sessionDir, e.name), 'utf8').split(/\r?\n/).slice(0, 100)
316
+ for (const line of lines) {
317
+ if (!line.includes('"role"') || !line.includes('"user"')) continue
318
+ try {
319
+ const o = JSON.parse(line)
320
+ const t = typeof o.message === 'string' ? o.message : typeof o.text === 'string' ? o.text : ''
321
+ if (t.trim()) return t.trim()
322
+ } catch {}
323
+ }
324
+ }
325
+ } catch {}
326
+ return ''
327
+ }
328
+
329
+ /** 最终标题:缓存标题 → 首条输入 → 客户端传来 → 「未命名」 */
330
+ function resolveTitle(cacheFile, sessionDir, clientTitle) {
331
+ const c = titleFromProjCache(cacheFile)
332
+ if (c.title && c.title.trim()) return c.title.trim()
333
+ if (c.first && c.first.trim()) return c.first.trim()
334
+ const fu = firstUserText(sessionDir)
335
+ if (fu) return fu
336
+ if (clientTitle && String(clientTitle).trim()) return String(clientTitle).trim()
337
+ return '未命名会话'
338
+ }
339
+
340
+ // ---- 系统文件夹对话框 ----
341
+ // 宿主是 Node 进程,可以 spawn pwsh 调 Windows 自带的 WinForms FolderBrowserDialog。
342
+ // 非 Windows、找不到 pwsh、或脚本出错时返回 supported=false,由前端回退到页面内浏览。
343
+
344
+ const PICK_TIMEOUT_MS = 30 * 60 * 1000 // 30 分钟:用户可能开着对话框走开
345
+
346
+ /** 找一个可用的 PowerShell 7 可执行文件(不写死任何本机路径) */
347
+ function findPwsh() {
348
+ const envPs = process.env.DSH_PWSH || process.env.DSH_POWERSHELL
349
+ if (envPs && fs.existsSync(envPs)) return envPs
350
+
351
+ // 先从 PATH 找(标准安装会自己加进去)
352
+ const exeName = process.platform === 'win32' ? 'pwsh.exe' : 'pwsh'
353
+ const pathParts = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
354
+ for (const dir of pathParts) {
355
+ try {
356
+ const c = path.join(dir, exeName)
357
+ if (fs.existsSync(c)) return c
358
+ } catch {}
359
+ }
360
+
361
+ // 再试常见安装位置(按平台约定,不含个人路径)
362
+ const candidates =
363
+ process.platform === 'win32'
364
+ ? [
365
+ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
366
+ path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'PowerShell', '7', 'pwsh.exe'),
367
+ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'PowerShell', '7-preview', 'pwsh.exe'),
368
+ path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'PowerShell', '7', 'pwsh.exe'),
369
+ ]
370
+ : ['/usr/local/bin/pwsh', '/opt/microsoft/powershell/7/pwsh', '/usr/bin/pwsh']
371
+
372
+ for (const c of candidates) {
373
+ if (c && fs.existsSync(c)) return c
374
+ }
375
+ return null
376
+ }
377
+
378
+ /**
379
+ * 弹系统文件夹对话框,返回选中路径。
380
+ * @param {string} initialDir 对话框初始位置
381
+ * @returns {Promise<{ok:boolean, path?:string|null, canceled?:boolean, supported?:boolean, error?:string}>}
382
+ */
383
+ function pickFolderNative(initialDir) {
384
+ if (process.platform !== 'win32') {
385
+ return Promise.resolve({ ok: false, supported: false, error: '当前系统没有可调用的原生文件夹对话框' })
386
+ }
387
+ const ps = findPwsh()
388
+ if (!ps) {
389
+ return Promise.resolve({ ok: false, supported: false, error: '找不到 pwsh 7' })
390
+ }
391
+
392
+ const start = initialDir && fs.existsSync(initialDir) ? initialDir : os.homedir()
393
+ // 用 -EncodedCommand 传脚本:UTF-16LE + base64,中文初始目录不会被命令行二次解析。
394
+ // 输出也必须防编码问题:PowerShell 往管道写中文会按系统代码页(GBK)编码,
395
+ // Node 按 UTF-8 解码就成了乱码,路径校验必然失败。
396
+ // 所以让脚本把结果转成 UTF-8 的 base64 再输出,纯 ASCII,任何代码页下都安全。
397
+ const script = [
398
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null',
399
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
400
+ "$d.Description = '选择 DSH 归档会话备份文件夹'",
401
+ '$d.ShowNewFolderButton = $true',
402
+ '$d.SelectedPath = ' + "'" + start.replace(/'/g, "''") + "'",
403
+ '$r = $d.ShowDialog()',
404
+ 'if ($r -eq [System.Windows.Forms.DialogResult]::OK) {',
405
+ ' $b = [Text.Encoding]::UTF8.GetBytes($d.SelectedPath)',
406
+ " [Console]::Out.Write('B64:' + [Convert]::ToBase64String($b))",
407
+ '} else {',
408
+ " [Console]::Out.Write('CANCEL')",
409
+ '}',
410
+ ].join('\r\n')
411
+ const encoded = Buffer.from(script, 'utf16le').toString('base64')
412
+
413
+ return new Promise((resolve) => {
414
+ let out = ''
415
+ let err = ''
416
+ let done = false
417
+ let child
418
+ try {
419
+ child = spawn(ps, ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', encoded], {
420
+ stdio: ['ignore', 'pipe', 'pipe'],
421
+ windowsHide: true,
422
+ })
423
+ } catch (e) {
424
+ resolve({ ok: false, supported: false, error: '无法启动 PowerShell:' + String((e && e.message) || e) })
425
+ return
426
+ }
427
+
428
+ const timer = setTimeout(() => {
429
+ if (done) return
430
+ done = true
431
+ try {
432
+ child.kill()
433
+ } catch {}
434
+ resolve({ ok: false, error: '文件夹对话框超时(30 分钟)' })
435
+ }, PICK_TIMEOUT_MS)
436
+
437
+ child.stdout.on('data', (d) => (out += d.toString('utf8')))
438
+ child.stderr.on('data', (d) => (err += d.toString('utf8')))
439
+ child.on('error', (e) => {
440
+ if (done) return
441
+ done = true
442
+ clearTimeout(timer)
443
+ resolve({ ok: false, supported: false, error: 'PowerShell 启动失败:' + String((e && e.message) || e) })
444
+ })
445
+ child.on('close', (code) => {
446
+ if (done) return
447
+ done = true
448
+ clearTimeout(timer)
449
+ const raw = (out || '').trim()
450
+ if (code !== 0 && !raw) {
451
+ return resolve({ ok: false, error: (err || '').trim().slice(0, 300) || 'PowerShell 退出码 ' + code })
452
+ }
453
+ if (!raw || raw === 'CANCEL') return resolve({ ok: true, path: null, canceled: true })
454
+
455
+ // 正常返回是 'B64:<base64(UTF8 路径)>',纯 ASCII,不受代码页影响;
456
+ // 兼容旧格式(纯路径),万一环境退回老行为也不至于整条失败。
457
+ let picked
458
+ if (raw.startsWith('B64:')) {
459
+ try {
460
+ picked = Buffer.from(raw.slice(4), 'base64').toString('utf8')
461
+ } catch {
462
+ return resolve({ ok: false, error: '无法解析 PowerShell 返回的路径' })
463
+ }
464
+ } else {
465
+ picked = raw
466
+ }
467
+ picked = picked.replace(/[\r\n]+$/, '')
468
+
469
+ if (!fs.existsSync(picked)) return resolve({ ok: false, error: '选中的路径不存在:' + picked })
470
+ resolve({ ok: true, path: picked })
471
+ })
472
+ })
473
+ }
474
+
475
+ export const name = '@device1999/dsh-session-archive-delete'
476
+
477
+ export function apply(root, config) {
478
+ const disposers = []
479
+ // 默认备份位置的工作区根:config.workspaceRoot 优先,否则启动后从会话列表推断
480
+ let workspaceRoot = config && typeof config.workspaceRoot === 'string' ? config.workspaceRoot.trim() : ''
481
+ log('v' + PKG_VERSION + ' 已加载,模式:' + readSettings().mode)
482
+
483
+ // 延迟推断:sessionQuery 可能比本插件晚就绪
484
+ const detectLater = () => {
485
+ const sq = root.get ? root.get('sessionQuery') : null
486
+ if (!sq) return
487
+ detectWorkspaceRoot(root)
488
+ .then((ws) => {
489
+ if (ws && !workspaceRoot) {
490
+ workspaceRoot = ws
491
+ log('默认备份位置推断为:' + path.join(ws, ARCHIVE_FOLDER))
492
+ }
493
+ })
494
+ .catch(() => {})
495
+ }
496
+ if (!workspaceRoot) {
497
+ try {
498
+ if (typeof root.inject === 'function') root.inject(['sessionQuery'], () => detectLater())
499
+ else detectLater()
500
+ } catch {}
501
+ }
502
+
503
+ // 2026-10-20 修复:默认停用 <script> 兜底注入。
504
+ // assets/client.js 以 `window.__ModuleLoader__.load({...})` 形式编写,只能由
505
+ // dsh.client 声明经 client-modules 加载;作为裸 <script> 注入时 __ModuleLoader__
506
+ // 尚未就绪,会抛 "Cannot read properties of undefined (reading 'load')",并把同一
507
+ // factory 注册两次(duplicate factory registration),最终导致 web boot 失败。
508
+ // 排查主路径问题时可用 DSH_ARCHIVE_DELETE_FALLBACK_INJECT=1 临时恢复兜底。
509
+ if (process.env.DSH_ARCHIVE_DELETE_FALLBACK_INJECT === '1') {
510
+ disposers.push(
511
+ root.on('webserver/index-inject', (table) => {
512
+ try {
513
+ if (!Array.isArray(table)) return
514
+ if (table.some((r) => r && r.kind === 'script-src' && r.src === CLIENT_PATH)) return
515
+ table.push({ kind: 'script-src', placement: 'head', src: CLIENT_PATH, 'data-dsh-session-archive-delete-fallback': '1' })
516
+ } catch {}
517
+ })
518
+ )
519
+ }
520
+
521
+ root.inject(['webServer'], (ctx) => {
522
+ const reg = (route) =>
523
+ disposers.push(
524
+ ctx.webServer.register({
525
+ ...route,
526
+ handler: (req, res) => {
527
+ Promise.resolve()
528
+ .then(() => route.handler(req, res))
529
+ .catch((err) => {
530
+ try {
531
+ json(res, { ok: false, error: String((err && err.message) || err).slice(0, 500) }, 500)
532
+ } catch {}
533
+ })
534
+ },
535
+ })
536
+ )
537
+
538
+ reg({
539
+ kind: 'exact',
540
+ path: CLIENT_PATH,
541
+ handler: (req, res) => {
542
+ let body = ''
543
+ try {
544
+ body = fs.readFileSync(CLIENT_FILE, 'utf8')
545
+ } catch {}
546
+ if (!body) {
547
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
548
+ res.end('client script missing')
549
+ return
550
+ }
551
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' })
552
+ res.end(body)
553
+ },
554
+ })
555
+
556
+ reg({
557
+ kind: 'exact',
558
+ path: CONFIG_PATH,
559
+ handler: (req, res) =>
560
+ json(res, {
561
+ ok: true,
562
+ version: PKG_VERSION,
563
+ settings: readSettings(),
564
+ // 实际生效的备份根(设置里留空时是默认值),供设置页如实显示
565
+ effectiveBackupRoot: resolveBackupRoot(readSettings(), workspaceRoot),
566
+ }),
567
+ })
568
+
569
+ // 目录浏览:给设置页的「文件夹选择」用
570
+ // 只列目录不列文件,隐藏 ~/.dsh(那里不能当备份根),并标出是否有子目录
571
+ reg({
572
+ kind: 'exact',
573
+ path: BROWSE_PATH,
574
+ handler: (req, res) => {
575
+ if (req.method !== 'GET') return json(res, { ok: false, error: 'method not allowed' }, 405)
576
+ const url = new URL(req.url, 'http://x')
577
+ let dir = url.searchParams.get('dir') || ''
578
+ if (!dir) dir = resolveBackupRoot(readSettings(), workspaceRoot)
579
+ dir = path.resolve(dir)
580
+
581
+ // 不存在就退到最近的已存在祖先,避免一次点空白
582
+ let probe = dir
583
+ while (probe && !fs.existsSync(probe)) {
584
+ const up = path.dirname(probe)
585
+ if (up === probe) break
586
+ probe = up
587
+ }
588
+ if (!probe || !fs.existsSync(probe)) return json(res, { ok: false, error: '目录不存在' }, 404)
589
+ dir = probe
590
+
591
+ const home = path.resolve(dshHome())
592
+ const out = []
593
+ try {
594
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
595
+ if (!e.isDirectory()) continue
596
+ if (e.name.startsWith('.')) continue
597
+ const full = path.join(dir, e.name)
598
+ // 隐藏 DSH 家目录本身及其内部,防止误选
599
+ if (path.resolve(full) === home || isInside(home, full)) continue
600
+ let hasChildren = false
601
+ try {
602
+ hasChildren = fs.readdirSync(full, { withFileTypes: true }).some((c) => c.isDirectory())
603
+ } catch {}
604
+ out.push({ name: e.name, path: full, hasChildren })
605
+ }
606
+ } catch (err) {
607
+ return json(res, { ok: false, error: '无法读取目录:' + String((err && err.message) || err) }, 500)
608
+ }
609
+ out.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
610
+ return json(res, { ok: true, dir, parent: path.dirname(dir), dirs: out })
611
+ },
612
+ })
613
+
614
+ reg({
615
+ kind: 'exact',
616
+ path: PICK_PATH,
617
+ handler: async (req, res) => {
618
+ if (req.method !== 'POST') return json(res, { ok: false, error: 'method not allowed' }, 405)
619
+ let body = {}
620
+ try {
621
+ const raw = await readBody(req)
622
+ body = raw ? JSON.parse(raw) : {}
623
+ } catch {}
624
+ const initial = typeof body.initial === 'string' && body.initial ? body.initial : resolveBackupRoot(readSettings(), workspaceRoot)
625
+ let r = await pickFolderNative(initial)
626
+ // 成功与「用户取消」都必须带上 supported:true,否则前端会误判为不支持而回退到页面内浏览
627
+ if (r.ok) r = { ...r, supported: true }
628
+ if (r.ok && r.path) {
629
+ const problem = checkBackupRoot(r.path)
630
+ if (problem) return json(res, { ok: false, supported: true, error: problem }, 400)
631
+ }
632
+ log('pick-folder', r.ok ? (r.path || 'canceled') : 'FAIL ' + r.error + (r.supported === false ? ' (unsupported)' : ''))
633
+ return json(res, r)
634
+ },
635
+ })
636
+
637
+ reg({
638
+ kind: 'exact',
639
+ path: SETTINGS_PATH,
640
+ handler: async (req, res) => {
641
+ if (req.method !== 'POST') return json(res, { ok: false, error: 'method not allowed' }, 405)
642
+ let body = {}
643
+ try {
644
+ const raw = await readBody(req)
645
+ body = raw ? JSON.parse(raw) : {}
646
+ } catch {
647
+ return json(res, { ok: false, error: 'bad json body' }, 400)
648
+ }
649
+ const next = normalize(body && body.settings ? body.settings : body)
650
+ // 保存前做完整校验:路径有效性、是否是目录、是否可写、是否在 ~/.dsh 内
651
+ const check = validateBackupPath(next.backupRoot, workspaceRoot)
652
+ if (!check.ok) return json(res, { ok: false, error: check.error, check }, 400)
653
+ const saved = writeSettings(next)
654
+ return json(res, {
655
+ ok: true,
656
+ settings: saved,
657
+ effectiveBackupRoot: check.resolved,
658
+ })
659
+ },
660
+ })
661
+
662
+ // 路径校验:前端在失焦/确认时先调这里,无效就当场提示、不保存
663
+ reg({
664
+ kind: 'exact',
665
+ path: CHECK_PATH,
666
+ handler: async (req, res) => {
667
+ if (req.method !== 'POST') return json(res, { ok: false, error: 'method not allowed' }, 405)
668
+ let body = {}
669
+ try {
670
+ const raw = await readBody(req)
671
+ body = raw ? JSON.parse(raw) : {}
672
+ } catch {}
673
+ const check = validateBackupPath(typeof body.path === 'string' ? body.path : '', workspaceRoot)
674
+ return json(res, check, check.ok ? 200 : 400)
675
+ },
676
+ })
677
+
678
+ reg({
679
+ kind: 'exact',
680
+ path: SESSION_PATH,
681
+ handler: async (req, res) => {
682
+ if (req.method !== 'POST') return json(res, { ok: false, error: 'method not allowed' }, 405)
683
+
684
+ let args = {}
685
+ try {
686
+ const raw = await readBody(req)
687
+ args = raw ? JSON.parse(raw) : {}
688
+ } catch {
689
+ return json(res, { ok: false, error: 'bad json body' }, 400)
690
+ }
691
+
692
+ const sessionId = String(args.sessionId || '').trim()
693
+ if (!isValidSessionId(sessionId)) {
694
+ return json(res, { ok: false, error: 'invalid session id' }, 400)
695
+ }
696
+
697
+ const settings = readSettings()
698
+ // both 模式下菜单会明确传来具体行为,这里只兜底非法值
699
+ const mode =
700
+ args.mode === 'delete-only' || args.mode === 'backup-then-delete' ? args.mode : settings.mode === 'both' ? 'backup-then-delete' : settings.mode
701
+
702
+ const loc = locateSession(sessionId)
703
+ if (!loc.sessionDir && !loc.cacheFile) {
704
+ return json(res, { ok: false, error: '找不到该会话(可能已被删除)' }, 404)
705
+ }
706
+
707
+ // ---- 收集子代理会话(主会话的兄弟目录,靠 header.parentSession 关联)----
708
+ let subtree = []
709
+ try {
710
+ subtree = await collectSubtree({ ctx, sessionId, sessionsRoot: loc.sessionsRoot })
711
+ } catch (e) {
712
+ log('collectSubtree FAIL', String((e && e.message) || e))
713
+ subtree = [{ id: sessionId }]
714
+ }
715
+ // 第一个恒为主会话;其余是子孙,解析出各自的目录/工作区/缓存
716
+ const children = []
717
+ for (const node of subtree.slice(1)) {
718
+ if (!isValidSessionId(node.id)) continue
719
+ const l = locateSession(node.id)
720
+ if (!l.sessionDir) continue
721
+ children.push({ id: node.id, dir: l.sessionDir, workspace: l.workspace, cacheFile: l.cacheFile })
722
+ }
723
+ if (children.length > 0) log('子代理会话', children.length, '个:', children.map((c) => c.id).join(','))
724
+
725
+ // ---- 删除模式:不备份 ----
726
+ if (mode === 'delete-only') {
727
+ if (!loc.sessionDir) {
728
+ return json(res, { ok: false, error: '会话目录不存在,无法删除' }, 404)
729
+ }
730
+ const r = await deleteSession({
731
+ ctx,
732
+ sessionId,
733
+ sessionDir: loc.sessionDir,
734
+ sessionsRoot: loc.sessionsRoot,
735
+ cacheFile: loc.cacheFile,
736
+ })
737
+ // 主会话删除成功后,连带删除子代理会话(逐个独立处理,失败不影响主结果)
738
+ const childResults = []
739
+ if (r.ok) {
740
+ for (const c of children) {
741
+ const cr = await deleteSession({
742
+ ctx,
743
+ sessionId: c.id,
744
+ sessionDir: c.dir,
745
+ sessionsRoot: loc.sessionsRoot,
746
+ cacheFile: c.cacheFile,
747
+ })
748
+ childResults.push({ id: c.id, ok: cr.ok, error: cr.error })
749
+ }
750
+ }
751
+ log('delete-only', sessionId, r.ok ? 'OK' : 'FAIL ' + r.error, '子代理', childResults.length)
752
+ return json(
753
+ res,
754
+ { ok: r.ok, mode, result: r, subagents: childResults, error: r.error },
755
+ r.ok ? 200 : 500,
756
+ )
757
+ }
758
+
759
+ // ---- 备份删除 ----
760
+ const backupRoot = resolveBackupRoot(settings, workspaceRoot)
761
+ const rootProblem = checkBackupRoot(backupRoot)
762
+ if (rootProblem) return json(res, { ok: false, error: rootProblem }, 400)
763
+
764
+ // 标题以宿主读到的为准:客户端 props 里没有 title 字段
765
+ const title = resolveTitle(loc.cacheFile, loc.sessionDir, args.title)
766
+ const b = backupSession({
767
+ backupRoot,
768
+ sessionId,
769
+ sessionDir: loc.sessionDir || '',
770
+ cacheFile: loc.cacheFile,
771
+ workspace: loc.workspace,
772
+ title,
773
+ dateStyle: settings.dateStyle,
774
+ snapshots: loc.snapshots,
775
+ extraSessions: children,
776
+ })
777
+ if (!b.ok) {
778
+ log('backup FAIL', sessionId, b.error)
779
+ return json(res, { ok: false, step: 'backup', error: '备份失败,已中止删除:' + b.error }, 500)
780
+ }
781
+ log('backup OK', sessionId, '→', b.name, '子代理', (b.subagents || []).length)
782
+
783
+ const d = await deleteSession({
784
+ ctx,
785
+ sessionId,
786
+ sessionDir: loc.sessionDir,
787
+ sessionsRoot: loc.sessionsRoot,
788
+ cacheFile: loc.cacheFile,
789
+ })
790
+ // 主会话删除成功后,连带删除子代理会话
791
+ const childResults = []
792
+ if (d.ok) {
793
+ for (const c of children) {
794
+ const cr = await deleteSession({
795
+ ctx,
796
+ sessionId: c.id,
797
+ sessionDir: c.dir,
798
+ sessionsRoot: loc.sessionsRoot,
799
+ cacheFile: c.cacheFile,
800
+ })
801
+ childResults.push({ id: c.id, ok: cr.ok, error: cr.error })
802
+ }
803
+ }
804
+ log('delete', sessionId, d.ok ? 'OK' : 'FAIL ' + d.error, '子代理已删', childResults.filter((x) => x.ok).length)
805
+
806
+ return json(
807
+ res,
808
+ {
809
+ ok: d.ok,
810
+ mode,
811
+ title,
812
+ backup: { dir: b.dir, name: b.name },
813
+ result: d,
814
+ subagents: childResults,
815
+ error: d.error,
816
+ },
817
+ d.ok ? 200 : 500,
818
+ )
819
+ },
820
+ })
821
+ })
822
+
823
+ root.effect(() => () => {
824
+ for (const d of disposers.splice(0)) {
825
+ try {
826
+ d()
827
+ } catch {}
828
+ }
829
+ })
830
+ }