dsh-plugin-delete-session 0.0.0-stage → 1.0.8

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.
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: dsh-plugin-delete-session
3
+ name: 'dsh-plugin-delete-session'
4
+ config:
5
+ retentionDays: 7
package/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session trash">
2
+ <defs>
3
+ <linearGradient id="st-bg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#2F6F8F"/>
5
+ <stop offset="1" stop-color="#173B52"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="15" fill="url(#st-bg)"/>
9
+ <path d="M20 22.5h24" fill="none" stroke="#EAF6FB" stroke-width="3.2" stroke-linecap="round"/>
10
+ <path d="M26.5 22.5v-2.2c0-1.2 1-2.3 2.3-2.3h6.4c1.3 0 2.3 1.1 2.3 2.3v2.2" fill="none" stroke="#EAF6FB" stroke-width="3.2" stroke-linecap="round"/>
11
+ <path d="M22.8 22.5l1.5 22c.1 1.5 1.3 2.7 2.8 2.7h9.8c1.5 0 2.7-1.2 2.8-2.7l1.5-22" fill="none" stroke="#EAF6FB" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
12
+ <path d="M28.4 30v10.4M35.6 30v10.4" fill="none" stroke="#9FD3E8" stroke-width="3.2" stroke-linecap="round"/>
13
+ <circle cx="49" cy="15" r="9" fill="#F2B34C"/>
14
+ <path d="M49 10.6v4.7l3 1.8" fill="none" stroke="#4A3208" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
15
+ </svg>
package/index.js ADDED
@@ -0,0 +1,652 @@
1
+ /**
2
+ * Host half of the session-trash bundle.
3
+ *
4
+ * It owns the durable work behind the UI's "delete conversation" action:
5
+ *
6
+ * 1. stop the session's running work and archive it so the row disappears;
7
+ * 2. detach it from its workspace's durable account;
8
+ * 3. copy its JSONL log directory and projection checkpoint into
9
+ * `<DSH_HOME>/session-trash/<sessionId>/`, then remove the originals;
10
+ * 4. unregister the deleted live Session/Agent so an ungrouped row cannot
11
+ * survive in the sidebar;
12
+ * 5. keep that copy for the configured retention window and purge it for good
13
+ * afterwards, or restore it when the user asks.
14
+ *
15
+ * The browser reaches this half through the exact HTTP route
16
+ * `/session-trash-api`, guarded by Connection's Host/Origin fence and browser
17
+ * credential check - the same admission every `/api` request passes.
18
+ */
19
+
20
+ import { Buffer } from 'node:buffer'
21
+ import { realpath } from 'node:fs/promises'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+
25
+ import { isSafeSessionId, samePath } from './lib/paths.js'
26
+ import { SessionTrash } from './lib/trash.js'
27
+
28
+ const CHANNEL = '/session-trash-api'
29
+ const DEFAULT_RETENTION_DAYS = 7
30
+ const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1000
31
+ const MAX_TITLE_LENGTH = 500
32
+ const MAX_REQUEST_BYTES = 64 * 1024
33
+
34
+ /**
35
+ * Required Host services: the browser transport (for Host/Origin and browser
36
+ * authentication checks) and the session log store. `webServer` is obtained
37
+ * through `ctx.inject` below, so a profile without a Web carrier simply keeps
38
+ * the plugin inactive instead of failing.
39
+ */
40
+ export const inject = ['connection', 'sessionPersistence']
41
+
42
+ /**
43
+ * @param {import('@deepseek-ai/cordis').Context} ctx host context.
44
+ * @param {Record<string, unknown> | undefined} config bundle row config.
45
+ */
46
+ export function apply(ctx, config = {}) {
47
+ const logger = {
48
+ info: (message) => ctx.logger?.info?.(message),
49
+ warn: (message) => ctx.logger?.warn?.(message),
50
+ }
51
+
52
+ const home = firstString(config.homeDir)
53
+ || firstString(process.env.DSH_HOME)
54
+ || path.join(os.homedir(), '.dsh')
55
+ const persistence = ctx.sessionPersistence
56
+ const sessionsRoot = firstString(config.sessionsRoot)
57
+ || firstString(persistence?.root)
58
+ || path.join(home, 'sessions')
59
+ const projectionRoot = firstString(config.projectionCacheDir)
60
+ || path.join(home, 'storages', 'session_projcache', 'sessions')
61
+ const trashRoot = firstString(config.trashRoot)
62
+ || path.join(home, 'session-trash')
63
+ const retentionDays = clampNumber(config.retentionDays, DEFAULT_RETENTION_DAYS, 1, 3650)
64
+ const purgeIntervalMs = clampNumber(config.purgeIntervalMs, DEFAULT_PURGE_INTERVAL_MS, 60_000, 24 * 60 * 60 * 1000)
65
+
66
+ const trash = new SessionTrash({
67
+ sessionsRoot,
68
+ trashRoot,
69
+ projectionRoot,
70
+ retentionMs: retentionDays * 24 * 60 * 60 * 1000,
71
+ logger,
72
+ })
73
+
74
+ logger.info(`session-trash: sessions root '${sessionsRoot}', trash root '${trashRoot}', retention ${retentionDays} day(s)`)
75
+
76
+ /**
77
+ * Permanently remove expired entries and drop their stale archive markers.
78
+ *
79
+ * @returns {Promise<string[]>} purged session ids.
80
+ */
81
+ const purgeExpired = async () => {
82
+ const entries = await trash.list()
83
+ const now = Date.now()
84
+ const purged = []
85
+ for (const entry of entries) {
86
+ if (entry.expiresAt > now) continue
87
+ const sessionId = String(entry.sessionId)
88
+ try {
89
+ // Re-read inside the serialized mutation: a concurrent restore/delete
90
+ // may have replaced this entry between list() and purge().
91
+ const removed = await trash.run(async () => {
92
+ const current = await trash.read(sessionId)
93
+ if (current === undefined || current.expiresAt > now) return false
94
+ await trash.purge(current)
95
+ return true
96
+ })
97
+ if (!removed) continue
98
+ purged.push(sessionId)
99
+ if (!(await storedSessionExists(persistence, sessionId))) {
100
+ detachDeletedLiveState(ctx, sessionId)
101
+ await unarchiveQuietly(ctx, sessionId)
102
+ }
103
+ } catch (error) {
104
+ logger.warn(`session-trash: cannot purge '${sessionId}': ${messageOf(error)}`)
105
+ }
106
+ }
107
+ if (purged.length > 0) {
108
+ logger.info(`session-trash: purged ${purged.length} expired session(s)`)
109
+ }
110
+ return purged
111
+ }
112
+
113
+ /**
114
+ * Delete one session.
115
+ *
116
+ * @param {{ sessionId?: unknown, title?: unknown }} payload request body.
117
+ * @returns {Promise<Record<string, unknown>>} deletion outcome.
118
+ */
119
+ const deleteSession = async (payload) => {
120
+ const sessionId = requireSessionId(payload)
121
+ const title = normalizeTitle(payload.title)
122
+ const sessions = ctx.get('sessions')
123
+ const live = sessions?.get(sessionId)
124
+ let header = live?.header
125
+ if (header === undefined) {
126
+ const snapshot = await persistence.stat(sessionId)
127
+ header = snapshot?.header
128
+ }
129
+ if (header === undefined) {
130
+ throw coded('not-found', '找不到这个对话,它可能已经被删除了')
131
+ }
132
+
133
+ // Refuse before doing any workspace or archive work: a genuinely running
134
+ // Agent turn is the only state delete must not touch. Everything else
135
+ // (idle, resident, background jobs waiting on a stopped turn) can be
136
+ // archived and removed without the old multi-second live-store wait.
137
+ if (agentStatus(ctx.get('agents')?.get(sessionId)) === 'running') {
138
+ logger.warn(`session-trash: refusing to delete running '${sessionId}'`)
139
+ throw coded('running', '这个对话仍在运行中,请先停止任务再删除')
140
+ }
141
+
142
+ const workspace = await findWorkspace(ctx, header.cwd, sessionId)
143
+ const registry = ctx.get('workspaceRegistry')
144
+ let wasArchived = false
145
+ try {
146
+ wasArchived = registry?.archivedSessionIds?.includes(sessionId) === true
147
+ } catch {
148
+ // An unreadable archive set is treated as "not archived"; the archive
149
+ // call below still fails closed if it cannot write the marker.
150
+ }
151
+
152
+ // Archive next: it hides the row through the archive set before any file
153
+ // moves and stops background work that is not an Agent turn. The client
154
+ // also clears an open conversation when its id enters that set.
155
+ let archived = false
156
+ const rollbackArchive = async () => {
157
+ // Only remove the marker this request added; an already-archived
158
+ // Session must stay archived when a later step fails.
159
+ if (archived && !wasArchived) await unarchiveQuietly(ctx, sessionId)
160
+ }
161
+ if (registry !== undefined) {
162
+ try {
163
+ await registry.archiveSession(sessionId, { stopActivity: true })
164
+ archived = true
165
+ } catch (error) {
166
+ // Do not detach or remove files when the archive write failed: without
167
+ // the archive marker the still-live Session can reappear after a
168
+ // refresh, and the user would have lost the log for a half-delete.
169
+ // archiveSession can also reject after committing the marker (a stop
170
+ // listener failure), so make the rollback explicit and idempotent.
171
+ logger.warn(`session-trash: archive before delete failed for '${sessionId}': ${messageOf(error)}`)
172
+ if (!wasArchived) await unarchiveQuietly(ctx, sessionId)
173
+ throw coded('archive-failed', '无法归档这个对话,删除已取消')
174
+ }
175
+ }
176
+
177
+ // A turn can win the race with the pre-check above; archive's stop gate
178
+ // then makes it short-lived, but do not delete underneath it.
179
+ if (agentStatus(ctx.get('agents')?.get(sessionId)) === 'running') {
180
+ logger.warn(`session-trash: refusing to delete running '${sessionId}' (after archive)`)
181
+ await rollbackArchive()
182
+ throw coded('running', '这个对话仍在运行中,请先停止任务再删除')
183
+ }
184
+
185
+ // Remove the durable workspace account so the row cannot come back when a
186
+ // stale snapshot is refreshed. Remember it so a failed copy can restore it.
187
+ let detached = false
188
+ if (workspace !== undefined) {
189
+ try {
190
+ await workspace.detachSession(sessionId)
191
+ detached = true
192
+ } catch (error) {
193
+ logger.warn(`session-trash: detaching '${sessionId}' from workspace failed: ${messageOf(error)}`)
194
+ await rollbackArchive()
195
+ throw coded('detach-failed', '无法从工作区移除这个对话,删除已取消')
196
+ }
197
+ }
198
+
199
+ try {
200
+ await persistence.flush()
201
+ } catch (error) {
202
+ // Capturing a log whose latest buffered events failed to reach disk would
203
+ // silently lose them. Put the Session back before touching the files.
204
+ logger.warn(`session-trash: session flush before delete failed: ${messageOf(error)}`)
205
+ if (detached) {
206
+ try {
207
+ await workspace.attachSession(sessionId)
208
+ } catch (attachError) {
209
+ logger.warn(`session-trash: re-attaching '${sessionId}' after a failed flush failed: ${messageOf(attachError)}`)
210
+ }
211
+ }
212
+ if (archived) await rollbackArchive()
213
+ throw coded('flush-failed', '无法写入会话日志,删除已取消')
214
+ }
215
+
216
+ // A late append can recreate the directory behind the copy; verify the
217
+ // source twice, not just when the first capture returned a non-null entry.
218
+ await delay(120)
219
+ let entry
220
+ try {
221
+ entry = await trash.run(() => trash.capture({
222
+ sessionId,
223
+ title,
224
+ cwd: header.cwd,
225
+ }))
226
+ if (await trash.sourceExists(sessionId, header.cwd)) {
227
+ await delay(400)
228
+ if (await trash.sourceExists(sessionId, header.cwd)) {
229
+ entry = await trash.run(() => trash.capture({
230
+ sessionId,
231
+ title,
232
+ cwd: header.cwd,
233
+ }))
234
+ }
235
+ }
236
+ } catch (error) {
237
+ // Keep the Session where it was whenever a complete trash copy is not
238
+ // guaranteed; trash.capture keeps a usable entry if an original removal
239
+ // failed part-way, so the user can still restore or purge it.
240
+ if (detached) {
241
+ try {
242
+ await workspace.attachSession(sessionId)
243
+ } catch (attachError) {
244
+ logger.warn(`session-trash: re-attaching '${sessionId}' after a failed delete failed: ${messageOf(attachError)}`)
245
+ }
246
+ }
247
+ if (archived) await rollbackArchive()
248
+ throw error
249
+ }
250
+
251
+ // The durable files are gone, but DSH can still hold this Session and its
252
+ // idle Agent in the Host registries. For a workspace row, detaching the
253
+ // workspace already removes it; an ungrouped row has no workspace account,
254
+ // so it would stay in the sidebar (and in the "show archived" filter)
255
+ // pointing at a conversation whose log no longer exists. There is no public
256
+ // Agent close API, so unregister the exact live entries only after the log
257
+ // has been moved successfully. `session/disposed` also releases the JSONL
258
+ // writer; the archived marker remains so a refresh cannot surface a ghost.
259
+ detachDeletedLiveState(ctx, sessionId)
260
+
261
+ return {
262
+ sessionId,
263
+ title,
264
+ archived,
265
+ empty: entry === null,
266
+ entry,
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Restore one trashed session.
272
+ *
273
+ * @param {{ sessionId?: unknown }} payload request body.
274
+ * @returns {Promise<Record<string, unknown>>} restore outcome.
275
+ */
276
+ const restoreSession = async (payload) => {
277
+ const sessionId = requireSessionId(payload)
278
+ const outcome = await trash.run(async () => {
279
+ const current = await trash.read(sessionId)
280
+ if (current === undefined) return { kind: 'missing' }
281
+ if (typeof current.expiresAt === 'number' && current.expiresAt <= Date.now()) {
282
+ await trash.purge(current)
283
+ return { kind: 'expired' }
284
+ }
285
+ await trash.restore(current)
286
+ return { kind: 'restored', entry: current }
287
+ })
288
+ if (outcome.kind === 'missing') {
289
+ throw coded('no-entry', '回收站里没有这个对话,可能已经恢复或已过期')
290
+ }
291
+ if (outcome.kind === 'expired') {
292
+ // The copy is gone; do not unarchive here unless there is no durable log
293
+ // left at all. In that case the live row is only a ghost and must go too.
294
+ if (!(await storedSessionExists(persistence, sessionId))) {
295
+ detachDeletedLiveState(ctx, sessionId)
296
+ await unarchiveQuietly(ctx, sessionId)
297
+ }
298
+ throw coded('no-entry', '这个对话已超过保留期,无法恢复')
299
+ }
300
+
301
+ const entry = outcome.entry
302
+ await unarchiveQuietly(ctx, sessionId)
303
+ if (typeof entry.cwd === 'string' && entry.cwd !== '') {
304
+ const workspace = await findWorkspace(ctx, entry.cwd, sessionId)
305
+ if (workspace !== undefined) {
306
+ try {
307
+ await workspace.attachSession(sessionId)
308
+ } catch (error) {
309
+ logger.warn(`session-trash: restoring '${sessionId}' left it ungrouped: ${messageOf(error)}`)
310
+ }
311
+ }
312
+ }
313
+ return { sessionId, restored: true }
314
+ }
315
+
316
+ /**
317
+ * Permanently remove one trashed session, skipping the remaining window.
318
+ *
319
+ * @param {{ sessionId?: unknown }} payload request body.
320
+ * @returns {Promise<Record<string, unknown>>} purge outcome.
321
+ */
322
+ const purgeSession = async (payload) => {
323
+ const sessionId = requireSessionId(payload)
324
+ const purged = await trash.run(async () => {
325
+ const current = await trash.read(sessionId)
326
+ if (current === undefined) return false
327
+ await trash.purge(current)
328
+ return true
329
+ })
330
+ if (!purged) {
331
+ // Never unarchive an id just because its copy is gone: the Session may
332
+ // still exist on disk, and unarchiving would make it visible again.
333
+ return { sessionId, purged: false }
334
+ }
335
+ // A live Session whose log was already deleted has no row left to account
336
+ // for. `sessionStillExists` includes the live store, so test persistence
337
+ // directly before deciding whether the archive and live entries can go.
338
+ const stored = await storedSessionExists(persistence, sessionId)
339
+ if (!stored) {
340
+ detachDeletedLiveState(ctx, sessionId)
341
+ await unarchiveQuietly(ctx, sessionId)
342
+ }
343
+ return { sessionId, purged: true }
344
+ }
345
+
346
+ /**
347
+ * Endpoint dispatcher for the dedicated RPC channel.
348
+ *
349
+ * @param {string} endpoint endpoint name.
350
+ * @param {unknown} payload request body.
351
+ * @param {AbortSignal | undefined} signal cancellation.
352
+ * @returns {Promise<unknown>} endpoint value.
353
+ */
354
+ const dispatch = async (endpoint, payload, signal) => {
355
+ signal?.throwIfAborted()
356
+ const body = isRecord(payload) ? payload : {}
357
+ switch (endpoint) {
358
+ case 'info':
359
+ return {
360
+ retentionDays,
361
+ purgeIntervalMs,
362
+ }
363
+ case 'list':
364
+ return { entries: await trash.list() }
365
+ case 'delete':
366
+ return await deleteSession(body)
367
+ case 'restore':
368
+ return await restoreSession(body)
369
+ case 'purge':
370
+ return await purgeSession(body)
371
+ case 'purge-expired': {
372
+ const purged = await purgeExpired()
373
+ return { purged }
374
+ }
375
+ default:
376
+ throw coded('unknown-endpoint', `unknown session-trash endpoint: ${endpoint}`)
377
+ }
378
+ }
379
+
380
+ const handleRequest = async (webCtx, req, res) => {
381
+ const write = (status, value) => {
382
+ if (res.headersSent === true) return
383
+ res.writeHead(status, {
384
+ 'content-type': 'application/json; charset=utf-8',
385
+ 'cache-control': 'no-store',
386
+ })
387
+ res.end(JSON.stringify(value))
388
+ }
389
+ try {
390
+ if (req.method !== 'POST') {
391
+ res.writeHead(405, { allow: 'POST' })
392
+ res.end()
393
+ return
394
+ }
395
+ const rejection = webCtx.connection.requestRejection(req)
396
+ if (rejection !== undefined) {
397
+ res.writeHead(rejection)
398
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
399
+ return
400
+ }
401
+ const request = await readJsonRequest(req)
402
+ if (!isRecord(request) || typeof request.method !== 'string' || request.method === '') {
403
+ write(400, { ok: false, error: { code: 'bad-request', message: 'invalid session-trash request', details: {} } })
404
+ return
405
+ }
406
+ try {
407
+ const value = await dispatch(request.method, request.payload, undefined)
408
+ write(200, { ok: true, value })
409
+ } catch (error) {
410
+ write(200, {
411
+ ok: false,
412
+ error: {
413
+ code: typeof error?.code === 'string' ? error.code : 'session-trash/failed',
414
+ message: messageOf(error),
415
+ details: {},
416
+ },
417
+ })
418
+ }
419
+ } catch (error) {
420
+ write(400, {
421
+ ok: false,
422
+ error: { code: 'bad-request', message: messageOf(error), details: {} },
423
+ })
424
+ }
425
+ }
426
+
427
+ // The route lives on the Web carrier, but the plugin itself does not require
428
+ // one: `ctx.inject` waits until a Web composition provides it, and registers
429
+ // inside that scoped context so the route is owned by this plugin's fiber.
430
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
431
+ webCtx.effect(() => webCtx.webServer.register({
432
+ kind: 'exact',
433
+ path: CHANNEL,
434
+ handler: (req, res) => handleRequest(webCtx, req, res),
435
+ }), 'session-trash: http route')
436
+ })
437
+
438
+ const unarchiveExpiredMarkers = async () => {
439
+ const registry = ctx.get('workspaceRegistry')
440
+ if (registry === undefined) return
441
+ let ids
442
+ try {
443
+ ids = [...registry.archivedSessionIds]
444
+ } catch {
445
+ return
446
+ }
447
+ for (const sessionId of ids) {
448
+ // A resident Session may be a deleted one kept hidden on purpose.
449
+ if (ctx.get('sessions')?.get(sessionId) !== undefined) continue
450
+ try {
451
+ if ((await persistence.stat(sessionId)) !== undefined) continue
452
+ } catch {
453
+ // A read failure is not evidence that the session is gone.
454
+ continue
455
+ }
456
+ try {
457
+ if ((await trash.read(sessionId)) !== undefined) continue
458
+ } catch {
459
+ continue
460
+ }
461
+ await unarchiveQuietly(ctx, sessionId)
462
+ }
463
+ }
464
+
465
+ ctx.effect(() => {
466
+ let stopped = false
467
+ const runPurge = () => {
468
+ if (stopped) return
469
+ void purgeExpired().catch((error) => {
470
+ logger.warn(`session-trash: scheduled purge failed: ${messageOf(error)}`)
471
+ })
472
+ }
473
+ void trash.init().then(async () => {
474
+ await unarchiveExpiredMarkers().catch((error) => {
475
+ logger.warn(`session-trash: archive-marker cleanup failed: ${messageOf(error)}`)
476
+ })
477
+ runPurge()
478
+ }).catch((error) => {
479
+ logger.warn(`session-trash: cannot initialize trash root '${trashRoot}': ${messageOf(error)}`)
480
+ })
481
+ const timer = setInterval(runPurge, purgeIntervalMs)
482
+ timer.unref?.()
483
+ return () => {
484
+ stopped = true
485
+ clearInterval(timer)
486
+ }
487
+ }, 'session-trash: retention purge')
488
+ }
489
+
490
+ async function sessionStillExists(ctx, persistence, sessionId) {
491
+ if (ctx.get('sessions')?.get(sessionId) !== undefined) return true
492
+ return storedSessionExists(persistence, sessionId)
493
+ }
494
+
495
+ /**
496
+ * Whether the durable log for a session still exists. Read failures are treated
497
+ * as "still there" so cleanup never detaches a Session whose files are only
498
+ * temporarily unreadable.
499
+ *
500
+ * @param {{ stat: (sessionId: string) => Promise<unknown> }} persistence session persistence service.
501
+ * @param {string} sessionId session id.
502
+ * @returns {Promise<boolean>} true when a persisted log remains.
503
+ */
504
+ async function storedSessionExists(persistence, sessionId) {
505
+ try {
506
+ return (await persistence.stat(sessionId)) !== undefined
507
+ } catch {
508
+ // A read failure is treated as "still there" so cleanup never resurrects.
509
+ return true
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Drop a deleted Session and its idle Agent from the Host registries.
515
+ *
516
+ * DSH has no public "close this Agent" capability; the lifecycle handle that
517
+ * owns the full factory teardown is not exposed to plugins. Once the log has
518
+ * been moved into the trash, leaving the live entries registered is what makes
519
+ * an ungrouped row survive deletion. Unregistering the exact entries removes
520
+ * the row immediately and, through `session/disposed`, releases the session
521
+ * writer. The Agent's scoped fiber stays parked until process exit, exactly as
522
+ * it did before this call; it can no longer be addressed by the sidebar or the
523
+ * session API.
524
+ *
525
+ * @param {import('@deepseek-ai/cordis').Context} ctx host context.
526
+ * @param {string} sessionId deleted session id.
527
+ */
528
+ function detachDeletedLiveState(ctx, sessionId) {
529
+ const sessions = ctx.get('sessions')
530
+ const agents = ctx.get('agents')
531
+ try {
532
+ agents?.get?.(sessionId)?.cancel?.({ kind: 'disposed' })
533
+ } catch {
534
+ // Cancelling idle work is best-effort; unregistering still has to happen.
535
+ }
536
+
537
+ // Session first: `session/disposed` makes the API publish a removal, and its
538
+ // persistence listener closes the file writer. Detaching the Agent afterwards
539
+ // cannot re-add a row because the session store no longer has it.
540
+ try {
541
+ const entry = sessions?.store?.get?.(sessionId)
542
+ if (entry !== undefined && typeof sessions?.detachEntered === 'function') {
543
+ sessions.detachEntered(entry)
544
+ }
545
+ } catch (error) {
546
+ ctx.logger?.warn?.(`session-trash: could not detach deleted session '${sessionId}': ${messageOf(error)}`)
547
+ }
548
+ try {
549
+ const entry = agents?.store?.get?.(sessionId)
550
+ if (entry !== undefined && typeof agents?.detachEntered === 'function') {
551
+ agents.detachEntered(entry)
552
+ }
553
+ } catch (error) {
554
+ ctx.logger?.warn?.(`session-trash: could not detach idle agent '${sessionId}': ${messageOf(error)}`)
555
+ }
556
+ }
557
+
558
+ async function findWorkspace(ctx, cwd, sessionId) {
559
+ const registry = ctx.get('workspaceRegistry')
560
+ if (registry === undefined || typeof cwd !== 'string' || cwd === '') return undefined
561
+ let canonical = cwd
562
+ try {
563
+ canonical = await realpath(cwd)
564
+ } catch {
565
+ // A missing directory is still comparable by its recorded spelling.
566
+ }
567
+ const candidates = registry.list().filter((workspace) => samePath(workspace.path, canonical))
568
+ return candidates.find((workspace) => {
569
+ if (typeof sessionId !== 'string') return false
570
+ try {
571
+ return Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)
572
+ } catch {
573
+ return false
574
+ }
575
+ }) ?? candidates[0]
576
+ }
577
+
578
+ function agentStatus(agent) {
579
+ if (agent === undefined) return 'none'
580
+ try {
581
+ return typeof agent.status === 'string' ? agent.status : 'unknown'
582
+ } catch {
583
+ return 'unknown'
584
+ }
585
+ }
586
+
587
+ async function unarchiveQuietly(ctx, sessionId) {
588
+ const registry = ctx.get('workspaceRegistry')
589
+ if (registry === undefined) return
590
+ try {
591
+ await registry.unarchiveSession(sessionId)
592
+ } catch {
593
+ // The id may already be absent; unarchiving is an idempotent no-op.
594
+ }
595
+ }
596
+
597
+ function requireSessionId(payload) {
598
+ const value = payload?.sessionId
599
+ if (!isSafeSessionId(value)) {
600
+ throw coded('bad-id', '无效的对话 ID')
601
+ }
602
+ return value
603
+ }
604
+
605
+ function normalizeTitle(value) {
606
+ if (typeof value !== 'string') return ''
607
+ return value.trim().slice(0, MAX_TITLE_LENGTH)
608
+ }
609
+
610
+ function firstString(value) {
611
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
612
+ }
613
+
614
+ function clampNumber(value, fallback, minimum, maximum) {
615
+ const number = typeof value === 'number' ? value : Number(value)
616
+ if (!Number.isFinite(number)) return fallback
617
+ return Math.min(maximum, Math.max(minimum, number))
618
+ }
619
+
620
+ function isRecord(value) {
621
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
622
+ }
623
+
624
+ function messageOf(error) {
625
+ return error instanceof Error ? error.message : String(error)
626
+ }
627
+
628
+ function coded(code, message) {
629
+ const error = new Error(message)
630
+ error.code = code
631
+ return error
632
+ }
633
+
634
+ async function readJsonRequest(req) {
635
+ const chunks = []
636
+ let size = 0
637
+ for await (const chunk of req) {
638
+ size += chunk.length
639
+ if (size > MAX_REQUEST_BYTES) throw coded('too-large', 'request body is too large')
640
+ chunks.push(chunk)
641
+ }
642
+ const text = Buffer.concat(chunks).toString('utf8')
643
+ if (text.trim() === '') return {}
644
+ return JSON.parse(text)
645
+ }
646
+
647
+ function delay(ms) {
648
+ return new Promise((resolve) => {
649
+ setTimeout(resolve, ms)
650
+ })
651
+ }
652
+