better-dsh-session-deletetool 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host.js ADDED
@@ -0,0 +1,1431 @@
1
+ /**
2
+ * better-dsh-session-deletetool — Host half.
3
+ *
4
+ * DSH can only archive a conversation: `ctx.workspaceRegistry.archiveSession()`
5
+ * hides a Session from the sidebar and keeps every artifact. The persistence
6
+ * seam has no deletion API either — `@deepseek-ai/dsh-session-persistence-jsonl`
7
+ * documents "Nothing deletes session files — logs accumulate under `root` until
8
+ * removed externally; the seam has no deletion API".
9
+ *
10
+ * This plugin adds the missing delete. For one session id it removes:
11
+ * 1. the Session's own artifact directory (every format generation in it),
12
+ * 2. its account in every Workspace record, plus archive and pin membership,
13
+ * 3. its projection-cache checkpoint record,
14
+ * then emits `api-session/removed` so connected Clients drop the row — the same
15
+ * event the shipped Session controller emits when a Session is disposed.
16
+ *
17
+ * Descendants are selected, not assumed, and the family is read from both durable
18
+ * relations because neither one alone is complete:
19
+ *
20
+ * - **The subagent catalog** — each Session's own `subagentCatalog` projection,
21
+ * the parent-owned record of the Sessions it spawned — walked from the target
22
+ * downwards, so a subagent is listed under the Session that spawned it rather
23
+ * than under the target. (The service's `subagents.listDescendants` is asked as
24
+ * well; it needs the live Session store, so it can fail wholesale, which is
25
+ * exactly the case the per-parent walk survives.)
26
+ * - **The header lineage** (`SessionHeader.parentSession`) — the relation the
27
+ * catalog has no row for at all, which is what a forked conversation is, plus
28
+ * its own view of subagent Sessions and the project directory each child's
29
+ * header carries for display naming.
30
+ *
31
+ * `GET /inspect` returns that family as a list — id, kind, depth, parent, title,
32
+ * whether it is open, and what it is running — and `POST /delete` takes the ids
33
+ * the user ticked. The selection is validated against the Host's own walk, so a
34
+ * crafted request can only ever name Sessions in this lineage. Descendants are
35
+ * removed deepest first, and the family is gathered before anything is removed.
36
+ *
37
+ * `GET /catalog` answers the bulk view: every Session the corpus holds, grouped
38
+ * by the Workspace that owns its `cwd` the way the sidebar groups them, with the
39
+ * one fact the page cannot derive — which rows are a parent Session and which are
40
+ * a spawned/forked child — carried per row. `POST /delete-batch` is the same
41
+ * single-Session delete applied to a list of (root, chosen descendants) pairs, so
42
+ * the batch path shares the selection validation, the activity gate, and the
43
+ * removal order with the single path instead of restating them.
44
+ *
45
+ * What may block a delete is RUNNING WORK, never mere residency, and the question
46
+ * is asked once per Session in the delete set: the shipped admission answers for
47
+ * the Session it is asked about, so a descendant's running turn or background job
48
+ * would be invisible if only the target were asked. A Session the Host still holds
49
+ * open (`ctx.sessions` / `ctx.agents`) is deleted anyway: measured on this
50
+ * platform, `rm` succeeds while the append handle is open, the directory entry
51
+ * disappears at once, and later appends land in the unlinked file instead of
52
+ * resurrecting it. The gate itself is DSH's own archive admission — the
53
+ * `workspace/session-activity` waterfall the shipped archive uses, answered by the
54
+ * Agent registry (a running turn), the job registry, the Subagent runtime, and
55
+ * Schedule. Pass `stop: true` to stop that work first, exactly as
56
+ * `archiveSession(id, { stopActivity: true })` does. Each deleted Session's own
57
+ * shells are closed too: terminals belong to no admission family, and the service
58
+ * only reaps them once the Agent is released.
59
+ *
60
+ * The Client half reaches this half over two same-origin HTTP routes on
61
+ * `ctx.webServer`, the transport the installed community plugin `dshmarket`
62
+ * uses for its own UI→Host calls: a build-free plain-JavaScript bundle cannot
63
+ * declare a typed `ctx.remote` namespace, because those need generated Typert
64
+ * descriptors.
65
+ */
66
+ import { rm } from 'node:fs/promises'
67
+ import { dirname } from 'node:path'
68
+
69
+ /** Services activation waits for; everything else is looked up per request. */
70
+ export const inject = ['webServer']
71
+
72
+ /**
73
+ * The pure halves of the bulk routes, exported only for a test run.
74
+ *
75
+ * The Loader reads `apply` and `inject`; a test imports this module directly and
76
+ * sets `globalThis.__DSD_TEST__` before it does.
77
+ */
78
+ export const __test = globalThis.__DSD_TEST__ === true
79
+ ? { buildCatalog, catalogSessions, normalizeRoots, workspaceLabel }
80
+ : undefined
81
+
82
+ const DELETE_PATH = '/better-dsh-session-deletetool/delete'
83
+ const INSPECT_PATH = '/better-dsh-session-deletetool/inspect'
84
+ const CATALOG_PATH = '/better-dsh-session-deletetool/catalog'
85
+ const BATCH_PATH = '/better-dsh-session-deletetool/delete-batch'
86
+
87
+ /** Session ids are opaque strings, but never paths: keep them to safe segments. */
88
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/
89
+
90
+ /**
91
+ * Request-body ceiling. The delete body carries the ticked descendant ids, and
92
+ * the cap it is validated against is MAX_DESCENDANTS, so this has to hold a few
93
+ * hundred of them.
94
+ */
95
+ const MAX_BODY_BYTES = 65536
96
+
97
+ const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
98
+
99
+ /** The families `workspace/session-activity` can report, in the reader's words. */
100
+ const ACTIVITY_LABELS = {
101
+ turn: '进行中的回合',
102
+ subagent: '运行中的子智能体',
103
+ job: '运行中的后台任务',
104
+ schedule: '生效中的定时提醒',
105
+ }
106
+
107
+ /** How many descendant Sessions one delete request may carry before it aborts. */
108
+ const MAX_DESCENDANTS = 200
109
+
110
+ /** How many descendants the state report lists for selection; beyond this only the count is shown. */
111
+ const MAX_LISTED_DESCENDANTS = 300
112
+
113
+ /** How many roots one bulk delete request may carry before it refuses. */
114
+ const MAX_BATCH_ROOTS = 200
115
+
116
+ /** How deep the lineage walk may go; a guard against a hand-edited header cycle. */
117
+ const MAX_LINEAGE_DEPTH = 64
118
+
119
+ /** A refusal carrying the HTTP status, stable code, and details the Client reports. */
120
+ class DeleteRefusal extends Error {
121
+ constructor(status, code, message, details) {
122
+ super(message)
123
+ this.name = 'DeleteRefusal'
124
+ this.status = status
125
+ this.code = code
126
+ this.details = details ?? {}
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Register the delete, inspect, catalog and batch routes.
132
+ * @param {object} ctx - Host Cordis context.
133
+ */
134
+ export function apply(ctx) {
135
+ const onDelete = (request, response) => {
136
+ void serveDelete(ctx, request, response).catch((error) => {
137
+ ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
138
+ send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
139
+ })
140
+ }
141
+ const onInspect = (request, response) => {
142
+ void serveInspect(ctx, request, response).catch((error) => {
143
+ ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
144
+ send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
145
+ })
146
+ }
147
+ const onCatalog = (request, response) => {
148
+ void serveCatalog(ctx, request, response).catch((error) => {
149
+ ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
150
+ send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
151
+ })
152
+ }
153
+ const onBatch = (request, response) => {
154
+ void serveBatch(ctx, request, response).catch((error) => {
155
+ ctx.logger?.warn?.(`[better-dsh-session-deletetool] route failure: ${messageOf(error)}`)
156
+ send(response, 500, { ok: false, code: 'internal', message: messageOf(error) })
157
+ })
158
+ }
159
+ ctx.effect(
160
+ () => ctx.webServer.register({ kind: 'exact', path: DELETE_PATH, handler: onDelete }),
161
+ 'better-dsh-session-deletetool: delete route',
162
+ )
163
+ ctx.effect(
164
+ () => ctx.webServer.register({ kind: 'exact', path: INSPECT_PATH, handler: onInspect }),
165
+ 'better-dsh-session-deletetool: inspect route',
166
+ )
167
+ ctx.effect(
168
+ () => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }),
169
+ 'better-dsh-session-deletetool: catalog route',
170
+ )
171
+ ctx.effect(
172
+ () => ctx.webServer.register({ kind: 'exact', path: BATCH_PATH, handler: onBatch }),
173
+ 'better-dsh-session-deletetool: batch route',
174
+ )
175
+ ctx.logger?.info?.(`[better-dsh-session-deletetool] routes mounted at ${DELETE_PATH}, ${INSPECT_PATH}, ${CATALOG_PATH} and ${BATCH_PATH}`)
176
+ }
177
+
178
+ /**
179
+ * Own one delete request end to end.
180
+ * @param {object} ctx - Host Cordis context.
181
+ * @param {import('node:http').IncomingMessage} request - the request.
182
+ * @param {import('node:http').ServerResponse} response - the response.
183
+ */
184
+ async function serveDelete(ctx, request, response) {
185
+ if (request.method !== 'POST') {
186
+ send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
187
+ return
188
+ }
189
+ if (!sameOrigin(request)) {
190
+ send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
191
+ return
192
+ }
193
+ let body
194
+ try {
195
+ body = JSON.parse(await readBody(request))
196
+ } catch (error) {
197
+ send(response, 400, { ok: false, code: 'bad-request', message: messageOf(error) })
198
+ return
199
+ }
200
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : ''
201
+ if (!SESSION_ID.test(sessionId)) {
202
+ send(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
203
+ return
204
+ }
205
+ try {
206
+ send(response, 200, await deleteSession(ctx, sessionId, {
207
+ stop: body?.stop === true,
208
+ descendants: body?.descendants,
209
+ }))
210
+ } catch (error) {
211
+ answer(ctx, response, error, sessionId)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Own one inspect request: report what the page cannot see by itself.
217
+ * @param {object} ctx - Host Cordis context.
218
+ * @param {import('node:http').IncomingMessage} request - the request.
219
+ * @param {import('node:http').ServerResponse} response - the response.
220
+ */
221
+ async function serveInspect(ctx, request, response) {
222
+ if (request.method !== 'GET') {
223
+ send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
224
+ return
225
+ }
226
+ if (!sameOrigin(request)) {
227
+ send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
228
+ return
229
+ }
230
+ let sessionId = ''
231
+ try {
232
+ sessionId = (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('sessionId') ?? '').trim()
233
+ } catch {
234
+ // A malformed request URL is reported as a malformed id below.
235
+ }
236
+ if (!SESSION_ID.test(sessionId)) {
237
+ send(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
238
+ return
239
+ }
240
+ try {
241
+ send(response, 200, await inspectSession(ctx, sessionId))
242
+ } catch (error) {
243
+ answer(ctx, response, error, sessionId)
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Own one catalog request: hand the bulk dialog every Session there is, grouped
249
+ * the way the sidebar groups them.
250
+ * @param {object} ctx - Host Cordis context.
251
+ * @param {import('node:http').IncomingMessage} request - the request.
252
+ * @param {import('node:http').ServerResponse} response - the response.
253
+ */
254
+ async function serveCatalog(ctx, request, response) {
255
+ if (request.method !== 'GET') {
256
+ send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
257
+ return
258
+ }
259
+ if (!sameOrigin(request)) {
260
+ send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
261
+ return
262
+ }
263
+ try {
264
+ send(response, 200, await buildCatalog(ctx))
265
+ } catch (error) {
266
+ answer(ctx, response, error, 'catalog')
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Own one bulk delete request: the same delete, once per selected root.
272
+ *
273
+ * Each root keeps its own selection, so the bulk dialog can carry the
274
+ * per-family checkbox state the single dialog offers. A root that fails is
275
+ * reported as a failure line and the remaining roots are still attempted: a
276
+ * batch is a sequence of independent deletes, not one transaction.
277
+ * @param {object} ctx - Host Cordis context.
278
+ * @param {import('node:http').IncomingMessage} request - the request.
279
+ * @param {import('node:http').ServerResponse} response - the response.
280
+ */
281
+ async function serveBatch(ctx, request, response) {
282
+ if (request.method !== 'POST') {
283
+ send(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
284
+ return
285
+ }
286
+ if (!sameOrigin(request)) {
287
+ send(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
288
+ return
289
+ }
290
+ let body
291
+ try {
292
+ body = JSON.parse(await readBody(request))
293
+ } catch (error) {
294
+ send(response, 400, { ok: false, code: 'bad-request', message: messageOf(error) })
295
+ return
296
+ }
297
+ const roots = normalizeRoots(body?.roots)
298
+ if (roots.length === 0) {
299
+ send(response, 400, {
300
+ ok: false,
301
+ code: 'bad-request',
302
+ message: 'roots 必须是非空的 { sessionId, descendants } 列表。',
303
+ })
304
+ return
305
+ }
306
+ if (roots.length > MAX_BATCH_ROOTS) {
307
+ send(response, 400, {
308
+ ok: false,
309
+ code: 'too-many-roots',
310
+ message: `一次最多处理 ${MAX_BATCH_ROOTS} 个会话,请分批删除。`,
311
+ })
312
+ return
313
+ }
314
+
315
+ const stop = body?.stop === true
316
+ const removed = []
317
+ const failed = []
318
+ for (const root of roots) {
319
+ try {
320
+ removed.push(await deleteSession(ctx, root.sessionId, { stop, descendants: root.descendants }))
321
+ } catch (error) {
322
+ const refusal = error instanceof DeleteRefusal
323
+ ? error
324
+ : new DeleteRefusal(500, 'delete-failed', messageOf(error))
325
+ ctx.logger?.warn?.(`[better-dsh-session-deletetool] batch ${root.sessionId}: ${refusal.code}: ${refusal.message}`)
326
+ failed.push({ sessionId: root.sessionId, code: refusal.code, message: refusal.message })
327
+ }
328
+ }
329
+ send(response, 200, {
330
+ ok: failed.length === 0,
331
+ roots: roots.map((root) => root.sessionId),
332
+ removed,
333
+ failed,
334
+ })
335
+ }
336
+
337
+ /**
338
+ * Read the caller's root list into `{ sessionId, descendants }` pairs.
339
+ *
340
+ * A root without a `descendants` array means "every descendant of it", which is
341
+ * what the bulk dialog sends for a row ticked as a whole family; an empty array
342
+ * means the row alone. Anything malformed is dropped here, and the caller
343
+ * reports the resulting empty list.
344
+ * @param {unknown} value - the request's `roots` field.
345
+ * @returns {Array<{ sessionId: string, descendants?: string[] }>} the roots.
346
+ */
347
+ function normalizeRoots(value) {
348
+ if (!Array.isArray(value)) return []
349
+ const roots = []
350
+ const seen = new Set()
351
+ for (const raw of value) {
352
+ const sessionId = typeof raw?.sessionId === 'string' ? raw.sessionId.trim() : ''
353
+ if (!SESSION_ID.test(sessionId) || seen.has(sessionId)) continue
354
+ seen.add(sessionId)
355
+ const descendants = Array.isArray(raw?.descendants)
356
+ ? raw.descendants
357
+ .filter((id) => typeof id === 'string' && SESSION_ID.test(id.trim()))
358
+ .map((id) => id.trim())
359
+ : undefined
360
+ roots.push({ sessionId, ...(descendants === undefined ? {} : { descendants }) })
361
+ }
362
+ return roots
363
+ }
364
+
365
+ /**
366
+ * Build the bulk view: every known Session, grouped by its owning Workspace.
367
+ *
368
+ * Grouping follows the sidebar. A Session belongs to the Workspace whose
369
+ * directory matches its header `cwd`, so a Session whose `cwd` no Workspace owns
370
+ * lands in one "未归类" group. Within a group, a Session that another listed
371
+ * Session of the same group names as `parentSession` is indented under it; a
372
+ * Session whose parent is not listed here stays a root.
373
+ *
374
+ * Each row carries the one fact the page cannot derive by itself: whether the
375
+ * Session was spawned as a subagent or forked off another Session, whether it
376
+ * has children in this group, and how many subagent and derived Sessions hang
377
+ * off it, so the dialog can offer the same per-family selection the single
378
+ * dialog offers.
379
+ * @param {object} ctx - Host Cordis context.
380
+ * @returns {Promise<object>} `{ ok, workspaces, totals }`.
381
+ */
382
+ async function buildCatalog(ctx) {
383
+ const query = ctx.get('sessionQuery')
384
+ if (query === undefined || typeof query.listSessions !== 'function') {
385
+ throw new DeleteRefusal(503, 'query-unavailable', 'sessionQuery is not mounted in this profile')
386
+ }
387
+ const records = await query.listSessions()
388
+ const headers = []
389
+ for (const record of records ?? []) {
390
+ const header = record?.header
391
+ if (header !== undefined && typeof header.id === 'string' && SESSION_ID.test(header.id)) headers.push(header)
392
+ }
393
+ await attachTitles(ctx, headers)
394
+
395
+ const registry = ctx.get('workspaceRegistry')
396
+ const workspaces = registry !== undefined && typeof registry.list === 'function' ? registry.list() : []
397
+ const rank = new Map()
398
+ /** Session id → owning Workspace id, taken from that Workspace's own account. */
399
+ const ownerOf = new Map()
400
+ for (const workspace of workspaces) {
401
+ const id = String(workspace?.id ?? '')
402
+ if (id !== '' && !rank.has(id)) rank.set(id, rank.size)
403
+ for (const sessionId of workspace?.sessionIds ?? []) {
404
+ const key = String(sessionId)
405
+ if (!ownerOf.has(key)) ownerOf.set(key, id)
406
+ }
407
+ }
408
+
409
+ const groups = new Map()
410
+ for (const header of headers) {
411
+ const owned = ownerOf.get(header.id)
412
+ const workspace = owned === undefined
413
+ ? undefined
414
+ : workspaces.find((entry) => String(entry?.id ?? '') === owned)
415
+ const cwd = typeof header.cwd === 'string' && header.cwd !== '' ? header.cwd : undefined
416
+ // The Workspace account leads and the canonical directory follows, the
417
+ // precedence the sidebar's own grouping uses; anything else is ungrouped.
418
+ const key = workspace !== undefined ? `w:${owned}` : cwd === undefined ? 'ungrouped' : `c:${cwd}`
419
+ if (!groups.has(key)) groups.set(key, { workspace, cwd, members: [] })
420
+ groups.get(key).members.push(header)
421
+ }
422
+
423
+ const sections = []
424
+ for (const [key, group] of groups) {
425
+ if (key === 'ungrouped') continue
426
+ sections.push({
427
+ key,
428
+ workspaceId: group.workspace === undefined ? null : String(group.workspace.id),
429
+ title: group.workspace === undefined ? workspaceLabel(group.cwd) : String(group.workspace.title ?? ''),
430
+ path: group.workspace === undefined ? String(group.cwd ?? '') : String(group.workspace.path ?? ''),
431
+ sessions: await catalogSessions(ctx, group.members),
432
+ })
433
+ }
434
+ sections.sort((left, right) => sectionRank(rank, left) - sectionRank(rank, right) || left.title.localeCompare(right.title))
435
+
436
+ const ungrouped = groups.get('ungrouped')
437
+ if (ungrouped !== undefined) {
438
+ sections.push({
439
+ key: 'ungrouped',
440
+ workspaceId: null,
441
+ title: '未归类',
442
+ path: '',
443
+ sessions: await catalogSessions(ctx, ungrouped.members),
444
+ })
445
+ }
446
+
447
+ return {
448
+ ok: true,
449
+ workspaces: sections,
450
+ totals: {
451
+ workspaces: sections.length,
452
+ sessions: headers.length,
453
+ ungrouped: ungrouped === undefined ? 0 : ungrouped.members.length,
454
+ },
455
+ }
456
+ }
457
+
458
+ /** Where a section sits in the registry's durable order; unknown or absent ids last. */
459
+ function sectionRank(rank, section) {
460
+ if (section.workspaceId === null || section.workspaceId === undefined) return Number.MAX_SAFE_INTEGER
461
+ return rank.has(section.workspaceId) ? rank.get(section.workspaceId) : Number.MAX_SAFE_INTEGER - 1
462
+ }
463
+
464
+ /**
465
+ * One group's rows: lineage depth, parent/child flags and family counts.
466
+ * @param {object} ctx - Host Cordis context.
467
+ * @param {object[]} members - the group's headers.
468
+ * @returns {Promise<object[]>} the rows, parents before their children.
469
+ */
470
+ async function catalogSessions(ctx, members) {
471
+ const inGroup = new Set(members.map((header) => header.id))
472
+ const childrenOf = new Map()
473
+ for (const header of members) {
474
+ const parent = typeof header.parentSession === 'string' ? header.parentSession : undefined
475
+ // A parent outside this group is not rendered here, so the row stays a root:
476
+ // indenting it under a row that does not exist would read as a lost row.
477
+ if (parent === undefined || !inGroup.has(parent) || parent === header.id) continue
478
+ if (!childrenOf.has(parent)) childrenOf.set(parent, [])
479
+ childrenOf.get(parent).push(header)
480
+ }
481
+
482
+ const depthOf = new Map()
483
+ const depthOfId = (id, guard) => {
484
+ if (depthOf.has(id)) return depthOf.get(id)
485
+ if (guard.has(id)) return 0
486
+ guard.add(id)
487
+ const header = members.find((entry) => entry.id === id)
488
+ const parent = typeof header?.parentSession === 'string' ? header.parentSession : undefined
489
+ const value = parent === undefined || !inGroup.has(parent) || parent === id ? 0 : depthOfId(parent, guard) + 1
490
+ depthOf.set(id, Math.min(value, MAX_LINEAGE_DEPTH))
491
+ return depthOf.get(id)
492
+ }
493
+ for (const header of members) depthOfId(header.id, new Set())
494
+
495
+ const rows = []
496
+ for (const header of members) {
497
+ const parent = typeof header.parentSession === 'string' && inGroup.has(header.parentSession) && header.parentSession !== header.id
498
+ ? header.parentSession
499
+ : undefined
500
+ const children = childrenOf.get(header.id) ?? []
501
+ const runtime = runtimeOf(ctx, header.id)
502
+ const activity = await catalogActivity(ctx, header.id, runtime)
503
+ // Display name, resolved here so the row never reads as untitled: the
504
+ // durable title when the log carries one, else the project directory's final
505
+ // segment, the same order the sidebar's own rows use.
506
+ const named = typeof header.title === 'string' && header.title.trim() !== ''
507
+ ? header.title
508
+ : workspaceTitleOf(header.cwd)
509
+ rows.push({
510
+ id: header.id,
511
+ kind: header.origin === 'subagent' ? DESCENDANT_KINDS.subagent : parent === undefined ? 'root' : DESCENDANT_KINDS.derived,
512
+ depth: depthOf.get(header.id) ?? 0,
513
+ createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0,
514
+ hasChildren: children.length > 0,
515
+ family: children.length,
516
+ subagents: children.filter((child) => child.origin === 'subagent').length,
517
+ derived: children.filter((child) => child.origin !== 'subagent').length,
518
+ ...(parent === undefined ? {} : { parentId: parent }),
519
+ ...(named === '' ? {} : { title: named }),
520
+ ...(typeof header.cwd === 'string' && header.cwd !== '' ? { cwd: header.cwd } : {}),
521
+ ...(header.isSeeded === true ? { seeded: true } : {}),
522
+ ...(header.origin === 'subagent' ? { origin: 'subagent' } : {}),
523
+ ...(typeof header.agentPreset === 'string' && header.agentPreset !== '' ? { agentPreset: header.agentPreset } : {}),
524
+ ...runtime,
525
+ activity,
526
+ })
527
+ }
528
+
529
+ // Parents before their children, so a family reads as one block; each level is
530
+ // newest-first, the order the sidebar's own list uses.
531
+ const newestFirst = (left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id)
532
+ const sorted = []
533
+ const emitted = new Set()
534
+ const walk = (parentId) => {
535
+ const children = rows.filter((row) => row.parentId === parentId).sort(newestFirst)
536
+ for (const child of children) {
537
+ if (emitted.has(child.id)) continue
538
+ emitted.add(child.id)
539
+ sorted.push(child)
540
+ walk(child.id)
541
+ }
542
+ }
543
+ const roots = rows.filter((row) => row.parentId === undefined).sort(newestFirst)
544
+ for (const root of roots) {
545
+ if (emitted.has(root.id)) continue
546
+ emitted.add(root.id)
547
+ sorted.push(root)
548
+ walk(root.id)
549
+ }
550
+ for (const row of rows.sort(newestFirst)) {
551
+ if (emitted.has(row.id)) continue
552
+ emitted.add(row.id)
553
+ sorted.push(row)
554
+ }
555
+ return sorted
556
+ }
557
+
558
+ /**
559
+ * The display label for a directory no Workspace owns: its final segment, the
560
+ * way the registry titles a Workspace it creates.
561
+ * @param {string|undefined} path - a directory path.
562
+ * @returns {string} the label.
563
+ */
564
+ function workspaceLabel(path) {
565
+ const cleaned = String(path ?? '').replace(/[\\/]+$/, '')
566
+ if (cleaned === '') return '未归类'
567
+ const segment = cleaned.slice(Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\')) + 1)
568
+ return segment === '' ? cleaned : segment
569
+ }
570
+
571
+ /**
572
+ * Delete the Session the user acted on, the descendants they selected, and
573
+ * everything keyed by those ids.
574
+ * @param {object} ctx - Host Cordis context.
575
+ * @param {string} sessionId - the Session the user acted on.
576
+ * @param {{ stop: boolean, descendants?: string[] }} options - whether to stop
577
+ * running work first, and which descendants to take; omitting `descendants`
578
+ * means every one of them.
579
+ * @returns {Promise<object>} the removal report.
580
+ */
581
+ async function deleteSession(ctx, sessionId, options) {
582
+ const persistence = ctx.get('sessionPersistence')
583
+ if (persistence === undefined || typeof persistence.stat !== 'function') {
584
+ throw new DeleteRefusal(503, 'persistence-unavailable', 'sessionPersistence is not mounted in this profile')
585
+ }
586
+
587
+ const snapshot = await persistence.stat(sessionId)
588
+ const header = snapshot?.header
589
+ if (header === undefined) {
590
+ throw new DeleteRefusal(404, 'session-not-found', `找不到会话 ${sessionId} 的持久化记录。`)
591
+ }
592
+
593
+ const artifactDirectory = locateDirectory(persistence, header)
594
+ if (artifactDirectory === undefined) {
595
+ throw new DeleteRefusal(501, 'no-artifact', '当前持久化后端不提供会话工件路径,无法删除文件。')
596
+ }
597
+
598
+ const runtime = runtimeOf(ctx, sessionId)
599
+ const removal = { stoppedActivity: false, warnings: [], terminalsKilled: 0 }
600
+
601
+ // The family is gathered first and the caller's selection is validated against
602
+ // it, so a crafted request can never name a Session outside this lineage.
603
+ const gathered = await gatherDescendants(ctx, sessionId, removal.warnings)
604
+ const selected = selectDescendants(gathered, options.descendants)
605
+ if (selected.length > MAX_DESCENDANTS) {
606
+ throw new DeleteRefusal(
607
+ 409,
608
+ 'too-many-descendants',
609
+ `一次最多删除 ${MAX_DESCENDANTS} 个子会话,当前选中 ${selected.length} 个,请分批删除。`,
610
+ )
611
+ }
612
+
613
+ // Every Session in the set is asked about its own work. The shipped admission
614
+ // answers for the Session it is asked about, so a descendant's running turn or
615
+ // background job would be invisible if only the target were asked.
616
+ const targets = [sessionId, ...selected.map((entry) => entry.id)]
617
+ const busy = await gatherActivities(ctx, targets)
618
+ if (busy.size > 0 && options.stop !== true) {
619
+ const busyList = [...busy.entries()].map(([id, activity]) => ({ sessionId: id, activity }))
620
+ const labels = [...new Set(busyList.flatMap((entry) => entry.activity.map((item) => item.label)))].join('、')
621
+ throw new DeleteRefusal(
622
+ 409,
623
+ 'session-active',
624
+ busy.size === 1 && busy.has(sessionId)
625
+ ? `这个会话还有未结束的工作(${labels})。确认后会先停掉它们再删除。`
626
+ : `选中的会话里还有未结束的工作(${labels},共 ${busy.size} 个会话)。确认后会先停掉它们再删除。`,
627
+ { activity: busy.get(sessionId) ?? [], activeSessions: busyList },
628
+ )
629
+ }
630
+
631
+ // Running work goes first, the way the shipped archive's stopActivity does.
632
+ if (busy.size > 0) {
633
+ for (const id of busy.keys()) await stopSessionActivity(ctx, id, removal)
634
+ removal.stoppedActivity = true
635
+ }
636
+
637
+ // A deleted Session's shells go with it. Terminals are not part of the shipped
638
+ // archive admission, and the service's own owner cleanup only fires once the
639
+ // Agent is released, which can be long after the log is gone.
640
+ for (const id of targets) removal.terminalsKilled += await killOwnedTerminals(ctx, id, removal)
641
+
642
+ // Descendants are Sessions of their own with their own logs: deepest first, so
643
+ // no child outlives its parent by more than one call.
644
+ const removedDescendants = []
645
+ for (const child of selected) {
646
+ const report = await removeSession(ctx, child.id)
647
+ report.kind = child.kind
648
+ removedDescendants.push(report)
649
+ }
650
+
651
+ const removed = await removeSession(ctx, sessionId, { artifactDirectory })
652
+
653
+ return {
654
+ ok: true,
655
+ sessionId,
656
+ removed,
657
+ descendants: removedDescendants,
658
+ kept: gathered.length - selected.length,
659
+ stoppedActivity: removal.stoppedActivity,
660
+ terminalsKilled: removal.terminalsKilled,
661
+ warnings: removal.warnings,
662
+ runtime,
663
+ activity: busy.get(sessionId) ?? [],
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Remove one Session's artifacts, workspace account and projection checkpoint,
669
+ * then tell connected Clients to drop its row.
670
+ * @param {object} ctx - Host Cordis context.
671
+ * @param {string} sessionId - the Session to remove.
672
+ * @param {{ artifactDirectory?: string }} known - an already-resolved artifact path.
673
+ * @returns {Promise<object>} the per-Session removal report.
674
+ */
675
+ async function removeSession(ctx, sessionId, known = {}) {
676
+ const report = {
677
+ sessionId,
678
+ artifactDirectory: null,
679
+ removed: false,
680
+ workspaces: 0,
681
+ projectionCheckpoint: false,
682
+ warnings: [],
683
+ }
684
+
685
+ let directory = known.artifactDirectory
686
+ if (directory === undefined) {
687
+ const persistence = ctx.get('sessionPersistence')
688
+ const snapshot = persistence === undefined || typeof persistence.stat !== 'function'
689
+ ? undefined
690
+ : await persistence.stat(sessionId)
691
+ const header = snapshot?.header
692
+ if (header === undefined) report.warnings.push('没有持久化记录,可能已经被删除')
693
+ else directory = locateDirectory(persistence, header)
694
+ }
695
+
696
+ // The Session's own directory: the current log, every retained historical
697
+ // generation, and any future session-local artifact live in it.
698
+ if (directory !== undefined) {
699
+ report.artifactDirectory = directory
700
+ await rm(directory, { recursive: true, force: true })
701
+ report.removed = true
702
+ }
703
+
704
+ // Workspace accounting: the sidebar groups Sessions by Workspace records, and
705
+ // the registry-global archive/pin sets are Session id arrays.
706
+ await detachFromWorkspaces(ctx, sessionId, report)
707
+
708
+ // The projection checkpoint is a fold shortcut and disposable; leaving it
709
+ // would keep a record for a Session that no longer exists.
710
+ report.projectionCheckpoint = await dropProjectionCheckpoint(ctx, sessionId, report)
711
+
712
+ // Connected Clients drop the row from their session list, exactly as they do
713
+ // when the Session controller reports a disposed Session.
714
+ ctx.emit('api-session/removed', sessionId)
715
+
716
+ return report
717
+ }
718
+
719
+ /**
720
+ * The kind labels a descendant can carry into the report and the dialog.
721
+ * `subagent` is a Session the Subagent runtime spawned (`origin: 'subagent'`);
722
+ * `derived` is any other child on the lineage, a conversation forked off this
723
+ * one, which DSH records as `parentSession` plus `isSeeded`.
724
+ */
725
+ const DESCENDANT_KINDS = { subagent: 'subagent', derived: 'derived' }
726
+
727
+ /**
728
+ * Turn a caller's selection into the descendant entries to delete.
729
+ *
730
+ * The selection is validated against the lineage the Host itself gathered, so a
731
+ * crafted request can only ever name Sessions in this family. An omitted field
732
+ * means "all of them"; an empty array means "only the Session itself".
733
+ * @param {Array<{ id: string, depth: number, kind: string }>} gathered - the family.
734
+ * @param {unknown} requested - the ids the Client sent, when it sent any.
735
+ * @returns {Array<{ id: string, depth: number, kind: string }>} deepest first.
736
+ */
737
+ function selectDescendants(gathered, requested) {
738
+ if (requested === undefined || requested === null) return gathered
739
+ if (!Array.isArray(requested)) {
740
+ throw new DeleteRefusal(400, 'bad-request', 'descendants 必须是会话 id 数组。')
741
+ }
742
+ const byId = new Map(gathered.map((entry) => [entry.id, entry]))
743
+ const seen = new Set()
744
+ const selected = []
745
+ for (const raw of requested) {
746
+ const id = typeof raw === 'string' ? raw.trim() : ''
747
+ if (id === '' || seen.has(id)) continue
748
+ const entry = byId.get(id)
749
+ if (entry === undefined) {
750
+ throw new DeleteRefusal(
751
+ 400,
752
+ 'unknown-descendant',
753
+ `${id} 不是这个会话的子会话,已中止,没有删除任何东西。`,
754
+ { sessionId: id },
755
+ )
756
+ }
757
+ seen.add(id)
758
+ selected.push(entry)
759
+ }
760
+ selected.sort((left, right) => right.depth - left.depth || left.id.localeCompare(right.id))
761
+ return selected
762
+ }
763
+
764
+ /**
765
+ * Ask every Session in a delete set about its own running work.
766
+ *
767
+ * The shipped admission answers per Session, so this must be one call per id: a
768
+ * descendant's running turn or background job is not reported when the target is
769
+ * asked.
770
+ * @param {object} ctx - Host Cordis context.
771
+ * @param {string[]} ids - the Sessions in the delete set.
772
+ * @returns {Promise<Map<string, object[]>>} only the busy ones, described.
773
+ */
774
+ async function gatherActivities(ctx, ids) {
775
+ const busy = new Map()
776
+ for (const id of ids) {
777
+ const activity = describeActivity(await sessionActivity(ctx, id))
778
+ if (activity.length > 0) busy.set(id, activity)
779
+ }
780
+ return busy
781
+ }
782
+
783
+ /**
784
+ * Read the descendant set without applying the delete cap, for the state report.
785
+ *
786
+ * Two durable sources, merged by the closest depth:
787
+ *
788
+ * 1. **The subagent catalog**, read per parent through the parent Session's own
789
+ * `subagentCatalog` projection, walked from the target downwards. This is the
790
+ * relation that carries no header field: every row is a Session the parent
791
+ * spawned, and walking it is what puts a subagent under the child that
792
+ * spawned it rather than under the target. The service's own
793
+ * `subagents.listDescendants` is asked as well, as a second opinion, because
794
+ * this walk reads sessions one at a time and a single unreadable parent must
795
+ * not take its whole branch with it.
796
+ * 2. **The header lineage** (`SessionHeader.parentSession`), which covers forked
797
+ * conversations — the relation that never appears in any catalog — plus its
798
+ * own view of subagent sessions, and the project directory each child's header
799
+ * carries for display naming.
800
+ *
801
+ * The result is the reachable family: a child is included when the lineage or a
802
+ * catalog links it to the target, or to another child that is already included.
803
+ * A Session whose parent header names an id outside the corpus is not reachable
804
+ * this way, yet it can still be a live fork of the target, so it is included too
805
+ * — deleting it is no worse than deleting it through the fork's own row.
806
+ * @param {object} ctx - Host Cordis context.
807
+ * @param {string} sessionId - the Session whose descendants are wanted.
808
+ * @param {string[]} warnings - collector for recoverable failures.
809
+ * @returns {Promise<Array<{ id: string, depth: number, kind: string }>>} deepest first.
810
+ */
811
+ async function gatherDescendants(ctx, sessionId, warnings) {
812
+ const collected = new Map()
813
+ /** The header lineage, indexed by parent, for the reachability pass. */
814
+ const childrenByParent = new Map()
815
+
816
+ /**
817
+ * One discovered child, keyed by id. The closest depth wins, a subagent label
818
+ * outranks a derived one, and the first parent that named it is kept for
819
+ * indentation. The project directory comes only from the lineage walk, which
820
+ * reads it off the child's own header; the catalog carries no path, so a child
821
+ * known only through the catalog falls back to its title and then its id.
822
+ */
823
+ const add = (rawId, depth, kind, parentId, cwd) => {
824
+ if (typeof rawId !== 'string' || rawId === sessionId || !SESSION_ID.test(rawId)) return
825
+ const parent = typeof parentId === 'string' && SESSION_ID.test(parentId) ? parentId : undefined
826
+ const path = typeof cwd === 'string' && cwd !== '' ? cwd : undefined
827
+ const existing = collected.get(rawId)
828
+ if (existing === undefined) {
829
+ collected.set(rawId, {
830
+ id: rawId,
831
+ depth,
832
+ kind,
833
+ ...(parent === undefined ? {} : { parentId: parent }),
834
+ ...(path === undefined ? {} : { cwd: path }),
835
+ })
836
+ return
837
+ }
838
+ existing.depth = Math.min(existing.depth, depth)
839
+ if (kind === DESCENDANT_KINDS.subagent) existing.kind = DESCENDANT_KINDS.subagent
840
+ if (existing.parentId === undefined && parent !== undefined) existing.parentId = parent
841
+ if (existing.cwd === undefined && path !== undefined) existing.cwd = path
842
+ }
843
+
844
+ // 1. The header lineage every Session records, indexed by parent.
845
+ const query = ctx.get('sessionQuery')
846
+ const known = new Set([sessionId])
847
+ if (query === undefined || typeof query.listSessions !== 'function') {
848
+ warnings.push('sessionQuery 服务不可用,派生对话与子智能体的血缘没有读到')
849
+ } else {
850
+ try {
851
+ for (const record of await query.listSessions()) {
852
+ const header = record?.header
853
+ if (header === undefined || typeof header.id !== 'string' || !SESSION_ID.test(header.id)) continue
854
+ known.add(header.id)
855
+ const parent = typeof header.parentSession === 'string' ? header.parentSession : undefined
856
+ if (parent === undefined) continue
857
+ if (!childrenByParent.has(parent)) childrenByParent.set(parent, [])
858
+ childrenByParent.get(parent).push(header)
859
+ }
860
+ } catch (error) {
861
+ warnings.push(`读取会话血缘失败,派生对话与子智能体的血缘没有读到:${messageOf(error)}`)
862
+ }
863
+ }
864
+
865
+ // 2. The subagent catalog, walked from the target down through each parent's own
866
+ // direct children, so every subagent hangs under the Session that spawned it.
867
+ const walk = await walkSubagentCatalog(ctx, sessionId, warnings)
868
+ for (const node of walk) {
869
+ add(node.id, node.depth, DESCENDANT_KINDS.subagent, node.parentId, node.cwd)
870
+ }
871
+
872
+ // 3. The catalog service's own descendant list, merged for depth and coverage:
873
+ // it reaches children the per-parent walk could not read, and it cannot
874
+ // invent an id this family has never seen.
875
+ const subagents = ctx.get('subagents')
876
+ if (subagents === undefined || typeof subagents.listDescendants !== 'function') {
877
+ warnings.push('subagents 服务不可用,子智能体名册没有读到')
878
+ } else {
879
+ try {
880
+ const entries = await subagents.listDescendants(sessionId)
881
+ if (Array.isArray(entries)) {
882
+ for (const entry of entries) {
883
+ add(
884
+ entry?.id,
885
+ typeof entry?.depth === 'number' ? entry.depth : 1,
886
+ DESCENDANT_KINDS.subagent,
887
+ typeof entry?.parentId === 'string' ? entry.parentId : undefined,
888
+ )
889
+ }
890
+ }
891
+ } catch (error) {
892
+ warnings.push(`读取子智能体名册失败:${messageOf(error)}`)
893
+ }
894
+ }
895
+
896
+ // 4. The lineage's own children of every reachable Session: subagent Sessions
897
+ // whose catalog row is gone, and forked conversations, which have no row at
898
+ // all. Breadth-first, so each child's depth is its distance from the target.
899
+ const reachable = new Set([sessionId])
900
+ let frontier = [sessionId]
901
+ for (let depth = 1; frontier.length > 0 && depth <= MAX_LINEAGE_DEPTH; depth += 1) {
902
+ const next = []
903
+ for (const parentId of frontier) {
904
+ for (const header of childrenByParent.get(parentId) ?? []) {
905
+ if (reachable.has(header.id)) continue
906
+ reachable.add(header.id)
907
+ const knownChild = collected.get(header.id)
908
+ add(
909
+ header.id,
910
+ depth,
911
+ header.origin === 'subagent' ? DESCENDANT_KINDS.subagent : DESCENDANT_KINDS.derived,
912
+ parentId,
913
+ header.cwd,
914
+ )
915
+ // The lineage found this child where the catalog did not, so the catalog
916
+ // walk never descended through it: queue it so its own subagents follow.
917
+ if (knownChild === undefined) next.push(header.id)
918
+ }
919
+ }
920
+ frontier = next
921
+ }
922
+
923
+ // 5. A fork whose parent header names an id the corpus no longer holds, yet
924
+ // which carries the target's own project directory: unreachable by structure,
925
+ // still part of this family in practice.
926
+ try {
927
+ const root = ctx.get('sessionPersistence')
928
+ const rootHeader = root === undefined || typeof root.stat !== 'function'
929
+ ? undefined
930
+ : (await root.stat(sessionId))?.header
931
+ const rootCwd = typeof rootHeader?.cwd === 'string' ? rootHeader.cwd : undefined
932
+ if (rootCwd !== undefined) {
933
+ for (const [, list] of childrenByParent) {
934
+ for (const header of list) {
935
+ if (reachable.has(header.id) || header.cwd !== rootCwd) continue
936
+ const parent = typeof header.parentSession === 'string' ? header.parentSession : ''
937
+ if (known.has(parent)) continue
938
+ reachable.add(header.id)
939
+ add(header.id, 1, DESCENDANT_KINDS.derived, undefined, header.cwd)
940
+ }
941
+ }
942
+ }
943
+ } catch {
944
+ // An unreadable root header only costs the orphan fork, which is optional.
945
+ }
946
+
947
+ const list = [...collected.values()]
948
+ list.sort((left, right) => right.depth - left.depth || left.id.localeCompare(right.id))
949
+ return attachTitles(ctx, list)
950
+ }
951
+
952
+ /**
953
+ * Walk the durable subagent catalog from one root, deepest structure preserved.
954
+ *
955
+ * Each level is read from the parent Session's own `subagentCatalog` projection —
956
+ * the parent-owned record of the Sessions it spawned — so a subagent is placed
957
+ * under the Session that actually spawned it. The walk is iterative and
958
+ * cycle-guarded: a hand-edited catalog cannot make it loop, and one unreadable
959
+ * parent costs that parent's branch rather than the whole walk.
960
+ * @param {object} ctx - Host Cordis context.
961
+ * @param {string} rootId - the Session whose descendants are wanted.
962
+ * @param {string[]} warnings - collector for recoverable failures.
963
+ * @returns {Promise<Array<{ id: string, depth: number, parentId: string }>>} the rows.
964
+ */
965
+ async function walkSubagentCatalog(ctx, rootId, warnings) {
966
+ const query = ctx.get('sessionQuery')
967
+ if (query === undefined || typeof query.observeSession !== 'function') {
968
+ warnings.push('sessionQuery 服务不可用,子智能体目录没有读到')
969
+ return []
970
+ }
971
+
972
+ const rows = []
973
+ const visited = new Set([rootId])
974
+ let frontier = [{ id: rootId, depth: 0 }]
975
+ while (frontier.length > 0) {
976
+ const next = []
977
+ for (const parent of frontier) {
978
+ let observation
979
+ try {
980
+ observation = await query.observeSession(parent.id)
981
+ } catch (error) {
982
+ warnings.push(`读取会话 ${parent.id} 的子智能体目录失败:${messageOf(error)}`)
983
+ continue
984
+ }
985
+ let entries
986
+ try {
987
+ entries = observation?.projections?.values?.subagentCatalog
988
+ } finally {
989
+ // A lease pins its cached preparation until it is disposed, and the only
990
+ // disposer is `Symbol.dispose` — the observation carries no `release()`.
991
+ // Calling it in a `finally` keeps the walk from pinning every parent it
992
+ // read; without the symbol (an older backend) there is nothing to free.
993
+ const release = observation?.[Symbol.dispose]
994
+ if (typeof release === 'function') {
995
+ try {
996
+ release.call(observation)
997
+ } catch {
998
+ // A lease that refuses to free is the Host's business, not ours.
999
+ }
1000
+ }
1001
+ }
1002
+ if (!Array.isArray(entries)) {
1003
+ warnings.push(`会话 ${parent.id} 的子智能体目录没有读到`)
1004
+ continue
1005
+ }
1006
+ for (const entry of entries) {
1007
+ const id = typeof entry?.id === 'string' ? entry.id : ''
1008
+ if (!SESSION_ID.test(id) || visited.has(id)) continue
1009
+ visited.add(id)
1010
+ const depth = parent.depth + 1
1011
+ if (depth > MAX_LINEAGE_DEPTH) continue
1012
+ rows.push({ id, depth, parentId: parent.id })
1013
+ next.push({ id, depth })
1014
+ }
1015
+ }
1016
+ frontier = next
1017
+ }
1018
+ return rows
1019
+ }
1020
+
1021
+ /**
1022
+ * The final non-empty segment of a directory path, POSIX or Windows.
1023
+ *
1024
+ * The same reading the shipped client's `workspaceTitleOf` uses, kept in step
1025
+ * with it so a Session's display name here matches the sidebar's.
1026
+ * @param {string|undefined} path - the directory path.
1027
+ * @returns {string} the final segment, or an empty string.
1028
+ */
1029
+ function workspaceTitleOf(path) {
1030
+ if (typeof path !== 'string') return ''
1031
+ const trimmed = path.replace(/[/\\]+$/, '')
1032
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
1033
+ return trimmed.slice(separator + 1)
1034
+ }
1035
+
1036
+ /**
1037
+ * Fill in each entry's title, so the dialogs list names instead of ids.
1038
+ *
1039
+ * Two sources, in the order the sidebar itself uses: the durable title the
1040
+ * Session log carries, then the final segment of its project directory, then the
1041
+ * raw id. The folded snapshot is `{ session, title: { title, … } }`, and the
1042
+ * query service reports a Session with no title event as fulfilled with no
1043
+ * `title`, so a Session that was never renamed still reads as its directory
1044
+ * rather than as "untitled".
1045
+ * @param {object} ctx - Host Cordis context.
1046
+ * @param {Array<object>} entries - the gathered entries, each with `id` and
1047
+ * optionally `cwd`.
1048
+ * @returns {Promise<Array<object>>} the same entries, with `title` where known.
1049
+ */
1050
+ async function attachTitles(ctx, entries) {
1051
+ if (entries.length === 0) return entries
1052
+ const titled = new Map()
1053
+ const query = ctx.get('sessionQuery')
1054
+ if (query !== undefined && typeof query.readTitleSnapshots === 'function') {
1055
+ try {
1056
+ const results = await query.readTitleSnapshots(entries.map((entry) => entry.id))
1057
+ if (Array.isArray(results)) {
1058
+ for (const result of results) {
1059
+ if (result?.status !== 'fulfilled') continue
1060
+ const id = result.value?.session?.id
1061
+ // The snapshot nests the folded title one level down; a Session whose
1062
+ // log holds no title event reports fulfilled without it.
1063
+ const title = result.value?.title?.title
1064
+ if (typeof id === 'string' && typeof title === 'string' && title.trim() !== '') titled.set(id, title)
1065
+ }
1066
+ }
1067
+ } catch {
1068
+ // Titles are optional: the directory and the id still name the row.
1069
+ }
1070
+ }
1071
+ for (const entry of entries) {
1072
+ const title = titled.get(entry.id)
1073
+ if (title !== undefined) {
1074
+ entry.title = title
1075
+ continue
1076
+ }
1077
+ const directory = workspaceTitleOf(entry.cwd)
1078
+ if (directory !== '') entry.title = directory
1079
+ }
1080
+ return entries
1081
+ }
1082
+
1083
+ /**
1084
+ * Read one Session's state without changing anything.
1085
+ * @param {object} ctx - Host Cordis context.
1086
+ * @param {string} sessionId - the Session to inspect.
1087
+ * @returns {Promise<object>} the state the Client dialog reports.
1088
+ */
1089
+ async function inspectSession(ctx, sessionId) {
1090
+ const persistence = ctx.get('sessionPersistence')
1091
+ const snapshot = persistence === undefined || typeof persistence.stat !== 'function'
1092
+ ? undefined
1093
+ : await persistence.stat(sessionId)
1094
+ const header = snapshot?.header
1095
+ // Why a branch may be missing is carried back to the dialog: the family is read
1096
+ // from two sources, and one of them failing silently is indistinguishable from a
1097
+ // Session that genuinely has no children.
1098
+ const warnings = []
1099
+ const descendants = await gatherDescendants(ctx, sessionId, warnings)
1100
+ const listed = descendants.slice(0, MAX_LISTED_DESCENDANTS)
1101
+ // Only the Sessions the dialog can show are asked about their work: each answer
1102
+ // costs one admission walk, and beyond the listing nothing can be selected.
1103
+ const busy = await gatherActivities(ctx, [sessionId, ...listed.map((entry) => entry.id)])
1104
+ const subagents = descendants.filter((entry) => entry.kind === DESCENDANT_KINDS.subagent).length
1105
+ return {
1106
+ ok: true,
1107
+ sessionId,
1108
+ stored: header !== undefined,
1109
+ artifactDirectory: header === undefined ? undefined : locateDirectory(persistence, header),
1110
+ ...runtimeOf(ctx, sessionId),
1111
+ activity: busy.get(sessionId) ?? [],
1112
+ warnings,
1113
+ descendants: {
1114
+ count: descendants.length,
1115
+ subagents,
1116
+ derived: descendants.length - subagents,
1117
+ truncated: descendants.length > listed.length,
1118
+ maxDeletable: MAX_DESCENDANTS,
1119
+ items: listed.map((entry) => ({
1120
+ id: entry.id,
1121
+ kind: entry.kind,
1122
+ depth: entry.depth,
1123
+ ...(entry.parentId === undefined ? {} : { parentId: entry.parentId }),
1124
+ ...(entry.title === undefined ? {} : { title: entry.title }),
1125
+ ...runtimeOf(ctx, entry.id),
1126
+ activity: busy.get(entry.id) ?? [],
1127
+ })),
1128
+ },
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Close the terminals a Session owns.
1134
+ *
1135
+ * Terminals are part of no shipped admission family, and the service's own owner
1136
+ * cleanup fires when the Agent is released, which can be long after the log is
1137
+ * gone; a shell left running for a deleted conversation helps nobody.
1138
+ * @param {object} ctx - Host Cordis context.
1139
+ * @param {string} sessionId - the Session whose shells close.
1140
+ * @param {object} removal - report being filled in.
1141
+ * @returns {Promise<number>} how many terminals were closed.
1142
+ */
1143
+ async function killOwnedTerminals(ctx, sessionId, removal) {
1144
+ const terminals = ctx.get('terminals')
1145
+ if (terminals === undefined || typeof terminals.list !== 'function' || typeof terminals.kill !== 'function') return 0
1146
+
1147
+ let owner
1148
+ try {
1149
+ const agents = ctx.get('agents')
1150
+ owner = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined
1151
+ } catch {
1152
+ owner = undefined
1153
+ }
1154
+ // `list` matches the exact owner object, and a cold Session owns no terminal.
1155
+ if (owner === undefined) return 0
1156
+
1157
+ let killed = 0
1158
+ try {
1159
+ for (const snapshot of terminals.list(owner) ?? []) {
1160
+ const id = typeof snapshot?.sessionId === 'string' ? snapshot.sessionId : ''
1161
+ if (id === '') continue
1162
+ try {
1163
+ if (await terminals.kill(owner, id, 'session deleted')) killed += 1
1164
+ } catch (error) {
1165
+ removal.warnings.push(`关闭终端 ${id} 失败:${messageOf(error)}`)
1166
+ }
1167
+ }
1168
+ } catch (error) {
1169
+ removal.warnings.push(`读取终端列表失败:${messageOf(error)}`)
1170
+ }
1171
+ return killed
1172
+ }
1173
+
1174
+ /**
1175
+ * Whether the Host still holds the Session open, and whether its Agent runs.
1176
+ * Residency never blocks a delete; it is reported so the dialog can say so.
1177
+ * @param {object} ctx - Host Cordis context.
1178
+ * @param {string} sessionId - the Session to describe.
1179
+ * @returns {{ open: boolean, agent: boolean, running: boolean }} the runtime facts.
1180
+ */
1181
+ function runtimeOf(ctx, sessionId) {
1182
+ let open = false
1183
+ let agent = false
1184
+ let running = false
1185
+ try {
1186
+ const sessions = ctx.get('sessions')
1187
+ if (typeof sessions?.get === 'function') open = sessions.get(sessionId) !== undefined
1188
+ } catch {
1189
+ // A service that cannot answer leaves the fact false.
1190
+ }
1191
+ try {
1192
+ const agents = ctx.get('agents')
1193
+ if (typeof agents?.get === 'function') {
1194
+ const live = agents.get(sessionId)
1195
+ agent = live !== undefined
1196
+ running = live?.status === 'running'
1197
+ }
1198
+ } catch {
1199
+ // Same here.
1200
+ }
1201
+ return { open, agent, running }
1202
+ }
1203
+
1204
+ /**
1205
+ * Ask the composed providers what still runs for this Session, through DSH's own
1206
+ * archive-admission waterfall.
1207
+ * @param {object} ctx - Host Cordis context.
1208
+ * @param {string} sessionId - the Session to ask about.
1209
+ * @returns {Promise<object[]>} the reported families, in listener order.
1210
+ */
1211
+ async function sessionActivity(ctx, sessionId) {
1212
+ if (typeof ctx.waterfall !== 'function') return []
1213
+ const value = await ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]))
1214
+ return Array.isArray(value) ? value : []
1215
+ }
1216
+
1217
+ /**
1218
+ * The activity of one Session as the bulk dialog shows it.
1219
+ *
1220
+ * The bulk list asks about every Session at once, and each answer costs one
1221
+ * admission walk, so a Session the Host holds neither open nor in an Agent is
1222
+ * reported idle without asking: such a Session cannot have work to stop, and a
1223
+ * long list stays one pass over the corpus.
1224
+ * @param {object} ctx - Host Cordis context.
1225
+ * @param {string} sessionId - the Session to ask about.
1226
+ * @param {{ open: boolean, agent: boolean, running: boolean }} runtime - its runtime facts.
1227
+ * @returns {Promise<object[]|null>} the described families, or null when idle.
1228
+ */
1229
+ async function catalogActivity(ctx, sessionId, runtime) {
1230
+ if (runtime.open !== true && runtime.agent !== true && runtime.running !== true) return null
1231
+ const described = describeActivity(await sessionActivity(ctx, sessionId))
1232
+ return described.length === 0 ? null : described
1233
+ }
1234
+
1235
+ /**
1236
+ * Stop this Session's running work the way the user's own stop actions do.
1237
+ * @param {object} ctx - Host Cordis context.
1238
+ * @param {string} sessionId - the Session whose work stops.
1239
+ * @param {object} removal - report being filled in.
1240
+ * @returns {Promise<void>} resolution once every provider was asked.
1241
+ */
1242
+ async function stopSessionActivity(ctx, sessionId, removal) {
1243
+ if (typeof ctx.parallel !== 'function') {
1244
+ removal.warnings.push('ctx.parallel is unavailable; running work was not stopped')
1245
+ return
1246
+ }
1247
+ try {
1248
+ await ctx.parallel('workspace/session-stop', { sessionId })
1249
+ } catch (error) {
1250
+ removal.warnings.push(`stopping running work failed: ${messageOf(error)}`)
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Project the activity entries into lossless JSON for the Client.
1256
+ * @param {object[]} entries - raw `workspace/session-activity` entries.
1257
+ * @returns {object[]} `{ kind, label, items }` rows.
1258
+ */
1259
+ function describeActivity(entries) {
1260
+ return entries.map((entry) => {
1261
+ const kind = typeof entry?.kind === 'string' && entry.kind !== '' ? entry.kind : 'unknown'
1262
+ const items = Array.isArray(entry?.items) ? entry.items : []
1263
+ return {
1264
+ kind,
1265
+ label: ACTIVITY_LABELS[kind] ?? kind,
1266
+ items: items.slice(0, 20).map((item) => ({
1267
+ id: item?.id === undefined ? '' : String(item.id),
1268
+ label: typeof item?.label === 'string' ? item.label : '',
1269
+ })),
1270
+ }
1271
+ })
1272
+ }
1273
+
1274
+ /**
1275
+ * The Session's own directory, through the persistence backend's artifact hook.
1276
+ * @param {object} persistence - the mounted sessionPersistence service.
1277
+ * @param {object} header - the stored Session header.
1278
+ * @returns {string|undefined} the directory, or undefined without that hook.
1279
+ */
1280
+ function locateDirectory(persistence, header) {
1281
+ if (typeof persistence.locate !== 'function') return undefined
1282
+ try {
1283
+ const location = persistence.locate(header)
1284
+ if (location !== undefined && typeof location.path === 'string' && location.path !== '') {
1285
+ return dirname(location.path)
1286
+ }
1287
+ } catch {
1288
+ // A backend that refuses to locate is a backend whose files stay untouched.
1289
+ }
1290
+ return undefined
1291
+ }
1292
+
1293
+ /**
1294
+ * Drop the id from every Workspace account and from the registry-wide sets.
1295
+ * @param {object} ctx - Host Cordis context.
1296
+ * @param {string} sessionId - the Session to forget.
1297
+ * @param {object} removal - report being filled in.
1298
+ * @returns {Promise<void>} resolution after the durable writes.
1299
+ */
1300
+ async function detachFromWorkspaces(ctx, sessionId, removal) {
1301
+ const registry = ctx.get('workspaceRegistry')
1302
+ if (registry === undefined) {
1303
+ removal.warnings.push('workspaceRegistry is not mounted; the Workspace record was left untouched')
1304
+ return
1305
+ }
1306
+ try {
1307
+ if (typeof registry.list === 'function') {
1308
+ for (const workspace of registry.list()) {
1309
+ if (typeof workspace?.detachSession !== 'function') continue
1310
+ await workspace.detachSession(sessionId)
1311
+ removal.workspaces += 1
1312
+ }
1313
+ }
1314
+ if (typeof registry.unarchiveSession === 'function') await registry.unarchiveSession(sessionId)
1315
+ if (typeof registry.unpinSession === 'function') await registry.unpinSession(sessionId)
1316
+ } catch (error) {
1317
+ removal.warnings.push(`workspace cleanup failed: ${messageOf(error)}`)
1318
+ }
1319
+ }
1320
+
1321
+ /**
1322
+ * Delete the projection-cache record for one Session.
1323
+ * @param {object} ctx - Host Cordis context.
1324
+ * @param {string} sessionId - the Session whose checkpoint goes away.
1325
+ * @param {object} removal - report being filled in.
1326
+ * @returns {Promise<boolean>} whether a record was dropped.
1327
+ */
1328
+ async function dropProjectionCheckpoint(ctx, sessionId, removal) {
1329
+ const storageDomain = ctx.get('storageDomain')
1330
+ if (typeof storageDomain?.get !== 'function') {
1331
+ removal.warnings.push('storageDomain is not mounted; the projection checkpoint was left untouched')
1332
+ return false
1333
+ }
1334
+ try {
1335
+ const domain = storageDomain.get('session_projcache')
1336
+ const table = typeof domain?.table === 'function' ? domain.table('sessions') : undefined
1337
+ if (typeof table?.delete !== 'function') return false
1338
+ return (await table.delete(sessionId)) === true
1339
+ } catch (error) {
1340
+ removal.warnings.push(`projection-cache cleanup failed: ${messageOf(error)}`)
1341
+ return false
1342
+ }
1343
+ }
1344
+
1345
+ /**
1346
+ * Report one failure with its status, code, and details.
1347
+ * @param {object} ctx - Host Cordis context.
1348
+ * @param {import('node:http').ServerResponse} response - the response.
1349
+ * @param {unknown} error - the caught value.
1350
+ * @param {string} sessionId - the Session the request named.
1351
+ */
1352
+ function answer(ctx, response, error, sessionId) {
1353
+ const refusal = error instanceof DeleteRefusal
1354
+ ? error
1355
+ : new DeleteRefusal(500, 'delete-failed', messageOf(error))
1356
+ ctx.logger?.warn?.(`[better-dsh-session-deletetool] ${sessionId}: ${refusal.code}: ${refusal.message}`)
1357
+ send(response, refusal.status, { ok: false, code: refusal.code, message: refusal.message, ...refusal.details })
1358
+ }
1359
+
1360
+ /**
1361
+ * Whether the request came from this Harness page rather than another site.
1362
+ *
1363
+ * The rebinding defence is the `Host` header: a page on `evil.com` aimed at
1364
+ * 127.0.0.1 sends a matching Origin/Host pair, so only `Host` can tell the
1365
+ * attack apart. An absent `Host` or `Origin` is not a cross-site request — the
1366
+ * Desktop build's proxy strips both, and only a non-page client omits them.
1367
+ * @param {import('node:http').IncomingMessage} request - the request.
1368
+ * @returns {boolean} whether the request may proceed.
1369
+ */
1370
+ function sameOrigin(request) {
1371
+ const host = request.headers.host
1372
+ if (host !== undefined && !loopbackAuthority(host)) return false
1373
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
1374
+ const origin = request.headers.origin
1375
+ if (origin === undefined) return true
1376
+ try {
1377
+ return new URL(origin).host === host
1378
+ } catch {
1379
+ return false
1380
+ }
1381
+ }
1382
+
1383
+ /**
1384
+ * Whether a `Host` header names a loopback authority.
1385
+ * @param {string} host - the request's Host header.
1386
+ * @returns {boolean} whether it is loopback.
1387
+ */
1388
+ function loopbackAuthority(host) {
1389
+ const lower = host.toLowerCase()
1390
+ const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
1391
+ return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
1392
+ }
1393
+
1394
+ /**
1395
+ * Read a size-capped JSON request body.
1396
+ * @param {import('node:http').IncomingMessage} request - the request.
1397
+ * @returns {Promise<string>} the decoded body.
1398
+ */
1399
+ async function readBody(request) {
1400
+ const chunks = []
1401
+ let size = 0
1402
+ for await (const chunk of request) {
1403
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1404
+ size += buffer.length
1405
+ if (size > MAX_BODY_BYTES) throw new Error('request body too large')
1406
+ chunks.push(buffer)
1407
+ }
1408
+ return Buffer.concat(chunks).toString('utf8')
1409
+ }
1410
+
1411
+ /**
1412
+ * Write one JSON response.
1413
+ * @param {import('node:http').ServerResponse} response - the response.
1414
+ * @param {number} status - HTTP status.
1415
+ * @param {object} payload - JSON body.
1416
+ */
1417
+ function send(response, status, payload) {
1418
+ if (response.headersSent || response.writableEnded) return
1419
+ const body = JSON.stringify(payload)
1420
+ response.writeHead(status, { ...JSON_HEADERS, 'content-length': Buffer.byteLength(body) })
1421
+ response.end(body)
1422
+ }
1423
+
1424
+ /**
1425
+ * Read one error's text.
1426
+ * @param {unknown} error - the caught value.
1427
+ * @returns {string} its message.
1428
+ */
1429
+ function messageOf(error) {
1430
+ return error instanceof Error ? error.message : String(error)
1431
+ }