@michengai/dsh-archive-manager 0.1.9 → 0.1.11

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
@@ -36,6 +36,8 @@ Safe archived session control
36
36
 
37
37
  > DSH Archive Manager is a community-maintained DeepSeek Harness (DSH) plugin, not an official DeepSeek AI product.
38
38
 
39
+ > Handover docs (Chinese): [docs/00-交接入口](docs/00-交接入口/项目概览.md)
40
+
39
41
  ## Features
40
42
 
41
43
  - Archive a session from the sidebar session menu.
package/README.zh-CN.md CHANGED
@@ -36,6 +36,8 @@ Safe archived session control
36
36
 
37
37
  > DSH Archive Manager 是社区维护的 DeepSeek Harness(DSH)插件,并非 DeepSeek AI 官方产品。
38
38
 
39
+ > 交接入口:[docs/00-交接入口](docs/00-交接入口/项目概览.md)
40
+
39
41
  ## 功能概览
40
42
 
41
43
  - 在侧栏会话菜单中选择「归档会话」。
package/lib/client.js CHANGED
@@ -199,66 +199,6 @@ window.__ModuleLoader__.load({
199
199
  function sessionVisible(session, current, archived, showArchived) {
200
200
  return session.origin !== "subagent" && (!archived.has(session.id) || showArchived === true) && (!session.blank || session.id === current);
201
201
  }
202
- /**
203
- * 单个删除按钮的目标:先子代理(含多层),再本会话。fork 分支不是 subagent,不会被收进来。
204
- */
205
- function collectSessionAndDescendantIds(sessionId, byId) {
206
- if (typeof sessionId !== "string" || sessionId.length === 0) return [];
207
- const catalog = byId ?? {};
208
- const childrenByParent = /* @__PURE__ */ new Map();
209
- for (const session of Object.values(catalog)) {
210
- if (session === void 0 || session.origin !== "subagent" || typeof session.id !== "string" || session.id.length === 0) continue;
211
- const parentId = session.parentId;
212
- if (typeof parentId !== "string") continue;
213
- const list = childrenByParent.get(parentId) ?? [];
214
- list.push(session.id);
215
- childrenByParent.set(parentId, list);
216
- }
217
- const ordered = [];
218
- const seen = /* @__PURE__ */ new Set();
219
- const visit = (id) => {
220
- for (const childId of childrenByParent.get(id) ?? []) {
221
- if (seen.has(childId)) continue;
222
- seen.add(childId);
223
- visit(childId);
224
- ordered.push(childId);
225
- }
226
- };
227
- visit(sessionId);
228
- if (!seen.has(sessionId)) ordered.push(sessionId);
229
- return ordered;
230
- }
231
- /**
232
- * 仅供 __test 使用:展开已归档 id 及其仍在目录中的 subagent 子会话,先子后父。运行时删除只提交根会话或归档集合。
233
- */
234
- function collectArchivedDeleteAllIds(archivedSessionIds, byId) {
235
- const catalog = byId ?? {};
236
- const ids = [];
237
- const seenArchived = /* @__PURE__ */ new Set();
238
- for (const id of archivedSessionIds ?? []) {
239
- if (typeof id !== "string" || id.length === 0 || seenArchived.has(id)) continue;
240
- seenArchived.add(id);
241
- ids.push(id);
242
- }
243
- const result = [];
244
- const seen = /* @__PURE__ */ new Set();
245
- const addTree = (id) => {
246
- for (const item of collectSessionAndDescendantIds(id, catalog)) {
247
- if (seen.has(item)) continue;
248
- seen.add(item);
249
- result.push(item);
250
- }
251
- };
252
- for (const id of ids) {
253
- if (catalog[id]?.origin === "subagent") continue;
254
- addTree(id);
255
- }
256
- for (const id of ids) {
257
- if (seen.has(id)) continue;
258
- addTree(id);
259
- }
260
- return result;
261
- }
262
202
  /** 识别删除路径上“会话已不存在”的稳定标记;保留旧文案作兜底。 */
263
203
  function isUnknownSessionError(reason) {
264
204
  const message = reason instanceof Error ? reason.message : String(reason);
@@ -1741,7 +1681,7 @@ window.__ModuleLoader__.load({
1741
1681
  (0, react.useEffect)(() => {
1742
1682
  if (list.phase !== "ready") return;
1743
1683
  const previousOrder = sessionOrderByAccount[FLAT_SESSION_ORDER_KEY];
1744
- const previousUpdatedAt = sessionUpdatedAtByAccount["__flat_session_order__"] ?? {};
1684
+ const previousUpdatedAt = sessionUpdatedAtByAccount[FLAT_SESSION_ORDER_KEY] ?? {};
1745
1685
  const switchedToUpdated = previousOrderBy.current !== "updated" && orderBy === "updated";
1746
1686
  previousOrderBy.current = orderBy;
1747
1687
  const next = nextSessionOrderAccount({
@@ -2564,133 +2504,187 @@ window.__ModuleLoader__.load({
2564
2504
  tag.textContent = ARCHIVE_SETTINGS_LAYOUT_OVERRIDE;
2565
2505
  document.head.appendChild(tag);
2566
2506
  }
2567
- /** 设置页项目筛选:自定义菜单,避免原生 select 弹出系统浅色下拉。 */
2568
- function ArchiveProjectSelect({ id, value, options, onChange, "aria-label": ariaLabel }) {
2569
- const [open, setOpen] = (0, react.useState)(false);
2570
- const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
2571
- const [active, setActive] = (0, react.useState)(selectedIndex);
2572
- const rootRef = (0, react.useRef)(null);
2573
- const triggerRef = (0, react.useRef)(null);
2574
- const listRef = (0, react.useRef)(null);
2575
- const wasOpen = (0, react.useRef)(false);
2576
- const selected = options[selectedIndex];
2577
- (0, react.useEffect)(() => {
2578
- if (!open) return;
2579
- setActive(selectedIndex);
2580
- const onPointerDown = (event) => {
2581
- const target = event.target;
2582
- if (target instanceof Node && rootRef.current?.contains(target) === true) return;
2583
- setOpen(false);
2584
- };
2585
- document.addEventListener("pointerdown", onPointerDown);
2586
- return () => document.removeEventListener("pointerdown", onPointerDown);
2587
- }, [open, selectedIndex]);
2588
- (0, react.useEffect)(() => {
2589
- if (open) {
2590
- listRef.current?.focus();
2591
- wasOpen.current = true;
2592
- return;
2593
- }
2594
- if (wasOpen.current) {
2595
- triggerRef.current?.focus();
2596
- wasOpen.current = false;
2597
- }
2598
- }, [open]);
2599
- (0, react.useEffect)(() => {
2600
- if (!open) return;
2601
- document.getElementById(id + "-opt-" + String(active))?.scrollIntoView({ block: "nearest" });
2602
- }, [active, open, id]);
2603
- const choose = (next) => {
2604
- onChange(next);
2605
- setOpen(false);
2606
- };
2607
- const move = (next) => {
2608
- if (options.length === 0) return;
2609
- setActive(Math.min(options.length - 1, Math.max(0, next)));
2610
- };
2611
- return (0, react_jsx_runtime.jsxs)("div", {
2612
- className: "dsham_settingsFilter",
2613
- ref: rootRef,
2614
- children: [(0, react_jsx_runtime.jsxs)("button", {
2615
- id,
2616
- ref: triggerRef,
2617
- type: "button",
2618
- className: "dsham_selectTrigger",
2619
- "aria-label": ariaLabel,
2620
- "aria-haspopup": "listbox",
2621
- "aria-expanded": open,
2622
- "aria-controls": id + "-list",
2623
- onClick: () => setOpen((current) => !current),
2624
- onKeyDown: (event) => {
2625
- if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
2626
- event.preventDefault();
2627
- setOpen(true);
2628
- }
2629
- },
2630
- children: [(0, react_jsx_runtime.jsx)("span", { className: "dsham_selectValue", children: selected === void 0 ? "" : selected.label }), (0, react_jsx_runtime.jsx)("svg", {
2631
- className: "dsham_selectCaret",
2632
- viewBox: "0 0 12 12",
2633
- "aria-hidden": true,
2634
- focusable: false,
2635
- children: (0, react_jsx_runtime.jsx)("path", {
2636
- d: "M2.5 4.5L6 8l3.5-3.5",
2637
- fill: "none",
2638
- stroke: "currentColor",
2639
- strokeWidth: "1.5",
2640
- strokeLinecap: "round",
2641
- strokeLinejoin: "round"
2642
- })
2643
- })]
2644
- }), open ? (0, react_jsx_runtime.jsx)("div", {
2645
- id: id + "-list",
2646
- ref: listRef,
2647
- className: "dsham_selectMenu",
2648
- role: "listbox",
2649
- tabIndex: 0,
2650
- "aria-activedescendant": id + "-opt-" + String(active),
2651
- onKeyDown: (event) => {
2652
- if (event.key === "ArrowDown") {
2653
- event.preventDefault();
2654
- move(active + 1);
2655
- return;
2656
- }
2657
- if (event.key === "ArrowUp") {
2658
- event.preventDefault();
2659
- move(active - 1);
2660
- return;
2661
- }
2662
- if (event.key === "Home") {
2663
- event.preventDefault();
2664
- move(0);
2665
- return;
2666
- }
2667
- if (event.key === "End") {
2668
- event.preventDefault();
2669
- move(options.length - 1);
2670
- return;
2671
- }
2672
- if (event.key === "Enter" || event.key === " ") {
2673
- event.preventDefault();
2674
- const option = options[active];
2675
- if (option !== void 0) choose(option.value);
2676
- return;
2677
- }
2678
- if (event.key === "Escape" || event.key === "Tab") setOpen(false);
2679
- },
2680
- children: options.map((option, index) => (0, react_jsx_runtime.jsx)("button", {
2681
- id: id + "-opt-" + String(index),
2682
- type: "button",
2683
- role: "option",
2684
- className: "dsham_selectOption",
2685
- "aria-selected": option.value === value,
2686
- "data-active": index === active,
2687
- onMouseEnter: () => setActive(index),
2688
- onClick: () => choose(option.value),
2689
- children: option.label
2690
- }, option.value === "all" ? "all" : option.value))
2691
- }) : null]
2692
- });
2693
- }
2507
+ /** 设置页项目筛选:自定义菜单,避免原生 select 弹出系统浅色下拉。 */
2508
+ function ArchiveProjectSelect({ id, value, options, onChange, "aria-label": ariaLabel }) {
2509
+ const [open, setOpen] = (0, react.useState)(false);
2510
+ const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
2511
+ const [active, setActive] = (0, react.useState)(selectedIndex);
2512
+ const rootRef = (0, react.useRef)(null);
2513
+ const triggerRef = (0, react.useRef)(null);
2514
+ const listRef = (0, react.useRef)(null);
2515
+ const wasOpen = (0, react.useRef)(false);
2516
+ const selected = options[selectedIndex];
2517
+ (0, react.useEffect)(() => {
2518
+ if (!open) return;
2519
+ setActive(selectedIndex);
2520
+ const onPointerDown = (event) => {
2521
+ const target = event.target;
2522
+ if (target instanceof Node && rootRef.current?.contains(target) === true) return;
2523
+ setOpen(false);
2524
+ };
2525
+ document.addEventListener("pointerdown", onPointerDown);
2526
+ return () => document.removeEventListener("pointerdown", onPointerDown);
2527
+ }, [open, selectedIndex]);
2528
+ (0, react.useEffect)(() => {
2529
+ if (open) {
2530
+ listRef.current?.focus();
2531
+ wasOpen.current = true;
2532
+ return;
2533
+ }
2534
+ if (wasOpen.current) {
2535
+ triggerRef.current?.focus();
2536
+ wasOpen.current = false;
2537
+ }
2538
+ }, [open]);
2539
+ (0, react.useEffect)(() => {
2540
+ if (!open) return;
2541
+ document.getElementById(id + "-opt-" + String(active))?.scrollIntoView({ block: "nearest" });
2542
+ }, [active, open, id]);
2543
+ const choose = (next) => {
2544
+ onChange(next);
2545
+ setOpen(false);
2546
+ };
2547
+ const move = (next) => {
2548
+ if (options.length === 0) return;
2549
+ setActive(Math.min(options.length - 1, Math.max(0, next)));
2550
+ };
2551
+ return (0, react_jsx_runtime.jsxs)("div", {
2552
+ className: "dsham_settingsFilter",
2553
+ ref: rootRef,
2554
+ children: [(0, react_jsx_runtime.jsxs)("button", {
2555
+ id,
2556
+ ref: triggerRef,
2557
+ type: "button",
2558
+ className: "dsham_selectTrigger",
2559
+ "aria-label": ariaLabel,
2560
+ "aria-haspopup": "listbox",
2561
+ "aria-expanded": open,
2562
+ "aria-controls": id + "-list",
2563
+ onClick: () => setOpen((current) => !current),
2564
+ onKeyDown: (event) => {
2565
+ if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
2566
+ event.preventDefault();
2567
+ setOpen(true);
2568
+ }
2569
+ },
2570
+ children: [(0, react_jsx_runtime.jsx)("span", { className: "dsham_selectValue", children: selected === void 0 ? "" : selected.label }), (0, react_jsx_runtime.jsx)("svg", {
2571
+ className: "dsham_selectCaret",
2572
+ viewBox: "0 0 12 12",
2573
+ "aria-hidden": true,
2574
+ focusable: false,
2575
+ children: (0, react_jsx_runtime.jsx)("path", {
2576
+ d: "M2.5 4.5L6 8l3.5-3.5",
2577
+ fill: "none",
2578
+ stroke: "currentColor",
2579
+ strokeWidth: "1.5",
2580
+ strokeLinecap: "round",
2581
+ strokeLinejoin: "round"
2582
+ })
2583
+ })]
2584
+ }), open ? (0, react_jsx_runtime.jsx)("div", {
2585
+ id: id + "-list",
2586
+ ref: listRef,
2587
+ className: "dsham_selectMenu",
2588
+ role: "listbox",
2589
+ tabIndex: 0,
2590
+ "aria-activedescendant": id + "-opt-" + String(active),
2591
+ onKeyDown: (event) => {
2592
+ if (event.key === "ArrowDown") {
2593
+ event.preventDefault();
2594
+ move(active + 1);
2595
+ return;
2596
+ }
2597
+ if (event.key === "ArrowUp") {
2598
+ event.preventDefault();
2599
+ move(active - 1);
2600
+ return;
2601
+ }
2602
+ if (event.key === "Home") {
2603
+ event.preventDefault();
2604
+ move(0);
2605
+ return;
2606
+ }
2607
+ if (event.key === "End") {
2608
+ event.preventDefault();
2609
+ move(options.length - 1);
2610
+ return;
2611
+ }
2612
+ if (event.key === "Enter" || event.key === " ") {
2613
+ event.preventDefault();
2614
+ const option = options[active];
2615
+ if (option !== void 0) choose(option.value);
2616
+ return;
2617
+ }
2618
+ if (event.key === "Escape" || event.key === "Tab") {
2619
+ event.preventDefault();
2620
+ event.stopPropagation();
2621
+ if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
2622
+ setOpen(false);
2623
+ }
2624
+ },
2625
+ children: options.map((option, index) => (0, react_jsx_runtime.jsx)("button", {
2626
+ id: id + "-opt-" + String(index),
2627
+ type: "button",
2628
+ role: "option",
2629
+ className: "dsham_selectOption",
2630
+ "aria-selected": option.value === value,
2631
+ "data-active": index === active,
2632
+ onMouseEnter: () => setActive(index),
2633
+ onClick: () => choose(option.value),
2634
+ children: option.label
2635
+ }, option.value === "all" ? "all" : option.value))
2636
+ }) : null]
2637
+ });
2638
+ }
2639
+ /** 设置页“未分组”桶的稳定 key(workspaceId 均为非空 uuid,不会冲突)。 */
2640
+ const ARCHIVE_UNGROUPED_KEY = "__ungrouped__";
2641
+ /**
2642
+ * 设置页归档分组派生:按工作区分组,组 key 用 workspaceId(上游允许
2643
+ * 不同路径的工作区同名,title 不能作 React key / 筛选 value),未归入
2644
+ * 任何工作区的归档会话进“未分组”桶;隐藏 subagent(其删除由服务端级联)。
2645
+ * @param byId - 会话摘要表(缺失摘要的归档会话不进列表)。
2646
+ * @param items - 工作区列表(Host 顺序,含唯一 workspaceId)。
2647
+ * @param archivedSessionIds - 注册表全局归档集合。
2648
+ * @param ungroupedLabel - “未分组”显示文案。
2649
+ * @returns 分组数组(仅含有会话的组),每项含 key / title / sessions。
2650
+ */
2651
+ function deriveArchivedGroups(byId, items, archivedSessionIds, ungroupedLabel) {
2652
+ const byWorkspace = items.map((workspace) => ({
2653
+ key: workspace.workspaceId,
2654
+ title: workspace.title,
2655
+ ids: new Set(workspace.sessionIds),
2656
+ sessions: []
2657
+ }));
2658
+ const ungrouped = [];
2659
+ for (const id of archivedSessionIds) {
2660
+ const session = byId[id];
2661
+ if (session === void 0 || session.origin === "subagent") continue;
2662
+ const group = byWorkspace.find((workspace) => workspace.ids.has(id));
2663
+ (group === void 0 ? ungrouped : group.sessions).push(session);
2664
+ }
2665
+ const result = byWorkspace.filter((group) => group.sessions.length > 0);
2666
+ if (ungrouped.length > 0) result.push({ key: ARCHIVE_UNGROUPED_KEY, title: ungroupedLabel, sessions: ungrouped });
2667
+ return result;
2668
+ }
2669
+ /**
2670
+ * “全部删除”的提交集合:直接取归档全集,仅排除摘要已知为 subagent 的
2671
+ * id(其删除由服务端级联)。不依赖摘要是否已加载,投影未就绪的会话
2672
+ * 不会被静默漏删。
2673
+ * @param archivedSessionIds - 注册表全局归档集合。
2674
+ * @param byId - 会话摘要表(可能不完整)。
2675
+ * @returns 去重后的待删除 id 列表。
2676
+ */
2677
+ function deriveArchivedDeleteAllIds(archivedSessionIds, byId) {
2678
+ const seen = /* @__PURE__ */ new Set();
2679
+ const ids = [];
2680
+ for (const id of archivedSessionIds ?? []) {
2681
+ if (typeof id !== "string" || id.length === 0 || seen.has(id)) continue;
2682
+ seen.add(id);
2683
+ if (byId?.[id]?.origin === "subagent") continue;
2684
+ ids.push(id);
2685
+ }
2686
+ return ids;
2687
+ }
2694
2688
  /** 管理设置页中的归档会话,数据直接订阅 DSH 的会话与工作区投影。 */
2695
2689
  function ArchivedSessionsSection({ sessionStore, workspaceStore, unarchiveSession, deleteSession, t }) {
2696
2690
  const sessions = (0, react.useSyncExternalStore)(sessionStore.subscribe, sessionStore.getSnapshot);
@@ -2700,42 +2694,22 @@ window.__ModuleLoader__.load({
2700
2694
  const [error, setError] = (0, react.useState)(null);
2701
2695
  const [query, setQuery] = (0, react.useState)("");
2702
2696
  const [project, setProject] = (0, react.useState)("all");
2703
- const groups = (0, react.useMemo)(() => {
2704
- // 列表仍隐藏 subagent;删除全部只提交已归档 id,子代理由服务端级联。
2705
- const archived = new Set(workspaceState.archivedSessionIds);
2706
- const byWorkspace = workspaceState.items.map((workspace) => ({
2707
- title: workspace.title,
2708
- ids: new Set(workspace.sessionIds),
2709
- sessions: []
2710
- }));
2711
- const ungrouped = [];
2712
- for (const id of workspaceState.archivedSessionIds) {
2713
- const session = sessions.byId[id];
2714
- if (session === void 0 || session.origin === "subagent" || !archived.has(id)) continue;
2715
- const group = byWorkspace.find((workspace) => workspace.ids.has(id));
2716
- (group === void 0 ? ungrouped : group.sessions).push(session);
2717
- }
2718
- const result = byWorkspace.filter((group) => group.sessions.length > 0);
2719
- if (ungrouped.length > 0) result.push({ title: t("group.ungrouped"), sessions: ungrouped });
2720
- return result;
2721
- }, [sessions, workspaceState, t]);
2697
+ const groups = (0, react.useMemo)(() => deriveArchivedGroups(sessions.byId, workspaceState.items, workspaceState.archivedSessionIds, t("group.ungrouped")), [sessions.byId, workspaceState, t]);
2698
+ (0, react.useEffect)(() => {
2699
+ // 选中的分组消失(如最后一个归档会话被取消归档)时回退到
2700
+ // “所有项目”,避免筛选值停留在失效 key 上把列表过滤为空。
2701
+ if (project === "all") return;
2702
+ if (groups.some((group) => group.key === project)) return;
2703
+ setProject("all");
2704
+ }, [groups, project]);
2722
2705
  const filteredGroups = (0, react.useMemo)(() => {
2723
2706
  const normalizedQuery = query.trim().toLocaleLowerCase();
2724
- return groups.filter((group) => project === "all" || project === group.title).map((group) => ({
2707
+ return groups.filter((group) => project === "all" || project === group.key).map((group) => ({
2725
2708
  ...group,
2726
2709
  sessions: group.sessions.filter((session) => normalizedQuery === "" || displayTitle(session, t).toLocaleLowerCase().includes(normalizedQuery))
2727
2710
  })).filter((group) => group.sessions.length > 0);
2728
2711
  }, [groups, project, query, t]);
2729
- const deleteAllSessionIds = (0, react.useMemo)(() => {
2730
- const ids = [];
2731
- const seen = /* @__PURE__ */ new Set();
2732
- for (const id of workspaceState.archivedSessionIds ?? []) {
2733
- if (typeof id !== "string" || id.length === 0 || seen.has(id)) continue;
2734
- seen.add(id);
2735
- ids.push(id);
2736
- }
2737
- return ids;
2738
- }, [workspaceState.archivedSessionIds]);
2712
+ const deleteAllSessionIds = (0, react.useMemo)(() => deriveArchivedDeleteAllIds(workspaceState.archivedSessionIds, sessions.byId), [workspaceState.archivedSessionIds, sessions.byId]);
2739
2713
  const onUnarchive = (sessionId) => {
2740
2714
  setError(null);
2741
2715
  unarchiveSession(sessionId).catch((reason) => {
@@ -2745,6 +2719,18 @@ window.__ModuleLoader__.load({
2745
2719
  const closeDelete = () => {
2746
2720
  if (!busy) setDeleteTarget(null);
2747
2721
  };
2722
+ (0, react.useEffect)(() => {
2723
+ if (deleteTarget === null) return;
2724
+ const onKeyDown = (event) => {
2725
+ if (event.key !== "Escape") return;
2726
+ event.preventDefault();
2727
+ event.stopPropagation();
2728
+ if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
2729
+ if (!busy) setDeleteTarget(null);
2730
+ };
2731
+ window.addEventListener("keydown", onKeyDown, true);
2732
+ return () => window.removeEventListener("keydown", onKeyDown, true);
2733
+ }, [deleteTarget, busy]);
2748
2734
  const confirmDelete = async () => {
2749
2735
  if (busy || deleteTarget === null) return;
2750
2736
  setBusy(true);
@@ -2768,7 +2754,7 @@ window.__ModuleLoader__.load({
2768
2754
  return (0, react_jsx_runtime.jsxs)("section", {
2769
2755
  className: "dsham_settings",
2770
2756
  "aria-label": t("archives.title"),
2771
- children: [(0, react_jsx_runtime.jsx)("style", { children: ARCHIVE_SETTINGS_CSS }), (0, react_jsx_runtime.jsxs)("header", { className: "dsham_settingsHeader", children: [(0, react_jsx_runtime.jsxs)("div", { children: [(0, react_jsx_runtime.jsx)("h2", { children: t("archives.title") }), (0, react_jsx_runtime.jsx)("p", { className: "dsham_settingsIntro", children: t("archives.description") })] }), (0, react_jsx_runtime.jsxs)("button", { type: "button", className: "dsham_settingsDanger", disabled: busy || deleteAllSessionIds.length === 0, onClick: () => setDeleteTarget({ all: true }), children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}), t("archives.deleteAll")] })] }), (0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsToolbar", children: [(0, react_jsx_runtime.jsxs)("label", { className: "dsham_settingsSearch", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSearchOutline16, {}), (0, react_jsx_runtime.jsx)("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: t("archives.searchPlaceholder"), "aria-label": t("archives.searchPlaceholder") })] }), (0, react_jsx_runtime.jsx)(ArchiveProjectSelect, { id: "dsham-project-filter", value: project, options: [{ value: "all", label: t("archives.allProjects") }, ...groups.map((group) => ({ value: group.title, label: group.title }))], onChange: setProject, "aria-label": t("archives.projectFilter") })] }), groups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.empty") }) : filteredGroups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.emptyFiltered") }) : filteredGroups.map((group) => (0, react_jsx_runtime.jsxs)("section", {
2757
+ children: [(0, react_jsx_runtime.jsx)("style", { children: ARCHIVE_SETTINGS_CSS }), (0, react_jsx_runtime.jsxs)("header", { className: "dsham_settingsHeader", children: [(0, react_jsx_runtime.jsxs)("div", { children: [(0, react_jsx_runtime.jsx)("h2", { children: t("archives.title") }), (0, react_jsx_runtime.jsx)("p", { className: "dsham_settingsIntro", children: t("archives.description") })] }), (0, react_jsx_runtime.jsxs)("button", { type: "button", className: "dsham_settingsDanger", disabled: busy || deleteAllSessionIds.length === 0, onClick: () => setDeleteTarget({ all: true }), children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}), t("archives.deleteAll")] })] }), (0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsToolbar", children: [(0, react_jsx_runtime.jsxs)("label", { className: "dsham_settingsSearch", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSearchOutline16, {}), (0, react_jsx_runtime.jsx)("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: t("archives.searchPlaceholder"), "aria-label": t("archives.searchPlaceholder") })] }), (0, react_jsx_runtime.jsx)(ArchiveProjectSelect, { id: "dsham-project-filter", value: project, options: [{ value: "all", label: t("archives.allProjects") }, ...groups.map((group) => ({ value: group.key, label: group.title }))], onChange: setProject, "aria-label": t("archives.projectFilter") })] }), groups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.empty") }) : filteredGroups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.emptyFiltered") }) : filteredGroups.map((group) => (0, react_jsx_runtime.jsxs)("section", {
2772
2758
  className: "dsham_settingsGroup",
2773
2759
  children: [(0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsGroupHeading", children: [(0, react_jsx_runtime.jsxs)("h3", { className: "dsham_settingsGroupTitle", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpenOutline16, {}), group.title] }), (0, react_jsx_runtime.jsx)("span", { className: "dsham_settingsCount", children: t("archives.sessionCount", { n: group.sessions.length }) })] }), (0, react_jsx_runtime.jsx)("div", {
2774
2760
  className: "dsham_settingsList",
@@ -2780,7 +2766,7 @@ window.__ModuleLoader__.load({
2780
2766
  })]
2781
2767
  }, session.id))
2782
2768
  })]
2783
- }, group.title)), error !== null && (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsError", role: "alert", children: error }), (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2769
+ }, group.key)), error !== null && (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsError", role: "alert", children: error }), (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2784
2770
  open: deleteTarget !== null,
2785
2771
  onClose: closeDelete,
2786
2772
  closeLabel: t("close"),
@@ -2841,16 +2827,16 @@ window.__ModuleLoader__.load({
2841
2827
  "archives.deleteAll": "全部删除",
2842
2828
  "archives.deleteAllTitle": "删除全部已归档聊天",
2843
2829
  "archives.deleteAllDesc": "将永久删除全部 {n} 个已归档聊天及其子代理(含正在运行的)和记录,此操作不可恢复。",
2844
- "archives.deleteAllPending": "正在删除已归档聊天…",
2845
- "archives.unarchiveUnknown": "会话已不存在,无法取消归档。",
2846
- "archives.unarchiveFailed": "取消归档失败:{detail}",
2830
+ "archives.deleteAllPending": "正在删除已归档聊天…",
2831
+ "archives.unarchiveUnknown": "会话已不存在,无法取消归档。",
2832
+ "archives.unarchiveFailed": "取消归档失败:{detail}",
2847
2833
  "archives.archiveUnknown": "会话已不存在,无法归档。",
2848
2834
  "archives.archiveFailed": "归档失败:{detail}",
2849
2835
  "archives.forkFailed": "分叉会话失败:{detail}",
2850
2836
  "deleteSession.title": "删除会话",
2851
2837
  "deleteSession.desc": "将永久删除会话“{name}”及其子代理(含正在运行的)和全部记录(对话内容、统计、缓存),此操作不可恢复。",
2852
- "deleteSession.pending": "正在删除会话…",
2853
- "deleteSession.unknown": "会话已不存在或已被删除。",
2838
+ "deleteSession.pending": "正在删除会话…",
2839
+ "deleteSession.unknown": "会话已不存在或已被删除。",
2854
2840
  "deleteSession.failed": "删除会话失败:{detail}",
2855
2841
  "picker.loading": "正在加载工作区…",
2856
2842
  "conflict.named": "已存在名为“{name}”的工作区。",
@@ -2898,7 +2884,7 @@ window.__ModuleLoader__.load({
2898
2884
  "section.sessions": "Sessions",
2899
2885
  "viewOptions.label": "View options",
2900
2886
  "groupBy.label": "Group by",
2901
- "groupBy.workspace": "WorkSpace",
2887
+ "groupBy.workspace": "Workspace",
2902
2888
  "groupBy.flat": "In one list",
2903
2889
  "orderBy.label": "Order by",
2904
2890
  "orderBy.manual": "Manual",
@@ -2933,16 +2919,16 @@ window.__ModuleLoader__.load({
2933
2919
  "archives.deleteAll": "Delete all",
2934
2920
  "archives.deleteAllTitle": "Delete all archived chats",
2935
2921
  "archives.deleteAllDesc": "This permanently deletes all {n} archived chats, their child agents (including any that are still running), and their records. This cannot be undone.",
2936
- "archives.deleteAllPending": "Deleting archived chats…",
2937
- "archives.unarchiveUnknown": "This session no longer exists, so it cannot be unarchived.",
2938
- "archives.unarchiveFailed": "Could not unarchive the session: {detail}",
2922
+ "archives.deleteAllPending": "Deleting archived chats…",
2923
+ "archives.unarchiveUnknown": "This session no longer exists, so it cannot be unarchived.",
2924
+ "archives.unarchiveFailed": "Could not unarchive the session: {detail}",
2939
2925
  "archives.archiveUnknown": "This session no longer exists, so it cannot be archived.",
2940
2926
  "archives.archiveFailed": "Could not archive the session: {detail}",
2941
2927
  "archives.forkFailed": "Could not fork the session: {detail}",
2942
2928
  "deleteSession.title": "Delete session",
2943
2929
  "deleteSession.desc": "This permanently deletes session “{name}”, its child agents (including any that are still running), and all of its records (conversation, stats, cache). This cannot be undone.",
2944
- "deleteSession.pending": "Deleting session…",
2945
- "deleteSession.unknown": "This session no longer exists or was already deleted.",
2930
+ "deleteSession.pending": "Deleting session…",
2931
+ "deleteSession.unknown": "This session no longer exists or was already deleted.",
2946
2932
  "deleteSession.failed": "Could not delete the session: {detail}",
2947
2933
  "picker.loading": "Loading workspaces…",
2948
2934
  "conflict.named": "A workspace named “{name}” already exists.",
@@ -3139,12 +3125,12 @@ window.__ModuleLoader__.load({
3139
3125
  exports.__test = {
3140
3126
  displayTitle,
3141
3127
  sessionVisible,
3142
- collectArchivedDeleteAllIds,
3143
- collectSessionAndDescendantIds,
3144
3128
  isUnknownSessionError,
3145
3129
  deriveGroups,
3146
3130
  deriveFlat,
3147
3131
  deriveSearchResults,
3132
+ deriveArchivedGroups,
3133
+ deriveArchivedDeleteAllIds,
3148
3134
  createWorkspaceViewStore,
3149
3135
  groupByWorkspace,
3150
3136
  byRecency,
package/lib/projcache.js CHANGED
@@ -1,74 +1,92 @@
1
- import { SessionProjectionCache } from "@deepseek-ai/dsh-session-projection-cache";
2
- //#region lib/types/index.js
3
- /**
4
- * dsh-archive-manager projcache half.
5
- *
6
- * `ArchiveProjectionCache` extends the shipped `SessionProjectionCache`
7
- * (service name `sessionProjectionCache`, same domain, same fail-soft write
8
- * path) and adds two public methods used by the archive manager's
9
- * `deleteSession`:
10
- *
11
- * - `delete(id)` — permanently remove one session's cached projection row
12
- * (`table.delete` on the `session_projcache` domain).
13
- * - `whenIdle()` — resolve once every in-flight public `write()` has settled.
14
- * - `delete(id)` installs a tombstone before removing the row, so an earlier
15
- * write that settles late removes its own stale row instead of resurrecting
16
- * the deleted cache entry.
17
- *
18
- * The default export is a Service subclass (same shape as the shipped
19
- * `@deepseek-ai/dsh-session-projection-cache` package), so the profile patch
20
- * can substitute this package for the `session-projection-cache` row with no
21
- * other wiring change.
22
- */
1
+ import { SessionProjectionCache } from "@deepseek-ai/dsh-session-projection-cache";
2
+ import { trackTombstone } from "./tombstone.js";
3
+ //#region lib/types/index.js
4
+ /**
5
+ * dsh-archive-manager projcache 半边。
6
+ *
7
+ * `ArchiveProjectionCache` 继承上游 `SessionProjectionCache`
8
+ * (服务名 `sessionProjectionCache`,同域、同 fail-soft 写路径),
9
+ * 为归档管理的 `deleteSession` 增加三处守护:
10
+ *
11
+ * - `delete(id)` - 永久移除一个会话的缓存投影行
12
+ * (`session_projcache` 域上的 `table.delete`),写入前先登记墓碑。
13
+ * - `whenIdle()` - 全部在途公开写入落定后 resolve。
14
+ * - 墓碑挡住已删除会话的写入:迟到的写入要么被直接拦截,要么在
15
+ * 落定后补删自己的残留行,不会复活已删除的缓存条目。守护覆盖
16
+ * `write()`(dispose 写后路径)与 `putSoft()`(`coldSnapshot` 的
17
+ * 冷读写回路径),两条落盘路径都纳入 `whenIdle` 跟踪。
18
+ *
19
+ * 默认导出是 Service 子类(与上游 `@deepseek-ai/dsh-session-projection-cache`
20
+ * 包同形),profile 补丁可直接替换 `session-projection-cache` 服务行,
21
+ * 无需其他接线改动。
22
+ */
23
23
  var ArchiveProjectionCache = class extends SessionProjectionCache {
24
- /** 已永久删除的会话不会再允许投影缓存写入。 */
24
+ /** 已永久删除的会话:其投影缓存行不再允许写入。 */
25
25
  deletedSessionIds = /* @__PURE__ */ new Set();
26
+ /** 墓碑插入顺序,用于在上限处淘汰最旧项。 */
26
27
  deletedSessionOrder = [];
27
28
  deletedSessionTombstoneLimit = 4096;
28
- /** Tail of in-flight checkpoint writes (settled promises only). */
29
+ /** 在途写入的队尾(只含已落定的 promise)。 */
29
30
  writeTail = Promise.resolve();
30
- constructor(ctx, config) {
31
- super(ctx, config);
32
- }
31
+ constructor(ctx, config) {
32
+ super(ctx, config);
33
+ }
33
34
  /** 跟踪公开写入路径,避免依赖上游私有 `flushSoft` 的实现细节。 */
34
35
  write(session) {
35
36
  const task = this.writeCore(session);
36
37
  this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
37
38
  return task;
38
39
  }
39
- /**
40
- * 墓碑正确性依赖:super.write(session) 一次整体写入,返回后不再有后续异步落盘。
41
- * 若上游改成多阶段异步,(C) 补删会漏掉后续写入,deletedSessionIds 挡不住复活。
42
- */
43
- async writeCore(session) {
40
+ /**
41
+ * 墓碑正确性依赖:super.write(session) 一次整体写入,返回后不再有后续异步落盘。
42
+ * 若上游改成多阶段异步,(C) 补删会漏掉后续写入,deletedSessionIds 挡不住复活。
43
+ */
44
+ async writeCore(session) {
44
45
  if (this.deletedSessionIds.has(session.id)) return;
45
46
  await super.write(session);
46
47
  if (this.deletedSessionIds.has(session.id)) await this.requireTable().delete(session.id);
47
48
  }
48
- /**
49
- * Resolve once every tracked in-flight write has settled (failures
50
- * included). Fresh calls during an idle cache resolve immediately.
51
- * @returns resolution after the tracked writes settle.
52
- */
53
- whenIdle() {
54
- return this.writeTail;
55
- }
56
- /**
57
- * Permanently remove one session's cached projection row.
58
- * @param id - the session whose cache row to delete.
59
- * @returns resolution after the row is gone.
60
- */
61
- async delete(id) {
62
- if (!this.deletedSessionIds.has(id)) {
63
- this.deletedSessionIds.add(id);
64
- this.deletedSessionOrder.push(id);
65
- }
66
- while (this.deletedSessionOrder.length > this.deletedSessionTombstoneLimit) {
67
- const oldest = this.deletedSessionOrder.shift();
68
- if (oldest !== void 0 && oldest !== id) this.deletedSessionIds.delete(oldest);
49
+ /**
50
+ * 冷读写回路径(上游 `coldSnapshot` 经 `putSoft` 落盘)与 `write` 同守:
51
+ * 已删除直接放弃;删除落在写回进行中则在其落定后补删残留行。
52
+ * 同样纳入 `whenIdle` 跟踪。
53
+ */
54
+ async putSoft(id, identity, rows, what) {
55
+ if (this.deletedSessionIds.has(id)) return;
56
+ const task = this.putSoftCore(id, identity, rows, what);
57
+ this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
58
+ return task;
59
+ }
60
+ async putSoftCore(id, identity, rows, what) {
61
+ await super.putSoft(id, identity, rows, what);
62
+ try {
63
+ if (this.deletedSessionIds.has(id)) await this.requireTable().delete(id);
64
+ } catch (error) {
65
+ this.ctx.logger.warn(`archive-manager projcache: cold-read write-back cleanup for "${id}" failed: ${String(error)}`);
69
66
  }
67
+ }
68
+ /**
69
+ * 全部被跟踪的在途写入落定后 resolve(含失败)。空闲缓存上调用立即返回。
70
+ * @returns 跟踪写入落定后的 resolution。
71
+ */
72
+ whenIdle() {
73
+ return this.writeTail;
74
+ }
75
+ /**
76
+ * 永久移除一个会话的缓存投影行。
77
+ * @param id - 要删除缓存行的会话。
78
+ * @returns 行删除完成后的 resolution。
79
+ */
80
+ async delete(id) {
81
+ trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, id, this.deletedSessionTombstoneLimit);
70
82
  await this.requireTable().delete(id);
71
83
  }
72
- };
73
- //#endregion
74
- export { ArchiveProjectionCache, ArchiveProjectionCache as default };
84
+ /** 撤销墓碑(供测试与同 id 新生命周期复用路径使用)。 */
85
+ clearTombstone(id) {
86
+ this.deletedSessionIds.delete(id);
87
+ const idx = this.deletedSessionOrder.indexOf(id);
88
+ if (idx !== -1) this.deletedSessionOrder.splice(idx, 1);
89
+ }
90
+ };
91
+ //#endregion
92
+ export { ArchiveProjectionCache, ArchiveProjectionCache as default };
@@ -0,0 +1,32 @@
1
+ //#region lib/types/tombstone.js
2
+ /**
3
+ * 通用墓碑簿记:登记一个已删除 id,并按 FIFO 在上限处淘汰最旧项。
4
+ * 工作区注册表(lib/workspace.js)与投影缓存(lib/projcache.js)共用
5
+ * 同一份语义,避免两处拷贝的淘汰策略分叉。
6
+ */
7
+ /**
8
+ * 登记已删除 id 并执行上限淘汰。
9
+ * @param {Set<string>} ids - 墓碑集合。
10
+ * @param {string[]} order - 插入顺序队列(FIFO 淘汰依据)。
11
+ * @param {string} id - 本次登记的 id。
12
+ * @param {number} limit - 墓碑上限。
13
+ * @returns {string[]} 被淘汰出集合的旧 id 列表,调用方据此清理附属簿记。
14
+ */
15
+ function trackTombstone(ids, order, id, limit) {
16
+ if (!ids.has(id)) {
17
+ ids.add(id);
18
+ order.push(id);
19
+ }
20
+ const evicted = [];
21
+ while (order.length > limit) {
22
+ const oldest = order.shift();
23
+ // id 自身被挤出队列时保留在集合中:宁多挡不错放。
24
+ if (oldest !== void 0 && oldest !== id) {
25
+ ids.delete(oldest);
26
+ evicted.push(oldest);
27
+ }
28
+ }
29
+ return evicted;
30
+ }
31
+ //#endregion
32
+ export { trackTombstone };
package/lib/workspace.js CHANGED
@@ -5,27 +5,30 @@ import {
5
5
  } from "@deepseek-ai/dsh-workspace";
6
6
  import { bindTypertRemote, Remote } from "@deepseek-ai/dsh-typert-protocol";
7
7
  import { sessionDir } from "@deepseek-ai/dsh-spill-local";
8
+ import { trackTombstone } from "./tombstone.js";
8
9
  //#region lib/types/index.js
9
- /**
10
- * dsh-archive-manager 宿主侧归档会话管理。
11
- *
12
- * `deleteSession(sessionId)` 的顺序即语义:校验已知会话;实时会话先
13
- * flush 再 detach;等待投影缓存写入完成;移除归档标记和工作区记账;
14
- * 删除投影缓存,级联删除 SUBAGENT 子会话并清理 spill;最后才删除转录
15
- * 目录。fork 分支虽有 `parentSession`,但属于独立用户会话,不参与级联。
16
- *
17
- * 所有可失败的持久化清理都先于不可逆的物理删除。此前任一步失败时,
18
- * 转录仍保留,可重试删除;物理删除失败时记录残留路径供人工排查。
19
- * 物理删除成功后必须遗忘父类内存索引(headers / sessionPaths /
20
- * invalidSessionPaths)并打上删除墓碑:父类 sessionKnown 以 headers.has
21
- * 短路,indexHeaders 只增不减,stale list() 否则会把已删 id 救活,
22
- * 进而被 archiveSession 重新写回 archivedSessionIds。
23
- * `unarchiveSession` 与 `deleteSession` 通过 Typert Remote 暴露给浏览器,并注册到 typert.local,避免生产环境只靠 SRC 扫描时 404。
24
- */
10
+ /**
11
+ * dsh-archive-manager 宿主侧归档会话管理。
12
+ *
13
+ * `deleteSession(sessionId)` 的顺序即语义:校验已知会话;实时会话先
14
+ * flush 再 detach;等待投影缓存写入完成;移除归档标记和工作区记账;
15
+ * 删除投影缓存,级联删除 SUBAGENT 子会话并清理 spill;最后才删除转录
16
+ * 目录。fork 分支虽有 `parentSession`,但属于独立用户会话,不参与级联。
17
+ *
18
+ * 所有可失败的持久化清理都先于不可逆的物理删除。此前任一步失败时,
19
+ * 转录仍保留,可重试删除;物理删除失败时记录残留路径供人工排查。
20
+ * 物理删除成功后必须遗忘父类内存索引(headers / sessionPaths /
21
+ * invalidSessionPaths)并打上删除墓碑:父类 sessionKnown 以 headers.has
22
+ * 短路,indexHeaders 只增不减,stale list() 否则会把已删 id 救活,
23
+ * 进而被 archiveSession 重新写回 archivedSessionIds。
24
+ * 墓碑同时记录被删生命周期的日志身份(createdAt/cwd):其他进程以同 id
25
+ * 重建并落盘新会话时(冷复用),sessionKnown 的探针以身份差异区分新旧
26
+ * 生命周期并撤掉墓碑,避免已重建的会话在本进程永久 UNKNOWN_SESSION。
27
+ * `unarchiveSession` 与 `deleteSession` 通过 Typert Remote 暴露给浏览器,并注册到 typert.local,避免生产环境只靠 SRC 扫描时 404。
28
+ */
25
29
  function markRemoteMethod(instance, method) {
26
- // Simulate the TS decorator pipeline `@Remote(method)` for one method:
27
- // `Remote` returns a standard method decorator; we hand it a decorator
28
- // context whose addInitializer runs immediately with `this` = instance.
30
+ // 模拟 TS 装饰器管线 `@Remote(method)`:`Remote` 返回标准方法装饰器,
31
+ // 这里构造一个 addInitializer 立即以 `this` = instance 执行的装饰器上下文。
29
32
  const context = {
30
33
  private: false,
31
34
  static: false,
@@ -36,17 +39,24 @@ function markRemoteMethod(instance, method) {
36
39
  };
37
40
  Remote(method)(void 0, context);
38
41
  }
39
- function unknownSessionMessage(sessionId) {
40
- return `unknown session "${sessionId}" (UNKNOWN_SESSION)`;
41
- }
42
- var ArchiveUnknownSessionError = class extends Error {
43
- sessionId;
44
- constructor(sessionId) {
45
- super(unknownSessionMessage(sessionId));
46
- this.sessionId = sessionId;
47
- this.name = "ArchiveUnknownSessionError";
48
- }
49
- };
42
+ function unknownSessionMessage(sessionId) {
43
+ return `unknown session "${sessionId}" (UNKNOWN_SESSION)`;
44
+ }
45
+ var ArchiveUnknownSessionError = class extends Error {
46
+ sessionId;
47
+ constructor(sessionId) {
48
+ super(unknownSessionMessage(sessionId));
49
+ this.sessionId = sessionId;
50
+ this.name = "ArchiveUnknownSessionError";
51
+ }
52
+ }
53
+ /** 头部投影到“日志身份”字段(与投影缓存的 identity 语义一致)。cwd 缺失统一归一为 null,避免一侧带键一侧不带键时的比较歧义。 */
54
+ function headerIdentity(header) {
55
+ return {
56
+ createdAt: header.createdAt,
57
+ cwd: header.cwd ?? null
58
+ };
59
+ }
50
60
  const sessionIdSchema = {
51
61
  parse(value) {
52
62
  if (typeof value !== "string" || value.length === 0) throw new TypeError(`sessionId must be a non-empty string, got ${String(value)}`);
@@ -141,6 +151,8 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
141
151
  deletedSessionOrder = [];
142
152
  /** 墓碑上限:足够挡住 stale list(),又避免长驻进程无限增长。 */
143
153
  deletedSessionTombstoneLimit = 4096;
154
+ /** 被删生命周期的日志身份(createdAt/cwd):冷复用探针区分“同 id 新会话”与 stale list() 的依据。 */
155
+ deletedIdentities = /* @__PURE__ */ new Map();
144
156
  constructor(ctx) {
145
157
  super(ctx);
146
158
  this.typertRemote = bindTypertRemote(this, this.name);
@@ -149,13 +161,11 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
149
161
  registerHostRemote(this.ctx);
150
162
  }
151
163
  /**
152
- * Move one session out of the registry-global archive set, restoring its
153
- * normal visibility (its accounting seat was never moved, so the session
154
- * reappears at its original workspace position). Idempotent: an
155
- * already-unarchived session resolves without writing; an unknown session
156
- * rejects like `archiveSession` does.
157
- * @param sessionId - the session to unarchive.
158
- * @returns the full updated archive set.
164
+ * 把一个会话移出注册表全局归档集合,恢复其正常可见性(其记账位从未
165
+ * 移动,会话在原工作区位置重新出现)。幂等:未归档的已知会话直接返回
166
+ * 当前集合不写入;未知会话与 `archiveSession` 一样抛错。
167
+ * @param sessionId - 要取消归档的会话。
168
+ * @returns 更新后的完整归档集合。
159
169
  */
160
170
  async unarchiveSession(sessionId) {
161
171
  return this.enqueueOperation(async () => {
@@ -171,51 +181,52 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
171
181
  });
172
182
  }
173
183
  /**
174
- * Permanently delete one session and every trace of it (transcript
175
- * directory, workspace accounting, archive marker, projection cache row).
176
- * @param sessionId - the session to delete.
177
- * @returns `{ deleted: true }` after durability.
178
- * @throws {@link ArchiveUnknownSessionError} when the session is unknown.
184
+ * 永久删除一个会话及其全部痕迹(转录目录、工作区记账、归档标记、
185
+ * 投影缓存行)。
186
+ * @param sessionId - 要删除的会话。
187
+ * @returns 持久化完成后的 `{ deleted: true }`。
188
+ * @throws {@link ArchiveUnknownSessionError} 会话未知时抛出。
179
189
  */
180
190
  async deleteSession(sessionId) {
181
191
  return this.enqueueOperation(() => this.deleteSessionCore(sessionId));
182
192
  }
183
- /** The serialized core deletion body (also used by the cascade path, which
184
- * already holds the operation chain — it must never re-enqueue). */
193
+ /** 串行化后的删除主体(级联路径复用:它已持有操作链,绝不能再入队)。 */
185
194
  async deleteSessionCore(sessionId) {
186
195
  if (!await this.sessionKnown(sessionId)) throw new ArchiveUnknownSessionError(sessionId);
187
196
  const sessions = this.ctx.get("sessions");
188
197
  const live = sessions?.get(sessionId);
189
- if (live !== void 0) {
190
- // Durability barrier first: no pending transcript writes may race
191
- // the directory removal (the persistence backend closes handles per
192
- // batch, so a flushed session leaves no open file).
198
+ // 先记录被删生命周期的日志身份:目录删除后头部不可再读,
199
+ // 冷复用探针(sessionKnown 墓碑分支)靠它区分同 id 的新生命周期。
200
+ const deletedHeader = this.headers.get(sessionId) ?? live?.header;
201
+ if (live !== void 0) {
202
+ // 持久化屏障先行:不能有未落盘的转录写入与目录删除竞争
203
+ //(持久化后端按批关闭句柄,flush 过的会话不再持有打开的文件)。
193
204
  await sessions.flush(live);
194
- // Detach from the store; `session/disposed` fires synchronously,
195
- // which drives the browser `host/session-removed` frame and starts
196
- // the projection cache's final write-behind.
197
- const entry = sessions.liveEntryFor(live);
198
- sessions.detachEntered(entry);
199
- } else if (sessions !== void 0) await this.publishColdSessionRemoval(sessionId, sessions);
205
+ // 从存储分离;`session/disposed` 同步触发,驱动浏览器端的
206
+ // `host/session-removed` 帧并启动投影缓存的最终写后落盘。
207
+ const entry = sessions.liveEntryFor(live);
208
+ sessions.detachEntered(entry);
209
+ } else if (sessions !== void 0) await this.publishColdSessionRemoval(sessionId, sessions);
200
210
  const projCache = this.ctx.get("sessionProjectionCache");
201
- // The dispose write-behind must land BEFORE the cache row is deleted,
202
- // otherwise the row is written back after deletion (resurrected).
203
- await projCache?.whenIdle?.();
204
- // 物理删除不可逆:先完成所有可失败的持久化清理,失败时仍可重试。
205
- const state = this.requireState();
211
+ // dispose 的写后落盘必须先于缓存行删除完成,
212
+ // 否则该行会在删除之后被写回(复活)。
213
+ await projCache?.whenIdle?.();
214
+ // 物理删除不可逆:先完成所有可失败的持久化清理,失败时仍可重试。
215
+ const state = this.requireState();
206
216
  if (state.archivedSessionIds.includes(sessionId)) {
207
217
  await this.setState({
208
218
  ...state,
209
219
  archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
210
220
  });
211
- }
212
- await this.removeFromWorkspaceAccounts(sessionId);
213
- if (projCache !== void 0) await projCache.delete(sessionId);
214
- await this.deleteDescendants(sessionId);
221
+ }
222
+ await this.removeFromWorkspaceAccounts(sessionId);
223
+ if (projCache !== void 0) await projCache.delete(sessionId);
224
+ await this.deleteDescendants(sessionId);
215
225
  await this.cleanSpill(sessionId);
216
226
  await this.removeTranscriptDirectory(sessionId);
217
227
  // 物理删除已成功:此时再清父类索引。失败时保留索引,便于重试。
218
228
  this.forgetIndexedSession(sessionId);
229
+ if (deletedHeader !== void 0) this.deletedIdentities.set(sessionId, headerIdentity(deletedHeader));
219
230
  return { deleted: true };
220
231
  }
221
232
  /**
@@ -224,18 +235,12 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
224
235
  */
225
236
  clearTombstone(sessionId) {
226
237
  this.deletedSessionIds.delete(sessionId);
238
+ this.deletedIdentities.delete(sessionId);
227
239
  const idx = this.deletedSessionOrder.indexOf(sessionId);
228
240
  if (idx !== -1) this.deletedSessionOrder.splice(idx, 1);
229
241
  }
230
242
  forgetIndexedSession(sessionId) {
231
- if (!this.deletedSessionIds.has(sessionId)) {
232
- this.deletedSessionIds.add(sessionId);
233
- this.deletedSessionOrder.push(sessionId);
234
- }
235
- while (this.deletedSessionOrder.length > this.deletedSessionTombstoneLimit) {
236
- const oldest = this.deletedSessionOrder.shift();
237
- if (oldest !== void 0 && oldest !== sessionId) this.deletedSessionIds.delete(oldest);
238
- }
243
+ for (const evicted of trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, sessionId, this.deletedSessionTombstoneLimit)) this.deletedIdentities.delete(evicted);
239
244
  this.headers.delete(sessionId);
240
245
  this.sessionPaths.delete(sessionId);
241
246
  this.invalidSessionPaths.delete(sessionId);
@@ -249,56 +254,80 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
249
254
  this.clearTombstone(id);
250
255
  return true;
251
256
  }
252
- if (this.deletedSessionIds.has(id)) return false;
257
+ if (this.deletedSessionIds.has(id)) return this.coldReuseKnown(id);
253
258
  return super.sessionKnown(id);
254
259
  }
255
260
  /**
261
+ * 墓碑分支的冷复用探针:其他进程以同 id 重建并落盘的新会话(日志身份
262
+ * 不同)撤墓碑放行并重新编入索引;stale list() 里同生命周期的旧头部
263
+ * 仍视为未知。身份不可考(删除时未取到头部)时保守维持未知。
264
+ */
265
+ async coldReuseKnown(id) {
266
+ const deletedIdentity = this.deletedIdentities.get(id);
267
+ if (deletedIdentity === void 0) return false;
268
+ const persistence = this.ctx.get("sessionPersistence");
269
+ if (persistence === void 0 || typeof persistence.list !== "function") return false;
270
+ let header;
271
+ try {
272
+ header = (await persistence.list()).find((item) => item.id === id);
273
+ } catch (error) {
274
+ this.ctx.logger.warn(`archive-manager: cold-reuse probe for "${id}" failed: ${String(error)}`);
275
+ return false;
276
+ }
277
+ if (header === void 0) return false;
278
+ const listed = headerIdentity(header);
279
+ if (listed.createdAt === deletedIdentity.createdAt && listed.cwd === deletedIdentity.cwd) return false;
280
+ this.clearTombstone(id);
281
+ await this.indexHeader(header);
282
+ return true;
283
+ }
284
+ /**
256
285
  * 父类 indexHeaders 只增不减;跳过墓碑 id,避免 stale persistence.list()
257
286
  * 把已删除会话重新编入 headers。
258
287
  */
259
288
  async indexHeader(header) {
260
289
  if (this.deletedSessionIds.has(header.id)) return;
261
290
  return super.indexHeader(header);
262
- }
263
- /** 为未处于实时状态的持久化会话发布相同的移除事件。 */
264
- async publishColdSessionRemoval(sessionId, sessions) {
265
- const persistence = this.ctx.get("sessionPersistence");
266
- if (persistence === void 0 || typeof persistence.prepare !== "function") return;
267
- try {
268
- const preparation = await persistence.prepare(sessionId);
269
- const detach = sessions.enter(preparation.session);
270
- try {
271
- sessions.announce(preparation.session);
272
- } finally {
273
- detach();
274
- preparation[Symbol.dispose]();
275
- }
276
- } catch (error) {
277
- this.ctx.logger.warn(`archive-manager: could not publish removal for stored session "${sessionId}": ${String(error)}`);
278
- }
279
- }
280
- /** 删除会话的转录目录;仅在所有记账清理完成后调用。 */
281
- async removeTranscriptDirectory(sessionId) {
291
+ }
292
+ /** 为未处于实时状态的持久化会话发布相同的移除事件。 */
293
+ async publishColdSessionRemoval(sessionId, sessions) {
294
+ const persistence = this.ctx.get("sessionPersistence");
295
+ if (persistence === void 0 || typeof persistence.prepare !== "function") return;
296
+ try {
297
+ const preparation = await persistence.prepare(sessionId);
298
+ const detach = sessions.enter(preparation.session);
299
+ try {
300
+ sessions.announce(preparation.session);
301
+ } finally {
302
+ detach();
303
+ preparation[Symbol.dispose]();
304
+ }
305
+ } catch (error) {
306
+ this.ctx.logger.warn(`archive-manager: could not publish removal for stored session "${sessionId}": ${String(error)}`);
307
+ }
308
+ }
309
+ /** 删除会话的转录目录;仅在所有记账清理完成后调用。 */
310
+ async removeTranscriptDirectory(sessionId) {
282
311
  const persistence = this.ctx.get("sessionPersistence");
283
312
  if (persistence === void 0 || typeof persistence.locate !== "function") {
284
313
  throw new Error(`cannot delete session "${sessionId}": the session persistence backend does not expose locate() to resolve its transcript directory`);
285
314
  }
286
- const header = await this.readSessionHeader(sessionId);
287
- const location = persistence.locate(header);
288
- if (location === void 0 || typeof location.path !== "string") {
289
- throw new Error(`cannot delete session "${sessionId}": the session persistence backend could not resolve its transcript directory`);
290
- }
291
- const transcriptDir = dirname(location.path);
292
- try {
293
- await rm(transcriptDir, { recursive: true, force: true });
294
- } catch (error) {
295
- const message = `cannot delete session "${sessionId}": transcript directory "${transcriptDir}" remains after bookkeeping cleanup`;
296
- const detail = `${message}: ${String(error)}`;
297
- this.ctx.logger.warn(`archive-manager: ${detail}`);
298
- throw new Error(detail, { cause: error });
299
- }
300
- }
301
- /** Drop the id from every workspace record and refresh entity snapshots. */
315
+ const header = await this.readSessionHeader(sessionId);
316
+ const location = persistence.locate(header);
317
+ if (location === void 0 || typeof location.path !== "string") {
318
+ throw new Error(`cannot delete session "${sessionId}": the session persistence backend could not resolve its transcript directory`);
319
+ }
320
+ const transcriptDir = dirname(location.path);
321
+ try {
322
+ await rm(transcriptDir, { recursive: true, force: true });
323
+ } catch (error) {
324
+ const message = `cannot delete session "${sessionId}": transcript directory "${transcriptDir}" remains after bookkeeping cleanup`;
325
+ const detail = `${message}: ${String(error)}`;
326
+ this.ctx.logger.warn(`archive-manager: ${detail}`);
327
+ throw new Error(detail, { cause: error });
328
+ }
329
+ }
330
+ /** 把 id 从每个工作区记录中移除,并刷新实体快照。 */
302
331
  async removeFromWorkspaceAccounts(sessionId) {
303
332
  const table = this.requireTable();
304
333
  const state = this.requireState();
@@ -314,10 +343,9 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
314
343
  if (entity !== void 0) entity.record = next;
315
344
  }
316
345
  }
317
- /** Best-effort cascade: delete SUBAGENT child sessions of `sessionId`.
318
- * Only sessions whose header marks `origin: "subagent"` qualify: `parentSession`
319
- * alone is ambiguous (fork branches also carry it), and a fork branch is an
320
- * independent user session that must never be cascade-deleted. */
346
+ /** 尽力而为的级联删除:删除 `sessionId` 的 SUBAGENT 子会话。
347
+ * 仅头部标记 `origin: "subagent"` 的会话参与:单凭 `parentSession` 有歧义
348
+ *(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。 */
321
349
  async deleteDescendants(sessionId) {
322
350
  try {
323
351
  const descendants = [];
@@ -341,7 +369,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
341
369
  this.ctx.logger.warn(`archive-manager: descendant enumeration for deleted session "${sessionId}" failed: ${String(error)}`);
342
370
  }
343
371
  }
344
- /** Best-effort spill cleanup: remove the session-scoped spill directory. */
372
+ /** 尽力而为的 spill 清理:移除该会话作用域的 spill 目录。 */
345
373
  async cleanSpill(sessionId) {
346
374
  try {
347
375
  const spill = this.ctx.get("spillStore");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-archive-manager",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "NPM-installable DSH Web plugin for managing archived sessions.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",