@murchey/dsh-session-manager 0.1.1 → 0.1.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/README.md CHANGED
@@ -41,11 +41,11 @@ dsh plugin --profile <profile> add link:<此目录绝对路径>
41
41
 
42
42
  - 顶部显示总会话数与磁盘占用;
43
43
  - 每个会话显示标题、ID、项目目录、创建日期、占用空间和比例条;
44
- - 运行中的会话标记为 `运行中`,不能选择(先结束会话再删除);已归档会话显示 `已归档` 徽标;页面打开期间每 5 秒自动同步一次状态,切回后台页面时会立即同步,因此结束会话后无需重启 DSH;
44
+ - 只有**正在生成**的会话标记为 `运行中` 并禁止选择;“已归档”会话显示 `已归档` 徽标,只要没有正在运行的一轮,就可以直接删除,不再需要重启 DSH;页面打开期间每 5 秒自动同步一次状态,切回后台页面时会立即同步;
45
45
  - 支持按标题 / ID / 项目目录搜索,按大小或时间排序;
46
46
  - 行尾“删除”按钮删除单个会话,“删除所选”批量删除;
47
47
  - 删除前弹出确认对话框,明确显示数量、总字节数和“不可撤销”警告,并默认勾选“同时删除不再被其他会话引用的附件”;
48
- - 删除成功后主动刷新 Client 会话目录,左侧会话列表同步移除条目;
48
+ - 删除成功后会同时把会话从 DSH 的归档 / 置顶集合中移除,避免侧边栏残留“已归档”空条目,并主动刷新 Client 会话目录;
49
49
  - 顶部“清理失效栏目”按钮用于历史遗留:先向 Host 重新拉取权威会话目录,再把侧边栏中数据已不存在的条目从本地目录移除(不会删除任何仍然存在的会话);删除成功后也会顺带执行同样的清理。
50
50
 
51
51
  页面通过 Connection 的已认证 `/api/session-shredder/*` 路由读写;UI 的确认对话框就是人工闸门,Host 路由仍要求 `confirm:true` 并只接受 owner 浏览器会话。
@@ -70,7 +70,9 @@ dsh plugin --profile <profile> add link:<此目录绝对路径>
70
70
  ## 安全规则
71
71
 
72
72
  - 插件在 Host 进程内运行,**不受**会话 workspace 文件沙箱限制;它只按配置的数据根删除,并校验每个目标确实位于根内。
73
- - 当前会话、本进程内仍存活的会话(含子代理)**拒绝删除**。
73
+ - 当前会话、正在运行的一轮(`agent.status === 'running'`)**拒绝删除**。
74
+ - “驻留内存但空闲”的 Agent 不再等同于“运行中”:DSH 归档只是把会话移入归档集合,并不会销毁 Agent,因此旧逻辑会把归档会话永久锁死,只能靠重启释放。现在这类会话可以删除,`plan` 会给出提示而不是阻止。
75
+ - 归档 / 置顶只是 `workspace.json` 里的元数据;插件在删除成功后会把对应 id 从这两个集合中移除,避免留下删不掉的空条目。
74
76
  - 会话 id 只接受安全字符(顶层会话 `session-<token>`、子代理裸 UUID);路径编码与 DSH 的 JSONL 后端完全一致,避免路径穿越。
75
77
  - 只删除 `sessionsRoot` / `projectionCacheRoot` / `attachmentsRoot` 内部的真实目录,不跟随符号链接。
76
78
  - 子代理会话不能调用该工具,只有顶层会话可以。
@@ -108,7 +110,7 @@ dsh plugin --profile <profile> add link:<此目录绝对路径>
108
110
 
109
111
  ## 不会处理的内容
110
112
 
111
- - `workspace.json` 里的会话排序 / 归档 id:只是元数据,DSH 在下次 workspace 变更时会自动剪除;它们不包含会话内容。
113
+ - `workspace.json` 里的会话排序 id:只是元数据,DSH 在下次 workspace 变更时会自动剪除;归档 / 置顶 id 会在删除成功后由插件主动移除(它们不包含会话内容)。
112
114
  - 会话搜索索引(若启用 SQLite 后端):DSH 启动时会按持久化列表对账删除,重启后消失。
113
115
  - `llm-deepseek/files-v3.json` 中的远端 `fileId` 映射:只有引用,不含内容,且远端文件会过期。
114
116
  - 系统临时目录中由子进程产生的日志/溢出文件,以及 Web 端浏览器缓存。
package/index.js CHANGED
@@ -30,7 +30,7 @@ const TOOL_DESCRIPTION = [
30
30
  'Permanently delete DSH session data from disk: the append-only session log plus every historical format generation, the projection-cache record, the legacy cache-seed entry, and attachments that no surviving session references.',
31
31
  'This is irreversible. Use it only when the user explicitly asks to permanently delete sessions. Never use it for "archive", "hide", cleanup suggestions, or the current session.',
32
32
  'Workflow: call action:"list" to discover ids, call action:"plan" for the exact footprint and blockers, show that plan to the user, and call action:"purge" with confirm:true only after the user agrees.',
33
- 'A purge refuses the current session and any live session, and asks the user for approval before deleting.',
33
+ 'A purge refuses the current session and any session with a running turn, and asks the user for approval before deleting. Archived sessions are deletable once idle, and their archive/pin entries are dropped with the data.',
34
34
  ].join(' ');
35
35
 
36
36
  /**
@@ -136,6 +136,7 @@ function registerHttpRoutes(ctx, options) {
136
136
  bytes: session.bytes,
137
137
  eventCount: session.eventCount ?? null,
138
138
  live: session.live,
139
+ resident: session.resident === true,
139
140
  archived: session.archived === true,
140
141
  orphan: session.orphan,
141
142
  subagent: !session.id.startsWith('session-'),
@@ -187,6 +188,7 @@ function registerHttpRoutes(ctx, options) {
187
188
  deletedBytes: result.deletedBytes,
188
189
  attachments: result.attachments,
189
190
  requestImageCache: result.requestImageCache,
191
+ metadata: result.metadata,
190
192
  targets: result.targets.map((target) => ({
191
193
  id: target.id,
192
194
  bytes: target.bytes,
@@ -283,8 +285,12 @@ async function planAction(ctx, options, sessionIds, currentSessionId) {
283
285
  `Purge plan (dry run) — ${plan.targets.length} session${plan.targets.length === 1 ? '' : 's'}, ${formatBytes(plan.totalBytes)} on disk.`,
284
286
  ];
285
287
  for (const target of plan.targets) {
288
+ const flags = [target.archived ? 'ARCHIVED' : '', target.resident ? 'RESIDENT' : '', target.live ? 'RUNNING' : '']
289
+ .filter(Boolean)
290
+ .join(', ');
286
291
  lines.push(
287
- `- ${target.id}${target.title !== undefined ? ` "${target.title}"` : ''} ${formatBytes(target.bytes)}`,
292
+ `- ${target.id}${target.title !== undefined ? ` "${target.title}"` : ''} ${formatBytes(target.bytes)}` +
293
+ `${flags === '' ? '' : ` [${flags}]`}`,
288
294
  );
289
295
  if (target.dirs.length > 0) {
290
296
  for (const dir of target.dirs) lines.push(` log: ${dir}`);
@@ -345,6 +351,9 @@ async function purgeAction(ctx, options, args, exec, currentSessionId) {
345
351
  `Request-image cache: cleared (${formatBytes(result.requestImageCache.bytes)}, ${result.requestImageCache.files} file(s)).`,
346
352
  );
347
353
  }
354
+ if (Array.isArray(result.metadata?.pruned) && result.metadata.pruned.length > 0) {
355
+ lines.push(`Archive/pin entries removed: ${result.metadata.pruned.join(', ')}.`);
356
+ }
348
357
  for (const target of result.targets) {
349
358
  lines.push(
350
359
  `- ${target.id}: ${formatBytes(target.bytes)}, ${target.files} file${target.files === 1 ? '' : 's'}` +
package/lib/core.js CHANGED
@@ -321,14 +321,42 @@ async function safeTitle(ctx, id) {
321
321
  }
322
322
  }
323
323
 
324
- function liveAgentIds(ctx) {
324
+ /**
325
+ * Snapshot the live agent registry as `session id -> running?`.
326
+ *
327
+ * `agents.list()` returns every Agent resident in this process, including idle
328
+ * ones that merely have an open session. Archiving a session does not dispose
329
+ * its Agent, so residency must never be reported as "running": a resident but
330
+ * idle agent is `status === 'idle'` and can be purged once its turn is over.
331
+ *
332
+ * @param {any} ctx
333
+ * @returns {Map<string, boolean>} session id -> true when a turn is running
334
+ */
335
+ function agentRunningStates(ctx) {
336
+ const states = new Map();
325
337
  const agents = ctx.get?.('agents');
326
- if (agents === undefined || typeof agents.list !== 'function') return new Set();
338
+ if (agents === undefined || typeof agents.list !== 'function') return states;
339
+ let list;
327
340
  try {
328
- return new Set(agents.list().map((agent) => agent.id));
341
+ list = agents.list();
329
342
  } catch {
330
- return new Set();
343
+ return states;
331
344
  }
345
+ for (const agent of Array.isArray(list) ? list : []) {
346
+ const id = agent?.id ?? agent?.session?.id;
347
+ if (typeof id !== 'string') continue;
348
+ // An agent without a readable status is treated as running: refusing is
349
+ // recoverable, deleting a live session is not.
350
+ let running = true;
351
+ try {
352
+ const status = agent?.status;
353
+ if (typeof status === 'string') running = status === 'running';
354
+ } catch {
355
+ running = true;
356
+ }
357
+ states.set(id, running);
358
+ }
359
+ return states;
332
360
  }
333
361
 
334
362
  /**
@@ -366,7 +394,7 @@ export async function listSessions(ctx, options, request = {}) {
366
394
  const snapshots = await listSnapshots(persistence, request.signal);
367
395
  const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
368
396
  const dirIndex = await indexSessionDirs(options.sessionsRoot);
369
- const live = liveAgentIds(ctx);
397
+ const agents = agentRunningStates(ctx);
370
398
  const archived = archivedSessionIds(ctx);
371
399
  const currentSessionId = request.currentSessionId;
372
400
  const limit = Number.isInteger(request.limit)
@@ -401,7 +429,8 @@ export async function listSessions(ctx, options, request = {}) {
401
429
  logDirs: dirs,
402
430
  stored: snapshot !== undefined,
403
431
  orphan: snapshot === undefined,
404
- live: live.has(id),
432
+ live: agents.get(id) === true,
433
+ resident: agents.has(id),
405
434
  archived: archived.has(id),
406
435
  current: id === currentSessionId,
407
436
  latestMtime: latestMtime || undefined,
@@ -440,7 +469,8 @@ export async function planPurge(ctx, options, ids, request = {}) {
440
469
  const snapshots = await listSnapshots(persistence, request.signal);
441
470
  const byId = new Map(snapshots.map((snapshot) => [snapshot.header.id, snapshot]));
442
471
  const dirIndex = await indexSessionDirs(options.sessionsRoot);
443
- const live = liveAgentIds(ctx);
472
+ const agents = agentRunningStates(ctx);
473
+ const archived = archivedSessionIds(ctx);
444
474
  const now = Date.now();
445
475
  const targets = [];
446
476
  const blockers = [];
@@ -489,7 +519,12 @@ export async function planPurge(ctx, options, ids, request = {}) {
489
519
  if (request.currentSessionId !== undefined && id === request.currentSessionId) {
490
520
  reasons.push('this is the current session; switch to another session to delete it');
491
521
  }
492
- if (live.has(id)) reasons.push('a live agent still owns this session in this process');
522
+ if (agents.get(id) === true) reasons.push('a running turn still owns this session in this process');
523
+ else if (agents.has(id)) {
524
+ warnings.push(
525
+ `${id}: the session is still resident in this DSH process but no turn is running; deletion is allowed`,
526
+ );
527
+ }
493
528
  if (dirs.length === 0 && cacheFiles.length === 0 && snapshot === undefined) {
494
529
  reasons.push('no session data found on disk or in persistence for this id');
495
530
  }
@@ -508,7 +543,9 @@ export async function planPurge(ctx, options, ids, request = {}) {
508
543
  createdAt: snapshot?.header?.createdAt,
509
544
  cwd: snapshot?.header?.cwd,
510
545
  stored: snapshot !== undefined,
511
- live: live.has(id),
546
+ live: agents.get(id) === true,
547
+ resident: agents.has(id),
548
+ archived: archived.has(id),
512
549
  current: request.currentSessionId !== undefined && id === request.currentSessionId,
513
550
  blockers: reasons,
514
551
  };
@@ -565,13 +602,14 @@ export async function purgeSessions(ctx, options, ids, request = {}) {
565
602
  });
566
603
  }
567
604
 
568
- // The reference scan can take minutes; a target may have become live since
569
- // the first plan. Re-refuse before deleting anything.
570
- const liveNow = liveAgentIds(ctx);
571
- const becameLive = plan.targets.filter((target) => liveNow.has(target.id));
605
+ // The reference scan can take minutes; a target may have started a turn
606
+ // since the first plan. Re-refuse before deleting anything. A resident but
607
+ // idle agent is fine: only a running turn keeps its log writer alive.
608
+ const runningNow = agentRunningStates(ctx);
609
+ const becameLive = plan.targets.filter((target) => runningNow.get(target.id) === true);
572
610
  if (becameLive.length > 0) {
573
611
  throw new PurgeError(
574
- `purge refused: session(s) became live during planning: ${becameLive.map((target) => target.id).join(', ')}`,
612
+ `purge refused: session(s) started running during planning: ${becameLive.map((target) => target.id).join(', ')}`,
575
613
  'BLOCKED',
576
614
  );
577
615
  }
@@ -696,9 +734,75 @@ export async function purgeSessions(ctx, options, ids, request = {}) {
696
734
  report.verified = false;
697
735
  warnings.push(`request-image cache still present after purge: ${options.requestImageCacheRoot}`);
698
736
  }
737
+
738
+ // A fully removed session must also leave the registry's archive and pin
739
+ // sets. Otherwise the sidebar keeps an "archived" row whose data is gone:
740
+ // it can never be opened again and every later delete attempt refuses with
741
+ // "no session data found", which looks like an archived session that cannot
742
+ // be deleted.
743
+ report.metadata = { pruned: [], skipped: true };
744
+ const removable = plan.targets.filter((target, index) => report.targets[index].errors.length === 0);
745
+ if (removable.length > 0) {
746
+ report.metadata = await pruneSessionMetadata(ctx, removable.map((target) => target.id), warnings);
747
+ }
699
748
  return { ...report, plan };
700
749
  }
701
750
 
751
+ /**
752
+ * Drop purged ids from the workspace registry's global archive and pin sets.
753
+ * Best effort: the filesystem removal already succeeded, so an unavailable
754
+ * registry must not turn a real purge into a reported failure.
755
+ *
756
+ * @param {any} ctx
757
+ * @param {string[]} ids
758
+ * @param {string[]} warnings
759
+ * @returns {Promise<{ pruned: string[], skipped: boolean }>}
760
+ */
761
+ async function pruneSessionMetadata(ctx, ids, warnings) {
762
+ let registry;
763
+ try {
764
+ registry = ctx.get?.('workspaceRegistry');
765
+ } catch {
766
+ registry = undefined;
767
+ }
768
+ if (registry === undefined || registry === null) return { pruned: [], skipped: true };
769
+ const archived = archivedSessionIds(ctx);
770
+ const pinned = pinnedSessionIds(ctx);
771
+ const pruned = [];
772
+ for (const id of ids) {
773
+ let changed = false;
774
+ if (archived.has(id) && typeof registry.unarchiveSession === 'function') {
775
+ try {
776
+ await registry.unarchiveSession(id);
777
+ changed = true;
778
+ } catch (error) {
779
+ warnings.push(`${id}: could not remove the archived entry (${error?.message ?? String(error)})`);
780
+ }
781
+ }
782
+ if (pinned.has(id) && typeof registry.unpinSession === 'function') {
783
+ try {
784
+ await registry.unpinSession(id);
785
+ changed = true;
786
+ } catch (error) {
787
+ warnings.push(`${id}: could not remove the pinned entry (${error?.message ?? String(error)})`);
788
+ }
789
+ }
790
+ if (changed) pruned.push(id);
791
+ }
792
+ return { pruned, skipped: false };
793
+ }
794
+
795
+ /** Read the registry-global pin set without requiring the service. */
796
+ function pinnedSessionIds(ctx) {
797
+ try {
798
+ const registry = ctx.get?.('workspaceRegistry');
799
+ const ids = registry?.pinnedSessionIds;
800
+ return new Set(Array.isArray(ids) ? ids : []);
801
+ } catch {
802
+ return new Set();
803
+ }
804
+ }
805
+
702
806
  /**
703
807
  * Decision support for the approval gate: a short human sentence with count,
704
808
  * byte total, and a bounded title list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murchey/dsh-session-manager",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "基于 DeepSeek Harness 的会话存储管理插件:查看会话磁盘占用、永久删除会话,并清理侧边栏失效条目。",
5
5
  "type": "module",
6
6
  "exports": {