@omdp/dsh-archived-sessions 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,776 @@
1
+ /**
2
+ * Host half of the archived-sessions page (@omdp/dsh-archived-sessions).
3
+ *
4
+ * Fork of @muwinds/dsh-archived-sessions 0.2.0, adapted for DSH 0.1.5-rc.1:
5
+ * - sessionPersistence.list() now returns SessionPersistenceSnapshot[]
6
+ * ({ header, revision, eventCount?, sizeBytes? }), not bare headers;
7
+ * - the abstract service no longer exposes locate(meta); the JSONL backend's
8
+ * path helpers (projectDir / sessionDir / logPath + encodeSegment) are used
9
+ * instead to resolve the on-disk artifact, so list/delete work again.
10
+ * - delete now removes the whole parentSession subtree (issue #2) and an
11
+ * orphan sweep removes subagent dirs whose parent is already gone.
12
+ *
13
+ * Exposes a small JSON API under /dsh-archived/* on the harness web server:
14
+ * POST /dsh-archived/list {} -> { items, totalBytes }
15
+ * POST /dsh-archived/unarchive { sessionId } -> { ok, changed, archivedSessionIds }
16
+ * POST /dsh-archived/delete { sessionId } -> { ok, deleted, sessionId, path?, sizeBytes?, reason?, alsoDeleted? }
17
+ * POST /dsh-archived/detail { sessionId } -> { id, createdAt, cwd, totalEvents, messageCount, truncated, messages }
18
+ * POST /dsh-archived/orphans {} -> { items, totalBytes }
19
+ * POST /dsh-archived/sweep {} -> { removed, freedBytes, items }
20
+ *
21
+ * The browser half ships in the same package (exports["./client"], dsh.client).
22
+ */
23
+
24
+ /** Wait for the browser HTTP carrier before registering the route. */
25
+ export const inject = ["webServer"];
26
+
27
+ /** Plugin display name for the loader. */
28
+ export const name = "dsh-archived-sessions";
29
+
30
+ // ---------- helpers ----------
31
+
32
+ function parentDir(p) {
33
+ const a = p.lastIndexOf("/");
34
+ const b = p.lastIndexOf("\\");
35
+ const i = a > b ? a : b;
36
+ return i <= 0 ? p : p.slice(0, i);
37
+ }
38
+
39
+ function sessionIdOf(args) {
40
+ if (args === null || typeof args !== "object") throw new Error("sessionId is required");
41
+ const id = args.sessionId;
42
+ if (typeof id !== "string" || id.length === 0) throw new Error("sessionId is required");
43
+ return id;
44
+ }
45
+
46
+ /** Extract readable text from a content-block array. */
47
+ function blocksText(blocks) {
48
+ if (!Array.isArray(blocks)) return "";
49
+ const parts = [];
50
+ for (const b of blocks) {
51
+ if (!b || typeof b !== "object") continue;
52
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
53
+ else if (b.type === "reasoning" && typeof b.text === "string") parts.push("[思考] " + b.text);
54
+ else if (b.type === "tool-call") parts.push("[调用 " + (b.name || "?") + "]");
55
+ else if (b.type === "tool-result") parts.push("[工具结果]");
56
+ else if (b.type === "image") parts.push("[图片]");
57
+ }
58
+ return parts.join("\n");
59
+ }
60
+
61
+ /**
62
+ * Recursive byte size of a session directory via the fs service.
63
+ * Returns null when the path cannot be resolved (missing on disk).
64
+ */
65
+ async function dirSizeBytes(fsSvc, dirPath, depth) {
66
+ let target;
67
+ try {
68
+ target = await fsSvc.resolve(dirPath);
69
+ } catch (e) {
70
+ return null;
71
+ }
72
+ try {
73
+ const info = await fsSvc.stat(target);
74
+ if (!info) return 0;
75
+ if (info.type !== "directory") return info.size || 0;
76
+ if (depth > 10) return 0;
77
+ let total = 0;
78
+ let entries = [];
79
+ try {
80
+ entries = await fsSvc.listDir(target);
81
+ } catch (e) {
82
+ entries = [];
83
+ }
84
+ for (const entry of entries) {
85
+ if (entry.type === "directory") {
86
+ const sub = await dirSizeBytes(fsSvc, fsSvc.processPath(entry.target), depth + 1);
87
+ total += sub === null ? 0 : sub;
88
+ } else {
89
+ total += entry.size || 0;
90
+ }
91
+ }
92
+ return total;
93
+ } catch (e) {
94
+ return 0;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Resolve an explicit danger-full-access policy so the shell executor runs the
100
+ * deletion unconfined. On Windows deployments where the ACL sandbox backend
101
+ * cannot start (its temp root must live outside the workspace), any confined
102
+ * mode would fail before the command even runs.
103
+ */
104
+ function dangerPolicy(ctx) {
105
+ const sp = ctx.get("sandboxPolicy");
106
+ if (!sp || typeof sp.resolve !== "function") return undefined;
107
+ try {
108
+ return sp.resolve({ mode: "danger-full-access" });
109
+ } catch (e) {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Refuse to delete anything that does not look like a per-session directory.
116
+ * The dir name must be a `session-` prefixed id (new format) or a bare UUID
117
+ * (old format), so a resolution mistake can never take out an arbitrary path.
118
+ */
119
+ function assertSessionDirName(dirPath) {
120
+ const last = dirPath.replace(/[\\/]+$/, "");
121
+ const name = last.slice(last.lastIndexOf("/") + 1, last.length).slice(last.lastIndexOf("\\") + 1);
122
+ const ok = /^session-[0-9a-fA-F-]{36}$/.test(name) || /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(name);
123
+ if (!ok) throw new Error("拒绝删除非会话目录: " + dirPath);
124
+ }
125
+
126
+ /** Delete a directory recursively through the shell executor (pwsh / rm). */
127
+ async function removeDir(ctx, dirPath) {
128
+ assertSessionDirName(dirPath);
129
+ const shell = ctx.get("shell");
130
+ if (!shell || typeof shell.resolve !== "function" || typeof shell.run !== "function") {
131
+ throw new Error("shell executor unavailable; cannot delete from disk");
132
+ }
133
+ const isWindows = /^[A-Za-z]:[\\/]/.test(dirPath);
134
+ const command = isWindows
135
+ ? "Remove-Item -LiteralPath '" + dirPath.replace(/'/g, "''") + "' -Recurse -Force -ErrorAction Stop"
136
+ : "rm -rf -- '" + dirPath.replace(/'/g, "'\\''") + "'";
137
+ const request = { command, timeoutMs: 60000 };
138
+ const policy = dangerPolicy(ctx);
139
+ if (policy) request.sandboxPolicy = policy;
140
+ let spec;
141
+ try {
142
+ spec = shell.resolve(request);
143
+ } catch (e) {
144
+ throw new Error("shell resolve failed: " + String((e && e.message) || e));
145
+ }
146
+ const result = await shell.run(spec);
147
+ if (result && result.exitCode === 0) return;
148
+ let detail = "";
149
+ try {
150
+ const out = result && (result.stderr || result.stdout);
151
+ if (out && typeof out.text === "string") detail = out.text.slice(0, 400);
152
+ } catch (e) {
153
+ detail = "";
154
+ }
155
+ throw new Error("删除失败 (exit " + String(result && result.exitCode) + "): " + (detail || dirPath));
156
+ }
157
+
158
+ /**
159
+ * Remove one id from the durable archive set and keep the registry's in-memory
160
+ * state consistent so its own later writes cannot clobber it.
161
+ */
162
+ async function removeFromArchiveSet(ctx, sessionId) {
163
+ const registry = ctx.get("workspaceRegistry");
164
+ if (!registry) throw new Error("workspace registry unavailable");
165
+ if (!registry.state || typeof registry.state !== "object") throw new Error("workspace registry is not started");
166
+ const current = registry.archivedSessionIds;
167
+ if (!Array.isArray(current) || !current.includes(sessionId)) return false;
168
+ const next = current.filter((id) => id !== sessionId);
169
+ const state = Object.assign({}, registry.state, { archivedSessionIds: next });
170
+ if (typeof registry.setState === "function") {
171
+ await registry.setState(state);
172
+ return true;
173
+ }
174
+ const domain = ctx.get("storageDomain");
175
+ if (!domain) throw new Error("storage domain unavailable");
176
+ const unit = domain.get("workspace");
177
+ if (!unit || !unit.global || typeof unit.global.set !== "function") throw new Error("workspace domain is not open");
178
+ await unit.global.set(state);
179
+ registry.state = state;
180
+ return true;
181
+ }
182
+
183
+ /** A session is deletable unless its agent is actively running a turn. */
184
+ function sessionRunning(ctx, sessionId) {
185
+ const agents = ctx.get("agents");
186
+ if (!agents || typeof agents.get !== "function") return false;
187
+ const agent = agents.get(sessionId);
188
+ return !!(agent && agent.status === "running");
189
+ }
190
+
191
+ /**
192
+ * Evict a live session from the in-memory store (the store's own detach path),
193
+ * so a deleted session cannot resurface in the workspace afterward.
194
+ */
195
+ function evictSessionFromMemory(ctx, sessionId) {
196
+ const sessions = ctx.get("sessions");
197
+ if (!sessions) return false;
198
+ try {
199
+ const store = sessions.store;
200
+ if (!store || typeof store.get !== "function") return false;
201
+ const entry = store.get(sessionId);
202
+ if (!entry || typeof entry.detach !== "function") return false;
203
+ entry.detach();
204
+ return true;
205
+ } catch (e) {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Resolve the on-disk location for one session in the DSH 0.1.5-rc.1 JSONL
212
+ * backend layout. The abstract sessionPersistence service no longer exposes
213
+ * locate(); the JSONL backend's own path helpers are exported and used here.
214
+ * Falls back to a best-effort path when the jsonl backend module is not
215
+ * importable (older DSH), where the old layout used the same encoding.
216
+ */
217
+ let jsonlBackend = null;
218
+ try {
219
+ jsonlBackend = await import("@deepseek-ai/dsh-session-persistence-jsonl");
220
+ } catch (e) {
221
+ jsonlBackend = null;
222
+ }
223
+
224
+ function encodeSegment(raw) {
225
+ if (raw.length === 0) throw new Error("cannot encode an empty path segment");
226
+ if (raw === ".") return "~002E";
227
+ if (raw === "..") return "~002E~002E";
228
+ let out = "";
229
+ for (let i = 0; i < raw.length; i++) {
230
+ const code = raw.charCodeAt(i);
231
+ const ch = String.fromCharCode(code);
232
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
233
+ else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
234
+ }
235
+ return out;
236
+ }
237
+
238
+ function projectKey(cwd) {
239
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
240
+ let readable = "";
241
+ let separatorRun = false;
242
+ for (let i = 0; i < cwd.length; i++) {
243
+ const code = cwd.charCodeAt(i);
244
+ const ch = String.fromCharCode(code);
245
+ if (ch === "/" || ch === "\\" || ch === ":") {
246
+ if (!separatorRun) readable += "-";
247
+ separatorRun = true;
248
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
249
+ readable += ch;
250
+ separatorRun = false;
251
+ } else {
252
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
253
+ separatorRun = false;
254
+ }
255
+ }
256
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
257
+ }
258
+
259
+ /** Read an environment variable without assuming `process` exists. */
260
+ function env(name) {
261
+ try {
262
+ return typeof process !== "undefined" && process.env ? process.env[name] : undefined;
263
+ } catch (e) {
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Candidate JSONL session roots, probed in order at call time.
270
+ *
271
+ * DSH stores every session under `<DSH_HOME>/sessions` (DSH_HOME defaults to
272
+ * `~/.dsh`), and the abstract sessionPersistence service exposes no root, so the
273
+ * location is derived from the environment rather than hard-coded — the plugin
274
+ * then works on any machine and any DSH_HOME.
275
+ */
276
+ function candidateRoots() {
277
+ const out = [];
278
+ const push = (p) => {
279
+ if (typeof p !== "string" || p.length === 0) return;
280
+ const trimmed = p.replace(/[\\/]+$/, "");
281
+ if (trimmed.length > 0 && !out.includes(trimmed)) out.push(trimmed);
282
+ };
283
+ const home = env("DSH_HOME");
284
+ if (typeof home === "string" && home.replace(/[\\/]+$/, "").length > 0) {
285
+ push(home + "/sessions");
286
+ } else {
287
+ const userHome = env("USERPROFILE") || env("HOME");
288
+ if (typeof userHome === "string" && userHome.length > 0) push(userHome + "/.dsh/sessions");
289
+ }
290
+ return out;
291
+ }
292
+
293
+ /** Build one session's on-disk directory path under a candidate root. */
294
+ function buildSessionDir(root, header) {
295
+ const key = header.cwd === undefined ? "_no-cwd" : projectKey(header.cwd);
296
+ const id = encodeSegment(header.id);
297
+ const sep = root.endsWith("/") || root.endsWith("\\") ? "" : "/";
298
+ return root + sep + key + sep + id;
299
+ }
300
+
301
+ /**
302
+ * Resolve the on-disk session directory for one header, confirming existence
303
+ * through the fs service. Returns { path, found } — `found` is false when no
304
+ * candidate root holds the session (deletion then prunes the archive id only).
305
+ */
306
+ async function resolveSessionLocation(ctx, header) {
307
+ const fsSvc = ctx.get("fs");
308
+ const backend = jsonlBackend && typeof jsonlBackend.sessionDir === "function" ? jsonlBackend : null;
309
+ const roots = candidateRoots();
310
+ if (roots.length === 0) return { path: "", found: false };
311
+ let last = null;
312
+ for (const root of roots) {
313
+ let dirPath;
314
+ if (backend && typeof jsonlBackend.projectDir === "function") {
315
+ try {
316
+ dirPath = backend.sessionDir(root, header.cwd, header.id);
317
+ } catch (e) {
318
+ dirPath = buildSessionDir(root, header);
319
+ }
320
+ } else {
321
+ dirPath = buildSessionDir(root, header);
322
+ }
323
+ last = { path: dirPath, found: false };
324
+ if (!fsSvc || typeof fsSvc.resolve !== "function") {
325
+ last.found = true;
326
+ break;
327
+ }
328
+ try {
329
+ await fsSvc.resolve(dirPath);
330
+ last.found = true;
331
+ break;
332
+ } catch (e) {
333
+ // not under this root; try the next
334
+ }
335
+ }
336
+ return last;
337
+ }
338
+
339
+ // ---------- API handlers ----------
340
+
341
+ async function handleList(ctx) {
342
+ const registry = ctx.get("workspaceRegistry");
343
+ const persistence = ctx.get("sessionPersistence");
344
+ if (!registry || !persistence) return { items: [], totalBytes: 0 };
345
+ const archived = Array.isArray(registry.archivedSessionIds) ? [...registry.archivedSessionIds] : [];
346
+ if (archived.length === 0) return { items: [], totalBytes: 0 };
347
+
348
+ const snapshots = await persistence.list();
349
+ const byId = new Map();
350
+ for (const snap of snapshots) {
351
+ if (snap && typeof snap === "object" && snap.header && typeof snap.header === "object") {
352
+ byId.set(snap.header.id, snap);
353
+ } else if (snap && typeof snap === "object" && typeof snap.id === "string") {
354
+ byId.set(snap.id, snap);
355
+ }
356
+ }
357
+
358
+ const titles = new Map();
359
+ const query = ctx.get("sessionQuery");
360
+ if (query && typeof query.readTitleSnapshots === "function") {
361
+ try {
362
+ const results = await query.readTitleSnapshots(archived);
363
+ for (const r of results) {
364
+ if (r && r.status === "fulfilled" && r.value && r.value.title && typeof r.value.title.title === "string") {
365
+ titles.set(r.sessionId, r.value.title.title);
366
+ }
367
+ }
368
+ } catch (e) {
369
+ // best effort
370
+ }
371
+ }
372
+
373
+ const liveSvc = ctx.get("sessions");
374
+ const fsSvc = ctx.get("fs");
375
+ const items = [];
376
+ let totalBytes = 0;
377
+ for (const id of archived) {
378
+ const snap = byId.get(id);
379
+ const header = snap && snap.header ? snap.header : (snap && typeof snap.id === "string" ? snap : null);
380
+ let sizeBytes = 0;
381
+ let missing = false;
382
+ let path = null;
383
+ if (header) {
384
+ // Prefer the snapshot's cheap size when present.
385
+ if (typeof snap.sizeBytes === "number" && snap.sizeBytes >= 0) {
386
+ sizeBytes = snap.sizeBytes;
387
+ path = null;
388
+ } else {
389
+ let location = null;
390
+ try {
391
+ location = await resolveSessionLocation(ctx, header);
392
+ } catch (e) {
393
+ location = null;
394
+ }
395
+ if (location && typeof location.path === "string" && location.path.length > 0) {
396
+ path = location.path;
397
+ if (fsSvc) {
398
+ const size = await dirSizeBytes(fsSvc, location.path, 0);
399
+ if (size === null) missing = true;
400
+ else sizeBytes = size;
401
+ }
402
+ } else {
403
+ missing = true;
404
+ }
405
+ }
406
+ } else {
407
+ missing = true;
408
+ }
409
+ totalBytes += sizeBytes;
410
+ items.push({
411
+ id,
412
+ title: titles.get(id) || null,
413
+ createdAt: header ? header.createdAt : null,
414
+ cwd: header ? header.cwd || null : null,
415
+ parentSession: header ? header.parentSession || null : null,
416
+ sizeBytes,
417
+ missing,
418
+ live: !!(liveSvc && liveSvc.get(id) !== undefined),
419
+ running: sessionRunning(ctx, id),
420
+ path
421
+ });
422
+ }
423
+ items.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
424
+ return { items, totalBytes };
425
+ }
426
+
427
+ async function handleUnarchive(ctx, args) {
428
+ const sessionId = sessionIdOf(args);
429
+ const registry = ctx.get("workspaceRegistry");
430
+ if (!registry) throw new Error("workspace registry unavailable");
431
+ if (!Array.isArray(registry.archivedSessionIds) || !registry.archivedSessionIds.includes(sessionId)) {
432
+ throw new Error("会话 '" + sessionId + "' 不在归档集合中");
433
+ }
434
+ const changed = await removeFromArchiveSet(ctx, sessionId);
435
+ return { ok: true, changed, archivedSessionIds: [...registry.archivedSessionIds] };
436
+ }
437
+
438
+ /** Collect the whole parentSession subtree (ids present in the archive set). */
439
+ async function collectSubtreeIds(ctx, rootId, snapshots) {
440
+ const byParent = new Map();
441
+ for (const snap of snapshots) {
442
+ const h = snap && typeof snap === "object" && snap.header ? snap.header : snap;
443
+ if (h && typeof h === "object" && typeof h.parentSession === "string" && h.parentSession.length > 0) {
444
+ let list = byParent.get(h.parentSession);
445
+ if (!list) {
446
+ list = [];
447
+ byParent.set(h.parentSession, list);
448
+ }
449
+ list.push(typeof h.id === "string" ? h.id : "");
450
+ }
451
+ }
452
+ const result = [rootId];
453
+ const seen = new Set(result);
454
+ let i = 0;
455
+ while (i < result.length) {
456
+ const cur = result[i];
457
+ const kids = byParent.get(cur) || [];
458
+ for (const k of kids) {
459
+ if (k && !seen.has(k)) {
460
+ seen.add(k);
461
+ result.push(k);
462
+ }
463
+ }
464
+ i++;
465
+ }
466
+ return result.slice(1);
467
+ }
468
+
469
+ async function handleDelete(ctx, args) {
470
+ const sessionId = sessionIdOf(args);
471
+ const registry = ctx.get("workspaceRegistry");
472
+ const persistence = ctx.get("sessionPersistence");
473
+ if (!registry || !persistence) throw new Error("workspace registry or session persistence unavailable");
474
+ if (!Array.isArray(registry.archivedSessionIds) || !registry.archivedSessionIds.includes(sessionId)) {
475
+ throw new Error("会话 '" + sessionId + "' 不在归档集合中");
476
+ }
477
+ if (sessionRunning(ctx, sessionId)) {
478
+ throw new Error("会话 '" + sessionId + "' 正在运行中,无法删除");
479
+ }
480
+ // A live (in-memory) session would otherwise resurface in the workspace once
481
+ // the archive entry is pruned; evict it so the deletion is complete.
482
+ const liveSvc = ctx.get("sessions");
483
+ const isLive = !!(liveSvc && typeof liveSvc.get === "function" && liveSvc.get(sessionId) !== undefined);
484
+ if (isLive && !evictSessionFromMemory(ctx, sessionId)) {
485
+ throw new Error("会话 '" + sessionId + "' 仍驻留内存且无法移除,删除未完成;请重启 Harness 后重试");
486
+ }
487
+
488
+ const snapshots = await persistence.list();
489
+ const subtree = await collectSubtreeIds(ctx, sessionId, snapshots);
490
+
491
+ const headers = new Map();
492
+ for (const snap of snapshots) {
493
+ const h = snap && typeof snap === "object" && snap.header ? snap.header : snap;
494
+ if (h && typeof h === "object" && typeof h.id === "string") headers.set(h.id, h);
495
+ }
496
+
497
+ const deletedIds = [];
498
+ const failedIds = [];
499
+ for (const id of [sessionId, ...subtree]) {
500
+ const header = headers.get(id);
501
+ if (!header) {
502
+ // Already gone from persistence: just prune the archive id.
503
+ if (registry.archivedSessionIds.includes(id)) {
504
+ await removeFromArchiveSet(ctx, id);
505
+ }
506
+ deletedIds.push(id);
507
+ continue;
508
+ }
509
+ let location = null;
510
+ try {
511
+ location = await resolveSessionLocation(ctx, header);
512
+ } catch (e) {
513
+ location = null;
514
+ }
515
+ if (!location || typeof location.path !== "string" || location.path.length === 0) {
516
+ if (registry.archivedSessionIds.includes(id)) {
517
+ await removeFromArchiveSet(ctx, id);
518
+ }
519
+ deletedIds.push(id);
520
+ continue;
521
+ }
522
+ const dirPath = location.path;
523
+ const fsSvc = ctx.get("fs");
524
+ let sizeBytes = 0;
525
+ if (fsSvc) {
526
+ const size = await dirSizeBytes(fsSvc, dirPath, 0);
527
+ sizeBytes = size === null ? 0 : size;
528
+ }
529
+ try {
530
+ await removeDir(ctx, dirPath);
531
+ } catch (e) {
532
+ failedIds.push(id);
533
+ continue;
534
+ }
535
+ if (registry.archivedSessionIds.includes(id)) {
536
+ await removeFromArchiveSet(ctx, id);
537
+ }
538
+ deletedIds.push(id);
539
+ }
540
+
541
+ if (failedIds.length > 0) {
542
+ throw new Error("部分会话删除失败: " + failedIds.join(", "));
543
+ }
544
+ return {
545
+ ok: true,
546
+ deleted: deletedIds.includes(sessionId),
547
+ sessionId,
548
+ alsoDeleted: subtree.filter((id) => deletedIds.includes(id)),
549
+ sizeBytes: 0,
550
+ reason: deletedIds.length === 0 ? "no-artifact" : undefined
551
+ };
552
+ }
553
+
554
+ async function handleDetail(ctx, args) {
555
+ const sessionId = sessionIdOf(args);
556
+ const query = ctx.get("sessionQuery");
557
+ if (!query || typeof query.readSession !== "function") {
558
+ throw new Error("session query unavailable");
559
+ }
560
+ let snapshot;
561
+ try {
562
+ snapshot = await query.readSession(sessionId);
563
+ } catch (e) {
564
+ throw new Error("无法读取会话内容(可能已从磁盘删除): " + String((e && e.message) || e));
565
+ }
566
+ if (!snapshot || !Array.isArray(snapshot.events)) {
567
+ throw new Error("会话内容为空或不可读");
568
+ }
569
+ const events = snapshot.events;
570
+ const messages = [];
571
+ const MAX_MESSAGES = 100;
572
+ const MAX_TEXT = 8000;
573
+ for (const ev of events) {
574
+ if (messages.length >= MAX_MESSAGES) break;
575
+ if (!ev || typeof ev !== "object" || !ev.data || typeof ev.data !== "object") continue;
576
+ const data = ev.data;
577
+ if (ev.type === "user/message") {
578
+ const text = blocksText(data.content);
579
+ if (text) messages.push({ seq: ev.seq, time: ev.time, role: "user", text: text.slice(0, MAX_TEXT) });
580
+ } else if (ev.type === "assistant/message" && data.message && typeof data.message === "object") {
581
+ const text = blocksText(data.message.content);
582
+ if (text) messages.push({ seq: ev.seq, time: ev.time, role: "assistant", text: text.slice(0, MAX_TEXT) });
583
+ } else if (ev.type === "tool/call") {
584
+ const name = typeof data.name === "string" ? data.name : "?";
585
+ const argsText = typeof data.arguments === "string" ? data.arguments.slice(0, 300) : "";
586
+ messages.push({ seq: ev.seq, time: ev.time, role: "tool", text: "[" + name + "] " + argsText });
587
+ }
588
+ }
589
+ const header = snapshot.session || null;
590
+ return {
591
+ id: sessionId,
592
+ createdAt: header ? header.createdAt : null,
593
+ cwd: header ? header.cwd || null : null,
594
+ parentSession: header ? header.parentSession || null : null,
595
+ totalEvents: events.length,
596
+ messageCount: messages.length,
597
+ truncated: messages.length >= MAX_MESSAGES,
598
+ messages
599
+ };
600
+ }
601
+
602
+ /** List orphaned subagent sessions: on disk, parent already gone, not archived. */
603
+ async function handleOrphans(ctx) {
604
+ const registry = ctx.get("workspaceRegistry");
605
+ const persistence = ctx.get("sessionPersistence");
606
+ if (!registry || !persistence) return { items: [], totalBytes: 0 };
607
+ const archived = Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds : [];
608
+ const live = new Set(archived);
609
+ const snapshots = await persistence.list();
610
+ const known = new Set();
611
+ const byParent = new Map();
612
+ for (const snap of snapshots) {
613
+ const h = snap && typeof snap === "object" && snap.header ? snap.header : snap;
614
+ if (h && typeof h === "object" && typeof h.id === "string") {
615
+ known.add(h.id);
616
+ if (typeof h.parentSession === "string" && h.parentSession.length > 0) {
617
+ let list = byParent.get(h.parentSession);
618
+ if (!list) {
619
+ list = [];
620
+ byParent.set(h.parentSession, list);
621
+ }
622
+ list.push(h.id);
623
+ }
624
+ }
625
+ }
626
+ const fsSvc = ctx.get("fs");
627
+ const items = [];
628
+ let totalBytes = 0;
629
+ for (const id of known) {
630
+ if (live.has(id)) continue;
631
+ let h = null;
632
+ for (const snap of snapshots) {
633
+ const hh = snap && typeof snap === "object" && snap.header ? snap.header : snap;
634
+ if (hh && typeof hh === "object" && hh.id === id) {
635
+ h = hh;
636
+ break;
637
+ }
638
+ }
639
+ if (!h || typeof h.parentSession !== "string" || h.parentSession.length === 0) continue;
640
+ if (known.has(h.parentSession)) continue;
641
+ let location = null;
642
+ try {
643
+ location = await resolveSessionLocation(ctx, h);
644
+ } catch (e) {
645
+ location = null;
646
+ }
647
+ if (!location || typeof location.path !== "string" || location.path.length === 0) continue;
648
+ const dirPath = location.path;
649
+ let sizeBytes = 0;
650
+ if (fsSvc) {
651
+ const size = await dirSizeBytes(fsSvc, dirPath, 0);
652
+ sizeBytes = size === null ? 0 : size;
653
+ }
654
+ totalBytes += sizeBytes;
655
+ items.push({
656
+ id,
657
+ parentSession: h.parentSession,
658
+ createdAt: h.createdAt || null,
659
+ cwd: h.cwd || null,
660
+ sizeBytes,
661
+ missing: sizeBytes === 0
662
+ });
663
+ }
664
+ items.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
665
+ return { items, totalBytes };
666
+ }
667
+
668
+ /** Sweep orphaned subagent sessions: delete their dirs + prune from archive set. */
669
+ async function handleSweep(ctx) {
670
+ const { items } = await handleOrphans(ctx);
671
+ const removed = [];
672
+ let freedBytes = 0;
673
+ for (const item of items) {
674
+ const persistence = ctx.get("sessionPersistence");
675
+ const snapshots = await persistence.list();
676
+ let h = null;
677
+ for (const snap of snapshots) {
678
+ const hh = snap && typeof snap === "object" && snap.header ? snap.header : snap;
679
+ if (hh && typeof hh === "object" && hh.id === item.id) {
680
+ h = hh;
681
+ break;
682
+ }
683
+ }
684
+ if (!h) continue;
685
+ let location = null;
686
+ try {
687
+ location = await resolveSessionLocation(ctx, h);
688
+ } catch (e) {
689
+ location = null;
690
+ }
691
+ if (!location || typeof location.path !== "string" || location.path.length === 0) continue;
692
+ const dirPath = location.path;
693
+ try {
694
+ await removeDir(ctx, dirPath);
695
+ } catch (e) {
696
+ continue;
697
+ }
698
+ freedBytes += item.sizeBytes;
699
+ removed.push(item.id);
700
+ }
701
+ return { removed, freedBytes, items: removed.length };
702
+ }
703
+
704
+ // ---------- HTTP route ----------
705
+
706
+ function readBody(req) {
707
+ return new Promise((resolve, reject) => {
708
+ const chunks = [];
709
+ req.on("data", (chunk) => chunks.push(chunk));
710
+ req.on("end", () => {
711
+ try {
712
+ resolve(Buffer.concat(chunks).toString("utf8"));
713
+ } catch (e) {
714
+ reject(e);
715
+ }
716
+ });
717
+ req.on("error", reject);
718
+ });
719
+ }
720
+
721
+ function sendJson(res, status, payload) {
722
+ res.writeHead(status, {
723
+ "content-type": "application/json; charset=utf-8",
724
+ "cache-control": "no-store"
725
+ });
726
+ res.end(JSON.stringify(payload));
727
+ }
728
+
729
+ export function apply(ctx) {
730
+ const handlers = {
731
+ list: () => handleList(ctx),
732
+ unarchive: (args) => handleUnarchive(ctx, args),
733
+ delete: (args) => handleDelete(ctx, args),
734
+ detail: (args) => handleDetail(ctx, args),
735
+ orphans: () => handleOrphans(ctx),
736
+ sweep: () => handleSweep(ctx)
737
+ };
738
+
739
+ async function handler(req, res) {
740
+ if ((req.method || "") !== "POST") {
741
+ sendJson(res, 405, { error: "method not allowed" });
742
+ return;
743
+ }
744
+ const pathname = (req.url || "").split("?")[0].replace(/\/+$/, "");
745
+ let action = null;
746
+ for (const key of Object.keys(handlers)) {
747
+ if (pathname === "/dsh-archived/" + key) {
748
+ action = key;
749
+ break;
750
+ }
751
+ }
752
+ if (action === null) {
753
+ sendJson(res, 404, { error: "not found" });
754
+ return;
755
+ }
756
+ let body = {};
757
+ try {
758
+ const raw = await readBody(req);
759
+ if (raw.trim().length > 0) body = JSON.parse(raw);
760
+ } catch (e) {
761
+ sendJson(res, 400, { error: "invalid JSON body" });
762
+ return;
763
+ }
764
+ try {
765
+ sendJson(res, 200, await handlers[action](body));
766
+ } catch (e) {
767
+ sendJson(res, 500, { error: (e && e.message) || String(e) });
768
+ }
769
+ }
770
+
771
+ ctx.webServer.register({
772
+ kind: "prefix",
773
+ path: "/dsh-archived",
774
+ handler
775
+ });
776
+ }