dsh-delete-session 0.1.10 → 0.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 CHANGED
@@ -1,517 +1,2213 @@
1
- // dsh-delete-session - host half.
2
- //
3
- // One loopback-only HTTP route serves the client menu item:
4
- //
5
- // POST /dsh-delete-session/delete { sessionId }
6
- //
7
- // Deletion pipeline, kept consistent with the live DSH services so in-memory
8
- // state and on-disk units cannot disagree after the request returns:
9
- //
10
- // 1. stop a running agent (cancel + time-boxed quiescence) when one exists;
11
- // 2. flush and detach the live session so its async dispose cannot rewrite
12
- // the log directory after we remove it, and wait for the persistence
13
- // retirement that session/disposed starts;
14
- // 3. take the persistence write lease briefly: a busy lease means the
15
- // session is still open in DSH, so the request is refused with 409
16
- // instead of racing the active writer;
17
- // 4. remove every on-disk session directory (raw id and session- prefix);
18
- // 5. verify through the persistence service and a directory re-scan;
19
- // 6. detach the session from workspace accounting so the sidebar drops the
20
- // row instead of re-grouping an orphan under "Ungrouped".
21
- //
22
- // The plugin imports nothing from the DSH SDK: every service is resolved
23
- // through the cordis context at call time, so the module loads on any profile
24
- // and degrades to the filesystem path when a service is absent.
25
- import fs from 'node:fs'
26
- import os from 'node:os'
27
- import path from 'node:path'
28
-
29
- export const name = 'dsh-delete-session'
30
-
31
- const ROUTE_PREFIX = '/dsh-delete-session'
32
- const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
33
- const MAX_BATCH = 100
34
-
35
- class HttpError extends Error {
36
- constructor(status, code, message) {
37
- super(message)
38
- this.status = status
39
- this.code = code
40
- }
41
- }
42
-
43
- // --- paths -------------------------------------------------------------------
44
-
45
- function dshHome() {
46
- return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
47
- }
48
-
49
- function sessionsRoot() {
50
- return path.join(dshHome(), 'sessions')
51
- }
52
-
53
- // A session id travels in two spellings: the raw uuid and `session-<uuid>`.
54
- // The on-disk JSONL backend uses the prefixed form for the directory name,
55
- // while workspace and projection rows may carry either. Clean both.
56
- function idVariants(sessionId) {
57
- const out = new Set([sessionId])
58
- if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
59
- else out.add(`session-${sessionId}`)
60
- return [...out]
61
- }
62
-
63
- // Scan every workspace directory under $DSH_HOME/sessions for the session's
64
- // directories, so the workspace-path encoding never has to be re-derived here.
65
- function findSessionDirs(sessionId) {
66
- const root = sessionsRoot()
67
- let entries
68
- try {
69
- entries = fs.readdirSync(root, { withFileTypes: true })
70
- } catch {
71
- return []
72
- }
73
- const variants = idVariants(sessionId)
74
- const found = []
75
- for (const entry of entries) {
76
- if (!entry.isDirectory()) continue
77
- for (const variant of variants) {
78
- const candidate = path.join(root, entry.name, variant)
79
- try {
80
- if (fs.statSync(candidate).isDirectory() && !found.includes(candidate)) found.push(candidate)
81
- } catch {
82
- // keep scanning
83
- }
84
- }
85
- }
86
- return found
87
- }
88
-
89
- function removeSessionDirs(sessionId) {
90
- const dirs = findSessionDirs(sessionId)
91
- for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true })
92
- return dirs
93
- }
94
-
95
- // Deleting the on-disk directory is not enough: projection caches keep a
96
- // per-session JSON row (`$DSH_HOME/storages/<store>/sessions/<id>.json`) that
97
- // would linger as a ghost — the sidebar list is directory-backed, so the row
98
- // can never be shown or deleted again (issue: "can't delete anymore"). Remove
99
- // the session's cache units together with its directories.
100
- function removeSessionCaches(sessionId) {
101
- const storesRoot = path.join(dshHome(), 'storages')
102
- const removed = []
103
- let stores
104
- try {
105
- stores = fs.readdirSync(storesRoot, { withFileTypes: true })
106
- } catch {
107
- return removed
108
- }
109
- const names = idVariants(sessionId).map((variant) => `${variant}.json`)
110
- for (const store of stores) {
111
- if (!store.isDirectory()) continue
112
- const dir = path.join(storesRoot, store.name, 'sessions')
113
- let entries
114
- try {
115
- entries = fs.readdirSync(dir)
116
- } catch {
117
- continue
118
- }
119
- for (const entry of entries) {
120
- if (!names.includes(entry)) continue
121
- const file = path.join(dir, entry)
122
- try {
123
- fs.rmSync(file, { force: true })
124
- removed.push(file)
125
- } catch {
126
- // best-effort: a locked cache row must not fail the deletion
127
- }
128
- }
129
- }
130
- return removed
131
- }
132
-
133
- // --- live state --------------------------------------------------------------
134
-
135
- // Stop a running agent before its session disappears: cancel the active turn
136
- // and wait, time-boxed, so a stuck driver never blocks the deletion.
137
- async function quiesceAgent(ctx, sessionId) {
138
- const agents = ctx.get('agents')
139
- if (!agents || typeof agents.get !== 'function') return false
140
- const agent = agents.get(sessionId)
141
- if (!agent) return false
142
- try {
143
- if (typeof agent.cancel === 'function') agent.cancel({ kind: 'user' })
144
- } catch {
145
- // agent may already be settling
146
- }
147
- if (typeof agent.whenIdle === 'function') {
148
- try {
149
- await Promise.race([agent.whenIdle(), new Promise((resolve) => setTimeout(resolve, 10000))])
150
- } catch {
151
- // proceed with deletion anyway
152
- }
153
- }
154
- return true
155
- }
156
-
157
- // Flush a live session, then detach its entered record so DSH emits
158
- // host/session-removed and the client list drops the row. The persistence
159
- // retirement started by session/disposed is awaited before the caller unlinks
160
- // anything, because its final drain could otherwise recreate the directory.
161
- async function flushAndDetach(ctx, sessionId) {
162
- const sessions = ctx.get('sessions')
163
- let flushed = false
164
- let detached = false
165
- if (sessions) {
166
- for (const variant of idVariants(sessionId)) {
167
- const live = typeof sessions.get === 'function' ? sessions.get(variant) : undefined
168
- if (!live) continue
169
- if (typeof sessions.flush === 'function') {
170
- try {
171
- await sessions.flush(live)
172
- flushed = true
173
- } catch {
174
- // the log is removed anyway
175
- }
176
- }
177
- try {
178
- const entered = sessions.store && typeof sessions.store.get === 'function' ? sessions.store.get(variant) : undefined
179
- if (entered && typeof entered.detach === 'function') {
180
- entered.detach()
181
- detached = true
182
- }
183
- } catch {
184
- // best effort: the directory removal below is the authority
185
- }
186
- }
187
- }
188
- const sp = ctx.get('sessionPersistence')
189
- try {
190
- const retirement = sp && sp.retirements && typeof sp.retirements.get === 'function' ? sp.retirements.get(sessionId) : undefined
191
- if (retirement && typeof retirement.then === 'function') await retirement
192
- } catch {
193
- // no retirement registry: nothing to wait for
194
- }
195
- return { flushed, detached }
196
- }
197
-
198
- // Probe the persistence write lease. Holding it proves no active writer is
199
- // around; it is released immediately. Detaching a live session releases its
200
- // lease asynchronously (session/disposed starts a persistence retirement), so
201
- // the probe is retried with a short backoff before the request is refused -
202
- // in practice the first click then succeeds without forcing anything.
203
- // The backoff is short (200ms x 12 = ~2.2s worst case) so a batch containing
204
- // live sessions stays responsive while the lease retirement lands.
205
- const BUSY_RETRY_ATTEMPTS = 12
206
- const BUSY_RETRY_DELAY_MS = 200
207
-
208
- function sleep(ms) {
209
- return new Promise((resolve) => setTimeout(resolve, ms))
210
- }
211
-
212
- async function assertNoActiveWriter(ctx, sessionId) {
213
- const sp = ctx.get('sessionPersistence')
214
- if (!sp || typeof sp.open !== 'function') return false
215
- for (let attempt = 1; attempt <= BUSY_RETRY_ATTEMPTS; attempt += 1) {
216
- let handle = null
217
- let busy = false
218
- try {
219
- handle = await sp.open(sessionId, 'write')
220
- } catch (error) {
221
- const message = String((error && error.message) || error)
222
- if (/own|busy|lock|hold|writer/i.test(message)) {
223
- busy = true
224
- } else {
225
- return false
226
- }
227
- } finally {
228
- try {
229
- if (handle && typeof handle.close === 'function') await handle.close()
230
- } catch {
231
- // lease release failure is harmless: the directory is removed next
232
- }
233
- }
234
- if (!busy) return true
235
- if (attempt < BUSY_RETRY_ATTEMPTS) await sleep(BUSY_RETRY_DELAY_MS)
236
- }
237
- throw new HttpError(409, 'busy', 'session is currently open in DSH')
238
- }
239
-
240
- // Bounded concurrency for batch deletes. Different sessions are independent
241
- // (agent quiesce, lease probe and directory sweep all key off the session id),
242
- // so the pipeline runs in parallel through a small pool. The one shared
243
- // mutation - workspace accounting - is serialized through withWorkspaceLock so
244
- // two deletions can never interleave on the same store.
245
- //
246
- // 6 is measured, not guessed: on 64 sessions of ~2MB the wall time was
247
- // 281ms at 4, 189ms at 6 and 142ms at 8 (the I/O parallelism saturates around
248
- // 8), so 6 keeps a ~33% gain over 4 while staying below the saturation knee
249
- // and limiting the number of simultaneous lease-probe loops.
250
- const BATCH_CONCURRENCY = 6
251
-
252
- let workspaceLock = Promise.resolve()
253
- function withWorkspaceLock(fn) {
254
- const run = workspaceLock.then(fn, fn)
255
- workspaceLock = run.then(() => undefined, () => undefined)
256
- return run
257
- }
258
-
259
- async function runWithConcurrency(items, limit, worker) {
260
- const results = new Array(items.length)
261
- let cursor = 0
262
- const runners = []
263
- for (let i = 0; i < Math.min(limit, items.length); i += 1) {
264
- runners.push((async () => {
265
- while (true) {
266
- const index = cursor
267
- cursor += 1
268
- if (index >= items.length) return
269
- results[index] = await worker(items[index], index)
270
- }
271
- })())
272
- }
273
- await Promise.all(runners)
274
- return results
275
- }
276
-
277
- async function detachFromWorkspace(ctx, sessionId) {
278
- const workspace = ctx.get('workspaceRegistry')
279
- if (!workspace) return false
280
- const variants = idVariants(sessionId)
281
- let touched = false
282
- try {
283
- if (typeof workspace.list === 'function') {
284
- for (const entry of workspace.list()) {
285
- if (!entry || !Array.isArray(entry.sessionIds)) continue
286
- if (!variants.some((variant) => entry.sessionIds.includes(variant))) continue
287
- try {
288
- await entry.detachSession(sessionId)
289
- touched = true
290
- } catch {
291
- // keep going: the header index cleanup below still applies
292
- }
293
- }
294
- }
295
- } catch {
296
- // registry unavailable
297
- }
298
- try {
299
- if (workspace.sessionPaths && typeof workspace.sessionPaths.delete === 'function') {
300
- for (const variant of variants) workspace.sessionPaths.delete(variant)
301
- }
302
- } catch {
303
- // private map absent on this version
304
- }
305
- try {
306
- if (workspace.headers && typeof workspace.headers.delete === 'function') {
307
- for (const variant of variants) workspace.headers.delete(variant)
308
- }
309
- } catch {
310
- // private map absent on this version
311
- }
312
- return touched
313
- }
314
-
315
- // --- core delete -------------------------------------------------------------
316
-
317
- async function deleteSession(ctx, sessionId) {
318
- if (!SESSION_ID_RE.test(sessionId)) {
319
- throw new HttpError(400, 'invalid', 'invalid session id')
320
- }
321
- const stopped = await quiesceAgent(ctx, sessionId)
322
- const { flushed, detached } = await flushAndDetach(ctx, sessionId)
323
- await assertNoActiveWriter(ctx, sessionId)
324
-
325
- // Remove the on-disk directories first. If the filesystem refuses, fail
326
- // before touching workspace accounting so a half-deleted session cannot
327
- // fall out of its group into "Ungrouped".
328
- let removed = removeSessionDirs(sessionId)
329
- await new Promise((resolve) => setImmediate(resolve))
330
- removed = removed.concat(removeSessionDirs(sessionId))
331
-
332
- const sp = ctx.get('sessionPersistence')
333
- if (sp && typeof sp.stat === 'function') {
334
- const after = await sp.stat(sessionId).catch(() => undefined)
335
- if (after) {
336
- throw new HttpError(500, 'verify-failed', 'session is still visible to the persistence service after deletion')
337
- }
338
- }
339
- const leftover = findSessionDirs(sessionId)
340
- if (leftover.length > 0) {
341
- throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
342
- }
343
-
344
- // Cache rows are projection state, not the source of truth: clean them even
345
- // when the directories were already gone, so a deleted session cannot leave
346
- // a ghost behind.
347
- const cachesRemoved = removeSessionCaches(sessionId)
348
-
349
- const workspaceDetached = await withWorkspaceLock(() => detachFromWorkspace(ctx, sessionId))
350
- if (removed.length === 0 && !workspaceDetached && !flushed && !detached && cachesRemoved.length === 0) {
351
- throw new HttpError(404, 'not-found', 'session not found')
352
- }
353
- return { removed: removed.length, caches: cachesRemoved.length, stopped, flushed, detached, workspaceDetached }
354
- }
355
-
356
- // --- http --------------------------------------------------------------------
357
-
358
- function isLoopbackAddress(address) {
359
- if (typeof address !== 'string' || address.length === 0) return false
360
- return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
361
- }
362
-
363
- function isLocalHostHeader(host) {
364
- if (typeof host !== 'string' || host.length === 0) return false
365
- const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
366
- return name === 'localhost' || name === '127.0.0.1' || name === '::1'
367
- }
368
-
369
- function sendJson(res, status, body) {
370
- const payload = JSON.stringify(body)
371
- res.writeHead(status, {
372
- 'content-type': 'application/json; charset=utf-8',
373
- 'content-length': Buffer.byteLength(payload),
374
- })
375
- res.end(payload)
376
- }
377
-
378
- function readBody(req) {
379
- return new Promise((resolve, reject) => {
380
- let data = ''
381
- req.on('data', (chunk) => {
382
- data += chunk
383
- if (data.length > 1e6) req.destroy()
384
- })
385
- req.on('end', () => resolve(data))
386
- req.on('error', reject)
387
- req.on('aborted', () => reject(new Error('aborted')))
388
- })
389
- }
390
-
391
- // Destructive local endpoint: loopback socket, loopback Host header, and a
392
- // same-origin check when the browser sends Origin. Tunneled or LAN clients
393
- // get 403 before any session data is touched.
394
- function guard(req, res) {
395
- if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
396
- sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
397
- return false
398
- }
399
- const host = req.headers.host
400
- if (!isLocalHostHeader(host)) {
401
- sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
402
- return false
403
- }
404
- const origin = req.headers.origin
405
- if (typeof origin === 'string' && origin.length > 0) {
406
- let originHost = null
407
- try {
408
- originHost = new URL(origin).host
409
- } catch {
410
- originHost = null
411
- }
412
- if (originHost !== host) {
413
- sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
414
- return false
415
- }
416
- }
417
- return true
418
- }
419
-
420
- // --- plugin ------------------------------------------------------------------
421
-
422
- export function apply(ctx) {
423
- const registerRoutes = (webServer, fiber) => {
424
- fiber.effect(() => webServer.register({
425
- kind: 'exact',
426
- path: `${ROUTE_PREFIX}/delete`,
427
- handler: async (req, res) => {
428
- if (!guard(req, res)) return
429
- if (req.method !== 'POST') {
430
- sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
431
- return
432
- }
433
- let body = {}
434
- try {
435
- const raw = await readBody(req)
436
- if (raw) body = JSON.parse(raw)
437
- } catch {
438
- sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
439
- return
440
- }
441
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
442
- if (!sessionId) {
443
- sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
444
- return
445
- }
446
- try {
447
- const result = await deleteSession(ctx, sessionId)
448
- sendJson(res, 200, { ok: true, ...result })
449
- } catch (error) {
450
- const status = error instanceof HttpError ? error.status : 500
451
- const code = error instanceof HttpError ? error.code : 'internal'
452
- sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
453
- }
454
- },
455
- }))
456
-
457
- fiber.effect(() => webServer.register({
458
- kind: 'exact',
459
- path: `${ROUTE_PREFIX}/delete-many`,
460
- handler: async (req, res) => {
461
- if (!guard(req, res)) return
462
- if (req.method !== 'POST') {
463
- sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
464
- return
465
- }
466
- let body = {}
467
- try {
468
- const raw = await readBody(req)
469
- if (raw) body = JSON.parse(raw)
470
- } catch {
471
- sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
472
- return
473
- }
474
- const sessionIds = Array.isArray(body.sessionIds)
475
- ? [...new Set(body.sessionIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
476
- : []
477
- if (sessionIds.length === 0) {
478
- sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionIds required' })
479
- return
480
- }
481
- if (sessionIds.length > MAX_BATCH) {
482
- sendJson(res, 400, { ok: false, code: 'invalid', error: `too many sessions (max ${MAX_BATCH})` })
483
- return
484
- }
485
- // Bounded-concurrency batch: every item runs the same guarded pipeline
486
- // (lease retry, directory sweep, workspace accounting); independent
487
- // sessions run in parallel and the workspace accounting mutation is
488
- // serialized inside deleteSession, so a batch can never interleave two
489
- // deletions of the same storage.
490
- const results = await runWithConcurrency(sessionIds, BATCH_CONCURRENCY, async (sessionId) => {
491
- try {
492
- const result = await deleteSession(ctx, sessionId)
493
- return { sessionId, ok: true, removed: result.removed }
494
- } catch (error) {
495
- return {
496
- sessionId,
497
- ok: false,
498
- code: error instanceof HttpError ? error.code : 'internal',
499
- error: String((error && error.message) || error),
500
- }
501
- }
502
- })
503
- const deleted = results.filter((item) => item.ok).length
504
- sendJson(res, 200, { ok: true, results, deleted, failed: results.length - deleted })
505
- },
506
- }))
507
- }
508
-
509
- const webServer = ctx.get('webServer')
510
- if (webServer) {
511
- registerRoutes(webServer, ctx)
512
- } else {
513
- // A terminal-only profile never provides a web surface; register once one
514
- // appears instead of waiting on a service that will never arrive.
515
- ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
516
- }
517
- }
1
+ // dsh-delete-session - host half.
2
+ //
3
+ // Loopback-only HTTP routes serve the client half:
4
+ //
5
+ // POST /dsh-delete-session/delete { sessionId }
6
+ //
7
+ // Deletion pipeline, kept consistent with the live DSH services so in-memory
8
+ // state and on-disk units cannot disagree after the request returns:
9
+ //
10
+ // 1. stop a running agent (cancel + time-boxed quiescence) when one exists;
11
+ // 2. flush and detach the live session so its async dispose cannot rewrite
12
+ // the log directory after we remove it, and wait for the persistence
13
+ // retirement that session/disposed starts;
14
+ // 3. take the persistence write lease briefly: a busy lease means the
15
+ // session is still open in DSH, so the request is refused with 409
16
+ // instead of racing the active writer;
17
+ // 4. remove every on-disk session directory (raw id and session- prefix);
18
+ // 5. verify through the persistence service and a directory re-scan;
19
+ // 6. detach the session from workspace accounting so the sidebar drops the
20
+ // row instead of re-grouping an orphan under "Ungrouped".
21
+ //
22
+ // The plugin imports nothing from the DSH SDK: every service is resolved
23
+ // through the cordis context at call time, so the module loads on any profile
24
+ // and degrades to the filesystem path when a service is absent.
25
+ import { spawn } from 'node:child_process'
26
+ import fs from 'node:fs'
27
+ import os from 'node:os'
28
+ import path from 'node:path'
29
+ import { promisify } from 'node:util'
30
+
31
+ /** The asynchronous face of `fs`, used wherever a blocking call could stall the server. */
32
+ const fsp = fs.promises
33
+ /** The same underlying call as `fs.realpathSync.native`, just not blocking. */
34
+ const realpathNative = promisify(fs.realpath.native)
35
+
36
+ export const name = 'dsh-delete-session'
37
+
38
+ const ROUTE_PREFIX = '/dsh-delete-session'
39
+ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
40
+ const MAX_BATCH = 100
41
+
42
+ class HttpError extends Error {
43
+ constructor(status, code, message) {
44
+ super(message)
45
+ this.status = status
46
+ this.code = code
47
+ }
48
+ }
49
+
50
+ // --- paths -------------------------------------------------------------------
51
+
52
+ function dshHome() {
53
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
54
+ }
55
+
56
+ function sessionsRoot() {
57
+ return path.join(dshHome(), 'sessions')
58
+ }
59
+
60
+ // A session id travels in two spellings: the raw uuid and `session-<uuid>`.
61
+ // The on-disk JSONL backend uses the prefixed form for the directory name,
62
+ // while workspace and projection rows may carry either. Clean both.
63
+ function idVariants(sessionId) {
64
+ const out = new Set([sessionId])
65
+ if (sessionId.startsWith('session-')) out.add(sessionId.slice('session-'.length))
66
+ else out.add(`session-${sessionId}`)
67
+ return [...out]
68
+ }
69
+ /** The bare uuid, for comparing ids that may or may not carry the prefix. */
70
+ function bareSessionId(sessionId) {
71
+ return sessionId.startsWith('session-') ? sessionId.slice('session-'.length) : sessionId
72
+ }
73
+
74
+ /**
75
+ * Every session header the host knows about, or `[]` when no service can list
76
+ * them. `sessionQuery` merges live and persisted sessions and carries
77
+ * `parentSession`, so the parent/child map never has to be decoded out of the
78
+ * zstd logs.
79
+ */
80
+ async function listSessionHeaders(ctx) {
81
+ try {
82
+ const query = ctx.get('sessionQuery')
83
+ if (query && typeof query.listSessions === 'function') {
84
+ const records = await query.listSessions()
85
+ const headers = records
86
+ .map((record) => record && record.header)
87
+ .filter((header) => header && typeof header.id === 'string')
88
+ if (headers.length > 0) return headers
89
+ }
90
+ } catch {
91
+ // fall through to the persistence service
92
+ }
93
+ try {
94
+ const persistence = ctx.get('sessionPersistence')
95
+ if (persistence && typeof persistence.list === 'function') {
96
+ const snapshots = await persistence.list({})
97
+ return snapshots
98
+ .map((snapshot) => snapshot && snapshot.header)
99
+ .filter((header) => header && typeof header.id === 'string')
100
+ }
101
+ } catch {
102
+ // no service can list sessions on this host
103
+ }
104
+ return []
105
+ }
106
+
107
+ /**
108
+ * Every descendant of one session, breadth first. A subagent is a session of
109
+ * its own — its own directory and its own log — linked only by `parentSession`,
110
+ * so a delete that ignores the link leaves orphans behind.
111
+ *
112
+ * @param headers - the host session headers.
113
+ * @param sessionId - the session being deleted.
114
+ * @returns descendant headers, parents before their own children.
115
+ */
116
+ function collectDescendants(headers, sessionId) {
117
+ const byParent = new Map()
118
+ for (const header of headers) {
119
+ if (typeof header.parentSession !== 'string') continue
120
+ const key = bareSessionId(header.parentSession)
121
+ const list = byParent.get(key)
122
+ if (list === undefined) byParent.set(key, [header])
123
+ else list.push(header)
124
+ }
125
+ const found = []
126
+ const seen = new Set([bareSessionId(sessionId)])
127
+ const queue = [bareSessionId(sessionId)]
128
+ while (queue.length > 0) {
129
+ const current = queue.shift()
130
+ for (const child of byParent.get(current) ?? []) {
131
+ const key = bareSessionId(child.id)
132
+ if (seen.has(key)) continue
133
+ seen.add(key)
134
+ found.push(child)
135
+ queue.push(key)
136
+ }
137
+ }
138
+ return found
139
+ }
140
+
141
+ /**
142
+ * Sessions whose parent no longer exists. Deleting a parent used to leave these
143
+ * on disk, and the sidebar cannot show the ones that never joined a workspace.
144
+ */
145
+ function collectOrphans(headers) {
146
+ const present = new Set(headers.map((entry) => bareSessionId(entry.id)))
147
+ return headers.filter((entry) => typeof entry.parentSession === 'string'
148
+ && !present.has(bareSessionId(entry.parentSession)))
149
+ }
150
+
151
+ /**
152
+ * The child list the dialog shows: identity, nesting depth and the project the
153
+ * subagent ran in, so the names are recognisable before anything is ticked.
154
+ */
155
+ function describeChildren(headers, sessionId) {
156
+ const byBare = new Map(headers.map((entry) => [bareSessionId(entry.id), entry]))
157
+ return collectDescendants(headers, sessionId).map((entry) => {
158
+ let depth = 1
159
+ let cursor = entry
160
+ while (typeof cursor.parentSession === 'string') {
161
+ const parent = byBare.get(bareSessionId(cursor.parentSession))
162
+ if (parent === undefined || bareSessionId(parent.id) === bareSessionId(sessionId)) break
163
+ depth += 1
164
+ cursor = parent
165
+ }
166
+ return {
167
+ id: entry.id,
168
+ depth,
169
+ cwd: typeof entry.cwd === 'string' ? entry.cwd : undefined,
170
+ }
171
+ })
172
+ }
173
+
174
+ /**
175
+ * The children the caller asked to delete, narrowed to real descendants. A
176
+ * stale or unrelated id is dropped instead of failing the delete: the parent is
177
+ * the session the user clicked, and that click must still land.
178
+ *
179
+ * @returns the descendants' own ids, in the order they were asked for.
180
+ */
181
+ function selectChildDeletes(headers, sessionId, requested) {
182
+ if (!Array.isArray(requested) || requested.length === 0) return []
183
+ const descendants = new Map()
184
+ for (const entry of collectDescendants(headers, sessionId)) {
185
+ descendants.set(bareSessionId(entry.id), entry.id)
186
+ }
187
+ const picked = []
188
+ for (const id of requested) {
189
+ if (typeof id !== 'string' || id === '') continue
190
+ const hit = descendants.get(bareSessionId(id))
191
+ if (hit !== undefined && !picked.includes(hit)) picked.push(hit)
192
+ }
193
+ return picked
194
+ }
195
+
196
+ // Scan every workspace directory under $DSH_HOME/sessions for the session's
197
+ // directories, so the workspace-path encoding never has to be re-derived here.
198
+ function findSessionDirs(sessionId) {
199
+ const root = sessionsRoot()
200
+ let entries
201
+ try {
202
+ entries = fs.readdirSync(root, { withFileTypes: true })
203
+ } catch {
204
+ return []
205
+ }
206
+ const variants = idVariants(sessionId)
207
+ const found = []
208
+ for (const entry of entries) {
209
+ if (!entry.isDirectory()) continue
210
+ for (const variant of variants) {
211
+ const candidate = path.join(root, entry.name, variant)
212
+ try {
213
+ if (fs.statSync(candidate).isDirectory() && !found.includes(candidate)) found.push(candidate)
214
+ } catch {
215
+ // keep scanning
216
+ }
217
+ }
218
+ }
219
+ return found
220
+ }
221
+
222
+ /**
223
+ * The C# behind the cached recycle helper. One process handles every path of a
224
+ * delete (the session directory and its cache rows), because each PowerShell
225
+ * start costs the better part of a second and a delete used to pay it twice.
226
+ */
227
+ const RECYCLE_CSHARP = `using System;
228
+ using System.IO;
229
+ using Microsoft.VisualBasic.FileIO;
230
+
231
+ public static class Program {
232
+ public static int Main(string[] args) {
233
+ int done = 0;
234
+ foreach (string target in args) {
235
+ try {
236
+ if (Directory.Exists(target)) {
237
+ FileSystem.DeleteDirectory(target, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
238
+ } else if (File.Exists(target)) {
239
+ FileSystem.DeleteFile(target, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
240
+ }
241
+ done++;
242
+ } catch {
243
+ // leave it: the caller verifies the path is gone
244
+ }
245
+ }
246
+ return done == args.Length ? 0 : 1;
247
+ }
248
+ }`
249
+
250
+ /** Bump when `RECYCLE_CSHARP` changes, so a cached build is not reused. */
251
+ const RECYCLE_EXE_VERSION = 1
252
+ const RECYCLE_TIMEOUT_MS = 60 * 1000
253
+
254
+ /** Compile the recycle helper once into the DSH cache; null when it cannot. */
255
+ async function ensureRecycleExe() {
256
+ if (process.platform !== 'win32') return null
257
+ const dir = path.join(dshHome(), 'cache', 'dsh-delete-session', `v${RECYCLE_EXE_VERSION}`)
258
+ const exe = path.join(dir, 'dsh-recycle.exe')
259
+ if (await pathExists(exe)) return exe
260
+ const csc = findCsc()
261
+ if (csc === null) return null
262
+ try {
263
+ await fsp.mkdir(dir, { recursive: true })
264
+ const source = path.join(dir, 'recycle.cs')
265
+ await fsp.writeFile(source, RECYCLE_CSHARP, 'utf8')
266
+ const built = await runExecutable(csc, ['-nologo', '-target:exe', `-out:${exe}`, '-r:System.dll', '-r:Microsoft.VisualBasic.dll', source], PICK_COMPILE_TIMEOUT_MS)
267
+ try { await fsp.rm(source, { force: true }) } catch { /* best effort */ }
268
+ if (built.code === 0) {
269
+ if (await pathExists(exe)) return exe
270
+ }
271
+ } catch {
272
+ // fall through to the PowerShell path
273
+ }
274
+ return null
275
+ }
276
+
277
+ /**
278
+ * The command that hands paths to the platform trash.
279
+ *
280
+ * Windows is done in-process (see `sendToRecycleBin`); macOS has no trash command
281
+ * of its own, so Finder performs the delete, and Linux delegates to `gio trash`,
282
+ * which ships with GLib on every mainstream desktop. Kept separate from running
283
+ * it so the shape can be tested on a machine that has neither.
284
+ */
285
+ /**
286
+ * Compile the helper executables while nothing is waiting for them. The first
287
+ * pick or delete otherwise pays for `csc` (0.5-1.7 s); by the time the user
288
+ * clicks, the cache is already there.
289
+ *
290
+ * Deliberately fire-and-forget and never fatal: a machine without `csc` keeps
291
+ * the PowerShell fallbacks, and a failure here must not stop the plugin loading.
292
+ */
293
+ function warmCompiledHelpers() {
294
+ if (process.platform !== 'win32') return
295
+ const warm = async () => {
296
+ try { await ensurePickerExe() } catch { /* the fallback covers it */ }
297
+ try { await ensureRecycleExe() } catch { /* the fallback covers it */ }
298
+ }
299
+ void warm()
300
+ }
301
+ function trashCommandFor(platform, targets) {
302
+ if (platform === 'darwin') {
303
+ // One -e per path: AppleScript has no list form for this, and a failure on one
304
+ // path must not stop the rest.
305
+ return {
306
+ program: 'osascript',
307
+ args: targets.flatMap((target) => [
308
+ '-e',
309
+ `tell application "Finder" to delete (POSIX file "${target.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`,
310
+ ]),
311
+ }
312
+ }
313
+ return { program: 'gio', args: ['trash', ...targets] }
314
+ }
315
+
316
+ /**
317
+ * Run the platform trash command. Returns false when the command is missing
318
+ * (`gio` on a bare container, a denied Finder automation prompt) so the caller
319
+ * keeps the staged copy instead of pretending the delete happened.
320
+ */
321
+ async function trashWithSystem(targets, platform) {
322
+ const command = trashCommandFor(platform, targets)
323
+ try {
324
+ const run = await runExecutable(command.program, command.args, RECYCLE_TIMEOUT_MS)
325
+ return run.code === 0
326
+ } catch {
327
+ return false
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Send paths to the Windows Recycle Bin.
333
+ *
334
+ * A delete has to be undoable, and the Recycle Bin is the undo the user already
335
+ * knows. Node has no API for it, and a Node process cannot synthesize the
336
+ * `$I`/`$R` pair the Shell owns, so one PowerShell does the work through
337
+ * `Microsoft.VisualBasic.FileIO.FileSystem` — the supported managed wrapper of
338
+ * "delete with undo". The script prints one verdict per path, so a partial
339
+ * failure is reported instead of being assumed away.
340
+ *
341
+ * @returns `true` only when every path actually left its original location.
342
+ */
343
+ async function sendToRecycleBin(targets) {
344
+ if (targets.length === 0) return true
345
+ // macOS and Linux have their own trash; Windows is handled below in-process.
346
+ if (process.platform !== 'win32') return trashWithSystem(targets, process.platform)
347
+
348
+ // The compiled helper answers in ~100 ms where one PowerShell start costs the
349
+ // better part of a second, and it takes every path of a delete at once.
350
+ const exe = await ensureRecycleExe()
351
+ if (exe !== null) {
352
+ try {
353
+ const run = await runExecutable(exe, targets, RECYCLE_TIMEOUT_MS)
354
+ return run.code === 0
355
+ } catch {
356
+ return false
357
+ }
358
+ }
359
+
360
+ const list = targets.map((target) => `'${target.replace(/'/g, "''")}'`).join(', ')
361
+ const script = [
362
+ 'Add-Type -AssemblyName Microsoft.VisualBasic',
363
+ `$targets = @(${list})`,
364
+ 'foreach ($target in $targets) {',
365
+ ' try {',
366
+ ' if (Test-Path -LiteralPath $target -PathType Container) {',
367
+ ' [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($target,',
368
+ ' [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,',
369
+ ' [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)',
370
+ ' } elseif (Test-Path -LiteralPath $target) {',
371
+ ' [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($target,',
372
+ ' [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,',
373
+ ' [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)',
374
+ ' }',
375
+ ' [Console]::Out.WriteLine(\'OK\')',
376
+ ' } catch {',
377
+ ' [Console]::Out.WriteLine(\'FAIL\')',
378
+ ' }',
379
+ '}',
380
+ ].join('\r\n')
381
+
382
+ const scriptPath = path.join(os.tmpdir(), `dsh-delete-session-bin-${process.pid}-${Date.now()}.ps1`)
383
+ const bytes = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(script, 'utf8')])
384
+ let output
385
+ try {
386
+ await fsp.writeFile(scriptPath, bytes)
387
+ output = await runPowershell(
388
+ ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
389
+ PICK_TIMEOUT_MS,
390
+ )
391
+ } catch {
392
+ return false
393
+ } finally {
394
+ try { await fsp.rm(scriptPath, { force: true }) } catch { /* best effort */ }
395
+ }
396
+ const ok = output.split(/\r?\n/).filter((line) => line.trim() === 'OK').length
397
+ return ok === targets.length
398
+ }
399
+
400
+ /**
401
+ * Move paths into a fresh `$DSH_HOME/session-trash-<stamp>` folder.
402
+ *
403
+ * This is the fast half of a retire: a same-volume rename costs microseconds,
404
+ * while the shell's recycle operation costs ~600 ms. The caller reports the
405
+ * delete as done after this, so the wait never reaches the user.
406
+ *
407
+ * @returns `{ moved, root }` — the source paths and the staging folder, or a null
408
+ * root when the folder could not be created.
409
+ */
410
+ async function stageToTrash(targets) {
411
+ if (targets.length === 0) return { moved: [], root: null }
412
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 17)
413
+ const root = path.join(dshHome(), `session-trash-${stamp}-${process.pid}-${retireCounter++}`)
414
+ try {
415
+ await fsp.mkdir(root, { recursive: true })
416
+ } catch {
417
+ return { moved: [], root: null }
418
+ }
419
+ const moved = []
420
+ for (const target of targets) {
421
+ const destination = await freeDestination(root, path.basename(target))
422
+ if (destination === undefined) continue
423
+ try {
424
+ await fsp.rename(target, destination)
425
+ moved.push(target)
426
+ continue
427
+ } catch {
428
+ // A cross-volume rename fails; copy then drop the original.
429
+ }
430
+ try {
431
+ await fsp.cp(target, destination, { recursive: true, force: false, errorOnExist: true })
432
+ await fsp.rm(target, { recursive: true, force: true })
433
+ moved.push(target)
434
+ } catch {
435
+ // Leave it: a path that cannot be retired is reported by the caller.
436
+ }
437
+ }
438
+ return { moved, root: moved.length > 0 ? root : null }
439
+ }
440
+
441
+ /** Keeps two retires in the same millisecond out of one staging folder. */
442
+ let retireCounter = 0
443
+
444
+ /** The staging move alone, for callers that just want the paths out of the way. */
445
+ async function moveToSessionTrash(targets) {
446
+ return (await stageToTrash(targets)).moved
447
+ }
448
+
449
+ /**
450
+ * Retire paths for good. The caller gets control back as soon as the paths are
451
+ * out of their original location — a same-volume rename — and the Recycle Bin
452
+ * move finishes in the background.
453
+ *
454
+ * That order is deliberate: the shell's recycle operation costs ~600 ms, which
455
+ * is exactly the wait a delete should not have. If the background call fails, the
456
+ * paths are still in `session-trash-<stamp>` and recoverable by hand; nothing is
457
+ * ever unlinked.
458
+ */
459
+ async function retirePaths(targets) {
460
+ if (targets.length === 0) return []
461
+ const staged = await stageToTrash(targets)
462
+ if (staged.root !== null) {
463
+ // Fire and forget: never awaited, never allowed to reject into the caller.
464
+ try {
465
+ sendToRecycleBin([staged.root]).catch(() => {})
466
+ } catch {
467
+ // the staged copy stays where it is
468
+ }
469
+ }
470
+ return staged.moved
471
+ }
472
+
473
+ /**
474
+ * Remove paths outright. This is the branch behind the unchecked Recycle Bin
475
+ * switch: the dialog says the delete is permanent in that state, so the files
476
+ * must not linger anywhere. Every other path through this plugin retires to the
477
+ * bin, and that difference is the whole point of the switch.
478
+ *
479
+ * @returns the paths that are gone afterwards.
480
+ */
481
+ function removeForever(targets) {
482
+ const gone = []
483
+ for (const target of targets) {
484
+ if (!fs.existsSync(target)) continue
485
+ try {
486
+ fs.rmSync(target, { recursive: true, force: true })
487
+ if (!fs.existsSync(target)) gone.push(target)
488
+ } catch {
489
+ // reported by the caller's own existence checks
490
+ }
491
+ }
492
+ return gone
493
+ }
494
+ /**
495
+ * Retire a list according to the switch: into the system trash (staged first,
496
+ * then the shell takes it) or off the disk. Kept separate from the delete so the
497
+ * choice itself can be tested without standing up a whole session.
498
+ */
499
+ async function retireTargets(targets, recycle) {
500
+ return recycle ? retirePaths(targets) : removeForever(targets)
501
+ }
502
+ async function retireSessionDirs(sessionId) {
503
+ return retirePaths(findSessionDirs(sessionId))
504
+ }
505
+
506
+ // Deleting the on-disk directory is not enough: projection caches keep a
507
+ // per-session JSON row (`$DSH_HOME/storages/<store>/sessions/<id>.json`) that
508
+ // would linger as a ghost — the sidebar list is directory-backed, so the row
509
+ // can never be shown or deleted again (issue: "can't delete anymore"). Retire
510
+ // the session's cache units together with its directories.
511
+ function findSessionCaches(sessionId) {
512
+ const storesRoot = path.join(dshHome(), 'storages')
513
+ const found = []
514
+ let stores
515
+ try {
516
+ stores = fs.readdirSync(storesRoot, { withFileTypes: true })
517
+ } catch {
518
+ return found
519
+ }
520
+ const names = idVariants(sessionId).map((variant) => `${variant}.json`)
521
+ for (const store of stores) {
522
+ if (!store.isDirectory()) continue
523
+ const dir = path.join(storesRoot, store.name, 'sessions')
524
+ let entries
525
+ try {
526
+ entries = fs.readdirSync(dir)
527
+ } catch {
528
+ continue
529
+ }
530
+ for (const entry of entries) {
531
+ if (!names.includes(entry)) continue
532
+ found.push(path.join(dir, entry))
533
+ }
534
+ }
535
+ return found
536
+ }
537
+
538
+ async function retireSessionCaches(sessionId) {
539
+ return retirePaths(findSessionCaches(sessionId))
540
+ }
541
+
542
+ // --- live state --------------------------------------------------------------
543
+
544
+ // Stop a running agent before its session disappears: cancel the active turn
545
+ // and wait, time-boxed, so a stuck driver never blocks the deletion.
546
+ async function quiesceAgent(ctx, sessionId) {
547
+ const agents = ctx.get('agents')
548
+ if (!agents || typeof agents.get !== 'function') return false
549
+ const agent = agents.get(sessionId)
550
+ if (!agent) return false
551
+ try {
552
+ if (typeof agent.cancel === 'function') agent.cancel({ kind: 'user' })
553
+ } catch {
554
+ // agent may already be settling
555
+ }
556
+ if (typeof agent.whenIdle === 'function') {
557
+ try {
558
+ await Promise.race([agent.whenIdle(), new Promise((resolve) => setTimeout(resolve, 10000))])
559
+ } catch {
560
+ // proceed with deletion anyway
561
+ }
562
+ }
563
+ return true
564
+ }
565
+
566
+ // Flush a live session, then detach its entered record so DSH emits
567
+ // host/session-removed and the client list drops the row. The persistence
568
+ // retirement started by session/disposed is awaited before the caller unlinks
569
+ // anything, because its final drain could otherwise recreate the directory.
570
+ async function flushAndDetach(ctx, sessionId) {
571
+ const sessions = ctx.get('sessions')
572
+ let flushed = false
573
+ let detached = false
574
+ if (sessions) {
575
+ for (const variant of idVariants(sessionId)) {
576
+ const live = typeof sessions.get === 'function' ? sessions.get(variant) : undefined
577
+ if (!live) continue
578
+ if (typeof sessions.flush === 'function') {
579
+ try {
580
+ await sessions.flush(live)
581
+ flushed = true
582
+ } catch {
583
+ // the log is removed anyway
584
+ }
585
+ }
586
+ try {
587
+ const entered = sessions.store && typeof sessions.store.get === 'function' ? sessions.store.get(variant) : undefined
588
+ if (entered && typeof entered.detach === 'function') {
589
+ entered.detach()
590
+ detached = true
591
+ }
592
+ } catch {
593
+ // best effort: the directory removal below is the authority
594
+ }
595
+ }
596
+ }
597
+ const sp = ctx.get('sessionPersistence')
598
+ try {
599
+ const retirement = sp && sp.retirements && typeof sp.retirements.get === 'function' ? sp.retirements.get(sessionId) : undefined
600
+ if (retirement && typeof retirement.then === 'function') await retirement
601
+ } catch {
602
+ // no retirement registry: nothing to wait for
603
+ }
604
+ return { flushed, detached }
605
+ }
606
+
607
+ // Probe the persistence write lease. Holding it proves no active writer is
608
+ // around; it is released immediately. Detaching a live session releases its
609
+ // lease asynchronously (session/disposed starts a persistence retirement), so
610
+ // the probe is retried with a short backoff before the request is refused -
611
+ // in practice the first click then succeeds without forcing anything.
612
+ // The backoff is short (200ms x 12 = ~2.2s worst case) so a batch containing
613
+ // live sessions stays responsive while the lease retirement lands.
614
+ const BUSY_RETRY_ATTEMPTS = 12
615
+ const BUSY_RETRY_DELAY_MS = 200
616
+
617
+ function sleep(ms) {
618
+ return new Promise((resolve) => setTimeout(resolve, ms))
619
+ }
620
+
621
+ async function assertNoActiveWriter(ctx, sessionId) {
622
+ const sp = ctx.get('sessionPersistence')
623
+ if (!sp || typeof sp.open !== 'function') return false
624
+ for (let attempt = 1; attempt <= BUSY_RETRY_ATTEMPTS; attempt += 1) {
625
+ let handle = null
626
+ let busy = false
627
+ try {
628
+ handle = await sp.open(sessionId, 'write')
629
+ } catch (error) {
630
+ const message = String((error && error.message) || error)
631
+ if (/own|busy|lock|hold|writer/i.test(message)) {
632
+ busy = true
633
+ } else {
634
+ return false
635
+ }
636
+ } finally {
637
+ try {
638
+ if (handle && typeof handle.close === 'function') await handle.close()
639
+ } catch {
640
+ // lease release failure is harmless: the directory is removed next
641
+ }
642
+ }
643
+ if (!busy) return true
644
+ if (attempt < BUSY_RETRY_ATTEMPTS) await sleep(BUSY_RETRY_DELAY_MS)
645
+ }
646
+ throw new HttpError(409, 'busy', 'session is currently open in DSH')
647
+ }
648
+
649
+ // Bounded concurrency for batch deletes. Different sessions are independent
650
+ // (agent quiesce, lease probe and directory sweep all key off the session id),
651
+ // so the pipeline runs in parallel through a small pool. The one shared
652
+ // mutation - workspace accounting - is serialized through withWorkspaceLock so
653
+ // two deletions can never interleave on the same store.
654
+ //
655
+ // 6 is measured, not guessed: on 64 sessions of ~2MB the wall time was
656
+ // 281ms at 4, 189ms at 6 and 142ms at 8 (the I/O parallelism saturates around
657
+ // 8), so 6 keeps a ~33% gain over 4 while staying below the saturation knee
658
+ // and limiting the number of simultaneous lease-probe loops.
659
+ const BATCH_CONCURRENCY = 6
660
+
661
+ let workspaceLock = Promise.resolve()
662
+ function withWorkspaceLock(fn) {
663
+ const run = workspaceLock.then(fn, fn)
664
+ workspaceLock = run.then(() => undefined, () => undefined)
665
+ return run
666
+ }
667
+
668
+ async function runWithConcurrency(items, limit, worker) {
669
+ const results = new Array(items.length)
670
+ let cursor = 0
671
+ const runners = []
672
+ for (let i = 0; i < Math.min(limit, items.length); i += 1) {
673
+ runners.push((async () => {
674
+ while (true) {
675
+ const index = cursor
676
+ cursor += 1
677
+ if (index >= items.length) return
678
+ results[index] = await worker(items[index], index)
679
+ }
680
+ })())
681
+ }
682
+ await Promise.all(runners)
683
+ return results
684
+ }
685
+
686
+ async function detachFromWorkspace(ctx, sessionId) {
687
+ const workspace = ctx.get('workspaceRegistry')
688
+ if (!workspace) return false
689
+ const variants = idVariants(sessionId)
690
+ let touched = false
691
+ try {
692
+ if (typeof workspace.list === 'function') {
693
+ for (const entry of workspace.list()) {
694
+ if (!entry || !Array.isArray(entry.sessionIds)) continue
695
+ if (!variants.some((variant) => entry.sessionIds.includes(variant))) continue
696
+ try {
697
+ await entry.detachSession(sessionId)
698
+ touched = true
699
+ } catch {
700
+ // keep going: the header index cleanup below still applies
701
+ }
702
+ }
703
+ }
704
+ } catch {
705
+ // registry unavailable
706
+ }
707
+ try {
708
+ if (workspace.sessionPaths && typeof workspace.sessionPaths.delete === 'function') {
709
+ for (const variant of variants) workspace.sessionPaths.delete(variant)
710
+ }
711
+ } catch {
712
+ // private map absent on this version
713
+ }
714
+ try {
715
+ if (workspace.headers && typeof workspace.headers.delete === 'function') {
716
+ for (const variant of variants) workspace.headers.delete(variant)
717
+ }
718
+ } catch {
719
+ // private map absent on this version
720
+ }
721
+ return touched
722
+ }
723
+
724
+ // --- core delete -------------------------------------------------------------
725
+
726
+ async function deleteSession(ctx, sessionId, options) {
727
+ if (!SESSION_ID_RE.test(sessionId)) {
728
+ throw new HttpError(400, 'invalid', 'invalid session id')
729
+ }
730
+ // Undoable by default: only an explicit `recycle: false` sends the files to a
731
+ // plain removal instead of the system trash.
732
+ const recycle = options === undefined || options === null || options.recycle !== false
733
+ // Subagent sessions are sessions of their own; the ones the caller ticked are
734
+ // taken with their parent. Anything that is not a well-formed id is dropped
735
+ // here, before it reaches the filesystem.
736
+ const childSessionIds = options !== undefined && options !== null && Array.isArray(options.childSessionIds)
737
+ ? options.childSessionIds
738
+ .filter((id) => typeof id === 'string' && SESSION_ID_RE.test(id))
739
+ .filter((id) => bareSessionId(id) !== bareSessionId(sessionId))
740
+ : []
741
+ const stopped = await quiesceAgent(ctx, sessionId)
742
+ const { flushed, detached } = await flushAndDetach(ctx, sessionId)
743
+ await assertNoActiveWriter(ctx, sessionId)
744
+
745
+ // Retire the on-disk directories first, then the cache rows. With the Recycle
746
+ // Bin switch on (the default) they are staged and the shell moves them to the
747
+ // bin in the background; switched off, they are removed outright — that is what
748
+ // the dialog promises in that state.
749
+ const { removed, caches } = await retireSessionFiles(sessionId, recycle)
750
+
751
+ const sp = ctx.get('sessionPersistence')
752
+ if (sp && typeof sp.stat === 'function') {
753
+ const after = await sp.stat(sessionId).catch(() => undefined)
754
+ if (after) {
755
+ throw new HttpError(500, 'verify-failed', 'session is still visible to the persistence service after deletion')
756
+ }
757
+ }
758
+ const leftover = findSessionDirs(sessionId)
759
+ if (leftover.length > 0) {
760
+ throw new HttpError(500, 'verify-failed', 'session directories could not be fully removed')
761
+ }
762
+
763
+ const workspaceDetached = await withWorkspaceLock(() => detachFromWorkspace(ctx, sessionId))
764
+ if (removed === 0 && !workspaceDetached && !flushed && !detached && caches === 0) {
765
+ throw new HttpError(404, 'not-found', 'session not found')
766
+ }
767
+
768
+ // Children last, and one failure never takes the parent result down with it:
769
+ // the parent is what the user clicked, so that click has to land.
770
+ const children = []
771
+ for (const childId of childSessionIds) {
772
+ try {
773
+ const childFiles = await retireSessionFiles(childId, recycle)
774
+ await withWorkspaceLock(() => detachFromWorkspace(ctx, childId))
775
+ children.push({ id: childId, removed: childFiles.removed, caches: childFiles.caches })
776
+ } catch (error) {
777
+ children.push({ id: childId, removed: 0, caches: 0, error: String((error && error.message) || error) })
778
+ }
779
+ }
780
+
781
+ return {
782
+ removed,
783
+ caches,
784
+ stopped,
785
+ flushed,
786
+ detached,
787
+ workspaceDetached,
788
+ children,
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Take one session off the disk: its directories and its cache rows, then any
794
+ * directory the drain recreated. Shared by the parent and every child, so a
795
+ * subagent is retired exactly like the conversation the user clicked.
796
+ *
797
+ * @returns how many paths went away, and how many cache rows are now gone.
798
+ */
799
+ async function retireSessionFiles(sessionId, recycle) {
800
+ const cacheFiles = findSessionCaches(sessionId)
801
+ const targets = [...findSessionDirs(sessionId), ...cacheFiles]
802
+ let gone = await retireTargets(targets, recycle)
803
+ await new Promise((resolve) => setImmediate(resolve))
804
+ gone = gone.concat(await retireTargets(findSessionDirs(sessionId), recycle))
805
+ return { removed: gone.length, caches: cacheFiles.filter((file) => !fs.existsSync(file)).length }
806
+ }
807
+
808
+ // --- artifacts: what a session touched, and quarantining it -----------------
809
+ //
810
+ // "Produced files" is derived from the durable session log alone: the log is the
811
+ // only record that outlives a closed Session (the host's own observation state
812
+ // lives in a plugin-local WeakMap and dies with the process, and the
813
+ // `workspace/changes` summaries live in a temporary directory that disposal
814
+ // removes). Classification rests on one invariant of the default
815
+ // fs-observation-policy: `write` may not overwrite a file that was not read
816
+ // first, and `edit` requires a prior read. So a write whose path has no earlier
817
+ // successful read CREATED the file; anything else already existed.
818
+ //
819
+ // Nothing here deletes. Files are moved into a timestamped quarantine directory
820
+ // under $DSH_HOME, keeping their path relative to the session's working
821
+ // directory, so a wrong call is recoverable.
822
+
823
+ const QUARANTINE_PREFIX = 'purge-quarantine'
824
+ const MAX_LISTED = 500
825
+ /** How many path resolutions run at once: enough to hide the threadpool round trip, few enough to stay polite. */
826
+ const RESOLVE_CONCURRENCY = 16
827
+ /** How many directories the folder picker lists before saying there are more. */
828
+ const MAX_BROWSE_ENTRIES = 500
829
+ /** How long a native folder dialog may stay open before the request gives up. */
830
+ const PICK_TIMEOUT_MS = 5 * 60 * 1000
831
+
832
+ /**
833
+ * The folder picker.
834
+ *
835
+ * Windows offers its modern chooser through COM (`IFileOpenDialog` +
836
+ * `FOS_PICKFOLDERS`), and the interesting part is not the dialog but the wait
837
+ * before it. Measured on the target machine:
838
+ *
839
+ * PowerShell startup ......... ~520 ms
840
+ * + Windows.Forms assembly .... ~950 ms (left over from the old
841
+ * `FolderBrowserDialog` version)
842
+ * compiled .exe, no console ... ~120 ms
843
+ *
844
+ * So the picker is a tiny executable: compiled once into the DSH home, spawned
845
+ * directly afterwards. The PowerShell script below stays as the fallback for a
846
+ * machine without `csc.exe`.
847
+ *
848
+ * The executable runs with `windowsHide`, which hides the first window it shows,
849
+ * so a background thread calls `ShowWindow(SW_SHOW)` and raises it. That thread
850
+ * also centres the dialog and retries, because the window reports no final size
851
+ * on the first tick.
852
+ */
853
+ const PICK_CSHARP = `
854
+ using System;
855
+ using System.Text;
856
+ using System.Threading;
857
+ using System.Runtime.InteropServices;
858
+
859
+ public static class DshFolderPick {
860
+ [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr h, int cmd);
861
+ [DllImport("user32.dll")] static extern IntPtr SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
862
+ [DllImport("user32.dll")] static extern bool BringWindowToTop(IntPtr h);
863
+ [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l);
864
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
865
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
866
+ [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT r);
867
+ [DllImport("user32.dll")] static extern bool SystemParametersInfoW(uint action, uint param, out RECT rect, uint winIni);
868
+ [DllImport("kernel32.dll")] static extern uint GetCurrentProcessId();
869
+ delegate bool EnumProc(IntPtr h, IntPtr l);
870
+
871
+ [StructLayout(LayoutKind.Sequential)]
872
+ struct RECT { public int Left, Top, Right, Bottom; }
873
+
874
+ static IntPtr FindDialog() {
875
+ uint me = GetCurrentProcessId();
876
+ IntPtr found = IntPtr.Zero;
877
+ EnumWindows((h, l) => {
878
+ uint pid;
879
+ GetWindowThreadProcessId(h, out pid);
880
+ if (pid == me) {
881
+ var cls = new StringBuilder(128, 128);
882
+ GetClassNameW(h, cls, 128);
883
+ if (cls.ToString().StartsWith("#32770") && found == IntPtr.Zero) found = h;
884
+ }
885
+ return true;
886
+ }, IntPtr.Zero);
887
+ return found;
888
+ }
889
+
890
+ static void Raise() {
891
+ bool centered = false;
892
+ for (int i = 0; i < 80; i++) {
893
+ Thread.Sleep(150);
894
+ IntPtr h = FindDialog();
895
+ if (h == IntPtr.Zero) continue;
896
+ ShowWindow(h, 5);
897
+ if (!centered) {
898
+ RECT work, box;
899
+ if (SystemParametersInfoW(0x0030, 0, out work, 0) && GetWindowRect(h, out box)) {
900
+ int w = box.Right - box.Left;
901
+ int ht = box.Bottom - box.Top;
902
+ int availW = work.Right - work.Left;
903
+ int availH = work.Bottom - work.Top;
904
+ // A half-built rect would park the window off screen, so only a
905
+ // believable size that actually fits is allowed to move it.
906
+ if (w > 600 && ht > 300 && w < availW && ht < availH) {
907
+ int x = work.Left + (availW - w) / 2;
908
+ int y = work.Top + (availH - ht) / 2;
909
+ SetWindowPos(h, (IntPtr)(-1), x, y, 0, 0, 0x0001);
910
+ centered = true;
911
+ }
912
+ }
913
+ }
914
+ SetWindowPos(h, (IntPtr)(-1), 0, 0, 0, 0, 0x0003);
915
+ BringWindowToTop(h);
916
+ }
917
+ }
918
+
919
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
920
+ interface IFileOpenDialog {
921
+ [PreserveSig] int Show(IntPtr parent);
922
+ void SetFileTypes(uint c, IntPtr f);
923
+ void SetFileTypeIndex(uint i);
924
+ void GetFileTypeIndex(out uint i);
925
+ void Advise(IntPtr p, out uint c);
926
+ void Unadvise(uint c);
927
+ void SetOptions(uint fos);
928
+ void GetOptions(out uint fos);
929
+ void SetDefaultFolder(IShellItem i);
930
+ void SetFolder(IShellItem i);
931
+ void GetFolder(out IShellItem i);
932
+ void GetCurrentSelection(out IShellItem i);
933
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n);
934
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
935
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t);
936
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
937
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
938
+ void GetResult(out IShellItem i);
939
+ void AddPlace(IShellItem i, uint f);
940
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string e);
941
+ void Close(int hr);
942
+ void SetClientGuid(ref Guid g);
943
+ void ClearClientData();
944
+ void SetFilter(IntPtr f);
945
+ }
946
+
947
+ [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
948
+ interface IShellItem {
949
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
950
+ void GetParent(out IShellItem ppsi);
951
+ void GetDisplayName(uint sigdn, out IntPtr name);
952
+ void GetAttributes(uint mask, out uint attrs);
953
+ void Compare(IShellItem psi, uint hint, out int order);
954
+ }
955
+
956
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
957
+ class FileOpenDialog { }
958
+
959
+ public static string Show(string title, string okLabel) {
960
+ var raiser = new Thread(Raise);
961
+ raiser.IsBackground = true;
962
+ raiser.Start();
963
+
964
+ var dialog = (IFileOpenDialog)new FileOpenDialog();
965
+ uint options;
966
+ dialog.GetOptions(out options);
967
+ dialog.SetOptions(options | 0x20u | 0x40u | 0x800u);
968
+ if (title != null) dialog.SetTitle(title);
969
+ if (okLabel != null) dialog.SetOkButtonLabel(okLabel);
970
+
971
+ int hr = dialog.Show(IntPtr.Zero);
972
+ if (hr != 0) return null;
973
+
974
+ IShellItem item;
975
+ dialog.GetResult(out item);
976
+ IntPtr name;
977
+ item.GetDisplayName(0x80058000u, out name);
978
+ string path = Marshal.PtrToStringUni(name);
979
+ Marshal.FreeCoTaskMem(name);
980
+ return path;
981
+ }
982
+ }`
983
+
984
+ /** The compiled form: the class above plus an entry point that prints one path. */
985
+ const PICK_EXE_SOURCE = PICK_CSHARP + `
986
+
987
+ public static class Program {
988
+ public static int Main(string[] args) {
989
+ string picked = DshFolderPick.Show(args.Length > 0 ? args[0] : null, args.Length > 1 ? args[1] : null);
990
+ if (picked == null) return 1;
991
+ // Written as UTF-8 bytes rather than through Console.Out: its encoding
992
+ // depends on a console this process does not own, and paths are not ASCII.
993
+ byte[] bytes = System.Text.Encoding.UTF8.GetBytes(picked);
994
+ using (System.IO.Stream stdout = Console.OpenStandardOutput()) {
995
+ stdout.Write(bytes, 0, bytes.Length);
996
+ stdout.Flush();
997
+ }
998
+ return 0;
999
+ }
1000
+ }`
1001
+
1002
+ /** The fallback for a host without `csc.exe`: the same class through PowerShell. */
1003
+ const PICK_SCRIPT = `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
1004
+ $source = @'
1005
+ ${PICK_CSHARP}
1006
+ '@
1007
+ Add-Type -TypeDefinition $source
1008
+ $picked = [DshFolderPick]::Show('选择隔离夹保存位置', '选择此文件夹')
1009
+ if ($picked) { [Console]::Out.Write($picked) }
1010
+ `
1011
+
1012
+ /** Bump when `PICK_EXE_SOURCE` changes, so a cached build is not reused. */
1013
+ const PICK_EXE_VERSION = 1
1014
+ /** Compiling once is slow; it happens on the first ever pick only. */
1015
+ const PICK_COMPILE_TIMEOUT_MS = 60 * 1000
1016
+
1017
+ /** The C# compiler that ships with .NET Framework, if this machine has it. */
1018
+ function findCsc() {
1019
+ const windir = process.env.WINDIR || 'C:\\Windows'
1020
+ for (const flavor of ['Framework64', 'Framework']) {
1021
+ const candidate = path.join(windir, 'Microsoft.NET', flavor, 'v4.0.30319', 'csc.exe')
1022
+ try {
1023
+ if (fs.statSync(candidate).isFile()) return candidate
1024
+ } catch {
1025
+ // try the next flavour
1026
+ }
1027
+ }
1028
+ return null
1029
+ }
1030
+
1031
+ /**
1032
+ * Compile the picker once into `$DSH_HOME/cache/dsh-delete-session`, then reuse
1033
+ * it. Returns null when this machine cannot compile, so the caller falls back to
1034
+ * the PowerShell path rather than failing the pick.
1035
+ */
1036
+ async function ensurePickerExe() {
1037
+ if (process.platform !== 'win32') return null
1038
+ const dir = path.join(dshHome(), 'cache', 'dsh-delete-session', `v${PICK_EXE_VERSION}`)
1039
+ const exe = path.join(dir, 'dsh-folder-pick.exe')
1040
+ if (await pathExists(exe)) return exe
1041
+ const csc = findCsc()
1042
+ if (csc === null) return null
1043
+ try {
1044
+ await fsp.mkdir(dir, { recursive: true })
1045
+ const source = path.join(dir, 'picker.cs')
1046
+ await fsp.writeFile(source, PICK_EXE_SOURCE, 'utf8')
1047
+ const built = await runExecutable(csc, ['-nologo', '-target:exe', `-out:${exe}`, '-r:System.dll', source], PICK_COMPILE_TIMEOUT_MS)
1048
+ try { await fsp.rm(source, { force: true }) } catch { /* best effort */ }
1049
+ if (built.code === 0) {
1050
+ if (await pathExists(exe)) return exe
1051
+ }
1052
+ } catch {
1053
+ // fall through to the PowerShell path
1054
+ }
1055
+ return null
1056
+ }
1057
+
1058
+ /** Run a program and collect its output, leaving the meaning of the exit to the caller. */
1059
+ function runExecutable(program, args, timeoutMs) {
1060
+ return new Promise((resolve, reject) => {
1061
+ const child = spawn(program, args, { windowsHide: true })
1062
+ let out = ''
1063
+ let err = ''
1064
+ let settled = false
1065
+ const finish = (fn, value) => {
1066
+ if (settled) return
1067
+ settled = true
1068
+ clearTimeout(timer)
1069
+ fn(value)
1070
+ }
1071
+ const timer = setTimeout(() => {
1072
+ try { child.kill() } catch { /* already gone */ }
1073
+ finish(reject, new HttpError(504, 'timeout', '等待系统对话框超时'))
1074
+ }, timeoutMs)
1075
+
1076
+ child.stdout.on('data', (chunk) => { out += chunk.toString('utf8') })
1077
+ child.stderr.on('data', (chunk) => { err += chunk.toString('utf8') })
1078
+ child.on('error', (error) => {
1079
+ finish(reject, new HttpError(500, 'spawn', `无法启动 ${path.basename(program)}:${String((error && error.message) || error)}`))
1080
+ })
1081
+ child.on('close', (code) => finish(resolve, { code, stdout: out, stderr: err }))
1082
+ })
1083
+ }
1084
+
1085
+ /** Argument keys carrying the target path, per writing tool. */
1086
+ const WRITE_TOOL_PATHS = {
1087
+ write: ['file_path', 'path'],
1088
+ edit: ['file_path', 'path'],
1089
+ str_replace_editor: ['path', 'file_path'],
1090
+ create_file: ['file_path', 'path'],
1091
+ }
1092
+
1093
+ /** Argument keys carrying the target path, per reading tool. */
1094
+ const READ_TOOL_PATHS = {
1095
+ read: ['file_path', 'path'],
1096
+ read_file: ['file_path', 'path'],
1097
+ }
1098
+
1099
+ /**
1100
+ * Resolve the target path one tool call names.
1101
+ * @param name - tool name.
1102
+ * @param argumentsJson - the model's raw argument JSON.
1103
+ * @param cwd - the session's recorded working directory, for relative paths.
1104
+ * @returns the absolute path and whether the tool writes, or undefined.
1105
+ */
1106
+ function toolTarget(name, argumentsJson, cwd) {
1107
+ const writes = Object.prototype.hasOwnProperty.call(WRITE_TOOL_PATHS, name)
1108
+ const keys = writes ? WRITE_TOOL_PATHS[name] : READ_TOOL_PATHS[name]
1109
+ if (keys === undefined) return undefined
1110
+ let args
1111
+ try {
1112
+ args = JSON.parse(typeof argumentsJson === 'string' ? argumentsJson : '')
1113
+ } catch {
1114
+ return undefined
1115
+ }
1116
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) return undefined
1117
+ const raw = keys.map((key) => args[key]).find((value) => typeof value === 'string' && value.trim() !== '')
1118
+ if (raw === undefined) return undefined
1119
+ let absolute
1120
+ if (path.isAbsolute(raw)) absolute = raw
1121
+ else if (cwd === undefined) return undefined
1122
+ else absolute = path.resolve(cwd, raw)
1123
+ return { path: absolute, writes }
1124
+ }
1125
+
1126
+ /** Read a stored session's events through the persistence seam. */
1127
+ async function readSessionEvents(ctx, sessionId) {
1128
+ const sp = ctx.get('sessionPersistence')
1129
+ if (!sp || typeof sp.open !== 'function') {
1130
+ return { events: [], warning: '持久化服务不可用,无法读取会话日志' }
1131
+ }
1132
+ let handle = null
1133
+ try {
1134
+ handle = await sp.open(sessionId, 'read')
1135
+ const result = await handle.read()
1136
+ const events = Array.isArray(result && result.events) ? result.events : []
1137
+ return { events, warning: undefined }
1138
+ } catch (error) {
1139
+ return { events: [], warning: `读取会话日志失败:${String((error && error.message) || error)}` }
1140
+ } finally {
1141
+ try {
1142
+ if (handle && typeof handle.close === 'function') await handle.close()
1143
+ } catch {
1144
+ // a close failure must not change the plan
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Identity of one path for "have we seen this file before?" purposes. The real
1151
+ * path when it resolves, so a symlink alias and its target count as the same
1152
+ * file; otherwise a case-folded resolved path on Windows, whose filesystem does
1153
+ * not distinguish `Foo.md` from `foo.md`. Comparing spellings instead would let
1154
+ * a write through an alias look like a creation.
1155
+ * @param absolute - the path a tool call named.
1156
+ * @param cache - optional per-scan memo; without it the resolution cost scales
1157
+ * with log length rather than with the number of distinct files.
1158
+ * @returns a stable key for the file, not for the spelling.
1159
+ */
1160
+ async function identityKey(absolute, cache) {
1161
+ if (cache !== undefined) {
1162
+ const hit = cache.get(absolute)
1163
+ if (hit !== undefined) return hit
1164
+ }
1165
+ let key
1166
+ try {
1167
+ key = await realpathNative(absolute)
1168
+ } catch {
1169
+ const resolved = path.resolve(absolute)
1170
+ key = process.platform === 'win32' ? resolved.toLowerCase() : resolved
1171
+ }
1172
+ if (cache !== undefined) cache.set(absolute, key)
1173
+ return key
1174
+ }
1175
+
1176
+ /**
1177
+ * Every path this session's writing tools touched, split by whether the session
1178
+ * created it or found it already there.
1179
+ * @param events - stored session events, in order.
1180
+ * @param cwd - the session's recorded working directory.
1181
+ * @param cache - optional per-scan memo for path resolution, shared across calls.
1182
+ * @returns `{ created, modified }`, keyed by file identity, each row `{ path, identity, ops }`.
1183
+ */
1184
+ async function collectSessionPaths(events, cwd, cache) {
1185
+ const pending = new Map()
1186
+ const read = new Set()
1187
+ const created = new Map()
1188
+ const modified = new Map()
1189
+ const memo = cache !== undefined ? cache : new Map()
1190
+
1191
+ // Resolve every distinct path the log names first, together. A long session
1192
+ // names the same handful of files thousands of times, so awaiting per call
1193
+ // would buy one threadpool round trip per mention; this buys one per file.
1194
+ const named = new Set()
1195
+ for (const event of events) {
1196
+ const data = event && event.data
1197
+ if (!data || typeof data !== 'object' || event.type !== 'tool/call') continue
1198
+ const target = toolTarget(typeof data.name === 'string' ? data.name : '', data.arguments, cwd)
1199
+ if (target !== undefined) named.add(target.path)
1200
+ }
1201
+ await runWithConcurrency([...named], RESOLVE_CONCURRENCY, async (absolute) => {
1202
+ await identityKey(absolute, memo)
1203
+ })
1204
+
1205
+ const commit = (call) => {
1206
+ // A file the session created and later edited stays "created": one identity
1207
+ // must never sit in both buckets, or a scope=all run would target it twice
1208
+ // and report a spurious failure for the second attempt.
1209
+ const alreadyCreated = created.get(call.identity)
1210
+ if (alreadyCreated !== undefined) {
1211
+ alreadyCreated.ops += 1
1212
+ return
1213
+ }
1214
+ const bucket = call.existedBefore ? modified : created
1215
+ const prior = bucket.get(call.identity)
1216
+ if (prior !== undefined) prior.ops += 1
1217
+ else bucket.set(call.identity, { path: call.path, identity: call.identity, ops: 1 })
1218
+ }
1219
+
1220
+ for (const event of events) {
1221
+ const data = event && event.data
1222
+ if (!data || typeof data !== 'object') continue
1223
+
1224
+ if (event.type === 'tool/call') {
1225
+ const name = typeof data.name === 'string' ? data.name : ''
1226
+ const target = toolTarget(name, data.arguments, cwd)
1227
+ // Existence is decided at call time, against the reads that already
1228
+ // succeeded - the policy's own ordering - and on identity, so an alias of
1229
+ // a file the session read is still an edit.
1230
+ if (target !== undefined) {
1231
+ const identity = await identityKey(target.path, memo)
1232
+ pending.set(data.callId, {
1233
+ path: target.path,
1234
+ identity,
1235
+ writes: target.writes,
1236
+ existedBefore: read.has(identity),
1237
+ })
1238
+ }
1239
+ continue
1240
+ }
1241
+
1242
+ if (event.type !== 'tool/result') continue
1243
+ const message = data.message
1244
+ const callId = message && typeof message.toolCallId === 'string' ? message.toolCallId : undefined
1245
+ if (callId === undefined) continue
1246
+ const call = pending.get(callId)
1247
+ if (call === undefined) continue
1248
+ pending.delete(callId)
1249
+ if (data.error !== undefined || (message && message.isError === true)) continue
1250
+ if (call.writes) commit(call)
1251
+ else read.add(call.identity)
1252
+ }
1253
+
1254
+ return { created, modified }
1255
+ }
1256
+
1257
+ /**
1258
+ * Whether `child` is strictly inside `parent`, comparing real paths. Folded on
1259
+ * Windows, where the filesystem ignores case: a realpath does not normalise the
1260
+ * case of a prefix that still matches a differently-spelled spelling.
1261
+ */
1262
+ function insidePath(parent, child) {
1263
+ if (parent === child) return false
1264
+ const fold = (value) => (process.platform === 'win32' ? value.toLowerCase() : value)
1265
+ const base = fold(parent)
1266
+ const prefix = base.endsWith(path.sep) ? base : base + path.sep
1267
+ return fold(child).startsWith(prefix)
1268
+ }
1269
+
1270
+ /**
1271
+ * Is this directory inside a git working tree? A `.git` entry marks the root —
1272
+ * a directory in a normal clone, a file in a worktree or a submodule.
1273
+ *
1274
+ * The walk memoises per directory: one plan can span hundreds of files that share
1275
+ * a handful of folders, and each miss would otherwise stat every level again.
1276
+ */
1277
+ async function isInsideGitRepo(directory, cache) {
1278
+ let current = path.resolve(directory)
1279
+ const walked = []
1280
+ for (;;) {
1281
+ if (cache.has(current)) {
1282
+ const verdict = cache.get(current)
1283
+ for (const dir of walked) cache.set(dir, verdict)
1284
+ return verdict
1285
+ }
1286
+ walked.push(current)
1287
+ try {
1288
+ const marker = await fsp.stat(path.join(current, '.git'))
1289
+ if (marker.isDirectory() || marker.isFile()) {
1290
+ for (const dir of walked) cache.set(dir, true)
1291
+ return true
1292
+ }
1293
+ } catch {
1294
+ // no .git here; keep walking up
1295
+ }
1296
+ const parent = path.dirname(current)
1297
+ if (parent === current) {
1298
+ for (const dir of walked) cache.set(dir, false)
1299
+ return false
1300
+ }
1301
+ current = parent
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Keep only the paths that may actually be moved: an existing regular file whose
1307
+ * real path is strictly inside the session's working directory and outside the
1308
+ * DSH home. Every rejection carries its reason.
1309
+ * @param rows - candidate rows `{ path, ops }`.
1310
+ * @param cwd - the session's recorded working directory.
1311
+ * @param home - resolved DSH home.
1312
+ * @param options - `{ skipGit }`: also refuse files inside a git working tree.
1313
+ * @returns `{ allowed, refused }`.
1314
+ */
1315
+ async function classifyPaths(rows, cwd, home, options) {
1316
+ const allowed = []
1317
+ const refused = []
1318
+ const skipGit = options !== undefined && options !== null && options.skipGit === true
1319
+ const gitCache = new Map()
1320
+ const list = [...rows.values()]
1321
+ if (cwd === undefined) {
1322
+ for (const row of list) refused.push({ path: row.path, reason: '会话未记录工作目录,无法安全定位' })
1323
+ return { allowed, refused }
1324
+ }
1325
+ let realCwd
1326
+ try {
1327
+ realCwd = await fsp.realpath(cwd)
1328
+ } catch {
1329
+ for (const row of list) refused.push({ path: row.path, reason: '会话工作目录已不存在,无法校验归属' })
1330
+ return { allowed, refused }
1331
+ }
1332
+ let realHome = home
1333
+ try {
1334
+ realHome = await fsp.realpath(home)
1335
+ } catch {
1336
+ // an absent home cannot contain anything that exists
1337
+ }
1338
+ // Resolve each row's stat and real path together rather than one after another:
1339
+ // a long session can list hundreds of files, and awaiting them serially turns
1340
+ // one threadpool round trip into hundreds. `runWithConcurrency` keeps the order.
1341
+ const verdicts = await runWithConcurrency(list, RESOLVE_CONCURRENCY, async (row) => {
1342
+ let info
1343
+ try {
1344
+ info = await fsp.stat(row.path)
1345
+ } catch {
1346
+ return { refuse: { path: row.path, reason: '文件已不存在' } }
1347
+ }
1348
+ if (!info.isFile()) return { refuse: { path: row.path, reason: '不是普通文件,已跳过' } }
1349
+ let real
1350
+ try {
1351
+ real = await fsp.realpath(row.path)
1352
+ } catch {
1353
+ return { refuse: { path: row.path, reason: '无法解析真实路径,已跳过' } }
1354
+ }
1355
+ if (!insidePath(realCwd, real)) {
1356
+ return { refuse: { path: row.path, reason: '不在该对话的工作目录内,已跳过' } }
1357
+ }
1358
+ if (insidePath(realHome, real)) {
1359
+ return { refuse: { path: row.path, reason: '位于 DSH 数据目录内,已跳过' } }
1360
+ }
1361
+ if (skipGit && await isInsideGitRepo(path.dirname(real), gitCache)) {
1362
+ return { refuse: { path: row.path, reason: '位于 git 仓库内,已跳过' } }
1363
+ }
1364
+ return { allow: { path: real, displayPath: row.path, ops: row.ops, bytes: info.size, mtime: info.mtimeMs } }
1365
+ })
1366
+ for (const verdict of verdicts) {
1367
+ if (verdict.allow !== undefined) allowed.push(verdict.allow)
1368
+ else refused.push(verdict.refuse)
1369
+ }
1370
+ return { allowed, refused }
1371
+ }
1372
+
1373
+ /**
1374
+ * Build the artifact plan for one session.
1375
+ * @param ctx - host plugin context.
1376
+ * @param sessionId - validated session id.
1377
+ * @returns `{ cwd, created, modified, refused, truncated, warnings }`.
1378
+ */
1379
+ async function planArtifacts(ctx, sessionId, cache, options) {
1380
+ const home = dshHome()
1381
+ const warnings = []
1382
+ const sp = ctx.get('sessionPersistence')
1383
+ let cwd
1384
+ if (sp && typeof sp.stat === 'function') {
1385
+ try {
1386
+ const snapshot = await sp.stat(sessionId)
1387
+ let header = null
1388
+ if (snapshot !== null && typeof snapshot === 'object') {
1389
+ header = snapshot.header !== null && typeof snapshot.header === 'object' ? snapshot.header : snapshot
1390
+ }
1391
+ if (header && typeof header.cwd === 'string' && header.cwd !== '') cwd = header.cwd
1392
+ } catch (error) {
1393
+ warnings.push(`未找到该会话的存储记录:${String((error && error.message) || error)}`)
1394
+ }
1395
+ }
1396
+ const { events, warning } = await readSessionEvents(ctx, sessionId)
1397
+ if (warning !== undefined) warnings.push(warning)
1398
+ const { created, modified } = await collectSessionPaths(events, cwd, cache)
1399
+ const createdPlan = await classifyPaths(created, cwd, home, options)
1400
+ const modifiedPlan = await classifyPaths(modified, cwd, home, options)
1401
+ const refused = [...createdPlan.refused, ...modifiedPlan.refused]
1402
+ return {
1403
+ // Stamped so a caller handing this plan back cannot smuggle in another
1404
+ // session's list: runQuarantine refuses a plan that does not belong here.
1405
+ sessionId,
1406
+ cwd,
1407
+ created: createdPlan.allowed.slice(0, MAX_LISTED),
1408
+ modified: modifiedPlan.allowed.slice(0, MAX_LISTED),
1409
+ refused: refused.slice(0, MAX_LISTED),
1410
+ truncated: createdPlan.allowed.length > MAX_LISTED
1411
+ || modifiedPlan.allowed.length > MAX_LISTED
1412
+ || refused.length > MAX_LISTED,
1413
+ warnings,
1414
+ }
1415
+ }
1416
+
1417
+ /**
1418
+ * Claim a quarantine directory under the DSH home. The name carries second
1419
+ * resolution only, so two deletes can land on the same name; an exclusive mkdir
1420
+ * (never `recursive: true`, which would happily reuse an existing tree) decides
1421
+ * the winner and the loser retries with a suffix. Two runs therefore can never
1422
+ * share a tree, which is what stops one run's file from overwriting another's.
1423
+ * @param home - resolved DSH home.
1424
+ * @returns the claimed directory, or undefined when none could be claimed.
1425
+ */
1426
+ async function claimQuarantineDirectory(home) {
1427
+ // A fresh home has no quarantine root yet; creating it is the only write this
1428
+ // side does outside the quarantine tree, and it keeps a missing home from
1429
+ // turning into a silent "cannot claim" on the very first delete.
1430
+ try {
1431
+ await fsp.mkdir(home, { recursive: true })
1432
+ } catch {
1433
+ return undefined
1434
+ }
1435
+ const base = `${QUARANTINE_PREFIX}-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`
1436
+ for (let attempt = 0; attempt < 1000; attempt += 1) {
1437
+ const candidate = path.join(home, attempt === 0 ? base : `${base}-${attempt}`)
1438
+ try {
1439
+ await fsp.mkdir(candidate, { recursive: false })
1440
+ return candidate
1441
+ } catch (error) {
1442
+ if (error && error.code === 'EEXIST') continue
1443
+ return undefined
1444
+ }
1445
+ }
1446
+ return undefined
1447
+ }
1448
+
1449
+ /** Whether a path exists, without throwing. */
1450
+ async function pathExists(target) {
1451
+ try {
1452
+ await fsp.access(target)
1453
+ return true
1454
+ } catch {
1455
+ return false
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * A destination inside the quarantine tree that does not already exist. The
1461
+ * move itself must never overwrite: a quarantine that silently replaced an
1462
+ * earlier copy would destroy the only surviving copy of the file it replaced.
1463
+ * @param directory - claimed quarantine directory.
1464
+ * @param relative - path relative to the session's working directory.
1465
+ * @returns a free absolute path, or undefined when every name was taken.
1466
+ */
1467
+ async function freeDestination(directory, relative) {
1468
+ const first = path.join(directory, relative)
1469
+ if (!(await pathExists(first))) return first
1470
+ const dir = path.dirname(first)
1471
+ const ext = path.extname(first)
1472
+ const stem = path.basename(first, ext)
1473
+ for (let n = 1; n < 10000; n += 1) {
1474
+ const candidate = path.join(dir, `${stem}-${n}${ext}`)
1475
+ if (!(await pathExists(candidate))) return candidate
1476
+ }
1477
+ return undefined
1478
+ }
1479
+
1480
+ /**
1481
+ * Move the chosen files into a timestamped quarantine directory under the DSH
1482
+ * home, preserving each path relative to the session's working directory. Files
1483
+ * are moved, never removed, and an existing quarantine entry is never replaced,
1484
+ * so a wrong call is always recoverable by hand.
1485
+ *
1486
+ * Every step is asynchronous on purpose: a cross-device move copies, and a
1487
+ * synchronous copy inside the request handler would freeze the event loop, which
1488
+ * means every other session's traffic stalls behind one large quarantine.
1489
+ *
1490
+ * @param targets - absolute file paths to move.
1491
+ * @param cwd - the session's recorded working directory.
1492
+ * @param home - resolved DSH home.
1493
+ * @returns `{ directory, moved, failed }`.
1494
+ */
1495
+ async function quarantineFiles(targets, cwd, home) {
1496
+ const moved = []
1497
+ const failed = []
1498
+ if (targets.length === 0) return { directory: null, moved, failed }
1499
+
1500
+ const directory = await claimQuarantineDirectory(home)
1501
+ if (directory === undefined) {
1502
+ for (const target of targets) failed.push({ path: target, error: '无法创建隔离目录' })
1503
+ return { directory: null, moved, failed }
1504
+ }
1505
+
1506
+ let realCwd = cwd
1507
+ if (typeof cwd === 'string') {
1508
+ try {
1509
+ realCwd = await fsp.realpath(cwd)
1510
+ } catch {
1511
+ // keep the recorded spelling; classifyPaths already proved containment
1512
+ }
1513
+ }
1514
+ for (const target of targets) {
1515
+ let relative = path.basename(target)
1516
+ if (typeof realCwd === 'string') {
1517
+ const candidate = path.relative(realCwd, target)
1518
+ if (candidate !== '' && !candidate.startsWith('..') && !path.isAbsolute(candidate)) relative = candidate
1519
+ }
1520
+ try {
1521
+ await fsp.mkdir(path.dirname(path.join(directory, relative)), { recursive: true })
1522
+ const destination = await freeDestination(directory, relative)
1523
+ if (destination === undefined) {
1524
+ failed.push({ path: target, error: '隔离目录内没有可用的目标文件名' })
1525
+ continue
1526
+ }
1527
+ try {
1528
+ await fsp.rename(target, destination)
1529
+ } catch {
1530
+ // A cross-device rename fails; fall back to copy-then-remove. `COPYFILE_EXCL`
1531
+ // keeps the copy from replacing anything that appeared in between.
1532
+ await fsp.copyFile(target, destination, fs.constants.COPYFILE_EXCL)
1533
+ await fsp.rm(target, { force: true })
1534
+ }
1535
+ moved.push({ from: target, to: destination })
1536
+ } catch (error) {
1537
+ failed.push({ path: target, error: String((error && error.message) || error) })
1538
+ }
1539
+ }
1540
+ return { directory, moved, failed }
1541
+ }
1542
+
1543
+ /**
1544
+ * Quarantine the files one request selected. The candidate set is recomputed
1545
+ * here from the session log and the request's paths are intersected with it, so
1546
+ * a tampered request cannot move anything the user was not shown.
1547
+ * @param ctx - host plugin context.
1548
+ * @param sessionId - validated session id.
1549
+ * @param request - `{ scope, paths }` from the client; undefined asks for none.
1550
+ * @returns the outcome, or null when the request asked for no quarantine.
1551
+ */
1552
+ async function runQuarantine(ctx, sessionId, request, knownPlan) {
1553
+ if (request === undefined || request === null || typeof request !== 'object') return null
1554
+ // Validated here as well as at the route, because `internals` exposes this
1555
+ // function and a direct caller must not be able to skip the check.
1556
+ if (!SESSION_ID_RE.test(sessionId)) throw new HttpError(400, 'invalid', 'invalid session id')
1557
+ const scope = request.scope === 'all' ? 'all' : 'created'
1558
+ // The same flag the dialog was built with, so a derived set cannot contain a
1559
+ // file the listing refused to show.
1560
+ const skipGit = request.skipGit === true
1561
+ // Re-deriving the plan is the expensive half of a delete; a caller that already
1562
+ // holds it for this session hands it over instead of paying for it twice.
1563
+ const plan = knownPlan === undefined || knownPlan === null
1564
+ ? await planArtifacts(ctx, sessionId, undefined, { skipGit })
1565
+ : knownPlan
1566
+ // A handed-in plan must be this session's plan. The route always derives it
1567
+ // from the same id, but `internals` exposes this function, and a future caller
1568
+ // passing its neighbour's plan would move files the user never saw.
1569
+ if (plan.sessionId !== sessionId) throw new HttpError(400, 'invalid', 'plan does not belong to this session')
1570
+ const candidates = scope === 'all' ? [...plan.created, ...plan.modified] : [...plan.created]
1571
+ const allowed = new Set(candidates.map((row) => row.path))
1572
+ const requested = Array.isArray(request.paths) ? request.paths : null
1573
+ const requestedStrings = requested === null ? null : new Set(requested.filter((value) => typeof value === 'string'))
1574
+ const targets = requested === null
1575
+ ? [...allowed]
1576
+ : [...requestedStrings].filter((value) => allowed.has(value))
1577
+ // Resolve the configured save path before moving a single file: a bad path must
1578
+ // stop the request, not send the files somewhere unintended.
1579
+ const rootInfo = await resolveQuarantineRoot(request.root, plan.cwd)
1580
+ if (rootInfo.error !== undefined) throw new HttpError(400, 'invalid', rootInfo.error)
1581
+ const root = rootInfo.root
1582
+ const outcome = await quarantineFiles(targets, plan.cwd, root)
1583
+ return {
1584
+ scope,
1585
+ directory: outcome.moved.length > 0 ? outcome.directory : null,
1586
+ root,
1587
+ moved: outcome.moved,
1588
+ failed: outcome.failed,
1589
+ candidates: allowed.size,
1590
+ selected: targets.length,
1591
+ dropped: requestedStrings === null ? 0 : requestedStrings.size - targets.length,
1592
+ }
1593
+ }
1594
+
1595
+ /**
1596
+ * Decide which directory the timestamped quarantine folder is created under. An
1597
+ * empty request means the DSH home, which is what every earlier version did.
1598
+ * @param requested - the directory the client asked for, if any.
1599
+ * @param cwd - the session's working directory, used to refuse a "quarantine"
1600
+ * that would only shuffle the files around inside the tree they came from.
1601
+ * @returns `{ root }` or `{ error }`.
1602
+ */
1603
+ async function resolveQuarantineRoot(requested, cwd) {
1604
+ const home = dshHome()
1605
+ if (requested === undefined || requested === null || requested === '') return { root: home }
1606
+ if (typeof requested !== 'string') return { error: '保存路径必须是字符串' }
1607
+ if (requested.length > 4096) return { error: '保存路径过长' }
1608
+ if (!path.isAbsolute(requested)) return { error: '保存路径必须是绝对路径' }
1609
+
1610
+ let real
1611
+ try {
1612
+ real = await fsp.realpath(requested)
1613
+ } catch {
1614
+ return { error: `保存路径不存在:${requested}` }
1615
+ }
1616
+ let info
1617
+ try {
1618
+ info = await fsp.stat(real)
1619
+ } catch {
1620
+ return { error: `无法读取保存路径:${requested}` }
1621
+ }
1622
+ if (!info.isDirectory()) return { error: '保存路径不是目录' }
1623
+
1624
+ // The sessions store belongs to DSH; filling it with quarantined files would
1625
+ // drop them back into the one tree the delete pipeline sweeps.
1626
+ const sessionsRoot = path.join(home, 'sessions')
1627
+ if (real === sessionsRoot || insidePath(sessionsRoot, real)) {
1628
+ return { error: '不能把隔离夹设在会话存储目录内' }
1629
+ }
1630
+ if (typeof cwd === 'string') {
1631
+ let realCwd
1632
+ try {
1633
+ realCwd = await fsp.realpath(cwd)
1634
+ } catch {
1635
+ realCwd = undefined
1636
+ }
1637
+ if (realCwd !== undefined && (real === realCwd || insidePath(realCwd, real))) {
1638
+ return { error: '不能把隔离夹设在会话工作目录内' }
1639
+ }
1640
+ }
1641
+ return { root: real }
1642
+ }
1643
+
1644
+ /** The drive letters Windows exposes, skipping the ones that cannot be read. */
1645
+ async function listDriveRoots() {
1646
+ if (process.platform !== 'win32') return []
1647
+ const roots = []
1648
+ for (let code = 65; code <= 90; code += 1) {
1649
+ const drive = `${String.fromCharCode(code)}:\\`
1650
+ try {
1651
+ await fsp.access(drive)
1652
+ roots.push({ name: drive, path: drive })
1653
+ } catch {
1654
+ // absent or unreadable: not offered
1655
+ }
1656
+ }
1657
+ return roots
1658
+ }
1659
+
1660
+ /**
1661
+ * One directory listing for the folder picker. Directories only: the picker
1662
+ * chooses where a folder is created, so files would be noise.
1663
+ * @param target - absolute directory to list; the DSH home when omitted.
1664
+ */
1665
+ async function listDirectories(target) {
1666
+ const home = dshHome()
1667
+ const start = target === undefined || target === null || target === '' ? home : target
1668
+ if (typeof start !== 'string') throw new HttpError(400, 'invalid', 'path must be a string')
1669
+ if (!path.isAbsolute(start)) throw new HttpError(400, 'invalid', 'path must be absolute')
1670
+
1671
+ let real
1672
+ try {
1673
+ real = await fsp.realpath(start)
1674
+ } catch {
1675
+ throw new HttpError(400, 'invalid', `目录不存在:${start}`)
1676
+ }
1677
+ let entries
1678
+ try {
1679
+ entries = await fsp.readdir(real, { withFileTypes: true })
1680
+ } catch (error) {
1681
+ throw new HttpError(400, 'invalid', `无法读取目录:${String((error && error.message) || error)}`)
1682
+ }
1683
+
1684
+ const directories = []
1685
+ for (const entry of entries) {
1686
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
1687
+ const full = path.join(real, entry.name)
1688
+ try {
1689
+ // `stat` follows a link, so a shortcut to a folder is still offered.
1690
+ const info = await fsp.stat(full)
1691
+ if (!info.isDirectory()) continue
1692
+ } catch {
1693
+ continue
1694
+ }
1695
+ directories.push({ name: entry.name, path: full })
1696
+ }
1697
+ directories.sort((a, b) => a.name.localeCompare(b.name))
1698
+
1699
+ const parent = real === path.parse(real).root ? null : path.dirname(real)
1700
+ return {
1701
+ path: real,
1702
+ parent,
1703
+ entries: directories.slice(0, MAX_BROWSE_ENTRIES),
1704
+ truncated: directories.length > MAX_BROWSE_ENTRIES,
1705
+ // At a drive root, offer the other drives so Windows users are not stranded.
1706
+ roots: parent === null ? await listDriveRoots() : [],
1707
+ defaultRoot: home,
1708
+ }
1709
+ }
1710
+
1711
+ /**
1712
+ * Run one PowerShell invocation and resolve its trimmed stdout.
1713
+ *
1714
+ * The script is passed as a file, never on stdin: `-Command -` reads stdin with
1715
+ * the console code page, and a non-ASCII byte in the script (the Chinese dialog
1716
+ * description, for one) silently killed the whole run instead of erroring.
1717
+ */
1718
+ function runPowershell(args, timeoutMs) {
1719
+ return new Promise((resolve, reject) => {
1720
+ const child = spawn('powershell.exe', args, {
1721
+ windowsHide: true,
1722
+ })
1723
+ let out = ''
1724
+ let err = ''
1725
+ let settled = false
1726
+ const finish = (fn, value) => {
1727
+ if (settled) return
1728
+ settled = true
1729
+ clearTimeout(timer)
1730
+ fn(value)
1731
+ }
1732
+ const timer = setTimeout(() => {
1733
+ try { child.kill() } catch { /* already gone */ }
1734
+ finish(reject, new HttpError(504, 'timeout', '等待系统对话框超时'))
1735
+ }, timeoutMs)
1736
+
1737
+ child.stdout.on('data', (chunk) => { out += chunk.toString('utf8') })
1738
+ child.stderr.on('data', (chunk) => { err += chunk.toString('utf8') })
1739
+ child.on('error', (error) => {
1740
+ finish(reject, new HttpError(500, 'spawn', `无法启动系统对话框:${String((error && error.message) || error)}`))
1741
+ })
1742
+ child.on('close', (code) => {
1743
+ if (code !== 0) {
1744
+ finish(reject, new HttpError(500, 'shell', `系统对话框异常退出(${code}):${err.trim().slice(0, 300)}`))
1745
+ return
1746
+ }
1747
+ finish(resolve, out.trim())
1748
+ })
1749
+ })
1750
+ }
1751
+
1752
+ /**
1753
+ * Open a directory in the operating system's file manager. The child is detached
1754
+ * and unreferenced so the HTTP response returns at once and the window lives on
1755
+ * its own; `explorer.exe` reports a non-zero exit even when it succeeds, so its
1756
+ * status is deliberately ignored.
1757
+ */
1758
+ function revealDirectory(directory) {
1759
+ const opener = process.platform === 'win32'
1760
+ ? { program: 'explorer.exe', args: [directory] }
1761
+ : process.platform === 'darwin'
1762
+ ? { program: 'open', args: [directory] }
1763
+ : { program: 'xdg-open', args: [directory] }
1764
+ const child = spawn(opener.program, opener.args, { detached: true, stdio: 'ignore', windowsHide: false })
1765
+ child.unref()
1766
+ }
1767
+
1768
+ /**
1769
+ * The slow path: the same dialog through PowerShell. Windows PowerShell 5.1
1770
+ * reads a script file as UTF-8 only when it carries a BOM, and without one the
1771
+ * Chinese labels corrupt the run into a silent exit.
1772
+ */
1773
+ async function pickFolderWithPowershell() {
1774
+ const scriptPath = path.join(os.tmpdir(), `dsh-delete-session-pick-${process.pid}-${Date.now()}.ps1`)
1775
+ const bytes = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(PICK_SCRIPT, 'utf8')])
1776
+ await fsp.writeFile(scriptPath, bytes)
1777
+ try {
1778
+ return await runPowershell(
1779
+ ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
1780
+ PICK_TIMEOUT_MS,
1781
+ )
1782
+ } finally {
1783
+ try { await fsp.rm(scriptPath, { force: true }) } catch { /* best effort */ }
1784
+ }
1785
+ }
1786
+
1787
+ /**
1788
+ * Raise the real Windows folder picker and return what the user chose. The
1789
+ * browser cannot hand a page a filesystem path, but this process can ask the OS.
1790
+ * @returns `{ cancelled, path }`.
1791
+ */
1792
+ async function pickFolderNative() {
1793
+ if (process.platform !== 'win32') {
1794
+ throw new HttpError(501, 'unsupported', '系统文件夹对话框只支持 Windows,请改用浏览…')
1795
+ }
1796
+
1797
+ // The compiled picker answers in ~120 ms; the PowerShell fallback costs a
1798
+ // second before the dialog even appears, so it is only the second choice.
1799
+ let picked = ''
1800
+ const exe = await ensurePickerExe()
1801
+ if (exe !== null) {
1802
+ const run = await runExecutable(exe, ['选择隔离夹保存位置', '选择此文件夹'], PICK_TIMEOUT_MS)
1803
+ // Exit 1 is a cancelled dialog; anything else means it never opened.
1804
+ if (run.code !== 0 && run.code !== 1) {
1805
+ throw new HttpError(500, 'shell', `系统对话框异常退出(${run.code})`)
1806
+ }
1807
+ picked = run.code === 0 ? run.stdout : ''
1808
+ } else {
1809
+ picked = await pickFolderWithPowershell()
1810
+ }
1811
+ if (picked === '') return { cancelled: true, path: null }
1812
+
1813
+ const resolved = path.resolve(picked)
1814
+ let info
1815
+ try {
1816
+ info = await fsp.stat(resolved)
1817
+ } catch {
1818
+ throw new HttpError(400, 'invalid', '选中的目录已不存在')
1819
+ }
1820
+ if (!info.isDirectory()) throw new HttpError(400, 'invalid', '选中的不是目录')
1821
+ return { cancelled: false, path: resolved }
1822
+ }
1823
+
1824
+ // --- http --------------------------------------------------------------------
1825
+
1826
+ function isLoopbackAddress(address) {
1827
+ if (typeof address !== 'string' || address.length === 0) return false
1828
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
1829
+ }
1830
+
1831
+ function isLocalHostHeader(host) {
1832
+ if (typeof host !== 'string' || host.length === 0) return false
1833
+ const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
1834
+ return name === 'localhost' || name === '127.0.0.1' || name === '::1'
1835
+ }
1836
+
1837
+ function sendJson(res, status, body) {
1838
+ const payload = JSON.stringify(body)
1839
+ res.writeHead(status, {
1840
+ 'content-type': 'application/json; charset=utf-8',
1841
+ 'content-length': Buffer.byteLength(payload),
1842
+ })
1843
+ res.end(payload)
1844
+ }
1845
+
1846
+ function readBody(req) {
1847
+ return new Promise((resolve, reject) => {
1848
+ let data = ''
1849
+ req.on('data', (chunk) => {
1850
+ data += chunk
1851
+ if (data.length > 1e6) req.destroy()
1852
+ })
1853
+ req.on('end', () => resolve(data))
1854
+ req.on('error', reject)
1855
+ req.on('aborted', () => reject(new Error('aborted')))
1856
+ })
1857
+ }
1858
+
1859
+ // Destructive local endpoint: loopback socket, loopback Host header, and a
1860
+ // same-origin check when the browser sends Origin. Tunneled or LAN clients
1861
+ // get 403 before any session data is touched.
1862
+ function guard(req, res) {
1863
+ if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
1864
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
1865
+ return false
1866
+ }
1867
+ const host = req.headers.host
1868
+ if (!isLocalHostHeader(host)) {
1869
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
1870
+ return false
1871
+ }
1872
+ const origin = req.headers.origin
1873
+ if (typeof origin === 'string' && origin.length > 0) {
1874
+ let originHost = null
1875
+ try {
1876
+ originHost = new URL(origin).host
1877
+ } catch {
1878
+ originHost = null
1879
+ }
1880
+ if (originHost !== host) {
1881
+ sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
1882
+ return false
1883
+ }
1884
+ }
1885
+ return true
1886
+ }
1887
+
1888
+ // --- plugin ------------------------------------------------------------------
1889
+
1890
+ export function apply(ctx) {
1891
+ warmCompiledHelpers()
1892
+
1893
+ const registerRoutes = (webServer, fiber) => {
1894
+ // Read-only scan: the dialog shows what a delete would move, before it does.
1895
+ fiber.effect(() => webServer.register({
1896
+ kind: 'exact',
1897
+ path: `${ROUTE_PREFIX}/artifacts`,
1898
+ handler: async (req, res) => {
1899
+ if (!guard(req, res)) return
1900
+ if (req.method !== 'POST') {
1901
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
1902
+ return
1903
+ }
1904
+ let body = {}
1905
+ try {
1906
+ const raw = await readBody(req)
1907
+ if (raw) body = JSON.parse(raw)
1908
+ } catch {
1909
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
1910
+ return
1911
+ }
1912
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
1913
+ if (!sessionId) {
1914
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
1915
+ return
1916
+ }
1917
+ try {
1918
+ if (!SESSION_ID_RE.test(sessionId)) throw new HttpError(400, 'invalid', 'invalid session id')
1919
+ const plan = await planArtifacts(ctx, sessionId, undefined, { skipGit: body.skipGit === true })
1920
+ // A bad save path is reported alongside the plan rather than replacing
1921
+ // it: the dialog can still show the files while saying where they cannot go.
1922
+ const rootInfo = await resolveQuarantineRoot(body.root, plan.cwd)
1923
+ sendJson(res, 200, {
1924
+ ok: true,
1925
+ sessionId,
1926
+ ...plan,
1927
+ defaultRoot: dshHome(),
1928
+ quarantineRoot: rootInfo.error === undefined ? rootInfo.root : null,
1929
+ rootError: rootInfo.error === undefined ? null : rootInfo.error,
1930
+ })
1931
+ } catch (error) {
1932
+ const status = error instanceof HttpError ? error.status : 500
1933
+ const code = error instanceof HttpError ? error.code : 'internal'
1934
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
1935
+ }
1936
+ },
1937
+ }))
1938
+
1939
+ // The real Windows folder dialog, raised on the user's behalf. Browsers cannot
1940
+ // hand a page a filesystem path, but this process is local, so it can.
1941
+ fiber.effect(() => webServer.register({
1942
+ kind: 'exact',
1943
+ path: `${ROUTE_PREFIX}/pick`,
1944
+ handler: async (req, res) => {
1945
+ if (!guard(req, res)) return
1946
+ if (req.method !== 'POST') {
1947
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
1948
+ return
1949
+ }
1950
+ try {
1951
+ const picked = await pickFolderNative()
1952
+ sendJson(res, 200, { ok: true, ...picked })
1953
+ } catch (error) {
1954
+ const status = error instanceof HttpError ? error.status : 500
1955
+ const code = error instanceof HttpError ? error.code : 'internal'
1956
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
1957
+ }
1958
+ },
1959
+ }))
1960
+
1961
+ // Open a folder in the OS file manager. The path is validated as an existing
1962
+ // directory first: this hands a string to Explorer, so it must never be able
1963
+ // to carry anything but a directory.
1964
+ fiber.effect(() => webServer.register({
1965
+ kind: 'exact',
1966
+ path: `${ROUTE_PREFIX}/reveal`,
1967
+ handler: async (req, res) => {
1968
+ if (!guard(req, res)) return
1969
+ if (req.method !== 'POST') {
1970
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
1971
+ return
1972
+ }
1973
+ let body = {}
1974
+ try {
1975
+ const raw = await readBody(req)
1976
+ if (raw) body = JSON.parse(raw)
1977
+ } catch {
1978
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
1979
+ return
1980
+ }
1981
+ try {
1982
+ const target = typeof body.path === 'string' ? body.path.trim() : ''
1983
+ if (target === '') throw new HttpError(400, 'invalid', 'path required')
1984
+ const resolved = path.resolve(target)
1985
+ let info
1986
+ try {
1987
+ info = await fsp.stat(resolved)
1988
+ } catch {
1989
+ throw new HttpError(400, 'invalid', '目录不存在')
1990
+ }
1991
+ if (!info.isDirectory()) throw new HttpError(400, 'invalid', '不是目录')
1992
+ revealDirectory(resolved)
1993
+ sendJson(res, 200, { ok: true, path: resolved })
1994
+ } catch (error) {
1995
+ const status = error instanceof HttpError ? error.status : 500
1996
+ const code = error instanceof HttpError ? error.code : 'internal'
1997
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
1998
+ }
1999
+ },
2000
+ }))
2001
+
2002
+ // Folder picker for the quarantine save path: directories only, never file
2003
+ // contents, and the same loopback guard as every other route here.
2004
+ fiber.effect(() => webServer.register({
2005
+ kind: 'exact',
2006
+ path: `${ROUTE_PREFIX}/browse`,
2007
+ handler: async (req, res) => {
2008
+ if (!guard(req, res)) return
2009
+ if (req.method !== 'POST') {
2010
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
2011
+ return
2012
+ }
2013
+ let body = {}
2014
+ try {
2015
+ const raw = await readBody(req)
2016
+ if (raw) body = JSON.parse(raw)
2017
+ } catch {
2018
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
2019
+ return
2020
+ }
2021
+ try {
2022
+ const listing = await listDirectories(body.path)
2023
+ sendJson(res, 200, { ok: true, ...listing })
2024
+ } catch (error) {
2025
+ const status = error instanceof HttpError ? error.status : 500
2026
+ const code = error instanceof HttpError ? error.code : 'internal'
2027
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2028
+ }
2029
+ },
2030
+ }))
2031
+
2032
+ // The subagent sessions behind one conversation. A subagent is a session of
2033
+ // its own, linked only by `parentSession`, so the dialog needs this list
2034
+ // before it can offer anything to tick.
2035
+ fiber.effect(() => webServer.register({
2036
+ kind: 'exact',
2037
+ path: `${ROUTE_PREFIX}/children`,
2038
+ handler: async (req, res) => {
2039
+ if (!guard(req, res)) return
2040
+ if (req.method !== 'POST') {
2041
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
2042
+ return
2043
+ }
2044
+ let body = {}
2045
+ try {
2046
+ const raw = await readBody(req)
2047
+ if (raw) body = JSON.parse(raw)
2048
+ } catch {
2049
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
2050
+ return
2051
+ }
2052
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2053
+ if (!SESSION_ID_RE.test(sessionId)) {
2054
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'invalid session id' })
2055
+ return
2056
+ }
2057
+ try {
2058
+ const headers = await listSessionHeaders(ctx)
2059
+ sendJson(res, 200, { ok: true, children: describeChildren(headers, sessionId) })
2060
+ } catch (error) {
2061
+ const status = error instanceof HttpError ? error.status : 500
2062
+ const code = error instanceof HttpError ? error.code : 'internal'
2063
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2064
+ }
2065
+ },
2066
+ }))
2067
+ fiber.effect(() => webServer.register({
2068
+ kind: 'exact',
2069
+ path: `${ROUTE_PREFIX}/delete`,
2070
+ handler: async (req, res) => {
2071
+ if (!guard(req, res)) return
2072
+ if (req.method !== 'POST') {
2073
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
2074
+ return
2075
+ }
2076
+ let body = {}
2077
+ try {
2078
+ const raw = await readBody(req)
2079
+ if (raw) body = JSON.parse(raw)
2080
+ } catch {
2081
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
2082
+ return
2083
+ }
2084
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
2085
+ if (!sessionId) {
2086
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionId required' })
2087
+ return
2088
+ }
2089
+ try {
2090
+ // Validate before touching anything: the quarantine runs first, so an
2091
+ // unchecked id would reach the filesystem ahead of deleteSession's own
2092
+ // guard. `/artifacts` already validates at the same point.
2093
+ if (!SESSION_ID_RE.test(sessionId)) throw new HttpError(400, 'invalid', 'invalid session id')
2094
+ // Quarantine first: the session log is still there to derive the plan
2095
+ // from, and a partial move leaves the conversation intact to retry.
2096
+ const wantsQuarantine = typeof body.quarantine === 'object' && body.quarantine !== null
2097
+ // The plan handed to the move must honour the same `skipGit` the dialog
2098
+ // listed, or a file the listing refused to show would move anyway.
2099
+ const plan = wantsQuarantine
2100
+ ? await planArtifacts(ctx, sessionId, undefined, { skipGit: body.quarantine.skipGit === true })
2101
+ : null
2102
+ const quarantine = await runQuarantine(ctx, sessionId, body.quarantine, plan)
2103
+ const result = await deleteSession(ctx, sessionId, {
2104
+ recycle: body.recycle !== false,
2105
+ childSessionIds: Array.isArray(body.childSessionIds) ? body.childSessionIds : [],
2106
+ })
2107
+ sendJson(res, 200, quarantine === null ? { ok: true, ...result } : { ok: true, ...result, quarantine })
2108
+ } catch (error) {
2109
+ const status = error instanceof HttpError ? error.status : 500
2110
+ const code = error instanceof HttpError ? error.code : 'internal'
2111
+ sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
2112
+ }
2113
+ },
2114
+ }))
2115
+
2116
+ fiber.effect(() => webServer.register({
2117
+ kind: 'exact',
2118
+ path: `${ROUTE_PREFIX}/delete-many`,
2119
+ handler: async (req, res) => {
2120
+ if (!guard(req, res)) return
2121
+ if (req.method !== 'POST') {
2122
+ sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
2123
+ return
2124
+ }
2125
+ let body = {}
2126
+ try {
2127
+ const raw = await readBody(req)
2128
+ if (raw) body = JSON.parse(raw)
2129
+ } catch {
2130
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
2131
+ return
2132
+ }
2133
+ const sessionIds = Array.isArray(body.sessionIds)
2134
+ ? [...new Set(body.sessionIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
2135
+ : []
2136
+ if (sessionIds.length === 0) {
2137
+ sendJson(res, 400, { ok: false, code: 'invalid', error: 'sessionIds required' })
2138
+ return
2139
+ }
2140
+ if (sessionIds.length > MAX_BATCH) {
2141
+ sendJson(res, 400, { ok: false, code: 'invalid', error: `too many sessions (max ${MAX_BATCH})` })
2142
+ return
2143
+ }
2144
+ // Bounded-concurrency batch: every item runs the same guarded pipeline
2145
+ // (lease retry, directory sweep, workspace accounting); independent
2146
+ // sessions run in parallel and the workspace accounting mutation is
2147
+ // serialized inside deleteSession, so a batch can never interleave two
2148
+ // deletions of the same storage.
2149
+ const results = await runWithConcurrency(sessionIds, BATCH_CONCURRENCY, async (sessionId) => {
2150
+ try {
2151
+ const result = await deleteSession(ctx, sessionId, {
2152
+ recycle: body.recycle !== false,
2153
+ childSessionIds: Array.isArray(body.childSessionIds) ? body.childSessionIds : [],
2154
+ })
2155
+ return { sessionId, ok: true, removed: result.removed }
2156
+ } catch (error) {
2157
+ return {
2158
+ sessionId,
2159
+ ok: false,
2160
+ code: error instanceof HttpError ? error.code : 'internal',
2161
+ error: String((error && error.message) || error),
2162
+ }
2163
+ }
2164
+ })
2165
+ const deleted = results.filter((item) => item.ok).length
2166
+ sendJson(res, 200, { ok: true, results, deleted, failed: results.length - deleted })
2167
+ },
2168
+ }))
2169
+ }
2170
+
2171
+ const webServer = ctx.get('webServer')
2172
+ if (webServer) {
2173
+ registerRoutes(webServer, ctx)
2174
+ } else {
2175
+ // A terminal-only profile never provides a web surface; register once one
2176
+ // appears instead of waiting on a service that will never arrive.
2177
+ ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
2178
+ }
2179
+ }
2180
+
2181
+ // Re-exported for the offline test suite, which drives the artifact plan and the
2182
+ // quarantine against a scratch tree and a stubbed persistence service. Nothing
2183
+ // else in the host reads this face.
2184
+ export const internals = {
2185
+ ensurePickerExe,
2186
+ claimQuarantineDirectory,
2187
+ classifyPaths,
2188
+ collectSessionPaths,
2189
+ findSessionCaches,
2190
+ freeDestination,
2191
+ identityKey,
2192
+ insidePath,
2193
+ listDirectories,
2194
+ moveToSessionTrash,
2195
+ planArtifacts,
2196
+ quarantineFiles,
2197
+ resolveQuarantineRoot,
2198
+ removeForever,
2199
+ deleteSession,
2200
+ retirePaths,
2201
+ retireTargets,
2202
+ listSessionHeaders,
2203
+ collectOrphans,
2204
+ collectDescendants,
2205
+ bareSessionId,
2206
+ describeChildren,
2207
+ selectChildDeletes,
2208
+ runQuarantine,
2209
+ sendToRecycleBin,
2210
+ toolTarget,
2211
+ trashCommandFor,
2212
+ warmCompiledHelpers,
2213
+ }