dsh-session-manager 0.6.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
26
26
  import { basename, dirname, join } from "node:path";
27
27
  import { randomBytes } from "node:crypto";
28
28
  import { createAnnotationStore } from "./annotation-store.js";
29
+ import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
29
30
  import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
30
31
  import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
32
 
@@ -169,7 +170,7 @@ const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHea
169
170
 
170
171
  export function apply(ctx) {
171
172
  try {
172
- const buildId = "dsh-session-manager v0.5.2+release-17 (clearSelection-keeps-bulk-mode-on)";
173
+ const buildId = "dsh-session-manager v0.7.1 (workspace-archive)";
173
174
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
174
175
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
175
176
  } catch (_) { /* ignore logging failures */ }
@@ -183,6 +184,24 @@ export function apply(ctx) {
183
184
  }
184
185
  return annotationStore;
185
186
  };
187
+ let workspaceArchiveStore;
188
+ const workspaceArchives = () => {
189
+ if (!workspaceArchiveStore) {
190
+ const home = adapter.homePath;
191
+ if (typeof home === "function") {
192
+ try {
193
+ workspaceArchiveStore = createWorkspaceArchiveStore(home("dsh-session-manager"));
194
+ } catch (_) {}
195
+ }
196
+ if (!workspaceArchiveStore) {
197
+ workspaceArchiveStore = createWorkspaceArchiveStore(null, { memory: true });
198
+ }
199
+ }
200
+ return workspaceArchiveStore;
201
+ };
202
+ const safeGetAgent = (sessionId) => {
203
+ try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
204
+ };
186
205
  const assertAnnotatable = id => {
187
206
  const registry = adapter.workspaceRegistry;
188
207
  if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
@@ -291,7 +310,7 @@ export function apply(ctx) {
291
310
 
292
311
  if (!stopActivity) {
293
312
  const liveSession = ctx.sessions?.get?.(sessionId);
294
- const liveAgent = ctx.agents?.get?.(sessionId);
313
+ const liveAgent = safeGetAgent(sessionId);
295
314
  const activeTurn = liveSession?.activeTurn;
296
315
  const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
297
316
  if (activeTurn !== undefined || agentRunning) {
@@ -360,18 +379,20 @@ export function apply(ctx) {
360
379
  /** Ordered workspace projection for the client. */
361
380
  const listWorkspaces = () => {
362
381
  return ctx.workspaceRegistry.list().map((entity) => {
363
- const rawIds = Array.isArray(entity.record?.sessionIds)
364
- ? [...entity.record.sessionIds]
365
- : [...entity.sessionIds];
366
- return {
367
- id: entity.id,
368
- name: entity.title || entity.id,
369
- title: entity.title || entity.id,
370
- path: entity.path,
371
- sessionCount: rawIds.length,
372
- sessionIds: rawIds
373
- };
374
-
382
+ const rawIds = Array.isArray(entity.record?.sessionIds)
383
+ ? [...entity.record.sessionIds]
384
+ : [...entity.sessionIds];
385
+ const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
386
+ return {
387
+ id: entity.id,
388
+ name: entity.title || entity.id,
389
+ title: entity.title || entity.id,
390
+ path: entity.path,
391
+ sessionCount: rawIds.length,
392
+ sessionIds: rawIds,
393
+ updatedAt: selfUpdatedAt,
394
+ createdAt: entity.createdAt ?? entity.record?.createdAt ?? null
395
+ };
375
396
  });
376
397
  };
377
398
 
@@ -444,6 +465,178 @@ ${rest}`, "utf8");
444
465
  return undefined;
445
466
  };
446
467
 
468
+ const MAX_BOOT_REFOLD_COUNT = 50;
469
+ const MAX_BOOT_REFOLD_MS = 2000;
470
+ const MAX_SINGLE_ARTIFACT_BYTES = 5 * 1024 * 1024; // 5 MB
471
+
472
+ const getSessionSizeBytes = async (sessionId, fallbackPath) => {
473
+ if (typeof adapter.statSession === "function") {
474
+ try {
475
+ const stat = await adapter.statSession(sessionId);
476
+ const size = stat?.sizeBytes ?? stat?.size;
477
+ if (typeof size === "number") return size;
478
+ } catch { /* proceed */ }
479
+ }
480
+ try {
481
+ const resolvedPath = fallbackPath ?? (await adapter.resolveSessionPath?.(sessionId));
482
+ if (typeof resolvedPath === "string") {
483
+ const fileStat = await lstat(resolvedPath);
484
+ return fileStat.size;
485
+ }
486
+ } catch { /* proceed */ }
487
+ return undefined;
488
+ };
489
+
490
+ /**
491
+ * Refold the projection cache for a session using properly decoded/migrated logical events.
492
+ * Prioritizes the official persistence interface (openSessionRead) to obtain logical events
493
+ * (expanding packed rows and handling generational migrations). Falls back to disk reading
494
+ * only when the official interface is unavailable, strictly verifying continuous logical seq
495
+ * and safe JSON parsing without swallowing corruption. (Issue #32)
496
+ */
497
+ const refoldSessionProjection = async ({
498
+ sessionId,
499
+ header,
500
+ movedArtifactRest,
501
+ isThreeArgCache,
502
+ cache: cacheParam,
503
+ liveSession: liveSessionParam,
504
+ caller = "boot"
505
+ }) => {
506
+ const cache = cacheParam ?? (ctx.get("sessionProjectionCache") ?? adapter.projectionCache);
507
+ if (typeof cache?.coldSnapshot !== "function") return false;
508
+ const isThreeArg = isThreeArgCache ?? (typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3);
509
+
510
+ // Check file size budget across all reconstruction paths before any decoding/reading
511
+ let artifactSize = await getSessionSizeBytes(sessionId);
512
+ if (typeof movedArtifactRest === "string") {
513
+ const restBytes = Buffer.byteLength(movedArtifactRest);
514
+ artifactSize = artifactSize !== undefined ? Math.max(artifactSize, restBytes) : restBytes;
515
+ }
516
+ if (artifactSize !== undefined && artifactSize > MAX_SINGLE_ARTIFACT_BYTES) {
517
+ ctx.logger.info(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for large artifact "${sessionId}" (${artifactSize} bytes)`);
518
+ return false;
519
+ }
520
+
521
+ let events = null;
522
+ let inheritedEventCount = undefined;
523
+ let effectiveHeader = header;
524
+
525
+ // Priority 1: Official persistence handle via adapter.openSessionRead
526
+ const hasOfficialReader = typeof adapter.persistence?.open === "function";
527
+
528
+ if (hasOfficialReader) {
529
+ let handle = null;
530
+ try {
531
+ handle = await adapter.openSessionRead(sessionId);
532
+ if (handle && typeof handle.read === "function") {
533
+ try {
534
+ const readResult = await handle.read(0);
535
+ const handleEvents = Array.isArray(readResult) ? readResult : readResult?.events;
536
+ if (Array.isArray(handleEvents)) {
537
+ // Verify all events are contiguous logical events
538
+ if (handleEvents.length === 0 || handleEvents.every((e, idx) => e && typeof e === "object" && e.seq === idx)) {
539
+ events = handleEvents;
540
+ inheritedEventCount = handle.inheritedEventCount;
541
+ if (handle.header && typeof handle.header === "object") {
542
+ effectiveHeader = { ...effectiveHeader, ...handle.header };
543
+ }
544
+ } else {
545
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official handle returned non-contiguous logical events`);
546
+ return false;
547
+ }
548
+ } else {
549
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official handle returned empty or invalid events`);
550
+ return false;
551
+ }
552
+ } catch (readErr) {
553
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official read/migration failed: ${safeErrorMessage(readErr)}`);
554
+ return false;
555
+ }
556
+ }
557
+ } catch (err) {
558
+ const msg = safeErrorMessage(err);
559
+ if (!msg.includes("unavailable")) {
560
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official read/migration failed: ${msg}`);
561
+ return false;
562
+ }
563
+ } finally {
564
+ if (typeof handle?.close === "function") {
565
+ try { await handle.close(); } catch { /* best-effort */ }
566
+ }
567
+ }
568
+ }
569
+
570
+ // Priority 2: Fallback to reading disk artifact or moved rest ONLY when official API is unavailable or handle lacks read
571
+ if (events === null) {
572
+ let rest = movedArtifactRest;
573
+ if (typeof rest !== "string") {
574
+ const raw = await readSessionArtifact(sessionId, undefined, { storedHeader: effectiveHeader });
575
+ if (!raw || typeof raw.content !== "string") return false;
576
+ const newlineAt = raw.content.indexOf("\n");
577
+ rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
578
+ }
579
+
580
+ if (typeof rest === "string" && rest.length > 0) {
581
+ const rawEvents = [];
582
+ let parseFailed = false;
583
+ for (const line of rest.split("\n")) {
584
+ if (!line.trim()) continue;
585
+ try {
586
+ rawEvents.push(JSON.parse(line));
587
+ } catch {
588
+ parseFailed = true;
589
+ break;
590
+ }
591
+ }
592
+
593
+ if (parseFailed) {
594
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": JSON parse error in artifact events`);
595
+ return false;
596
+ }
597
+
598
+ // Strictly check that all rows are contiguous logical events with valid seq starting at 0.
599
+ // Physical rows without seq (like packed tool-call-chunks) MUST NOT be passed to coldSnapshot.
600
+ const isContiguous = rawEvents.length === 0 || rawEvents.every((e, idx) => e && typeof e === "object" && e.seq === idx);
601
+ if (!isContiguous) {
602
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": physical rows cannot be restored as logical events without official migration`);
603
+ return false;
604
+ }
605
+
606
+ events = rawEvents;
607
+ inheritedEventCount = await resolveInheritedEventCount({
608
+ sessionId,
609
+ header: effectiveHeader,
610
+ events,
611
+ persistence: adapter.persistence,
612
+ liveSession: liveSessionParam ?? adapter.getSession(sessionId)
613
+ });
614
+ }
615
+ }
616
+
617
+ if (events !== null) {
618
+ if (effectiveHeader?.isSeeded && inheritedEventCount === undefined) {
619
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for seeded session "${sessionId}": cannot determine inheritedEventCount`);
620
+ return false;
621
+ }
622
+ if (isThreeArg) {
623
+ await cache.coldSnapshot(effectiveHeader, inheritedEventCount, events);
624
+ } else {
625
+ try {
626
+ await cache.coldSnapshot(effectiveHeader.id);
627
+ } catch (error) {
628
+ if (String(error).includes("SessionLogOffset") && effectiveHeader) {
629
+ await cache.coldSnapshot(effectiveHeader, inheritedEventCount, events);
630
+ } else {
631
+ throw error;
632
+ }
633
+ }
634
+ }
635
+ return true;
636
+ }
637
+ return false;
638
+ };
639
+
447
640
  /**
448
641
  * TRUE cross-workspace move. See the module docstring for the full design:
449
642
  * re-home the stored cwd, migrate the artifact, then swap accounting.
@@ -562,17 +755,13 @@ ${rest}`, "utf8");
562
755
  if (typeof oldPath !== "string" || newLocation === void 0) {
563
756
  throw new Error("持久化后端无法定位会话工件路径");
564
757
  }
565
- const newPath = newLocation.path;
566
- // Keep the stored generation in sync with the filename selected by
567
- // persistence.locate(). persistence.locate() always returns the
568
- // SESSION_FORMAT_VERSION path (DSH 0.1.7+ ships v4 by default),
569
- // regardless of oldHeader.version. Without the stamp below, the
570
- // rewritten file is `session.v4.jsonl.zstd` carrying a v0/v3 header,
571
- // which JsonlSessionPersistence.listArtifacts rejects on the next
572
- // startup (`session generation filename identifies v4, but its
573
- // header identifies v0`), breaking persistence.list() and crashing
574
- // the workspace registry while it boots. (issue #18)
575
- const expectedVersion = parseSessionGenerationFromFilename(basename(newPath));
758
+ // For live sessions, the active writer targets the latest generation returned by persistence.locate().
759
+ // For cold sessions, preserve the source artifact's filename and generation to avoid producing an
760
+ // artifact claiming a newer format version without its event contents being migrated. (Issue #32)
761
+ const targetFilename = liveSession !== void 0 ? basename(newLocation.path) : (raw.filename ?? basename(oldPath));
762
+ const targetDir = dirname(newLocation.path);
763
+ const newPath = join(targetDir, targetFilename);
764
+ const expectedVersion = parseSessionGenerationFromFilename(targetFilename);
576
765
  if (typeof expectedVersion === "number") newHeader.version = expectedVersion;
577
766
  const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
578
767
  await assertSessionDirectory(root, dirname(oldPath), sessionId);
@@ -702,36 +891,15 @@ ${rest}`, "utf8");
702
891
  if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
703
892
  try {
704
893
  const isThreeArg = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
705
- const header = movedArtifact?.header;
706
- const events = [];
707
- if (typeof movedArtifact?.rest === "string") {
708
- for (const line of movedArtifact.rest.split("\n")) {
709
- if (!line.trim()) continue;
710
- try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
711
- }
712
- }
713
- const inheritedEventCount = await resolveInheritedEventCount({
894
+ await refoldSessionProjection({
714
895
  sessionId,
715
- header,
716
- events,
717
- persistence,
718
- liveSession
896
+ header: movedArtifact?.header,
897
+ movedArtifactRest: movedArtifact?.rest,
898
+ isThreeArgCache: isThreeArg,
899
+ cache,
900
+ liveSession,
901
+ caller: "post-move"
719
902
  });
720
- if (header?.isSeeded && inheritedEventCount === undefined) {
721
- ctx.logger.warn(`session-manager: skipping post-move cache refresh for seeded session "${sessionId}": cannot determine inheritedEventCount`);
722
- } else if (isThreeArg) {
723
- await cache.coldSnapshot(header, inheritedEventCount, events);
724
- } else {
725
- try {
726
- await cache.coldSnapshot(sessionId);
727
- } catch (error) {
728
- if (String(error).includes("SessionLogOffset") && header) {
729
- await cache.coldSnapshot(header, inheritedEventCount, events);
730
- } else {
731
- throw error;
732
- }
733
- }
734
- }
735
903
  } catch (error) {
736
904
  ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
737
905
  }
@@ -1100,98 +1268,326 @@ ${rest}`, "utf8");
1100
1268
  return undefined;
1101
1269
  };
1102
1270
 
1103
- const sessionDirOf = async (sessionId) => {
1271
+ const sessionDirOf = async (sessionId, storedHeader) => {
1104
1272
  // Require a matching artifact header instead of deleting an arbitrary
1105
1273
  // directory merely because its name matches user input.
1106
- const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
1274
+ const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true, storedHeader });
1107
1275
  return artifact === undefined ? undefined : dirname(artifact.path);
1108
1276
  };
1109
1277
 
1110
1278
  /**
1111
- * Delete one session end to end. Returns a summary of what was torn down.
1112
- * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
1279
+ * Enumerate all descendant subagent session headers for one or more root session IDs.
1280
+ * Recursively traverses subagent relations (origin === "subagent" && parentSession === parentId).
1281
+ * Normal fork sessions (origin !== "subagent") are strictly ignored.
1282
+ * Only returns verified local sessions with valid directories or live runtime state.
1113
1283
  */
1114
- const deleteSession = async (sessionId) => {
1115
- assertSessionId(sessionId);
1116
- // Resolve/validate before cancelling the agent or changing registry state.
1117
- let dir = await sessionDirOf(sessionId);
1118
- const session = ctx.sessions.get(sessionId);
1119
- // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
1120
- let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
1121
-
1122
- if (agent !== void 0) {
1123
- // Stop any running turn (disposed-kind suppresses re-wake).
1124
- agent.cancel({ kind: "disposed" });
1125
- // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
1126
- if (typeof agent.scope?.dispose === "function") {
1127
- await Promise.race([agent.scope.dispose(), sleep(3000)]);
1128
- }
1129
- // Drop the zombie from the registry so a later session.create/open with
1130
- // the same id cannot resurrect it.
1131
- try {
1132
- ctx.agents.store?.delete?.(sessionId);
1133
- } catch { /* best-effort */ }
1284
+ const findDescendantSubagents = async (rootSessionIds) => {
1285
+ const roots = Array.from(new Set((rootSessionIds || []).filter(id => typeof id === "string" && id.trim() !== "")));
1286
+ if (roots.length === 0) return [];
1287
+ for (const id of roots) assertSessionId(id);
1288
+
1289
+ const headerMap = new Map();
1290
+ try {
1291
+ const cached = adapter.cachedSessionHeaders?.();
1292
+ if (Array.isArray(cached)) {
1293
+ for (const h of cached) if (h?.id) headerMap.set(h.id, h);
1294
+ }
1295
+ } catch { /* proceed */ }
1296
+
1297
+ try {
1298
+ const live = adapter.sessions?.list?.();
1299
+ if (Array.isArray(live)) {
1300
+ for (const s of live) if (s?.header?.id) headerMap.set(s.header.id, s.header);
1301
+ }
1302
+ } catch { /* proceed */ }
1303
+
1304
+ const scanResult = await scanSessionHeaders(adapter.persistence, adapter.homePath);
1305
+ if (!scanResult || !scanResult.complete || (Array.isArray(scanResult.errors) && scanResult.errors.length > 0)) {
1306
+ const errSummary = (scanResult?.errors || []).map(safeErrorMessage).join("; ") || "扫描未完成";
1307
+ throw new Error("扫描子智能体会话失败 (元数据不完整): " + errSummary);
1308
+ }
1309
+ if (Array.isArray(scanResult.headers)) {
1310
+ for (const h of scanResult.headers) {
1311
+ if (h?.id) {
1312
+ headerMap.set(h.id, { ...(headerMap.get(h.id) || {}), ...h });
1313
+ }
1314
+ }
1134
1315
  }
1135
1316
 
1136
- let detached = false;
1137
- if (session !== void 0) {
1138
- // Flush buffered events to disk first so the retirement drain is a no-op.
1139
- try {
1140
- await ctx.sessions.flush(session);
1141
- } catch { /* best-effort */ }
1142
- // Detach the session store entry: emits session/disposed, which the
1143
- // persistence write-path answers with a final drain, and the API proxy
1144
- // relays as host/session-removed so every connected client drops the row.
1145
- try {
1146
- const entry = ctx.sessions.store?.get?.(sessionId);
1147
- if (entry !== void 0 && typeof entry.detach === "function") {
1148
- entry.detach();
1149
- await sleep(200); // let the write-behind retirement settle
1150
- detached = true;
1317
+ const allHeaders = [...headerMap.values()];
1318
+ const visited = new Set(roots);
1319
+ const descendants = [];
1320
+ let currentParents = new Set(roots);
1321
+
1322
+ while (currentParents.size > 0) {
1323
+ const nextParents = new Set();
1324
+ for (const header of allHeaders) {
1325
+ if (!header || !header.id || !header.parentSession) continue;
1326
+ if (header.origin === "subagent" && currentParents.has(header.parentSession)) {
1327
+ if (!visited.has(header.id)) {
1328
+ visited.add(header.id);
1329
+ nextParents.add(header.id);
1330
+ descendants.push(header);
1331
+ }
1151
1332
  }
1152
- } catch { /* best-effort */ }
1333
+ }
1334
+ currentParents = nextParents;
1335
+ }
1336
+
1337
+ const verifiedDescendants = [];
1338
+ for (const sub of descendants) {
1339
+ assertSessionId(sub.id);
1340
+ const dir = await sessionDirOf(sub.id, sub);
1341
+ const isLive = ctx.sessions?.get?.(sub.id) !== undefined || safeGetAgent(sub.id) !== undefined;
1342
+ if (dir !== undefined || isLive) {
1343
+ verifiedDescendants.push({ ...sub, dir });
1344
+ }
1153
1345
  }
1154
- // Some sessions have a stored artifact but no live store row (e.g.
1155
- // already-scoped scope torn down, or never attached). Without a live
1156
- // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
1157
- // entry.detach() never fires — so the apiproxy never queues a
1158
- // host/session-removed frame, every connected client keeps showing the
1159
- // session row, and the row collapses into the ungrouped section the
1160
- // moment host/workspace-changed drops its workspace membership. Emit
1161
- // session/disposed explicitly so every connected client drops the row.
1162
- if (!detached) {
1346
+
1347
+ return verifiedDescendants;
1348
+ };
1349
+
1350
+ /**
1351
+ * Safety check and activity stop for a session before deletion.
1352
+ * If running, attempts safe stop via cancel, dispose, and activity stop interfaces.
1353
+ * If it cannot be safely stopped, rejects deletion.
1354
+ */
1355
+ const assertAndStopActiveSession = async (sessionId, isSubagent = false) => {
1356
+ assertSessionId(sessionId);
1357
+ const liveSession = ctx.sessions?.get?.(sessionId);
1358
+ const liveAgent = safeGetAgent(sessionId);
1359
+
1360
+ const activeTurn = liveSession?.activeTurn;
1361
+ const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
1362
+ let waterfallActivities = [];
1363
+ if (typeof ctx.waterfall === "function") {
1163
1364
  try {
1164
- ctx.emit("session/disposed", { id: sessionId });
1165
- } catch (_) { /* best-effort */ }
1365
+ waterfallActivities = await ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
1366
+ } catch (err) {
1367
+ throw new Error(`无法检查会话 '${sessionId}' 的活动状态: ${safeErrorMessage(err)}`);
1368
+ }
1166
1369
  }
1167
1370
 
1168
- // A blank live session may have materialized its first artifact during
1169
- // flush/retirement. Re-resolve after teardown rather than leaking that file.
1170
- dir = await sessionDirOf(sessionId);
1371
+ const isActive = activeTurn !== undefined || agentRunning || (Array.isArray(waterfallActivities) && waterfallActivities.length > 0);
1372
+ if (!isActive) return;
1171
1373
 
1172
- // Workspace accounting + archive-set membership.
1173
- await detachFromWorkspaces(sessionId);
1174
- await unarchiveSession(sessionId);
1374
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
1375
+ const hasAgent = liveAgent !== void 0;
1376
+ const hasStopFn = typeof registry?.stopSessionActivity === "function" || typeof ctx.parallel === "function" || typeof liveAgent?.cancel === "function";
1175
1377
 
1176
- // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1177
- if (dir !== void 0) {
1178
- await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
1179
- await rm(dir, { recursive: true, force: true });
1378
+ if (!hasStopFn) {
1379
+ const label = isSubagent ? `子智能体 '${sessionId}'` : `会话 '${sessionId}'`;
1380
+ throw new Error(`无法安全停止运行中的${label}: 没有可用的停止接口`);
1180
1381
  }
1181
1382
 
1182
- let warning;
1183
- try { await annotations().remove(sessionId); }
1184
- catch (error) {
1185
- warning = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
1186
- ctx.logger.warn("session-manager: " + warning);
1383
+ if (hasAgent) {
1384
+ if (typeof liveAgent.cancel === "function") {
1385
+ liveAgent.cancel({ kind: "disposed" });
1386
+ }
1387
+ if (typeof liveAgent.scope?.dispose === "function") {
1388
+ await Promise.race([liveAgent.scope.dispose(), sleep(3000)]);
1389
+ }
1390
+ }
1391
+
1392
+ if (typeof registry?.stopSessionActivity === "function") {
1393
+ try { await registry.stopSessionActivity(sessionId); }
1394
+ catch (err) {
1395
+ throw new Error(`停止会话 '${sessionId}' 活动失败: ${safeErrorMessage(err)}`);
1396
+ }
1397
+ } else if (typeof ctx.parallel === "function") {
1398
+ try { await ctx.parallel("workspace/session-stop", { sessionId }); }
1399
+ catch (err) {
1400
+ throw new Error(`停止会话 '${sessionId}' 活动失败: ${safeErrorMessage(err)}`);
1401
+ }
1402
+ }
1403
+
1404
+ const postAgent = safeGetAgent(sessionId);
1405
+ if (postAgent?.status === "running" || postAgent?.busy === true) {
1406
+ const label = isSubagent ? `子智能体 '${sessionId}'` : `会话 '${sessionId}'`;
1407
+ throw new Error(`${label} 仍在运行中,无法安全停止`);
1408
+ }
1409
+ };
1410
+
1411
+ /**
1412
+ * Delete one session end to end. Returns a summary of what was torn down.
1413
+ * Recursively cascades to all descendant subagents (origin === "subagent").
1414
+ * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
1415
+ */
1416
+ const deleteSession = async (sessionId, options = {}) => {
1417
+ assertSessionId(sessionId);
1418
+
1419
+ let deletedSubagentIds = [];
1420
+ const cleanupWarnings = [];
1421
+
1422
+ if (!options.skipSubagents) {
1423
+ // 1. Discover all descendant subagents (verifying scan completeness)
1424
+ const descendants = await findDescendantSubagents([sessionId]);
1425
+
1426
+ // 2. Pre-delete validation for BOTH main session and all descendants BEFORE any deletion
1427
+ // 2.1 Identity validation
1428
+ assertSessionId(sessionId);
1429
+ for (const sub of descendants) {
1430
+ assertSessionId(sub.id);
1431
+ }
1432
+
1433
+ // 2.2 Directory validation (prevent traversal / outside directory issues)
1434
+ const mainDir = await sessionDirOf(sessionId);
1435
+ if (mainDir !== undefined) {
1436
+ await assertSessionDirectory(adapter.homePath("sessions"), mainDir, sessionId);
1437
+ }
1438
+ for (const sub of descendants) {
1439
+ const subDir = sub.dir ?? (await sessionDirOf(sub.id, sub));
1440
+ if (subDir !== undefined) {
1441
+ await assertSessionDirectory(adapter.homePath("sessions"), subDir, sub.id);
1442
+ }
1443
+ }
1444
+
1445
+ // 2.3 Activity validation and safe stop
1446
+ for (const sub of descendants) {
1447
+ await assertAndStopActiveSession(sub.id, true);
1448
+ }
1449
+ await assertAndStopActiveSession(sessionId, false);
1450
+
1451
+ // 3. Deletion order: descendant-first (reverse), parent-last
1452
+ const deletePlan = [...descendants].reverse();
1453
+ const remainingSubagentIds = descendants.map(d => d.id);
1454
+
1455
+ for (const sub of deletePlan) {
1456
+ try {
1457
+ const subResult = await mutate(sub.id, async () => {
1458
+ return await deleteSession(sub.id, { skipSubagents: true });
1459
+ });
1460
+ deletedSubagentIds.push(sub.id);
1461
+ const rIdx = remainingSubagentIds.indexOf(sub.id);
1462
+ if (rIdx !== -1) remainingSubagentIds.splice(rIdx, 1);
1463
+
1464
+ if (Array.isArray(subResult?.warnings)) {
1465
+ cleanupWarnings.push(...subResult.warnings);
1466
+ } else if (subResult?.warning) {
1467
+ cleanupWarnings.push(subResult.warning);
1468
+ }
1469
+
1470
+ try {
1471
+ await workspaceArchives().removeSession(sub.id);
1472
+ } catch (archErr) {
1473
+ cleanupWarnings.push(`子智能体 '${sub.id}' 归档记录清理失败: ${safeErrorMessage(archErr)}`);
1474
+ }
1475
+ } catch (subErr) {
1476
+ const err = new Error(`删除子智能体 '${sub.id}' 失败: ${safeErrorMessage(subErr)}`);
1477
+ err.deletedSubagentIds = [...deletedSubagentIds];
1478
+ err.remainingSessionIds = [sessionId, ...remainingSubagentIds];
1479
+ err.partialFailure = deletedSubagentIds.length > 0;
1480
+ if (subErr?.code) err.code = subErr.code;
1481
+ throw err;
1482
+ }
1483
+ }
1484
+ } else {
1485
+ await assertAndStopActiveSession(sessionId, true);
1486
+ }
1487
+
1488
+ try {
1489
+ // Resolve/validate before cancelling the agent or changing registry state.
1490
+ let dir = await sessionDirOf(sessionId);
1491
+ const session = ctx.sessions?.get?.(sessionId);
1492
+ let agent = safeGetAgent(sessionId);
1493
+
1494
+ if (agent !== void 0) {
1495
+ // Stop any running turn (disposed-kind suppresses re-wake).
1496
+ agent.cancel({ kind: "disposed" });
1497
+ // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
1498
+ if (typeof agent.scope?.dispose === "function") {
1499
+ await Promise.race([agent.scope.dispose(), sleep(3000)]);
1500
+ }
1501
+ // Drop the zombie from the registry so a later session.create/open with
1502
+ // the same id cannot resurrect it.
1503
+ try {
1504
+ ctx.agents?.store?.delete?.(sessionId);
1505
+ } catch { /* best-effort */ }
1506
+ }
1507
+
1508
+ let detached = false;
1509
+ if (session !== void 0) {
1510
+ // Flush buffered events to disk first so the retirement drain is a no-op.
1511
+ try {
1512
+ await ctx.sessions?.flush?.(session);
1513
+ } catch { /* best-effort */ }
1514
+ // Detach the session store entry: emits session/disposed, which the
1515
+ // persistence write-path answers with a final drain, and the API proxy
1516
+ // relays as host/session-removed so every connected client drops the row.
1517
+ try {
1518
+ const entry = ctx.sessions?.store?.get?.(sessionId);
1519
+ if (entry !== void 0 && typeof entry.detach === "function") {
1520
+ entry.detach();
1521
+ await sleep(200); // let the write-behind retirement settle
1522
+ detached = true;
1523
+ }
1524
+ } catch { /* best-effort */ }
1525
+ }
1526
+ // Some sessions have a stored artifact but no live store row (e.g.
1527
+ // already-scoped scope torn down, or never attached). Without a live
1528
+ // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
1529
+ // entry.detach() never fires — so the apiproxy never queues a
1530
+ // host/session-removed frame, every connected client keeps showing the
1531
+ // session row, and the row collapses into the ungrouped section the
1532
+ // moment host/workspace-changed drops its workspace membership. Emit
1533
+ // session/disposed explicitly so every connected client drops the row.
1534
+ if (!detached) {
1535
+ try {
1536
+ ctx.emit("session/disposed", { id: sessionId });
1537
+ } catch (_) { /* best-effort */ }
1538
+ }
1539
+
1540
+ // A blank live session may have materialized its first artifact during
1541
+ // flush/retirement. Re-resolve after teardown rather than leaking that file.
1542
+ dir = await sessionDirOf(sessionId);
1543
+
1544
+ // Workspace accounting + archive-set membership.
1545
+ await detachFromWorkspaces(sessionId);
1546
+ await unarchiveSession(sessionId);
1547
+
1548
+ // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1549
+ if (dir !== void 0) {
1550
+ await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
1551
+ await rm(dir, { recursive: true, force: true });
1552
+ }
1553
+
1554
+ try {
1555
+ await annotations().remove(sessionId);
1556
+ } catch (error) {
1557
+ const warn = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
1558
+ cleanupWarnings.push(warn);
1559
+ ctx.logger.warn("session-manager: " + warn);
1560
+ }
1561
+
1562
+ try {
1563
+ await workspaceArchives().removeSession(sessionId);
1564
+ } catch (error) {
1565
+ const warn = "会话已删除,但工作区归档记录清理失败: " + safeErrorMessage(error);
1566
+ cleanupWarnings.push(warn);
1567
+ ctx.logger.warn("session-manager: " + warn);
1568
+ }
1569
+
1570
+ const aggregatedWarning = cleanupWarnings.length > 0 ? cleanupWarnings.join("; ") : undefined;
1571
+
1572
+ return {
1573
+ ok: true,
1574
+ ...(aggregatedWarning ? { warning: aggregatedWarning, warnings: cleanupWarnings } : {}),
1575
+ sessionId,
1576
+ wasLive: session !== void 0 || agent !== void 0,
1577
+ filesRemoved: dir !== void 0,
1578
+ deletedSubagents: deletedSubagentIds,
1579
+ subagentCount: deletedSubagentIds.length
1580
+ };
1581
+ } catch (parentErr) {
1582
+ const err = deletedSubagentIds.length > 0
1583
+ ? new Error(`删除主会话 '${sessionId}' 失败: ${safeErrorMessage(parentErr)}`)
1584
+ : parentErr;
1585
+ err.deletedSubagentIds = [...deletedSubagentIds];
1586
+ err.remainingSessionIds = [sessionId];
1587
+ err.partialFailure = deletedSubagentIds.length > 0;
1588
+ if (parentErr?.code && err !== parentErr) err.code = parentErr.code;
1589
+ throw err;
1187
1590
  }
1188
- return {
1189
- ok: true,
1190
- ...(warning ? { warning } : {}),
1191
- sessionId,
1192
- wasLive: session !== void 0 || agent !== void 0,
1193
- filesRemoved: dir !== void 0
1194
- };
1195
1591
  };
1196
1592
 
1197
1593
  /**
@@ -1205,11 +1601,20 @@ ${rest}`, "utf8");
1205
1601
  * pass-through refold, and a stale one triggers the unrelated-identity
1206
1602
  * full re-read path.
1207
1603
  */
1604
+ /**
1605
+ * On plugin boot:
1606
+ * 1. Reconcile workspace session membership using header metadata only.
1607
+ * This step is fast, safe, and completely separated from projection cache refolding. (Issue #32)
1608
+ * 2. Perform a budgeted background projection cache sweep for missing cache records,
1609
+ * prioritizing sessions in active workspaces, yielding to the event loop, and
1610
+ * safely skipping unmigrated physical rows or corrupt artifacts. (Issue #32)
1611
+ */
1208
1612
  ctx.effect(async () => {
1209
1613
  const cache = adapter.projectionCache;
1210
1614
  const persistence = adapter.persistence;
1211
1615
  const registry = adapter.workspaceRegistry;
1212
- if (typeof cache?.coldSnapshot !== "function" || typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1616
+ if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1617
+
1213
1618
  try {
1214
1619
  // Remember only ids that existed before the async scan. Concurrently
1215
1620
  // attached ids must never be removed using this older disk snapshot.
@@ -1221,55 +1626,10 @@ ${rest}`, "utf8");
1221
1626
  }
1222
1627
  const headers = scan.headers;
1223
1628
  const validIds = new Set(headers.map(header => header.id));
1224
- const isThreeArgCache = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
1225
- if (isThreeArgCache) {
1226
- for (const header of headers) {
1227
- try {
1228
- // Fast path: cachedSnapshot performs an in-memory lifecycle check (formatVersion, cwd, createdAt, isSeeded).
1229
- // If the cache record is already present and matches, skip disk I/O and projection fold.
1230
- if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
1231
- continue;
1232
- }
1233
- const raw = await readSessionArtifact(header.id, undefined, { storedHeader: header });
1234
- if (!raw || typeof raw.content !== "string") continue;
1235
- const newlineAt = raw.content.indexOf("\n");
1236
- const rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
1237
- const events = [];
1238
- if (rest) {
1239
- for (const line of rest.split("\n")) {
1240
- if (!line.trim()) continue;
1241
- try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
1242
- }
1243
- }
1244
- const inheritedEventCount = await resolveInheritedEventCount({
1245
- sessionId: header.id,
1246
- header,
1247
- events,
1248
- persistence,
1249
- liveSession: adapter.getSession(header.id)
1250
- });
1251
- if (header.isSeeded && inheritedEventCount === undefined) {
1252
- ctx.logger.warn('session-manager: skipping cache refold for seeded session "' + header.id + '": cannot determine inheritedEventCount');
1253
- continue;
1254
- }
1255
- await cache.coldSnapshot(header, inheritedEventCount, events);
1256
- } catch (error) {
1257
- ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
1258
- }
1259
- }
1260
- } else {
1261
- for (const header of headers) {
1262
- try { await cache.coldSnapshot(header.id); }
1263
- catch (error) {
1264
- if (String(error).includes("SessionLogOffset")) break;
1265
- ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
1266
- }
1267
- }
1268
- }
1629
+
1630
+ // Step 1: Immediate Workspace Member Reconciliation (isolated from projection rebuild)
1269
1631
  let removed = 0;
1270
1632
  let added = 0;
1271
- // Read current state *inside* its queue, and publish an immutable copy.
1272
- // A failed setState must not mutate the registry's live state object.
1273
1633
  await registry.enqueueOperation(async () => {
1274
1634
  const state = registry.requireState();
1275
1635
  const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
@@ -1291,9 +1651,68 @@ ${rest}`, "utf8");
1291
1651
  });
1292
1652
  if (removed || added) await registry.setState({ ...state, workspaces });
1293
1653
  });
1294
- if (removed || added) ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
1654
+ if (removed || added) {
1655
+ ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
1656
+ }
1657
+
1658
+ // Step 2: Budgeted Background Projection Cache Rebuild
1659
+ if (typeof cache?.coldSnapshot !== "function") return;
1660
+ const isThreeArgCache = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
1661
+
1662
+ const assignedIds = new Set(registry.list().flatMap(ws => ws.record?.sessionIds ?? []));
1663
+ const candidates = headers.filter(header => assignedIds.has(header.id) || headers.length <= MAX_BOOT_REFOLD_COUNT);
1664
+
1665
+ const startTime = Date.now();
1666
+ let attemptedCount = 0;
1667
+ let refoldCount = 0;
1668
+
1669
+ for (const header of candidates) {
1670
+ if (attemptedCount >= MAX_BOOT_REFOLD_COUNT || Date.now() - startTime > MAX_BOOT_REFOLD_MS) {
1671
+ ctx.logger.info(`session-manager: boot projection cache refold budget reached (${attemptedCount} attempted, ${refoldCount} refolded, ${candidates.length - attemptedCount} remaining deferred)`);
1672
+ break;
1673
+ }
1674
+
1675
+ try {
1676
+ if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
1677
+ continue;
1678
+ }
1679
+
1680
+ attemptedCount++;
1681
+
1682
+ // Check file size budget before any I/O
1683
+ const size = await getSessionSizeBytes(header.id);
1684
+ if (typeof size === "number" && size > MAX_SINGLE_ARTIFACT_BYTES) {
1685
+ ctx.logger.info(`session-manager: skipping boot cache refold for large artifact "${header.id}" (${size} bytes)`);
1686
+ continue;
1687
+ }
1688
+
1689
+ // Yield to event loop between sessions so other tasks/HTTP requests stay responsive
1690
+ await new Promise(resolve => setImmediate(resolve));
1691
+
1692
+ if (!isThreeArgCache) {
1693
+ try {
1694
+ await cache.coldSnapshot(header.id);
1695
+ refoldCount++;
1696
+ } catch (error) {
1697
+ if (String(error).includes("SessionLogOffset")) break;
1698
+ ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
1699
+ }
1700
+ continue;
1701
+ }
1702
+
1703
+ const ok = await refoldSessionProjection({
1704
+ sessionId: header.id,
1705
+ header,
1706
+ isThreeArgCache,
1707
+ cache
1708
+ });
1709
+ if (ok) refoldCount++;
1710
+ } catch (error) {
1711
+ ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
1712
+ }
1713
+ }
1295
1714
  } catch (error) {
1296
- ctx.logger.warn('session-manager: post-boot reconcile failed: ' + safeErrorMessage(error));
1715
+ ctx.logger.warn("session-manager: post-boot reconcile failed: " + safeErrorMessage(error));
1297
1716
  }
1298
1717
  }, "session-manager: post-boot re-fold stale projection cache rows");
1299
1718
 
@@ -1393,8 +1812,22 @@ ${rest}`, "utf8");
1393
1812
  if (req.method === "POST" && ["/delete", "/unarchive", "/move", "/preset-migrate"].includes(path)) {
1394
1813
  assertSessionId(sessionId);
1395
1814
  let result;
1396
- if (path === "/delete") result = await mutate(sessionId, () => deleteSession(sessionId));
1397
- else if (path === "/unarchive") {
1815
+ if (path === "/delete") {
1816
+ try {
1817
+ result = await mutate(sessionId, () => deleteSession(sessionId));
1818
+ try { await workspaceArchives().removeSession(sessionId); } catch (_) {}
1819
+ } catch (delErr) {
1820
+ const status = delErr?.code === "bad-request" ? 400 : 500;
1821
+ return send(res, status, {
1822
+ ok: false,
1823
+ error: safeErrorMessage(delErr),
1824
+ ...(typeof delErr?.code === "string" ? { code: delErr.code } : {}),
1825
+ deletedSubagents: Array.isArray(delErr?.deletedSubagentIds) ? delErr.deletedSubagentIds : [],
1826
+ remainingSessions: Array.isArray(delErr?.remainingSessionIds) ? delErr.remainingSessionIds : [sessionId],
1827
+ partialFailure: Boolean(delErr?.partialFailure)
1828
+ });
1829
+ }
1830
+ } else if (path === "/unarchive") {
1398
1831
  await mutate(sessionId, () => unarchiveSession(sessionId));
1399
1832
  result = { sessionId };
1400
1833
  } else if (path === "/move") {
@@ -1408,6 +1841,38 @@ ${rest}`, "utf8");
1408
1841
  }
1409
1842
  return send(res, 200, { ok: true, result });
1410
1843
  }
1844
+ if (req.method === "POST" && path === "/workspace-archive") {
1845
+ const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId.trim() : "";
1846
+ assertWorkspaceId(workspaceId);
1847
+ const sessionIds = Array.isArray(body.sessionIds) ? body.sessionIds : [];
1848
+ for (const id of sessionIds) assertSessionId(id);
1849
+ const pending = Array.isArray(body.pending) ? body.pending : [];
1850
+ for (const id of pending) assertSessionId(id);
1851
+ const record = await workspaceArchives().setArchive(workspaceId, {
1852
+ sessionIds,
1853
+ pending,
1854
+ archivedAt: typeof body.archivedAt === "number" ? body.archivedAt : Date.now()
1855
+ });
1856
+ return send(res, 200, { ok: true, result: { workspaceId, record } });
1857
+ }
1858
+ if (req.method === "POST" && path === "/workspace-unarchive") {
1859
+ const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId.trim() : "";
1860
+ assertWorkspaceId(workspaceId);
1861
+ const pending = Array.isArray(body.pending) ? body.pending : [];
1862
+ for (const id of pending) assertSessionId(id);
1863
+ const retainSessionIds = body.retainSessionIds === true;
1864
+ const record = await workspaceArchives().setUnarchive(workspaceId, { pending, retainSessionIds });
1865
+ return send(res, 200, { ok: true, result: { workspaceId, record } });
1866
+ }
1867
+ if (req.method === "GET" && path === "/workspace-archives") {
1868
+ let archives = {};
1869
+ try {
1870
+ archives = await workspaceArchives().getAll();
1871
+ } catch (err) {
1872
+ return send(res, 500, { ok: false, error: safeErrorMessage(err) });
1873
+ }
1874
+ return send(res, 200, { ok: true, result: { archives } });
1875
+ }
1411
1876
  if (req.method === "POST" && path === "/batch") {
1412
1877
  // Per-action body shape (each action validates its own payload):
1413
1878
  // archive : no payload
@@ -1433,21 +1898,66 @@ ${rest}`, "utf8");
1433
1898
  if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
1434
1899
  const items = [];
1435
1900
  let success = 0, failed = 0, skipped = 0;
1436
- for (const sessionId of ids) {
1437
- try {
1438
- await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
1439
- items.push({ sessionId, status: "success" });
1440
- success++;
1441
- } catch (error) {
1442
- const code = error && typeof error.code === "string" ? error.code : undefined;
1443
- if (code === "subagent-skip" || code === "blank-skip") {
1444
- items.push({ sessionId, status: "skipped", reason: code });
1445
- skipped++;
1446
- } else {
1447
- items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
1901
+ if (rawAction === "delete") {
1902
+ const deletedSet = new Set();
1903
+ for (const sessionId of ids) {
1904
+ if (deletedSet.has(sessionId)) {
1905
+ items.push({ sessionId, status: "success", cascaded: true, subagentCount: 0 });
1906
+ success++;
1907
+ continue;
1908
+ }
1909
+ try {
1910
+ const res = await mutate(sessionId, () => deleteSession(sessionId));
1911
+ deletedSet.add(sessionId);
1912
+ if (Array.isArray(res?.deletedSubagents)) {
1913
+ for (const subId of res.deletedSubagents) {
1914
+ deletedSet.add(subId);
1915
+ }
1916
+ }
1917
+ items.push({
1918
+ sessionId,
1919
+ status: "success",
1920
+ deletedSubagents: res?.deletedSubagents || [],
1921
+ subagentCount: res?.subagentCount || 0,
1922
+ warnings: res?.warnings || (res?.warning ? [res.warning] : [])
1923
+ });
1924
+ success++;
1925
+ } catch (error) {
1926
+ const code = error && typeof error.code === "string" ? error.code : undefined;
1927
+ if (Array.isArray(error?.deletedSubagentIds)) {
1928
+ for (const subId of error.deletedSubagentIds) {
1929
+ deletedSet.add(subId);
1930
+ }
1931
+ }
1932
+ items.push({
1933
+ sessionId,
1934
+ status: "failed",
1935
+ code,
1936
+ error: safeErrorMessage(error),
1937
+ deletedSubagents: error?.deletedSubagentIds || [],
1938
+ remainingSessions: error?.remainingSessionIds || [sessionId],
1939
+ partialFailure: Boolean(error?.partialFailure)
1940
+ });
1448
1941
  failed++;
1449
1942
  }
1450
1943
  }
1944
+ } else {
1945
+ for (const sessionId of ids) {
1946
+ try {
1947
+ await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
1948
+ items.push({ sessionId, status: "success" });
1949
+ success++;
1950
+ } catch (error) {
1951
+ const code = error && typeof error.code === "string" ? error.code : undefined;
1952
+ if (code === "subagent-skip" || code === "blank-skip") {
1953
+ items.push({ sessionId, status: "skipped", reason: code });
1954
+ skipped++;
1955
+ } else {
1956
+ items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
1957
+ failed++;
1958
+ }
1959
+ }
1960
+ }
1451
1961
  }
1452
1962
  return send(res, 200, { ok: true, result: {
1453
1963
  summary: { total: ids.length, success, failed, skipped },
@@ -1455,11 +1965,46 @@ ${rest}`, "utf8");
1455
1965
  aborted: false,
1456
1966
  } });
1457
1967
  }
1968
+ if (req.method === "GET" && path === "/delete-preview") {
1969
+ const rawId = url.searchParams.get("sessionId") || "";
1970
+ const sessionId = rawId.trim();
1971
+ if (sessionId) assertSessionId(sessionId);
1972
+ const rawIds = url.searchParams.get("sessionIds");
1973
+ let sessionIds = sessionId ? [sessionId] : [];
1974
+ if (rawIds) {
1975
+ const split = rawIds.split(",").map(s => s.trim()).filter(Boolean);
1976
+ for (const id of split) assertSessionId(id);
1977
+ sessionIds = Array.from(new Set([...sessionIds, ...split]));
1978
+ }
1979
+ if (sessionIds.length === 0) {
1980
+ return send(res, 400, { ok: false, error: "sessionId 或 sessionIds 必填" });
1981
+ }
1982
+ try {
1983
+ const descendants = await findDescendantSubagents(sessionIds);
1984
+ return send(res, 200, {
1985
+ ok: true,
1986
+ result: {
1987
+ subagentCount: descendants.length,
1988
+ subagents: descendants.map(d => ({ id: d.id, parentSession: d.parentSession }))
1989
+ }
1990
+ });
1991
+ } catch (err) {
1992
+ return send(res, 500, { ok: false, error: safeErrorMessage(err) });
1993
+ }
1994
+ }
1458
1995
  if (req.method === "GET" && path === "/workspaces") {
1459
- const sessionCreatedAt = Object.fromEntries(adapter.cachedSessionHeaders()
1996
+ const cachedHeaders = adapter.cachedSessionHeaders();
1997
+ const sessionCreatedAt = Object.fromEntries(cachedHeaders
1460
1998
  .filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
1461
1999
  .map(header => [header.id, header.createdAt]));
1462
- return send(res, 200, { ok: true, result: { workspaces: listWorkspaces(), sessionCreatedAt } });
2000
+ const sessionUpdatedAt = Object.fromEntries(cachedHeaders
2001
+ .filter(header => header && (typeof header.updatedAt === "number" || typeof header.updatedAt === "string" || typeof header.lastModified === "number"))
2002
+ .map(header => [header.id, header.updatedAt ?? header.lastModified]));
2003
+ let workspaceArchivesMap = {};
2004
+ try {
2005
+ workspaceArchivesMap = await workspaceArchives().getAll();
2006
+ } catch (_) {}
2007
+ return send(res, 200, { ok: true, result: { workspaces: listWorkspaces(), sessionCreatedAt, sessionUpdatedAt, workspaceArchives: workspaceArchivesMap } });
1463
2008
  }
1464
2009
  if (req.method === "GET" && path === "/preset-scan") {
1465
2010
  const sessionId = url.searchParams.get("sessionId") || "";
@@ -1468,7 +2013,7 @@ ${rest}`, "utf8");
1468
2013
  return send(res, 200, { ok: true, result });
1469
2014
  }
1470
2015
  if (req.method === "GET" && path === "/update/check") {
1471
- const currentVersion = adapter.pluginVersion() || "0.6.4";
2016
+ const currentVersion = adapter.pluginVersion() || "0.7.1";
1472
2017
  let latestVersion = currentVersion;
1473
2018
  let hasUpdate = false;
1474
2019
  try {
@@ -1553,7 +2098,14 @@ ${rest}`, "utf8");
1553
2098
  const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
1554
2099
  : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
1555
2100
  : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
1556
- return send(res, status, { ok: false, error: safeErrorMessage(error), ...(typeof error?.code === "string" ? { code: error.code } : {}) });
2101
+ return send(res, status, {
2102
+ ok: false,
2103
+ error: safeErrorMessage(error),
2104
+ ...(typeof error?.code === "string" ? { code: error.code } : {}),
2105
+ ...(Array.isArray(error?.deletedSubagentIds) ? { deletedSubagents: error.deletedSubagentIds } : {}),
2106
+ ...(Array.isArray(error?.remainingSessionIds) ? { remainingSessions: error.remainingSessionIds } : {}),
2107
+ ...(typeof error?.partialFailure === "boolean" ? { partialFailure: error.partialFailure } : {})
2108
+ });
1557
2109
  }
1558
2110
  }
1559
2111
  }), "session-manager: http api");