dsh-session-manager 0.7.5 → 0.7.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.7.6 — 2026-11-13
6
+
7
+ - Fix conditional React hooks in the session header and move dialog (#49).
8
+ - Open sessions from their titles in the management panel and remove the redundant Open button (#54).
9
+ - Display Migrate preset as an icon button with a hover description, accessible label, and active state, matching the other row actions.
10
+ - Use the shared DSH tooltip for session row priority badges instead of the browser's native title tooltip.
11
+ - Stream session headers from first Zstd frames larger than 1 MiB, whether compressed or decompressed, without buffering the entire frame output. Preserve the header-line size limit and validate the complete first frame and its checksum before accepting the header (#53).
12
+ - Delete projection checkpoints through the host storage queue for single, batch, and cascaded subagent deletion. Prevent delayed writes from recreating deleted lifecycle records, and remove matching entries from the retained legacy cache with locking and atomic replacement. Report cleanup failures without hiding successful session deletion (#50, #55).
13
+ - Add regression tests and an optional integration test using the exact DSH `0.2.2-rc.1` cache and storage packages.
14
+ - Manually verified with the official DSH Desktop `0.2.2-rc.1`.
15
+
5
16
  ## 0.7.5 — 2026-10-22
6
17
 
7
18
  - **fix(security)**: Enforce centralized API authentication on all `/session-manager/api/*` routes using DSH official `ctx.connection.admit(req)`. Enforce strict Owner-only authorization (`peer.principal.kind === "owner"`), reject untrusted, unauthenticated, empty, or non-owner requests before body reading and database queries, and fail closed with HTTP 503 (`auth-unavailable`) if connection service is missing or unready, preventing unauthorized access across public tunnels, reverse proxies, and local networks (#43).
package/README.md CHANGED
@@ -12,13 +12,14 @@ English | [中文](README.zh.md)
12
12
 
13
13
  DeepSeek Harness session and workspace manager —— Workspaces: archive and visibility. Sessions: move, migrate presets, delete, favorite, review later, prioritize, tag, and annotate. Batch operations.
14
14
 
15
- Verified with the official DSH Desktop client; see [5 Compatibility](#5-compatibility) for the current verification scope. Both environments share the same plugin Host/client functionality; environment-specific installation notes are documented below.
15
+ The Web UI and Desktop app share the same plugin Host/client functionality; environment-specific installation notes are documented below.
16
16
 
17
17
  ## 1 Features
18
18
 
19
19
  ### 1.1 Session lifecycle management
20
20
 
21
21
  - **Delete** is irreversible and always asks for confirmation. Subagent sessions and transient blank placeholders (no persisted artifact) cannot be deleted.
22
+ - **Projection cache cleanup (DSH 0.2.2-rc.1)**: session deletion also removes its checkpoint and retained legacy cache entry, including cascaded subagents and batch deletion. Delayed writes from the deleted lifecycle are blocked. Cleanup failures are reported as warnings; they do not undo session deletion.
22
23
  - **Archive / Unarchive** moves a session in and out of the active list without touching its disk content.
23
24
  - **Move to workspace** keeps history, title, archive state and derived-session relationships intact, rewrites the session's `cwd` to the target workspace, synchronizes the runtime `workingDirectory` projection for active agents, and updates the live writer's header in place so any pending tool calls keep landing on the new path. When the session's `cwd` already equals the target workspace path but its membership was never recorded, only the missing membership is repaired: no `cwd` rewrite, no artifact rewrite or move, and no session restart.
24
25
  - **Migrate Agent preset** rewrites the latest `agent-preset/selected` event (or the session header if no such event exists) so a session whose preset was renamed or removed can resume. Message history is never altered.
@@ -70,7 +71,7 @@ The right side of the title area exposes actions for the **current session**: **
70
71
 
71
72
  ### 2.2 Sessions and workspaces management entry and interface
72
73
 
73
- Open **Sessions & Workspaces** from the bottom of DSH's sidebar to browse all sessions and workspaces: the session management tab supports switching workspaces, searching by title / ID / notes / tags, applying filters and sorting, and running **Open / Archive / Unarchive / Tags / Notes / Move / Migrate preset / Delete** on any session row; the workspace management tab supports searching, sorting, bulk actions, and visibility toggling.
74
+ Open **Sessions & Workspaces** from the bottom of DSH's sidebar to browse all sessions and workspaces. Click a session title to open it; there is no separate Open button. Row actions, including **Migrate preset**, use icons with hover descriptions and accessible labels. The session tab supports switching workspaces, searching by title / ID / notes / tags, filtering, and sorting; the workspace tab supports searching, sorting, bulk actions, and visibility toggling.
74
75
 
75
76
  ### 2.3 Bulk management
76
77
 
@@ -142,12 +143,11 @@ After changing plugin dependencies, `package.json`, bundle configuration, or oth
142
143
 
143
144
  ## 5 Compatibility
144
145
 
145
- The table below lists verified version combinations between DeepSeek Harness (DSH) and `dsh-session-manager`.
146
+ The table below lists compatible version combinations between DeepSeek Harness (DSH) and `dsh-session-manager`.
146
147
 
147
- > **Verification note**: From version 0.6.3 onwards, this plugin is verified with the official DSH Desktop client only (the Web UI shares the same Host/client codebase but is no longer covered by the verification matrix).
148
-
149
- | DeepSeek Harness (DSH) | dsh-session-manager | Verified Environment |
148
+ | DeepSeek Harness (DSH) | dsh-session-manager | Environment |
150
149
  | :--- | :--- | :--- |
150
+ | **v0.2.2-rc.1** | `0.7.6` | Desktop |
151
151
  | **v0.2.1-rc.2** | `0.7.5`, `0.7.4`, `0.7.3`, `0.7.2` | Desktop |
152
152
  | **v0.2.0-rc.2** | `0.7.1`, `0.7.0`, `0.6.4`, `0.6.3`, `0.6.2`, `0.5.4` | Desktop |
153
153
  | **v0.1.7-rc.2** | `0.5.3` | Desktop / Web UI |
@@ -159,8 +159,6 @@ The table below lists verified version combinations between DeepSeek Harness (DS
159
159
  | **v0.1.2-rc.1** | `0.4.0` | Web UI |
160
160
  | **v0.1.0-rc.7** | `0.1.2`, `0.1.1`, `0.1.0` | Web UI |
161
161
 
162
- The version combinations above have been tested with either the official Web UI or Desktop app. Other version combinations may also work but have not been individually verified.
163
-
164
162
  When using a standalone DSH CLI/runtime, Node.js 22.15+ (22.x) or 24+ is required for built-in Zstd support. The official Desktop app ships and manages its matching runtime separately.
165
163
 
166
164
  This is a Cordis plugin and declares `cordis: ">=4.0.0-rc <5"` as its peer dependency.
package/README.zh.md CHANGED
@@ -12,13 +12,14 @@
12
12
 
13
13
  DSH 会话与工作区管理——工作区:归档与显示。会话:跨工作区移动、预设迁移、删除;收藏、待回看、优先级、标签、备注。批量操作。
14
14
 
15
- 已在官方 DSH Desktop 客户端完成实际测试,当前验证范围见「5 兼容性」;两种环境共用本插件的 Host 与客户端功能,具体安装方式见下文。
15
+ Web UI 与 Desktop 共用本插件的 Host 与客户端功能,具体安装方式见下文。
16
16
 
17
17
  ## 1 功能
18
18
 
19
19
  ### 1.1 会话生命周期管理
20
20
 
21
21
  - **删除**:不可逆操作,UI 始终要求二次确认。subagent 会话和尚未落盘的空白会话占位不能删除。
22
+ - **投影缓存清理(DSH 0.2.2-rc.1)**:删除会话时同时清理其 checkpoint 与旧版聚合缓存条目,覆盖批量删除和级联子智能体删除,并阻止已删生命周期的延迟写回。清理失败会返回警告,不会撤销已完成的会话删除。
22
23
  - **归档 / 移出归档**:把会话移出或移回主列表,不删除磁盘内容。
23
24
  - **移动至工作区**:跨工作区移动时保留历史、标题、归档状态和派生会话关系,同时把 `cwd` 重写为目标工作区,实时同步活动 Agent 的运行时 `workingDirectory` 投影,并就地更新 live writer 的 header,使挂起的工具调用继续落到新路径。若会话的 `cwd` 本来就等于目标工作区、只是归属未登记,则只补全归属记录,不改写 `cwd`、不重写或移动工件、不重启会话。
24
25
  - **迁移 Agent 预设**:原预设被改名或删除导致会话无法恢复时,可修复该会话。迁移会就地重写最后一条 `agent-preset/selected` 事件(若从未记录则修改会话 header),不改动历史消息。
@@ -68,7 +69,7 @@ DSH 会话与工作区管理——工作区:归档与显示。会话:跨工
68
69
 
69
70
  ### 2.2 会话与工作区管理入口与界面
70
71
 
71
- 从 DSH 侧边栏底部进入 **会话与工作区**,可浏览全部会话与工作区:会话管理页支持切换工作区、按标题 / ID / 备注 / 标签搜索、应用筛选与排序,并对每条会话执行 **打开 / 归档 / 移出归档 / 标签 / 备注 / 移动 / 迁移预设 / 删除** 操作;工作区管理页支持搜索、排序、批量处理与显示开关切换。
72
+ 从 DSH 侧边栏底部进入 **会话与工作区**,可浏览全部会话与工作区。点击会话名称即可打开会话,不再提供独立的「打开」按钮。行操作(含 **迁移预设**)统一使用图标,并提供悬停提示与无障碍名称。会话管理页支持切换工作区、按标题 / ID / 备注 / 标签搜索、筛选与排序;工作区管理页支持搜索、排序、批量处理与显示开关切换。
72
73
 
73
74
  ### 2.3 批量管理入口
74
75
 
@@ -140,12 +141,11 @@ dsh plugin --profile web add <本仓库路径>
140
141
 
141
142
  ## 5 兼容性
142
143
 
143
- 下表列出 DeepSeek Harness(DSH)与 `dsh-session-manager` 已验证的版本组合。
144
+ 下表列出 DeepSeek Harness(DSH)与 `dsh-session-manager` 的兼容版本组合。
144
145
 
145
- > **版本验证说明**:自 0.6.3 起,本插件仅在官方 DSH Desktop 客户端上进行验证(Web UI 与本插件共用同一套 Host / 客户端代码,但不再纳入常规验证范围)。
146
-
147
- | DeepSeek Harness (DSH) | dsh-session-manager | 验证环境 |
146
+ | DeepSeek Harness (DSH) | dsh-session-manager | 环境 |
148
147
  | :--- | :--- | :--- |
148
+ | **v0.2.2-rc.1** | `0.7.6` | Desktop |
149
149
  | **v0.2.1-rc.2** | `0.7.5`, `0.7.4`, `0.7.3`, `0.7.2` | Desktop |
150
150
  | **v0.2.0-rc.2** | `0.7.1`, `0.7.0`, `0.6.4`, `0.6.3`, `0.6.2`, `0.5.4` | Desktop |
151
151
  | **v0.1.7-rc.2** | `0.5.3` | Desktop / Web UI |
@@ -157,8 +157,6 @@ dsh plugin --profile web add <本仓库路径>
157
157
  | **v0.1.2-rc.1** | `0.4.0` | Web UI |
158
158
  | **v0.1.0-rc.7** | `0.1.2`, `0.1.1`, `0.1.0` | Web UI |
159
159
 
160
- 以上版本组合已在官方 Web UI 或 Desktop 客户端中完成测试。其他版本组合可能同样兼容,但未逐一验证。
161
-
162
160
  使用独立 DSH CLI / runtime 时,需要 Node.js 22.15+(22.x)或 24+,以提供内置 Zstd 支持。官方 Desktop 客户端单独携带并管理与其版本匹配的 runtime。
163
161
 
164
162
  本插件是 Cordis 插件,peer dependency 为 `cordis: ">=4.0.0-rc <5"`。
package/lib/client.js CHANGED
@@ -728,7 +728,7 @@ function isTopmostDialog(z) {
728
728
  ".sm-rowCurrent{border-color:var(--dsw-alias-state-business-primary,var(--dsw-alias-label-secondary))}",
729
729
  ".sm-rowMain{min-width:0;display:flex;flex-direction:column;gap:2px;flex:1 1 280px}",
730
730
  ".sm-rowTitle{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;display:flex;align-items:center;gap:6px;min-width:0}",
731
- ".sm-rowTitle>span:first-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}",
731
+ ".sm-rowTitle>span:first-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.sm-rowTitleButton{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:0;padding:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}.sm-rowTitleButton:hover{text-decoration:underline}",
732
732
  ".sm-rowMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}",
733
733
  ".sm-rowActions{display:flex;gap:4px;flex:none;flex-wrap:wrap;justify-content:flex-end}",
734
734
  ".sm-badge{flex:none;border-radius:999px;padding:0 6px;font-size:10px;line-height:16px}",
@@ -2585,6 +2585,20 @@ function isTopmostDialog(z) {
2585
2585
  );
2586
2586
  }
2587
2587
 
2588
+ function PresetIcon() {
2589
+ return h("svg", {
2590
+ viewBox: "0 0 24 24", width: 14, height: 14,
2591
+ fill: "none", stroke: "currentColor", strokeWidth: "1.5",
2592
+ strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
2593
+ style: { display: "block" }
2594
+ },
2595
+ h("path", { d: "M3 6h3m4 0h11M3 12h11m4 0h3M3 18h5m4 0h9" }),
2596
+ h("circle", { cx: 8, cy: 6, r: 2 }),
2597
+ h("circle", { cx: 16, cy: 12, r: 2 }),
2598
+ h("circle", { cx: 10, cy: 18, r: 2 })
2599
+ );
2600
+ }
2601
+
2588
2602
  function HeaderEllipsisIcon(props = {}) {
2589
2603
  const size = props.size || 14;
2590
2604
  return h("svg", {
@@ -3019,7 +3033,9 @@ function isTopmostDialog(z) {
3019
3033
  h("div", { className: "sm-bulkResultList" },
3020
3034
  group.map((item, i) => h("div", { key: (item.sessionId || i) + "-" + i, className: "sm-bulkResultItem", title: item.sessionId || "" },
3021
3035
  h("div", { className: "sm-bulkResultItemTitle" }, item.displayTitle || item.sessionId || "—"),
3022
- reasonClass && (item.reason || item.error) ? h("div", { className: reasonClass }, item.reason || item.error) : null
3036
+ reasonClass && (item.reason || item.error) ? h("div", { className: reasonClass }, item.reason || item.error) : null,
3037
+ Array.isArray(item.warnings) && item.warnings.length > 0
3038
+ ? h("div", { className: "sm-warn", role: "status" }, item.warnings.filter(warning => typeof warning === "string").join("\n")) : null
3023
3039
  ))
3024
3040
  )
3025
3041
  );
@@ -3661,6 +3677,7 @@ function isTopmostDialog(z) {
3661
3677
  window.removeEventListener("click", clickHandler, true);
3662
3678
  };
3663
3679
  }, [open, zIndex]);
3680
+ const floatingCoords = useFloatingPosition(props.anchor, open, { placement: "bottom-end", dialogWidth: 360, dialogHeight: 320 });
3664
3681
  if (!open) return null;
3665
3682
 
3666
3683
  const isSame = Boolean(selectedWorkspaceId && currentWorkspaceId && selectedWorkspaceId === currentWorkspaceId);
@@ -3694,7 +3711,6 @@ function isTopmostDialog(z) {
3694
3711
  onConfirm(selectedWorkspaceId);
3695
3712
  }
3696
3713
  };
3697
- const floatingCoords = useFloatingPosition(props.anchor, open, { placement: "bottom-end", dialogWidth: 360, dialogHeight: 320 });
3698
3714
  const topPx = floatingCoords ? floatingCoords.top : (props.anchor && typeof props.anchor.top === "number" ? props.anchor.top : null);
3699
3715
  const leftPx = floatingCoords ? floatingCoords.left : (props.anchor && typeof props.anchor.left === "number" ? props.anchor.left : null);
3700
3716
  const isCentered = Boolean(floatingCoords && floatingCoords.centered);
@@ -3927,7 +3943,7 @@ function isTopmostDialog(z) {
3927
3943
  ));
3928
3944
  }
3929
3945
 
3930
- const CURRENT_VERSION = "0.7.5";
3946
+ const CURRENT_VERSION = "0.7.6";
3931
3947
 
3932
3948
  function compareSemver(v1, v2) {
3933
3949
  if (typeof v1 !== "string" || typeof v2 !== "string") return 0;
@@ -5125,6 +5141,15 @@ function isTopmostDialog(z) {
5125
5141
  const mark = annotationOf(annotations, s.id);
5126
5142
  const isSelected = selectedIds.has(s.id);
5127
5143
  const isWsArchived = isSessionWorkspaceArchived(s);
5144
+ const openRow = () => {
5145
+ if (isArchived) {
5146
+ if (isWsArchived) {
5147
+ if (typeof window !== "undefined" && typeof window.alert === "function") window.alert(t("error.workspaceArchived"));
5148
+ return;
5149
+ }
5150
+ runRow(s.id, async () => { await onUnarchive(s.id); await onOpen(s.id); });
5151
+ } else onOpen(s.id);
5152
+ };
5128
5153
  const checkbox = (selectedIds.size === 0 && !selectModeEnabled)
5129
5154
  ? null
5130
5155
  : h("label", { className: "sm-bulkRowCheckbox", onClick: (e) => e.stopPropagation() },
@@ -5144,7 +5169,7 @@ function isTopmostDialog(z) {
5144
5169
  h("div", { className: "sm-rowCheckboxSlot" + ((selectedIds.size > 0 || selectModeEnabled) ? " sm-rowCheckboxSlot-visible" : "") }, checkbox),
5145
5170
  h("div", { className: "sm-rowMain" },
5146
5171
  h("div", { className: "sm-rowTitle" },
5147
- h("span", null, s.blank ? t("panel.session.new") : (s.displayTitle || s.id)),
5172
+ h("button", { type: "button", className: "sm-rowTitleButton", title: t("row.open"), "aria-label": t("row.open") + ": " + (s.blank ? t("panel.session.new") : (s.displayTitle || s.id)), disabled: busy || bulkBusy, onClick: openRow }, s.blank ? t("panel.session.new") : (s.displayTitle || s.id)),
5148
5173
  isWsArchived ? h("span", { className: "sm-badge sm-badgeWorkspaceArchived" }, t("panel.workspaceArchived")) : null,
5149
5174
  isArchived ? h("span", { className: "sm-badge sm-badgeArchived" }, t("panel.archived")) : null,
5150
5175
  s.running ? h("span", { className: "sm-badge sm-badgeRunning" }, t("panel.running")) : null,
@@ -5152,14 +5177,13 @@ function isTopmostDialog(z) {
5152
5177
  ),
5153
5178
  h("div", { className: "sm-rowSecondary" },
5154
5179
  h("div", { className: "sm-rowBadges" },
5155
- h("span", { className: "sm-priorityBadge sm-priority-" + (mark.priority ?? 3), title: t("marks.priority." + (mark.priority ?? 3)) }, "P" + (mark.priority ?? 3)),
5180
+ renderTip(t("marks.priority." + (mark.priority ?? 3)), h("span", { className: "sm-priorityBadge sm-priority-" + (mark.priority ?? 3), "aria-label": t("marks.priority." + (mark.priority ?? 3)) }, "P" + (mark.priority ?? 3))),
5156
5181
  mark.tags.slice(0, 2).map(tag => h("button", { key: tag, type: "button", className: "sm-tagChip", title: tag, onClick: () => setTagFilter("tag:" + tag.toLowerCase()) }, tag)),
5157
5182
  mark.tags.length > 2 ? h("span", { title: mark.tags.join(", ") }, "+" + (mark.tags.length - 2)) : null,
5158
5183
  mark.note ? h("button", { type: "button", className: "sm-noteBadge", title: t("marks.note.exists"), onClick: () => { setAnnotationAnchor(null); setAnnotationFor(current => current?.id === s.id ? null : s); } }, "✎") : null
5159
5184
  ),
5160
5185
  h("div", { className: "sm-rowMeta" }, shortPath(s.cwd) + (s.cwd ? " · " : "") + formatRelative(s.updatedAt, now)),
5161
5186
  h("div", { className: "sm-rowActions" },
5162
- h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => { if (isArchived) { if (isWsArchived) { if (typeof window !== "undefined" && typeof window.alert === "function") { window.alert(t("error.workspaceArchived")); } return; } /* DSH refuses to surface an archived session as the current view: clicking "Open" on an archived row flashes an empty chat panel and bounces back to "no session selected". Unarchive first, then open. (issue #17.4) */ runRow(s.id, async () => { await onUnarchive(s.id); await onOpen(s.id); }); } else { onOpen(s.id); } } }, t("row.open")),
5163
5187
  renderTip(t(isArchived ? "row.unarchive" : "row.archive"), h("button", {
5164
5188
  type: "button",
5165
5189
  className: "sm-rowBtn sm-rowBtnIcon" + (isArchived ? " sm-rowBtnActive" : ""),
@@ -5192,7 +5216,16 @@ function isTopmostDialog(z) {
5192
5216
  h("span", { className: "sm-headerBtnIcon" }, HeaderMoveIcon()),
5193
5217
  h("span", { className: "sm-rowBtnLabel" }, t("row.move"))
5194
5218
  )),
5195
- h("button", { type: "button", className: "sm-rowBtn", disabled: busy, onClick: () => { if (migrateFor && migrateFor.id === s.id) setMigrateFor(null); else setMigrateFor(s); } }, t("row.migrate")),
5219
+ renderTip(t("row.migrate"), h("button", {
5220
+ type: "button",
5221
+ className: "sm-rowBtn sm-rowBtnIcon" + (migrateFor && migrateFor.id === s.id ? " sm-rowBtnActive" : ""),
5222
+ disabled: busy,
5223
+ "aria-label": t("row.migrate") + ": " + (s.displayTitle || s.id),
5224
+ onClick: () => { if (migrateFor && migrateFor.id === s.id) setMigrateFor(null); else setMigrateFor(s); }
5225
+ },
5226
+ h("span", { className: "sm-headerBtnIcon" }, PresetIcon()),
5227
+ h("span", { className: "sm-rowBtnLabel" }, t("row.migrate"))
5228
+ )),
5196
5229
  renderTip(t("row.delete"), h("button", {
5197
5230
  type: "button",
5198
5231
  className: "sm-rowBtn sm-rowBtnDanger sm-rowBtnIcon" + (confirmFor && confirmFor.id === s.id ? " sm-rowBtnActive" : ""),
@@ -6089,16 +6122,15 @@ function isTopmostDialog(z) {
6089
6122
  // <div data-slot-error="…" />, taking every title-bar button down
6090
6123
  // and leaving 移动 / 删除 with no click target. Compute the value
6091
6124
  // here once per render, then reuse the captured const in handlers.
6125
+ const sessionsSnapshot = useSessions ? useSessions((s) => s) : null;
6092
6126
  const displayTitle = (() => {
6093
6127
  const fallback = props.displayTitle;
6094
6128
  if (fallback && fallback !== sessionId) return fallback;
6095
- const list = useSessions ? useSessions((s) => s) : null;
6096
- const found = list ? list.byId[sessionId] : null;
6129
+ const found = sessionsSnapshot ? sessionsSnapshot.byId[sessionId] : null;
6097
6130
  return (found && found.displayTitle) || "";
6098
6131
  })();
6099
6132
  const currentWorkspaceId = (() => {
6100
- const list = useSessions ? useSessions((s) => s) : null;
6101
- const found = list ? list.byId[sessionId] : null;
6133
+ const found = sessionsSnapshot ? sessionsSnapshot.byId[sessionId] : null;
6102
6134
  return (found && found.workspaceId) || props.workspaceId || "";
6103
6135
  })();
6104
6136
  const annotations = useAnnotations();
@@ -0,0 +1,110 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { assertSessionId, replaceSessionFile } from "../session-files.js";
6
+
7
+ // rc.1 cache.write() may await sessions.flush() before reaching table.put().
8
+ // A queue barrier alone cannot drain such a delayed writer. Keep a lifecycle
9
+ // tombstone at the table entry point: queued puts precede delete(), later puts
10
+ // of the deleted lifecycle are ignored. A recreated id with a new identity is
11
+ // independent. The guard lives with the table (including across plugin reloads).
12
+ const guards = new WeakMap();
13
+ const lifecycleKey = identity => Number.isSafeInteger(identity?.createdAt)
14
+ ? JSON.stringify([identity.createdAt, identity.cwd ?? null, identity.isSeeded ?? false])
15
+ : undefined;
16
+ export function beginProjectionCacheDeletion(cache, id, header) {
17
+ assertSessionId(id);
18
+ const table = typeof cache?.requireTable === "function" ? cache.requireTable() : cache?.table;
19
+ if (!table || !["get", "put", "delete"].every(key => typeof table[key] === "function")) return undefined;
20
+ let guard = guards.get(table);
21
+ if (!guard) {
22
+ const removed = new Map();
23
+ const put = table.put;
24
+ table.put = function(key, record) {
25
+ if (removed.get(key)?.has(lifecycleKey(record?.identity))) return Promise.resolve();
26
+ return put.call(this, key, record);
27
+ };
28
+ guard = { removed };
29
+ guards.set(table, guard);
30
+ }
31
+ const identity = table.get(id)?.identity;
32
+ const stamps = [header, identity].map(lifecycleKey).filter(key => key !== undefined);
33
+ if (!stamps.length && (header || identity)) throw new Error("无法确认投影缓存的会话生命周期");
34
+ const previous = guard.removed.get(id);
35
+ const blocked = new Set([...(previous ?? []), ...stamps]);
36
+ guard.removed.set(id, blocked);
37
+ return {
38
+ remove: () => table.delete(id),
39
+ legacyRetained: table.host?.unit?.descriptor?.layout === "per-record",
40
+ legacyManaged: Boolean(table.host?.unit?.descriptor) && table.host.unit.descriptor.layout !== "per-record",
41
+ rollback() {
42
+ if (guard.removed.get(id) !== blocked) return;
43
+ if (previous) guard.removed.set(id, previous); else guard.removed.delete(id);
44
+ }
45
+ };
46
+ }
47
+
48
+ async function regular(path, directory = false) {
49
+ const info = await fs.lstat(path);
50
+ if (info.isSymbolicLink() || !(directory ? info.isDirectory() : info.isFile())) {
51
+ throw new Error("投影缓存路径不是安全的普通" + (directory ? "目录" : "文件") + ": " + path);
52
+ }
53
+ }
54
+
55
+ export async function assertProjectionCachePath(home, id) {
56
+ assertSessionId(id);
57
+ const paths = [home, join(home, "storages"), join(home, "storages", "session_projcache"),
58
+ join(home, "storages", "session_projcache", "sessions"), join(home, "storages", "session_projcache", "sessions", `${id}.json`)];
59
+ for (let i = 0; i < paths.length; i++) {
60
+ try { await regular(paths[i], i < paths.length - 1); }
61
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
62
+ }
63
+ }
64
+
65
+ /** rc.1 bootstraps but never writes its retained legacy unit. Serialize our
66
+ * read/modify/publish across deletes and processes; preserve its stamp/schema
67
+ * and every unrelated table/record. Unknown shapes fail without publication.
68
+ */
69
+ export async function removeLegacyProjectionCache(home, id) {
70
+ assertSessionId(id);
71
+ const root = join(home, "storages");
72
+ const path = join(root, "session_projcache.json");
73
+ try {
74
+ await regular(home, true);
75
+ await regular(root, true);
76
+ await regular(path);
77
+ } catch (error) { if (error.code === "ENOENT") return; throw error; }
78
+ const lockPath = join(root, "session_projcache.session-manager.lock");
79
+ const token = randomBytes(16).toString("hex");
80
+ const deadline = Date.now() + 3000;
81
+ let lock;
82
+ while (!lock) {
83
+ try { lock = await fs.open(lockPath, "wx", 0o600); }
84
+ catch (error) {
85
+ if (error.code !== "EEXIST") throw error;
86
+ if (Date.now() >= deadline) throw new Error("投影缓存清理锁正被占用: " + lockPath);
87
+ await delay(25);
88
+ }
89
+ }
90
+ let stamped = false;
91
+ try {
92
+ await lock.writeFile(token);
93
+ stamped = true;
94
+ await regular(root, true);
95
+ await regular(path);
96
+ const document = JSON.parse(await fs.readFile(path, "utf8"));
97
+ const records = document?.tables?.sessions;
98
+ if (document?.unit?.name !== "session_projcache" || ![3, 4, 5, 6, 7].includes(document.unit.version)
99
+ || !records || typeof records !== "object" || Array.isArray(records)) {
100
+ throw new Error("未知的旧版投影缓存格式,未修改文件");
101
+ }
102
+ if (!Object.hasOwn(records, id)) return;
103
+ delete records[id];
104
+ const result = await replaceSessionFile(path, JSON.stringify(document) + "\n");
105
+ if (result.backupPath) throw new Error("旧版投影缓存已更新,但备份清理失败: " + result.backupPath);
106
+ } finally {
107
+ await lock.close();
108
+ if (!stamped || await fs.readFile(lockPath, "utf8").catch(() => undefined) === token) await fs.rm(lockPath, { force: true });
109
+ }
110
+ }
@@ -12,8 +12,10 @@
12
12
  *
13
13
  * @module dsh-session-manager/compat/zstd-frames
14
14
  */
15
- import { zstdDecompress } from "node:zlib";
15
+ import { zstdDecompress, createZstdDecompress } from "node:zlib";
16
16
  import { promisify } from "node:util";
17
+ import { Readable } from "node:stream";
18
+ import { pipeline } from "node:stream/promises";
17
19
 
18
20
  const zstdDecompressAsync = promisify(zstdDecompress);
19
21
  const ZSTD_MAGIC = 0xfd2fb528;
@@ -76,6 +78,81 @@ export async function decompressZstdFrame(frame, options) {
76
78
  return zstdDecompressAsync(frame, options);
77
79
  }
78
80
 
81
+ export function decompressZstdFirstLine(frame, limit) {
82
+ return new Promise((resolve, reject) => {
83
+ const stream = createZstdDecompress();
84
+ const chunks = [];
85
+ let size = 0;
86
+ let settled = false;
87
+ let lineFound = false;
88
+ const finish = (error, value) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ stream.destroy();
92
+ if (error) reject(error); else resolve(value);
93
+ };
94
+ stream.on("error", error => finish(error));
95
+ stream.on("end", () => lineFound
96
+ ? finish(null, Buffer.concat(chunks, size))
97
+ : finish(new Error("会话头行不完整或超过大小限制")));
98
+ stream.on("data", chunk => {
99
+ if (lineFound) return;
100
+ const newline = chunk.indexOf(10);
101
+ const part = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
102
+ size += part.length;
103
+ if (size > limit) return finish(new Error("会话头行不完整或超过大小限制"));
104
+ chunks.push(part);
105
+ if (newline >= 0) lineFound = true;
106
+ });
107
+ if (Buffer.isBuffer(frame)) stream.end(frame);
108
+ else pipeline(Readable.from(frame), stream).catch(error => finish(error));
109
+ });
110
+ }
111
+
112
+ /** Feed exactly the first complete frame, with bounded reads even for huge batches.
113
+ * Keep consuming after the first line to validate the frame/checksum before discovery.
114
+ */
115
+ export async function* firstZstdFrameChunks(handle) {
116
+ let position = 0;
117
+ const exact = async size => {
118
+ const bytes = Buffer.alloc(size);
119
+ let offset = 0;
120
+ while (offset < size) {
121
+ const { bytesRead } = await handle.read(bytes, offset, size - offset, position);
122
+ if (!bytesRead) throw new Error("会话日志存在截断的 Zstd 帧");
123
+ offset += bytesRead;
124
+ position += bytesRead;
125
+ }
126
+ return bytes;
127
+ };
128
+ const prefix = await exact(5);
129
+ if (prefix.readUInt32LE(0) !== ZSTD_MAGIC) throw new Error("corrupt Zstandard session log: invalid frame magic");
130
+ const descriptor = prefix[4];
131
+ if (descriptor & 0x18) throw new Error("corrupt Zstandard session log: reserved frame-header bit");
132
+ yield prefix;
133
+ const sizeFlag = descriptor >>> 6;
134
+ const singleSegment = Boolean(descriptor & 0x20);
135
+ const dictionaryFlag = descriptor & 3;
136
+ const headerSize = (singleSegment ? 0 : 1) + (dictionaryFlag === 3 ? 4 : dictionaryFlag)
137
+ + (sizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << sizeFlag);
138
+ if (headerSize) yield await exact(headerSize);
139
+ for (;;) {
140
+ const header = await exact(3);
141
+ const block = header.readUIntLE(0, 3);
142
+ const type = (block >>> 1) & 3;
143
+ if (type === 3) throw new Error("corrupt Zstandard session log: reserved block type");
144
+ yield header;
145
+ let remaining = type === 1 ? 1 : block >>> 3;
146
+ while (remaining) {
147
+ const count = Math.min(remaining, 64 * 1024);
148
+ yield await exact(count);
149
+ remaining -= count;
150
+ }
151
+ if (block & 1) break;
152
+ }
153
+ if (descriptor & 4) yield await exact(4);
154
+ }
155
+
79
156
  export async function decompressAllZstdFrames(buffer) {
80
157
  const scan = scanZstdFrames(buffer);
81
158
  if (scan.frames.length === 0) {
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import { randomBytes } from "node:crypto";
29
29
  import { createAnnotationStore } from "./annotation-store.js";
30
30
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
31
31
  import { createDshAdapter, getService, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
32
+ import { assertProjectionCachePath, beginProjectionCacheDeletion, removeLegacyProjectionCache } from "./compat/projection-cache-delete.js";
32
33
  import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
33
34
 
34
35
  export const name = "dsh-session-manager";
@@ -386,7 +387,7 @@ function verifyInstalledPackage(profileDir, expectedVersion) {
386
387
 
387
388
  export function apply(ctx) {
388
389
  try {
389
- const buildId = "dsh-session-manager v0.7.5 (workspace-archive)";
390
+ const buildId = "dsh-session-manager v0.7.6 (workspace-archive)";
390
391
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
391
392
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
392
393
  } catch (_) { /* ignore logging failures */ }
@@ -2058,11 +2059,20 @@ ${rest}`, "utf8");
2058
2059
  await assertAndStopActiveSession(sessionId, true);
2059
2060
  }
2060
2061
 
2062
+ let projectionDeletion;
2063
+ let artifactsDeleted = false;
2061
2064
  try {
2062
2065
  // Resolve/validate before cancelling the agent or changing registry state.
2063
2066
  let dir = await sessionDirOf(sessionId);
2064
2067
  const session = ctx.sessions?.get?.(sessionId);
2065
2068
  let agent = safeGetAgent(sessionId);
2069
+ try {
2070
+ await assertProjectionCachePath(dirname(adapter.homePath("storages")), sessionId);
2071
+ const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
2072
+ projectionDeletion = beginProjectionCacheDeletion(adapter.projectionCache, sessionId, session?.header ?? artifact?.meta);
2073
+ } catch (error) {
2074
+ cleanupWarnings.push("投影缓存回写防护不可用: " + safeErrorMessage(error));
2075
+ }
2066
2076
 
2067
2077
  if (agent !== void 0) {
2068
2078
  // Stop any running turn (disposed-kind suppresses re-wake).
@@ -2123,6 +2133,29 @@ ${rest}`, "utf8");
2123
2133
  await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
2124
2134
  await rm(dir, { recursive: true, force: true });
2125
2135
  }
2136
+ artifactsDeleted = true;
2137
+
2138
+ // Use the host queue to remove both its in-memory row and durable record.
2139
+ // Tombstones installed before disposal also stop writes still awaiting flush.
2140
+ try {
2141
+ await assertProjectionCachePath(dirname(adapter.homePath("storages")), sessionId);
2142
+ if (projectionDeletion) await projectionDeletion.remove();
2143
+ else {
2144
+ const file = adapter.homePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
2145
+ if (existsSync(file)) throw new Error("宿主投影缓存删除接口不可用,缓存尚未清理");
2146
+ }
2147
+ } catch (error) {
2148
+ cleanupWarnings.push("会话已删除,但投影缓存清理失败: " + safeErrorMessage(error));
2149
+ }
2150
+ try {
2151
+ if (projectionDeletion?.legacyRetained) await removeLegacyProjectionCache(dirname(adapter.homePath("storages")), sessionId);
2152
+ else if (!projectionDeletion?.legacyManaged && existsSync(adapter.homePath("storages", "session_projcache.json"))) {
2153
+ throw new Error("无法确认旧版缓存已停止写入,未修改文件");
2154
+ }
2155
+ }
2156
+ catch (error) {
2157
+ cleanupWarnings.push("会话已删除,但旧版投影缓存清理失败: " + safeErrorMessage(error));
2158
+ }
2126
2159
 
2127
2160
  try {
2128
2161
  await annotations().remove(sessionId);
@@ -2152,6 +2185,7 @@ ${rest}`, "utf8");
2152
2185
  subagentCount: deletedSubagentIds.length
2153
2186
  };
2154
2187
  } catch (parentErr) {
2188
+ if (!artifactsDeleted) projectionDeletion?.rollback();
2155
2189
  const err = deletedSubagentIds.length > 0
2156
2190
  ? new Error(`删除主会话 '${sessionId}' 失败: ${safeErrorMessage(parentErr)}`)
2157
2191
  : parentErr;
@@ -2743,7 +2777,7 @@ ${rest}`, "utf8");
2743
2777
  return send(res, 200, { ok: true, result });
2744
2778
  }
2745
2779
  if (req.method === "GET" && path === "/update/check") {
2746
- const currentVersion = adapter.pluginVersion() || "0.7.5";
2780
+ const currentVersion = adapter.pluginVersion() || "0.7.6";
2747
2781
  const runningVersion = currentVersion;
2748
2782
  let installedVersion = runningVersion;
2749
2783
  const profileDir = resolveProfileDir(adapter, ctx);
@@ -2927,7 +2961,7 @@ ${rest}`, "utf8");
2927
2961
  };
2928
2962
  setActiveTask(taskRecord);
2929
2963
 
2930
- const currentVersion = adapter.pluginVersion() || "0.7.5";
2964
+ const currentVersion = adapter.pluginVersion() || "0.7.6";
2931
2965
  let installedVersion = currentVersion;
2932
2966
  if (profileDir) {
2933
2967
  const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
@@ -3457,7 +3491,7 @@ ${rest}`, "utf8");
3457
3491
  }
3458
3492
  if (req.method === "GET" && path === "/update/status") {
3459
3493
  const profileDir = resolveProfileDir(adapter, ctx);
3460
- const currentVersion = adapter.pluginVersion() || "0.7.5";
3494
+ const currentVersion = adapter.pluginVersion() || "0.7.6";
3461
3495
  let installedVersion = currentVersion;
3462
3496
  if (profileDir) {
3463
3497
  const diskCheck = verifyInstalledPackage(profileDir);
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { decompressAllZstdFrames, decompressZstdFrame, scanZstdFrames } from "./compat/zstd-frames.js";
4
+ import { decompressAllZstdFrames, decompressZstdFirstLine, firstZstdFrameChunks } from "./compat/zstd-frames.js";
5
5
 
6
6
  /**
7
7
  * Filenames tried in this order to locate a session's durable artifact.
@@ -190,20 +190,17 @@ export async function readSessionHeader(path) {
190
190
  await assertRegularFile(path);
191
191
  const handle = await fs.open(path, "r");
192
192
  try {
193
+ if (path.endsWith(".zstd")) {
194
+ const decoded = await decompressZstdFirstLine(firstZstdFrameChunks(handle), MAX_HEADER_BYTES);
195
+ return parseHeader(decoded.toString("utf8"));
196
+ }
193
197
  let buffer = Buffer.alloc(0);
194
198
  while (buffer.length < MAX_HEADER_BYTES) {
195
199
  const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HEADER_BYTES - buffer.length));
196
200
  const { bytesRead } = await handle.read(chunk, 0, chunk.length, buffer.length);
197
201
  if (bytesRead === 0) break;
198
202
  buffer = Buffer.concat([buffer, chunk.subarray(0, bytesRead)]);
199
- if (path.endsWith(".zstd")) {
200
- const { frames } = scanZstdFrames(buffer, 1);
201
- if (frames.length) {
202
- const frame = frames[0];
203
- const decoded = await decompressZstdFrame(buffer.subarray(frame.start, frame.end), { maxOutputLength: MAX_HEADER_BYTES });
204
- return parseHeader(decoded.toString("utf8"));
205
- }
206
- } else if (buffer.includes(10)) {
203
+ if (buffer.includes(10)) {
207
204
  return parseHeader(buffer.toString("utf8"));
208
205
  }
209
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-manager",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "DeepSeek Harness session and workspace manager —— Workspaces: archive and visibility. Sessions: move, migrate presets, delete, favorite, review later, prioritize, tag, and annotate. Batch operations. | DSH 会话与工作区管理——工作区:归档与显示。会话:跨工作区移动、预设迁移、删除;收藏、待回看、优先级、标签、备注。批量操作。",
5
5
  "license": "MIT",
6
6
  "keywords": [