dsh-session-manager 0.7.0 → 0.7.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 CHANGED
@@ -22,14 +22,13 @@
22
22
  * The compatibility adapter normalizes service access and list snapshots.
23
23
  * Optional live-writer/coordinator internals are guarded before mutation.
24
24
  */
25
- import { statSync } from "node:fs";
26
25
  import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
27
26
  import { basename, dirname, join } from "node:path";
28
27
  import { randomBytes } from "node:crypto";
29
28
  import { createAnnotationStore } from "./annotation-store.js";
30
29
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
31
30
  import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
32
- import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
+ import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
33
32
 
34
33
  export const name = "dsh-session-manager";
35
34
 
@@ -126,10 +125,16 @@ const scanSessionHeaders = async (persistence, dshHome) => {
126
125
  }
127
126
  const result = () => ({ headers, complete: listed && errors.length === 0, errors });
128
127
  // Skip known files before fopen; retain the orphan/raw-id compatibility path.
128
+ // Only add canonical session directories that do not start and end with "--"
129
+ // to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
129
130
  const knownDirs = new Set();
130
131
  for (const id of seen) {
131
- knownDirs.add(id);
132
- knownDirs.add(encodeSessionSegment(id));
132
+ try {
133
+ const canonical = encodeFsSessionSegment(id);
134
+ if (!canonical.startsWith("--") || !canonical.endsWith("--")) {
135
+ knownDirs.add(canonical);
136
+ }
137
+ } catch (_) {}
133
138
  }
134
139
  if (typeof dshHome !== "function") return result();
135
140
  const root = dshHome("sessions");
@@ -151,7 +156,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
151
156
  for (const filename of ARTIFACT_NAMES) {
152
157
  try {
153
158
  const header = await readSessionHeader(join(root, proj.name, entry.name, filename));
154
- if (![header.id, encodeSessionSegment(header.id)].includes(entry.name)) {
159
+ if (!candidateSessionDirs(header.id).includes(entry.name)) {
155
160
  throw new Error("会话目录与 header id 不一致");
156
161
  }
157
162
  if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
@@ -171,7 +176,7 @@ const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHea
171
176
 
172
177
  export function apply(ctx) {
173
178
  try {
174
- const buildId = "dsh-session-manager v0.7.0 (workspace-archive)";
179
+ const buildId = "dsh-session-manager v0.7.2 (workspace-archive)";
175
180
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
176
181
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
177
182
  } catch (_) { /* ignore logging failures */ }
@@ -200,6 +205,9 @@ export function apply(ctx) {
200
205
  }
201
206
  return workspaceArchiveStore;
202
207
  };
208
+ const safeGetAgent = (sessionId) => {
209
+ try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
210
+ };
203
211
  const assertAnnotatable = id => {
204
212
  const registry = adapter.workspaceRegistry;
205
213
  if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
@@ -308,7 +316,7 @@ export function apply(ctx) {
308
316
 
309
317
  if (!stopActivity) {
310
318
  const liveSession = ctx.sessions?.get?.(sessionId);
311
- const liveAgent = ctx.agents?.get?.(sessionId);
319
+ const liveAgent = safeGetAgent(sessionId);
312
320
  const activeTurn = liveSession?.activeTurn;
313
321
  const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
314
322
  if (activeTurn !== undefined || agentRunning) {
@@ -380,13 +388,7 @@ export function apply(ctx) {
380
388
  const rawIds = Array.isArray(entity.record?.sessionIds)
381
389
  ? [...entity.record.sessionIds]
382
390
  : [...entity.sessionIds];
383
- let selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
384
- if (selfUpdatedAt === null && entity.path) {
385
- try {
386
- const st = statSync(entity.path);
387
- selfUpdatedAt = st.mtimeMs;
388
- } catch (_) {}
389
- }
391
+ const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
390
392
  return {
391
393
  id: entity.id,
392
394
  name: entity.title || entity.id,
@@ -469,6 +471,178 @@ ${rest}`, "utf8");
469
471
  return undefined;
470
472
  };
471
473
 
474
+ const MAX_BOOT_REFOLD_COUNT = 50;
475
+ const MAX_BOOT_REFOLD_MS = 2000;
476
+ const MAX_SINGLE_ARTIFACT_BYTES = 5 * 1024 * 1024; // 5 MB
477
+
478
+ const getSessionSizeBytes = async (sessionId, fallbackPath) => {
479
+ if (typeof adapter.statSession === "function") {
480
+ try {
481
+ const stat = await adapter.statSession(sessionId);
482
+ const size = stat?.sizeBytes ?? stat?.size;
483
+ if (typeof size === "number") return size;
484
+ } catch { /* proceed */ }
485
+ }
486
+ try {
487
+ const resolvedPath = fallbackPath ?? (await adapter.resolveSessionPath?.(sessionId));
488
+ if (typeof resolvedPath === "string") {
489
+ const fileStat = await lstat(resolvedPath);
490
+ return fileStat.size;
491
+ }
492
+ } catch { /* proceed */ }
493
+ return undefined;
494
+ };
495
+
496
+ /**
497
+ * Refold the projection cache for a session using properly decoded/migrated logical events.
498
+ * Prioritizes the official persistence interface (openSessionRead) to obtain logical events
499
+ * (expanding packed rows and handling generational migrations). Falls back to disk reading
500
+ * only when the official interface is unavailable, strictly verifying continuous logical seq
501
+ * and safe JSON parsing without swallowing corruption. (Issue #32)
502
+ */
503
+ const refoldSessionProjection = async ({
504
+ sessionId,
505
+ header,
506
+ movedArtifactRest,
507
+ isThreeArgCache,
508
+ cache: cacheParam,
509
+ liveSession: liveSessionParam,
510
+ caller = "boot"
511
+ }) => {
512
+ const cache = cacheParam ?? (ctx.get("sessionProjectionCache") ?? adapter.projectionCache);
513
+ if (typeof cache?.coldSnapshot !== "function") return false;
514
+ const isThreeArg = isThreeArgCache ?? (typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3);
515
+
516
+ // Check file size budget across all reconstruction paths before any decoding/reading
517
+ let artifactSize = await getSessionSizeBytes(sessionId);
518
+ if (typeof movedArtifactRest === "string") {
519
+ const restBytes = Buffer.byteLength(movedArtifactRest);
520
+ artifactSize = artifactSize !== undefined ? Math.max(artifactSize, restBytes) : restBytes;
521
+ }
522
+ if (artifactSize !== undefined && artifactSize > MAX_SINGLE_ARTIFACT_BYTES) {
523
+ ctx.logger.info(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for large artifact "${sessionId}" (${artifactSize} bytes)`);
524
+ return false;
525
+ }
526
+
527
+ let events = null;
528
+ let inheritedEventCount = undefined;
529
+ let effectiveHeader = header;
530
+
531
+ // Priority 1: Official persistence handle via adapter.openSessionRead
532
+ const hasOfficialReader = typeof adapter.persistence?.open === "function";
533
+
534
+ if (hasOfficialReader) {
535
+ let handle = null;
536
+ try {
537
+ handle = await adapter.openSessionRead(sessionId);
538
+ if (handle && typeof handle.read === "function") {
539
+ try {
540
+ const readResult = await handle.read(0);
541
+ const handleEvents = Array.isArray(readResult) ? readResult : readResult?.events;
542
+ if (Array.isArray(handleEvents)) {
543
+ // Verify all events are contiguous logical events
544
+ if (handleEvents.length === 0 || handleEvents.every((e, idx) => e && typeof e === "object" && e.seq === idx)) {
545
+ events = handleEvents;
546
+ inheritedEventCount = handle.inheritedEventCount;
547
+ if (handle.header && typeof handle.header === "object") {
548
+ effectiveHeader = { ...effectiveHeader, ...handle.header };
549
+ }
550
+ } else {
551
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official handle returned non-contiguous logical events`);
552
+ return false;
553
+ }
554
+ } else {
555
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official handle returned empty or invalid events`);
556
+ return false;
557
+ }
558
+ } catch (readErr) {
559
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official read/migration failed: ${safeErrorMessage(readErr)}`);
560
+ return false;
561
+ }
562
+ }
563
+ } catch (err) {
564
+ const msg = safeErrorMessage(err);
565
+ if (!msg.includes("unavailable")) {
566
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": official read/migration failed: ${msg}`);
567
+ return false;
568
+ }
569
+ } finally {
570
+ if (typeof handle?.close === "function") {
571
+ try { await handle.close(); } catch { /* best-effort */ }
572
+ }
573
+ }
574
+ }
575
+
576
+ // Priority 2: Fallback to reading disk artifact or moved rest ONLY when official API is unavailable or handle lacks read
577
+ if (events === null) {
578
+ let rest = movedArtifactRest;
579
+ if (typeof rest !== "string") {
580
+ const raw = await readSessionArtifact(sessionId, undefined, { storedHeader: effectiveHeader });
581
+ if (!raw || typeof raw.content !== "string") return false;
582
+ const newlineAt = raw.content.indexOf("\n");
583
+ rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
584
+ }
585
+
586
+ if (typeof rest === "string" && rest.length > 0) {
587
+ const rawEvents = [];
588
+ let parseFailed = false;
589
+ for (const line of rest.split("\n")) {
590
+ if (!line.trim()) continue;
591
+ try {
592
+ rawEvents.push(JSON.parse(line));
593
+ } catch {
594
+ parseFailed = true;
595
+ break;
596
+ }
597
+ }
598
+
599
+ if (parseFailed) {
600
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for "${sessionId}": JSON parse error in artifact events`);
601
+ return false;
602
+ }
603
+
604
+ // Strictly check that all rows are contiguous logical events with valid seq starting at 0.
605
+ // Physical rows without seq (like packed tool-call-chunks) MUST NOT be passed to coldSnapshot.
606
+ const isContiguous = rawEvents.length === 0 || rawEvents.every((e, idx) => e && typeof e === "object" && e.seq === idx);
607
+ if (!isContiguous) {
608
+ 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`);
609
+ return false;
610
+ }
611
+
612
+ events = rawEvents;
613
+ inheritedEventCount = await resolveInheritedEventCount({
614
+ sessionId,
615
+ header: effectiveHeader,
616
+ events,
617
+ persistence: adapter.persistence,
618
+ liveSession: liveSessionParam ?? adapter.getSession(sessionId)
619
+ });
620
+ }
621
+ }
622
+
623
+ if (events !== null) {
624
+ if (effectiveHeader?.isSeeded && inheritedEventCount === undefined) {
625
+ ctx.logger.warn(`session-manager: skipping ${caller === "post-move" ? "post-move cache refresh" : "cache refold"} for seeded session "${sessionId}": cannot determine inheritedEventCount`);
626
+ return false;
627
+ }
628
+ if (isThreeArg) {
629
+ await cache.coldSnapshot(effectiveHeader, inheritedEventCount, events);
630
+ } else {
631
+ try {
632
+ await cache.coldSnapshot(effectiveHeader.id);
633
+ } catch (error) {
634
+ if (String(error).includes("SessionLogOffset") && effectiveHeader) {
635
+ await cache.coldSnapshot(effectiveHeader, inheritedEventCount, events);
636
+ } else {
637
+ throw error;
638
+ }
639
+ }
640
+ }
641
+ return true;
642
+ }
643
+ return false;
644
+ };
645
+
472
646
  /**
473
647
  * TRUE cross-workspace move. See the module docstring for the full design:
474
648
  * re-home the stored cwd, migrate the artifact, then swap accounting.
@@ -587,17 +761,13 @@ ${rest}`, "utf8");
587
761
  if (typeof oldPath !== "string" || newLocation === void 0) {
588
762
  throw new Error("持久化后端无法定位会话工件路径");
589
763
  }
590
- const newPath = newLocation.path;
591
- // Keep the stored generation in sync with the filename selected by
592
- // persistence.locate(). persistence.locate() always returns the
593
- // SESSION_FORMAT_VERSION path (DSH 0.1.7+ ships v4 by default),
594
- // regardless of oldHeader.version. Without the stamp below, the
595
- // rewritten file is `session.v4.jsonl.zstd` carrying a v0/v3 header,
596
- // which JsonlSessionPersistence.listArtifacts rejects on the next
597
- // startup (`session generation filename identifies v4, but its
598
- // header identifies v0`), breaking persistence.list() and crashing
599
- // the workspace registry while it boots. (issue #18)
600
- const expectedVersion = parseSessionGenerationFromFilename(basename(newPath));
764
+ // For live sessions, the active writer targets the latest generation returned by persistence.locate().
765
+ // For cold sessions, preserve the source artifact's filename and generation to avoid producing an
766
+ // artifact claiming a newer format version without its event contents being migrated. (Issue #32)
767
+ const targetFilename = liveSession !== void 0 ? basename(newLocation.path) : (raw.filename ?? basename(oldPath));
768
+ const targetDir = dirname(newLocation.path);
769
+ const newPath = join(targetDir, targetFilename);
770
+ const expectedVersion = parseSessionGenerationFromFilename(targetFilename);
601
771
  if (typeof expectedVersion === "number") newHeader.version = expectedVersion;
602
772
  const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
603
773
  await assertSessionDirectory(root, dirname(oldPath), sessionId);
@@ -727,36 +897,15 @@ ${rest}`, "utf8");
727
897
  if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
728
898
  try {
729
899
  const isThreeArg = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
730
- const header = movedArtifact?.header;
731
- const events = [];
732
- if (typeof movedArtifact?.rest === "string") {
733
- for (const line of movedArtifact.rest.split("\n")) {
734
- if (!line.trim()) continue;
735
- try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
736
- }
737
- }
738
- const inheritedEventCount = await resolveInheritedEventCount({
900
+ await refoldSessionProjection({
739
901
  sessionId,
740
- header,
741
- events,
742
- persistence,
743
- liveSession
902
+ header: movedArtifact?.header,
903
+ movedArtifactRest: movedArtifact?.rest,
904
+ isThreeArgCache: isThreeArg,
905
+ cache,
906
+ liveSession,
907
+ caller: "post-move"
744
908
  });
745
- if (header?.isSeeded && inheritedEventCount === undefined) {
746
- ctx.logger.warn(`session-manager: skipping post-move cache refresh for seeded session "${sessionId}": cannot determine inheritedEventCount`);
747
- } else if (isThreeArg) {
748
- await cache.coldSnapshot(header, inheritedEventCount, events);
749
- } else {
750
- try {
751
- await cache.coldSnapshot(sessionId);
752
- } catch (error) {
753
- if (String(error).includes("SessionLogOffset") && header) {
754
- await cache.coldSnapshot(header, inheritedEventCount, events);
755
- } else {
756
- throw error;
757
- }
758
- }
759
- }
760
909
  } catch (error) {
761
910
  ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
762
911
  }
@@ -1109,14 +1258,19 @@ ${rest}`, "utf8");
1109
1258
  catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
1110
1259
  for (const proj of projects) {
1111
1260
  if (!proj.isDirectory() || proj.isSymbolicLink()) continue;
1112
- for (const candidate of [encodeSessionSegment(sessionId), sessionId]) {
1261
+ for (const candidate of candidateSessionDirs(sessionId)) {
1113
1262
  const dir = join(root, proj.name, candidate);
1114
1263
  try { await assertSessionDirectory(root, dir, sessionId); }
1115
1264
  catch (error) { if (error.code === "ENOENT") continue; throw error; }
1116
1265
  for (const filename of ARTIFACT_NAMES) {
1117
1266
  try {
1118
1267
  const artifact = await read(join(dir, filename));
1119
- if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
1268
+ if (artifact.meta.id !== sessionId) {
1269
+ if (candidate === candidateSessionDirs(sessionId)[0]) {
1270
+ throw new Error("会话工件 header id 与请求不一致");
1271
+ }
1272
+ break;
1273
+ }
1120
1274
  return artifact;
1121
1275
  } catch (error) { if (error.code !== "ENOENT") throw error; }
1122
1276
  }
@@ -1125,98 +1279,326 @@ ${rest}`, "utf8");
1125
1279
  return undefined;
1126
1280
  };
1127
1281
 
1128
- const sessionDirOf = async (sessionId) => {
1282
+ const sessionDirOf = async (sessionId, storedHeader) => {
1129
1283
  // Require a matching artifact header instead of deleting an arbitrary
1130
1284
  // directory merely because its name matches user input.
1131
- const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
1285
+ const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true, storedHeader });
1132
1286
  return artifact === undefined ? undefined : dirname(artifact.path);
1133
1287
  };
1134
1288
 
1135
1289
  /**
1136
- * Delete one session end to end. Returns a summary of what was torn down.
1137
- * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
1290
+ * Enumerate all descendant subagent session headers for one or more root session IDs.
1291
+ * Recursively traverses subagent relations (origin === "subagent" && parentSession === parentId).
1292
+ * Normal fork sessions (origin !== "subagent") are strictly ignored.
1293
+ * Only returns verified local sessions with valid directories or live runtime state.
1138
1294
  */
1139
- const deleteSession = async (sessionId) => {
1140
- assertSessionId(sessionId);
1141
- // Resolve/validate before cancelling the agent or changing registry state.
1142
- let dir = await sessionDirOf(sessionId);
1143
- const session = ctx.sessions.get(sessionId);
1144
- // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
1145
- let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
1146
-
1147
- if (agent !== void 0) {
1148
- // Stop any running turn (disposed-kind suppresses re-wake).
1149
- agent.cancel({ kind: "disposed" });
1150
- // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
1151
- if (typeof agent.scope?.dispose === "function") {
1152
- await Promise.race([agent.scope.dispose(), sleep(3000)]);
1153
- }
1154
- // Drop the zombie from the registry so a later session.create/open with
1155
- // the same id cannot resurrect it.
1156
- try {
1157
- ctx.agents.store?.delete?.(sessionId);
1158
- } catch { /* best-effort */ }
1295
+ const findDescendantSubagents = async (rootSessionIds) => {
1296
+ const roots = Array.from(new Set((rootSessionIds || []).filter(id => typeof id === "string" && id.trim() !== "")));
1297
+ if (roots.length === 0) return [];
1298
+ for (const id of roots) assertSessionId(id);
1299
+
1300
+ const headerMap = new Map();
1301
+ try {
1302
+ const cached = adapter.cachedSessionHeaders?.();
1303
+ if (Array.isArray(cached)) {
1304
+ for (const h of cached) if (h?.id) headerMap.set(h.id, h);
1305
+ }
1306
+ } catch { /* proceed */ }
1307
+
1308
+ try {
1309
+ const live = adapter.sessions?.list?.();
1310
+ if (Array.isArray(live)) {
1311
+ for (const s of live) if (s?.header?.id) headerMap.set(s.header.id, s.header);
1312
+ }
1313
+ } catch { /* proceed */ }
1314
+
1315
+ const scanResult = await scanSessionHeaders(adapter.persistence, adapter.homePath);
1316
+ if (!scanResult || !scanResult.complete || (Array.isArray(scanResult.errors) && scanResult.errors.length > 0)) {
1317
+ const errSummary = (scanResult?.errors || []).map(safeErrorMessage).join("; ") || "扫描未完成";
1318
+ throw new Error("扫描子智能体会话失败 (元数据不完整): " + errSummary);
1319
+ }
1320
+ if (Array.isArray(scanResult.headers)) {
1321
+ for (const h of scanResult.headers) {
1322
+ if (h?.id) {
1323
+ headerMap.set(h.id, { ...(headerMap.get(h.id) || {}), ...h });
1324
+ }
1325
+ }
1159
1326
  }
1160
1327
 
1161
- let detached = false;
1162
- if (session !== void 0) {
1163
- // Flush buffered events to disk first so the retirement drain is a no-op.
1164
- try {
1165
- await ctx.sessions.flush(session);
1166
- } catch { /* best-effort */ }
1167
- // Detach the session store entry: emits session/disposed, which the
1168
- // persistence write-path answers with a final drain, and the API proxy
1169
- // relays as host/session-removed so every connected client drops the row.
1170
- try {
1171
- const entry = ctx.sessions.store?.get?.(sessionId);
1172
- if (entry !== void 0 && typeof entry.detach === "function") {
1173
- entry.detach();
1174
- await sleep(200); // let the write-behind retirement settle
1175
- detached = true;
1328
+ const allHeaders = [...headerMap.values()];
1329
+ const visited = new Set(roots);
1330
+ const descendants = [];
1331
+ let currentParents = new Set(roots);
1332
+
1333
+ while (currentParents.size > 0) {
1334
+ const nextParents = new Set();
1335
+ for (const header of allHeaders) {
1336
+ if (!header || !header.id || !header.parentSession) continue;
1337
+ if (header.origin === "subagent" && currentParents.has(header.parentSession)) {
1338
+ if (!visited.has(header.id)) {
1339
+ visited.add(header.id);
1340
+ nextParents.add(header.id);
1341
+ descendants.push(header);
1342
+ }
1176
1343
  }
1177
- } catch { /* best-effort */ }
1344
+ }
1345
+ currentParents = nextParents;
1178
1346
  }
1179
- // Some sessions have a stored artifact but no live store row (e.g.
1180
- // already-scoped scope torn down, or never attached). Without a live
1181
- // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
1182
- // entry.detach() never fires — so the apiproxy never queues a
1183
- // host/session-removed frame, every connected client keeps showing the
1184
- // session row, and the row collapses into the ungrouped section the
1185
- // moment host/workspace-changed drops its workspace membership. Emit
1186
- // session/disposed explicitly so every connected client drops the row.
1187
- if (!detached) {
1347
+
1348
+ const verifiedDescendants = [];
1349
+ for (const sub of descendants) {
1350
+ assertSessionId(sub.id);
1351
+ const dir = await sessionDirOf(sub.id, sub);
1352
+ const isLive = ctx.sessions?.get?.(sub.id) !== undefined || safeGetAgent(sub.id) !== undefined;
1353
+ if (dir !== undefined || isLive) {
1354
+ verifiedDescendants.push({ ...sub, dir });
1355
+ }
1356
+ }
1357
+
1358
+ return verifiedDescendants;
1359
+ };
1360
+
1361
+ /**
1362
+ * Safety check and activity stop for a session before deletion.
1363
+ * If running, attempts safe stop via cancel, dispose, and activity stop interfaces.
1364
+ * If it cannot be safely stopped, rejects deletion.
1365
+ */
1366
+ const assertAndStopActiveSession = async (sessionId, isSubagent = false) => {
1367
+ assertSessionId(sessionId);
1368
+ const liveSession = ctx.sessions?.get?.(sessionId);
1369
+ const liveAgent = safeGetAgent(sessionId);
1370
+
1371
+ const activeTurn = liveSession?.activeTurn;
1372
+ const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
1373
+ let waterfallActivities = [];
1374
+ if (typeof ctx.waterfall === "function") {
1188
1375
  try {
1189
- ctx.emit("session/disposed", { id: sessionId });
1190
- } catch (_) { /* best-effort */ }
1376
+ waterfallActivities = await ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
1377
+ } catch (err) {
1378
+ throw new Error(`无法检查会话 '${sessionId}' 的活动状态: ${safeErrorMessage(err)}`);
1379
+ }
1191
1380
  }
1192
1381
 
1193
- // A blank live session may have materialized its first artifact during
1194
- // flush/retirement. Re-resolve after teardown rather than leaking that file.
1195
- dir = await sessionDirOf(sessionId);
1382
+ const isActive = activeTurn !== undefined || agentRunning || (Array.isArray(waterfallActivities) && waterfallActivities.length > 0);
1383
+ if (!isActive) return;
1196
1384
 
1197
- // Workspace accounting + archive-set membership.
1198
- await detachFromWorkspaces(sessionId);
1199
- await unarchiveSession(sessionId);
1385
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
1386
+ const hasAgent = liveAgent !== void 0;
1387
+ const hasStopFn = typeof registry?.stopSessionActivity === "function" || typeof ctx.parallel === "function" || typeof liveAgent?.cancel === "function";
1200
1388
 
1201
- // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1202
- if (dir !== void 0) {
1203
- await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
1204
- await rm(dir, { recursive: true, force: true });
1389
+ if (!hasStopFn) {
1390
+ const label = isSubagent ? `子智能体 '${sessionId}'` : `会话 '${sessionId}'`;
1391
+ throw new Error(`无法安全停止运行中的${label}: 没有可用的停止接口`);
1205
1392
  }
1206
1393
 
1207
- let warning;
1208
- try { await annotations().remove(sessionId); }
1209
- catch (error) {
1210
- warning = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
1211
- ctx.logger.warn("session-manager: " + warning);
1394
+ if (hasAgent) {
1395
+ if (typeof liveAgent.cancel === "function") {
1396
+ liveAgent.cancel({ kind: "disposed" });
1397
+ }
1398
+ if (typeof liveAgent.scope?.dispose === "function") {
1399
+ await Promise.race([liveAgent.scope.dispose(), sleep(3000)]);
1400
+ }
1401
+ }
1402
+
1403
+ if (typeof registry?.stopSessionActivity === "function") {
1404
+ try { await registry.stopSessionActivity(sessionId); }
1405
+ catch (err) {
1406
+ throw new Error(`停止会话 '${sessionId}' 活动失败: ${safeErrorMessage(err)}`);
1407
+ }
1408
+ } else if (typeof ctx.parallel === "function") {
1409
+ try { await ctx.parallel("workspace/session-stop", { sessionId }); }
1410
+ catch (err) {
1411
+ throw new Error(`停止会话 '${sessionId}' 活动失败: ${safeErrorMessage(err)}`);
1412
+ }
1413
+ }
1414
+
1415
+ const postAgent = safeGetAgent(sessionId);
1416
+ if (postAgent?.status === "running" || postAgent?.busy === true) {
1417
+ const label = isSubagent ? `子智能体 '${sessionId}'` : `会话 '${sessionId}'`;
1418
+ throw new Error(`${label} 仍在运行中,无法安全停止`);
1419
+ }
1420
+ };
1421
+
1422
+ /**
1423
+ * Delete one session end to end. Returns a summary of what was torn down.
1424
+ * Recursively cascades to all descendant subagents (origin === "subagent").
1425
+ * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
1426
+ */
1427
+ const deleteSession = async (sessionId, options = {}) => {
1428
+ assertSessionId(sessionId);
1429
+
1430
+ let deletedSubagentIds = [];
1431
+ const cleanupWarnings = [];
1432
+
1433
+ if (!options.skipSubagents) {
1434
+ // 1. Discover all descendant subagents (verifying scan completeness)
1435
+ const descendants = await findDescendantSubagents([sessionId]);
1436
+
1437
+ // 2. Pre-delete validation for BOTH main session and all descendants BEFORE any deletion
1438
+ // 2.1 Identity validation
1439
+ assertSessionId(sessionId);
1440
+ for (const sub of descendants) {
1441
+ assertSessionId(sub.id);
1442
+ }
1443
+
1444
+ // 2.2 Directory validation (prevent traversal / outside directory issues)
1445
+ const mainDir = await sessionDirOf(sessionId);
1446
+ if (mainDir !== undefined) {
1447
+ await assertSessionDirectory(adapter.homePath("sessions"), mainDir, sessionId);
1448
+ }
1449
+ for (const sub of descendants) {
1450
+ const subDir = sub.dir ?? (await sessionDirOf(sub.id, sub));
1451
+ if (subDir !== undefined) {
1452
+ await assertSessionDirectory(adapter.homePath("sessions"), subDir, sub.id);
1453
+ }
1454
+ }
1455
+
1456
+ // 2.3 Activity validation and safe stop
1457
+ for (const sub of descendants) {
1458
+ await assertAndStopActiveSession(sub.id, true);
1459
+ }
1460
+ await assertAndStopActiveSession(sessionId, false);
1461
+
1462
+ // 3. Deletion order: descendant-first (reverse), parent-last
1463
+ const deletePlan = [...descendants].reverse();
1464
+ const remainingSubagentIds = descendants.map(d => d.id);
1465
+
1466
+ for (const sub of deletePlan) {
1467
+ try {
1468
+ const subResult = await mutate(sub.id, async () => {
1469
+ return await deleteSession(sub.id, { skipSubagents: true });
1470
+ });
1471
+ deletedSubagentIds.push(sub.id);
1472
+ const rIdx = remainingSubagentIds.indexOf(sub.id);
1473
+ if (rIdx !== -1) remainingSubagentIds.splice(rIdx, 1);
1474
+
1475
+ if (Array.isArray(subResult?.warnings)) {
1476
+ cleanupWarnings.push(...subResult.warnings);
1477
+ } else if (subResult?.warning) {
1478
+ cleanupWarnings.push(subResult.warning);
1479
+ }
1480
+
1481
+ try {
1482
+ await workspaceArchives().removeSession(sub.id);
1483
+ } catch (archErr) {
1484
+ cleanupWarnings.push(`子智能体 '${sub.id}' 归档记录清理失败: ${safeErrorMessage(archErr)}`);
1485
+ }
1486
+ } catch (subErr) {
1487
+ const err = new Error(`删除子智能体 '${sub.id}' 失败: ${safeErrorMessage(subErr)}`);
1488
+ err.deletedSubagentIds = [...deletedSubagentIds];
1489
+ err.remainingSessionIds = [sessionId, ...remainingSubagentIds];
1490
+ err.partialFailure = deletedSubagentIds.length > 0;
1491
+ if (subErr?.code) err.code = subErr.code;
1492
+ throw err;
1493
+ }
1494
+ }
1495
+ } else {
1496
+ await assertAndStopActiveSession(sessionId, true);
1497
+ }
1498
+
1499
+ try {
1500
+ // Resolve/validate before cancelling the agent or changing registry state.
1501
+ let dir = await sessionDirOf(sessionId);
1502
+ const session = ctx.sessions?.get?.(sessionId);
1503
+ let agent = safeGetAgent(sessionId);
1504
+
1505
+ if (agent !== void 0) {
1506
+ // Stop any running turn (disposed-kind suppresses re-wake).
1507
+ agent.cancel({ kind: "disposed" });
1508
+ // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
1509
+ if (typeof agent.scope?.dispose === "function") {
1510
+ await Promise.race([agent.scope.dispose(), sleep(3000)]);
1511
+ }
1512
+ // Drop the zombie from the registry so a later session.create/open with
1513
+ // the same id cannot resurrect it.
1514
+ try {
1515
+ ctx.agents?.store?.delete?.(sessionId);
1516
+ } catch { /* best-effort */ }
1517
+ }
1518
+
1519
+ let detached = false;
1520
+ if (session !== void 0) {
1521
+ // Flush buffered events to disk first so the retirement drain is a no-op.
1522
+ try {
1523
+ await ctx.sessions?.flush?.(session);
1524
+ } catch { /* best-effort */ }
1525
+ // Detach the session store entry: emits session/disposed, which the
1526
+ // persistence write-path answers with a final drain, and the API proxy
1527
+ // relays as host/session-removed so every connected client drops the row.
1528
+ try {
1529
+ const entry = ctx.sessions?.store?.get?.(sessionId);
1530
+ if (entry !== void 0 && typeof entry.detach === "function") {
1531
+ entry.detach();
1532
+ await sleep(200); // let the write-behind retirement settle
1533
+ detached = true;
1534
+ }
1535
+ } catch { /* best-effort */ }
1536
+ }
1537
+ // Some sessions have a stored artifact but no live store row (e.g.
1538
+ // already-scoped scope torn down, or never attached). Without a live
1539
+ // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
1540
+ // entry.detach() never fires — so the apiproxy never queues a
1541
+ // host/session-removed frame, every connected client keeps showing the
1542
+ // session row, and the row collapses into the ungrouped section the
1543
+ // moment host/workspace-changed drops its workspace membership. Emit
1544
+ // session/disposed explicitly so every connected client drops the row.
1545
+ if (!detached) {
1546
+ try {
1547
+ ctx.emit("session/disposed", { id: sessionId });
1548
+ } catch (_) { /* best-effort */ }
1549
+ }
1550
+
1551
+ // A blank live session may have materialized its first artifact during
1552
+ // flush/retirement. Re-resolve after teardown rather than leaking that file.
1553
+ dir = await sessionDirOf(sessionId);
1554
+
1555
+ // Workspace accounting + archive-set membership.
1556
+ await detachFromWorkspaces(sessionId);
1557
+ await unarchiveSession(sessionId);
1558
+
1559
+ // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1560
+ if (dir !== void 0) {
1561
+ await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
1562
+ await rm(dir, { recursive: true, force: true });
1563
+ }
1564
+
1565
+ try {
1566
+ await annotations().remove(sessionId);
1567
+ } catch (error) {
1568
+ const warn = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
1569
+ cleanupWarnings.push(warn);
1570
+ ctx.logger.warn("session-manager: " + warn);
1571
+ }
1572
+
1573
+ try {
1574
+ await workspaceArchives().removeSession(sessionId);
1575
+ } catch (error) {
1576
+ const warn = "会话已删除,但工作区归档记录清理失败: " + safeErrorMessage(error);
1577
+ cleanupWarnings.push(warn);
1578
+ ctx.logger.warn("session-manager: " + warn);
1579
+ }
1580
+
1581
+ const aggregatedWarning = cleanupWarnings.length > 0 ? cleanupWarnings.join("; ") : undefined;
1582
+
1583
+ return {
1584
+ ok: true,
1585
+ ...(aggregatedWarning ? { warning: aggregatedWarning, warnings: cleanupWarnings } : {}),
1586
+ sessionId,
1587
+ wasLive: session !== void 0 || agent !== void 0,
1588
+ filesRemoved: dir !== void 0,
1589
+ deletedSubagents: deletedSubagentIds,
1590
+ subagentCount: deletedSubagentIds.length
1591
+ };
1592
+ } catch (parentErr) {
1593
+ const err = deletedSubagentIds.length > 0
1594
+ ? new Error(`删除主会话 '${sessionId}' 失败: ${safeErrorMessage(parentErr)}`)
1595
+ : parentErr;
1596
+ err.deletedSubagentIds = [...deletedSubagentIds];
1597
+ err.remainingSessionIds = [sessionId];
1598
+ err.partialFailure = deletedSubagentIds.length > 0;
1599
+ if (parentErr?.code && err !== parentErr) err.code = parentErr.code;
1600
+ throw err;
1212
1601
  }
1213
- return {
1214
- ok: true,
1215
- ...(warning ? { warning } : {}),
1216
- sessionId,
1217
- wasLive: session !== void 0 || agent !== void 0,
1218
- filesRemoved: dir !== void 0
1219
- };
1220
1602
  };
1221
1603
 
1222
1604
  /**
@@ -1230,11 +1612,20 @@ ${rest}`, "utf8");
1230
1612
  * pass-through refold, and a stale one triggers the unrelated-identity
1231
1613
  * full re-read path.
1232
1614
  */
1615
+ /**
1616
+ * On plugin boot:
1617
+ * 1. Reconcile workspace session membership using header metadata only.
1618
+ * This step is fast, safe, and completely separated from projection cache refolding. (Issue #32)
1619
+ * 2. Perform a budgeted background projection cache sweep for missing cache records,
1620
+ * prioritizing sessions in active workspaces, yielding to the event loop, and
1621
+ * safely skipping unmigrated physical rows or corrupt artifacts. (Issue #32)
1622
+ */
1233
1623
  ctx.effect(async () => {
1234
1624
  const cache = adapter.projectionCache;
1235
1625
  const persistence = adapter.persistence;
1236
1626
  const registry = adapter.workspaceRegistry;
1237
- if (typeof cache?.coldSnapshot !== "function" || typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1627
+ if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1628
+
1238
1629
  try {
1239
1630
  // Remember only ids that existed before the async scan. Concurrently
1240
1631
  // attached ids must never be removed using this older disk snapshot.
@@ -1246,55 +1637,10 @@ ${rest}`, "utf8");
1246
1637
  }
1247
1638
  const headers = scan.headers;
1248
1639
  const validIds = new Set(headers.map(header => header.id));
1249
- const isThreeArgCache = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
1250
- if (isThreeArgCache) {
1251
- for (const header of headers) {
1252
- try {
1253
- // Fast path: cachedSnapshot performs an in-memory lifecycle check (formatVersion, cwd, createdAt, isSeeded).
1254
- // If the cache record is already present and matches, skip disk I/O and projection fold.
1255
- if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
1256
- continue;
1257
- }
1258
- const raw = await readSessionArtifact(header.id, undefined, { storedHeader: header });
1259
- if (!raw || typeof raw.content !== "string") continue;
1260
- const newlineAt = raw.content.indexOf("\n");
1261
- const rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
1262
- const events = [];
1263
- if (rest) {
1264
- for (const line of rest.split("\n")) {
1265
- if (!line.trim()) continue;
1266
- try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
1267
- }
1268
- }
1269
- const inheritedEventCount = await resolveInheritedEventCount({
1270
- sessionId: header.id,
1271
- header,
1272
- events,
1273
- persistence,
1274
- liveSession: adapter.getSession(header.id)
1275
- });
1276
- if (header.isSeeded && inheritedEventCount === undefined) {
1277
- ctx.logger.warn('session-manager: skipping cache refold for seeded session "' + header.id + '": cannot determine inheritedEventCount');
1278
- continue;
1279
- }
1280
- await cache.coldSnapshot(header, inheritedEventCount, events);
1281
- } catch (error) {
1282
- ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
1283
- }
1284
- }
1285
- } else {
1286
- for (const header of headers) {
1287
- try { await cache.coldSnapshot(header.id); }
1288
- catch (error) {
1289
- if (String(error).includes("SessionLogOffset")) break;
1290
- ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
1291
- }
1292
- }
1293
- }
1640
+
1641
+ // Step 1: Immediate Workspace Member Reconciliation (isolated from projection rebuild)
1294
1642
  let removed = 0;
1295
1643
  let added = 0;
1296
- // Read current state *inside* its queue, and publish an immutable copy.
1297
- // A failed setState must not mutate the registry's live state object.
1298
1644
  await registry.enqueueOperation(async () => {
1299
1645
  const state = registry.requireState();
1300
1646
  const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
@@ -1316,9 +1662,68 @@ ${rest}`, "utf8");
1316
1662
  });
1317
1663
  if (removed || added) await registry.setState({ ...state, workspaces });
1318
1664
  });
1319
- if (removed || added) ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
1665
+ if (removed || added) {
1666
+ ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
1667
+ }
1668
+
1669
+ // Step 2: Budgeted Background Projection Cache Rebuild
1670
+ if (typeof cache?.coldSnapshot !== "function") return;
1671
+ const isThreeArgCache = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
1672
+
1673
+ const assignedIds = new Set(registry.list().flatMap(ws => ws.record?.sessionIds ?? []));
1674
+ const candidates = headers.filter(header => assignedIds.has(header.id) || headers.length <= MAX_BOOT_REFOLD_COUNT);
1675
+
1676
+ const startTime = Date.now();
1677
+ let attemptedCount = 0;
1678
+ let refoldCount = 0;
1679
+
1680
+ for (const header of candidates) {
1681
+ if (attemptedCount >= MAX_BOOT_REFOLD_COUNT || Date.now() - startTime > MAX_BOOT_REFOLD_MS) {
1682
+ ctx.logger.info(`session-manager: boot projection cache refold budget reached (${attemptedCount} attempted, ${refoldCount} refolded, ${candidates.length - attemptedCount} remaining deferred)`);
1683
+ break;
1684
+ }
1685
+
1686
+ try {
1687
+ if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
1688
+ continue;
1689
+ }
1690
+
1691
+ attemptedCount++;
1692
+
1693
+ // Check file size budget before any I/O
1694
+ const size = await getSessionSizeBytes(header.id);
1695
+ if (typeof size === "number" && size > MAX_SINGLE_ARTIFACT_BYTES) {
1696
+ ctx.logger.info(`session-manager: skipping boot cache refold for large artifact "${header.id}" (${size} bytes)`);
1697
+ continue;
1698
+ }
1699
+
1700
+ // Yield to event loop between sessions so other tasks/HTTP requests stay responsive
1701
+ await new Promise(resolve => setImmediate(resolve));
1702
+
1703
+ if (!isThreeArgCache) {
1704
+ try {
1705
+ await cache.coldSnapshot(header.id);
1706
+ refoldCount++;
1707
+ } catch (error) {
1708
+ if (String(error).includes("SessionLogOffset")) break;
1709
+ ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
1710
+ }
1711
+ continue;
1712
+ }
1713
+
1714
+ const ok = await refoldSessionProjection({
1715
+ sessionId: header.id,
1716
+ header,
1717
+ isThreeArgCache,
1718
+ cache
1719
+ });
1720
+ if (ok) refoldCount++;
1721
+ } catch (error) {
1722
+ ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
1723
+ }
1724
+ }
1320
1725
  } catch (error) {
1321
- ctx.logger.warn('session-manager: post-boot reconcile failed: ' + safeErrorMessage(error));
1726
+ ctx.logger.warn("session-manager: post-boot reconcile failed: " + safeErrorMessage(error));
1322
1727
  }
1323
1728
  }, "session-manager: post-boot re-fold stale projection cache rows");
1324
1729
 
@@ -1419,8 +1824,20 @@ ${rest}`, "utf8");
1419
1824
  assertSessionId(sessionId);
1420
1825
  let result;
1421
1826
  if (path === "/delete") {
1422
- result = await mutate(sessionId, () => deleteSession(sessionId));
1423
- try { await workspaceArchives().removeSession(sessionId); } catch (_) {}
1827
+ try {
1828
+ result = await mutate(sessionId, () => deleteSession(sessionId));
1829
+ try { await workspaceArchives().removeSession(sessionId); } catch (_) {}
1830
+ } catch (delErr) {
1831
+ const status = delErr?.code === "bad-request" ? 400 : 500;
1832
+ return send(res, status, {
1833
+ ok: false,
1834
+ error: safeErrorMessage(delErr),
1835
+ ...(typeof delErr?.code === "string" ? { code: delErr.code } : {}),
1836
+ deletedSubagents: Array.isArray(delErr?.deletedSubagentIds) ? delErr.deletedSubagentIds : [],
1837
+ remainingSessions: Array.isArray(delErr?.remainingSessionIds) ? delErr.remainingSessionIds : [sessionId],
1838
+ partialFailure: Boolean(delErr?.partialFailure)
1839
+ });
1840
+ }
1424
1841
  } else if (path === "/unarchive") {
1425
1842
  await mutate(sessionId, () => unarchiveSession(sessionId));
1426
1843
  result = { sessionId };
@@ -1492,21 +1909,66 @@ ${rest}`, "utf8");
1492
1909
  if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
1493
1910
  const items = [];
1494
1911
  let success = 0, failed = 0, skipped = 0;
1495
- for (const sessionId of ids) {
1496
- try {
1497
- await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
1498
- items.push({ sessionId, status: "success" });
1499
- success++;
1500
- } catch (error) {
1501
- const code = error && typeof error.code === "string" ? error.code : undefined;
1502
- if (code === "subagent-skip" || code === "blank-skip") {
1503
- items.push({ sessionId, status: "skipped", reason: code });
1504
- skipped++;
1505
- } else {
1506
- items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
1912
+ if (rawAction === "delete") {
1913
+ const deletedSet = new Set();
1914
+ for (const sessionId of ids) {
1915
+ if (deletedSet.has(sessionId)) {
1916
+ items.push({ sessionId, status: "success", cascaded: true, subagentCount: 0 });
1917
+ success++;
1918
+ continue;
1919
+ }
1920
+ try {
1921
+ const res = await mutate(sessionId, () => deleteSession(sessionId));
1922
+ deletedSet.add(sessionId);
1923
+ if (Array.isArray(res?.deletedSubagents)) {
1924
+ for (const subId of res.deletedSubagents) {
1925
+ deletedSet.add(subId);
1926
+ }
1927
+ }
1928
+ items.push({
1929
+ sessionId,
1930
+ status: "success",
1931
+ deletedSubagents: res?.deletedSubagents || [],
1932
+ subagentCount: res?.subagentCount || 0,
1933
+ warnings: res?.warnings || (res?.warning ? [res.warning] : [])
1934
+ });
1935
+ success++;
1936
+ } catch (error) {
1937
+ const code = error && typeof error.code === "string" ? error.code : undefined;
1938
+ if (Array.isArray(error?.deletedSubagentIds)) {
1939
+ for (const subId of error.deletedSubagentIds) {
1940
+ deletedSet.add(subId);
1941
+ }
1942
+ }
1943
+ items.push({
1944
+ sessionId,
1945
+ status: "failed",
1946
+ code,
1947
+ error: safeErrorMessage(error),
1948
+ deletedSubagents: error?.deletedSubagentIds || [],
1949
+ remainingSessions: error?.remainingSessionIds || [sessionId],
1950
+ partialFailure: Boolean(error?.partialFailure)
1951
+ });
1507
1952
  failed++;
1508
1953
  }
1509
1954
  }
1955
+ } else {
1956
+ for (const sessionId of ids) {
1957
+ try {
1958
+ await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
1959
+ items.push({ sessionId, status: "success" });
1960
+ success++;
1961
+ } catch (error) {
1962
+ const code = error && typeof error.code === "string" ? error.code : undefined;
1963
+ if (code === "subagent-skip" || code === "blank-skip") {
1964
+ items.push({ sessionId, status: "skipped", reason: code });
1965
+ skipped++;
1966
+ } else {
1967
+ items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
1968
+ failed++;
1969
+ }
1970
+ }
1971
+ }
1510
1972
  }
1511
1973
  return send(res, 200, { ok: true, result: {
1512
1974
  summary: { total: ids.length, success, failed, skipped },
@@ -1514,14 +1976,41 @@ ${rest}`, "utf8");
1514
1976
  aborted: false,
1515
1977
  } });
1516
1978
  }
1979
+ if (req.method === "GET" && path === "/delete-preview") {
1980
+ const rawId = url.searchParams.get("sessionId") || "";
1981
+ const sessionId = rawId.trim();
1982
+ if (sessionId) assertSessionId(sessionId);
1983
+ const rawIds = url.searchParams.get("sessionIds");
1984
+ let sessionIds = sessionId ? [sessionId] : [];
1985
+ if (rawIds) {
1986
+ const split = rawIds.split(",").map(s => s.trim()).filter(Boolean);
1987
+ for (const id of split) assertSessionId(id);
1988
+ sessionIds = Array.from(new Set([...sessionIds, ...split]));
1989
+ }
1990
+ if (sessionIds.length === 0) {
1991
+ return send(res, 400, { ok: false, error: "sessionId 或 sessionIds 必填" });
1992
+ }
1993
+ try {
1994
+ const descendants = await findDescendantSubagents(sessionIds);
1995
+ return send(res, 200, {
1996
+ ok: true,
1997
+ result: {
1998
+ subagentCount: descendants.length,
1999
+ subagents: descendants.map(d => ({ id: d.id, parentSession: d.parentSession }))
2000
+ }
2001
+ });
2002
+ } catch (err) {
2003
+ return send(res, 500, { ok: false, error: safeErrorMessage(err) });
2004
+ }
2005
+ }
1517
2006
  if (req.method === "GET" && path === "/workspaces") {
1518
2007
  const cachedHeaders = adapter.cachedSessionHeaders();
1519
2008
  const sessionCreatedAt = Object.fromEntries(cachedHeaders
1520
2009
  .filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
1521
2010
  .map(header => [header.id, header.createdAt]));
1522
2011
  const sessionUpdatedAt = Object.fromEntries(cachedHeaders
1523
- .filter(header => header && (typeof header.updatedAt === "number" || typeof header.updatedAt === "string" || typeof header.lastModified === "number" || typeof header.mtime === "number"))
1524
- .map(header => [header.id, header.updatedAt ?? header.lastModified ?? header.mtime]));
2012
+ .filter(header => header && (typeof header.updatedAt === "number" || typeof header.updatedAt === "string" || typeof header.lastModified === "number"))
2013
+ .map(header => [header.id, header.updatedAt ?? header.lastModified]));
1525
2014
  let workspaceArchivesMap = {};
1526
2015
  try {
1527
2016
  workspaceArchivesMap = await workspaceArchives().getAll();
@@ -1535,7 +2024,7 @@ ${rest}`, "utf8");
1535
2024
  return send(res, 200, { ok: true, result });
1536
2025
  }
1537
2026
  if (req.method === "GET" && path === "/update/check") {
1538
- const currentVersion = adapter.pluginVersion() || "0.7.0";
2027
+ const currentVersion = adapter.pluginVersion() || "0.7.2";
1539
2028
  let latestVersion = currentVersion;
1540
2029
  let hasUpdate = false;
1541
2030
  try {
@@ -1620,10 +2109,17 @@ ${rest}`, "utf8");
1620
2109
  const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
1621
2110
  : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
1622
2111
  : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
1623
- return send(res, status, { ok: false, error: safeErrorMessage(error), ...(typeof error?.code === "string" ? { code: error.code } : {}) });
2112
+ return send(res, status, {
2113
+ ok: false,
2114
+ error: safeErrorMessage(error),
2115
+ ...(typeof error?.code === "string" ? { code: error.code } : {}),
2116
+ ...(Array.isArray(error?.deletedSubagentIds) ? { deletedSubagents: error.deletedSubagentIds } : {}),
2117
+ ...(Array.isArray(error?.remainingSessionIds) ? { remainingSessions: error.remainingSessionIds } : {}),
2118
+ ...(typeof error?.partialFailure === "boolean" ? { partialFailure: error.partialFailure } : {})
2119
+ });
1624
2120
  }
1625
2121
  }
1626
2122
  }), "session-manager: http api");
1627
2123
  }
1628
2124
 
1629
- export { listSessionHeaders, scanSessionHeaders, encodeSessionSegment, compareSemver };
2125
+ export { listSessionHeaders, scanSessionHeaders, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, compareSemver };