dsh-session-manager 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.7.2 — 2026-10-17
6
+
7
+ - **fix(encoding)**: Correct session directory encoding and candidate matching for IM and non-UUID sessions (`encodeFsSessionSegment` matching DSH `encodeSegment` with `~XXXX` hex escapes). Support colons and non-alphanumeric characters in session IDs without directory traversal risks, distinguish project keys from session directory segments, and maintain multi-candidate fallback compatibility for existing sessions (#39).
8
+ - **fix(ui)**: Scope custom dropdown interceptor to Session Manager elements only (`getTargetSmSelect`), preventing global interception of third-party plugin selects and DSH native dropdowns and eliminating unwanted `preventDefault` / `stopPropagation` calls (#36).
9
+ - **fix(ui)**: Prevent scrolling within the custom select menu from closing the menu, enabling smooth wheel scrolling and scrollbar dragging across long option lists (PR #37).
10
+ - **fix(ui)**: Improve sidebar footer layout compatibility when multiple plugins inject actions into `sidebar.footer.action`. Apply scoped `flex-wrap: wrap` on the footer actions container and update `.sm-footerBtn-wide` to `flex: 1 1 100%`, preventing overlapping buttons and horizontal squishing without fragile CSS module hashes or targeting other plugins' private classes (#38).
11
+ - **compat**: Compatible with DSH Desktop V0.2.1-rc.2 (and V0.2.0-rc.2).
12
+
5
13
  ## 0.7.1 — 2026-10-15
6
14
 
7
15
  - **fix(cache)**: Decouple boot-time workspace session membership reconciliation from projection cache reconstruction, preventing background event-loop blocking and watchdog recovery timeouts on large session libraries (#32).
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  English | [中文](README.zh.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-session-manager)](https://www.npmjs.com/package/dsh-session-manager)
6
+ [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-upstream-1082ff)](https://github.com/deepseek-ai/deepseek-harness)
6
7
  [![GitHub](https://img.shields.io/badge/GitHub-repository-blue)](https://github.com/hkkz9522/dsh-session-manager)
7
8
  [![CI](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
8
9
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
@@ -51,7 +52,7 @@ Verified with the official DSH Desktop client; see [5 Compatibility](#5-compatib
51
52
  - **Theme**: Automatically follows the active DSH theme (including third-party theme plugins). Dialogs, settings cards, title-bar buttons, menus, inputs, and all other UI elements seamlessly adapt to light and dark themes.
52
53
  - **Unified dropdown styling**: All select dropdowns (sort, filter, priority) use unified shadow-free rounded custom floating menus matching active themes, strictly aligned with trigger widths, eliminating Chromium duplicate native popups.
53
54
 
54
- ### 1.5 Workspace Archiving and Management (new in v0.7.0)
55
+ ### 1.5 Workspace Archiving and Management
55
56
 
56
57
  - **Two-page tab switching**: Seamlessly switch between "Session Management" and "Workspace Management" in the dialog header, preserving each page's distinct search, filter, and sort states.
57
58
  - **Workspace list**: Displays all registered workspaces with title/name, physical path, session count, latest activity timestamp computed from member sessions, and right-side toggle switch.
@@ -140,21 +141,22 @@ After changing plugin dependencies, `package.json`, bundle configuration, or oth
140
141
 
141
142
  ## 5 Compatibility
142
143
 
143
- DSH versions are shown above plugin versions; each column represents a tested version combination.
144
-
145
- > Verification note: from 0.6.3 on, this plugin is verified with the official DSH Desktop client only (the Web UI shares the same Host/client code but is no longer covered by the verification matrix).
146
-
147
- | v0.2.0-rc.2 | v0.1.7-rc.2 | v0.1.7-rc.1 |
148
- | --- | --- | --- |
149
- | 0.7.1, 0.7.0, 0.6.4, 0.6.3, 0.6.2, 0.5.4 | 0.5.3 | 0.5.2 |
150
-
151
- | v0.1.6-alpha.2 | v0.1.5-rc.2 | v0.1.5-rc.1 |
152
- | --- | --- | --- |
153
- | 0.5.1 | 0.4.11 | 0.4.10, 0.4.9, 0.4.7 |
154
-
155
- | v0.1.3-alpha.2 | v0.1.2-rc.1 | v0.1.0-rc.7 |
156
- | --- | --- | --- |
157
- | 0.4.6, 0.4.4, 0.4.1 | 0.4.0 | 0.1.2, 0.1.1, 0.1.0 |
144
+ The table below lists verified version combinations between DeepSeek Harness (DSH) and `dsh-session-manager`.
145
+
146
+ > **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).
147
+
148
+ | DeepSeek Harness (DSH) | dsh-session-manager | Verified Environment |
149
+ | :--- | :--- | :--- |
150
+ | **v0.2.1-rc.2** | `0.7.2` | Desktop |
151
+ | **v0.2.0-rc.2** | `0.7.1`, `0.7.0`, `0.6.4`, `0.6.3`, `0.6.2`, `0.5.4` | Desktop |
152
+ | **v0.1.7-rc.2** | `0.5.3` | Desktop / Web UI |
153
+ | **v0.1.7-rc.1** | `0.5.2` | Desktop / Web UI |
154
+ | **v0.1.6-alpha.2** | `0.5.1` | Web UI |
155
+ | **v0.1.5-rc.2** | `0.4.11` | Web UI |
156
+ | **v0.1.5-rc.1** | `0.4.10`, `0.4.9`, `0.4.7` | Web UI |
157
+ | **v0.1.3-alpha.2** | `0.4.6`, `0.4.4`, `0.4.1` | Web UI |
158
+ | **v0.1.2-rc.1** | `0.4.0` | Web UI |
159
+ | **v0.1.0-rc.7** | `0.1.2`, `0.1.1`, `0.1.0` | Web UI |
158
160
 
159
161
  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.
160
162
 
package/README.zh.md CHANGED
@@ -3,6 +3,7 @@
3
3
  [English](README.md) | 中文
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-session-manager)](https://www.npmjs.com/package/dsh-session-manager)
6
+ [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-宿主-1082ff)](https://github.com/deepseek-ai/deepseek-harness)
6
7
  [![GitHub](https://img.shields.io/badge/GitHub-仓库-blue)](https://github.com/hkkz9522/dsh-session-manager)
7
8
  [![CI](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
8
9
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
@@ -42,14 +43,14 @@ DSH 会话与工作区管理——工作区:归档与显示。会话:跨工
42
43
 
43
44
  ### 1.4 插件更新与设置卡片
44
45
 
45
- - **检查更新**:会话管理窗口顶部增加 🐋(鲸鱼)图标按钮,点击可检查新版本并在有更新时展示小红点提示;点击弹出更新窗口,展示当前版本与最新版本,支持一键调用插件管理器安装更新。
46
+ - **检查更新**:会话管理窗口顶部提供 🐋(鲸鱼)图标按钮,点击可检查新版本并在有更新时展示小红点提示;点击弹出更新窗口,展示当前版本与最新版本,支持一键调用插件管理器安装更新。
46
47
  - **设置卡片**:注册在 DSH 设置的插件配置页(`settings.plugin.item`)。展示当前安装版本、最新版本状态、卡片内检查更新与更新按钮、自动检查偏好开关以及 GitHub 仓库链接。
47
48
  - **安装源选择**:在设置卡片中可随时切换安装源,可选 **npm 官方源**(`registry.npmjs.org`,默认)与 **中国大陆镜像源**(`registry.npmmirror.com`);检查更新与版本下载将直接请求所选源。
48
49
  - **弹窗透明度**:设置卡片「偏好设置」区域提供 50%–100% 滑块(步长 5%,默认 100%)与「恢复默认」按钮,支持调节插件弹窗的背景填充透明度,文字、按钮、输入框、标签与边框等内容保持不透明并即时生效。
49
50
  - **主题**:全面跟随 DSH 当前主题(包含第三方主题插件),弹窗、设置卡片、标题栏按钮、下拉菜单、输入框等界面元素自动适配浅色与深色主题。
50
51
  - **统一下拉样式**:所有下拉选择框(排序、筛选、优先级等)采用无四周阴影的统一圆角自定义浮层,严格对齐触发框宽度,并解决 Chromium 原生系统弹层与自定义浮层重叠的双层问题。
51
52
 
52
- ### 1.5 工作区归档与管理(v0.7.0 新增)
53
+ ### 1.5 工作区归档与管理
53
54
 
54
55
  - **双页面切换**:在管理弹窗顶部可在「会话管理」与「工作区管理」之间无缝切换,保留各自的搜索与筛选状态。
55
56
  - **工作区列表**:完整展示全部已注册工作区,包含工作区名称、物理路径、所属会话数、基于所属会话计算的最近更新时间及状态开关。
@@ -138,21 +139,22 @@ dsh plugin --profile web add <本仓库路径>
138
139
 
139
140
  ## 5 兼容性
140
141
 
141
- DSH 版本在上,插件版本在下;每列表示一组已测试的版本组合。
142
-
143
- > 版本验证说明:自 0.6.3 起,本插件仅在官方 DSH Desktop 客户端上进行验证(Web UI 与本插件共用同一套 Host / 客户端代码,但不再纳入验证范围)。
144
-
145
- | v0.2.0-rc.2 | v0.1.7-rc.2 | v0.1.7-rc.1 |
146
- | --- | --- | --- |
147
- | 0.7.1, 0.7.0, 0.6.4, 0.6.3, 0.6.2, 0.5.4 | 0.5.3 | 0.5.2 |
148
-
149
- | v0.1.6-alpha.2 | v0.1.5-rc.2 | v0.1.5-rc.1 |
150
- | --- | --- | --- |
151
- | 0.5.1 | 0.4.11 | 0.4.10, 0.4.9, 0.4.7 |
152
-
153
- | v0.1.3-alpha.2 | v0.1.2-rc.1 | v0.1.0-rc.7 |
154
- | --- | --- | --- |
155
- | 0.4.6, 0.4.4, 0.4.1 | 0.4.0 | 0.1.2, 0.1.1, 0.1.0 |
142
+ 下表列出 DeepSeek Harness(DSH)与 `dsh-session-manager` 已验证的版本组合。
143
+
144
+ > **版本验证说明**:自 0.6.3 起,本插件仅在官方 DSH Desktop 客户端上进行验证(Web UI 与本插件共用同一套 Host / 客户端代码,但不再纳入常规验证范围)。
145
+
146
+ | DeepSeek Harness (DSH) | dsh-session-manager | 验证环境 |
147
+ | :--- | :--- | :--- |
148
+ | **v0.2.1-rc.2** | `0.7.2` | Desktop |
149
+ | **v0.2.0-rc.2** | `0.7.1`, `0.7.0`, `0.6.4`, `0.6.3`, `0.6.2`, `0.5.4` | Desktop |
150
+ | **v0.1.7-rc.2** | `0.5.3` | Desktop / Web UI |
151
+ | **v0.1.7-rc.1** | `0.5.2` | Desktop / Web UI |
152
+ | **v0.1.6-alpha.2** | `0.5.1` | Web UI |
153
+ | **v0.1.5-rc.2** | `0.4.11` | Web UI |
154
+ | **v0.1.5-rc.1** | `0.4.10`, `0.4.9`, `0.4.7` | Web UI |
155
+ | **v0.1.3-alpha.2** | `0.4.6`, `0.4.4`, `0.4.1` | Web UI |
156
+ | **v0.1.2-rc.1** | `0.4.0` | Web UI |
157
+ | **v0.1.0-rc.7** | `0.1.2`, `0.1.1`, `0.1.0` | Web UI |
156
158
 
157
159
  以上版本组合已在官方 Web UI 或 Desktop 客户端中完成测试。其他版本组合可能同样兼容,但未逐一验证。
158
160
 
package/lib/client.js CHANGED
@@ -862,7 +862,8 @@ function isTopmostDialog(z) {
862
862
  ".sm-bulkResultItemReason{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;word-break:break-word}",
863
863
  ".sm-bulkResultItemReasonSkip{color:var(--dsw-alias-label-tertiary,#888)}",
864
864
  ".sm-bulkResultFooter{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}",
865
- ".sm-footerBtn-wide{flex:none;display:flex;align-items:center;gap:8px;width:calc(100% + 8px);height:34px;margin:4px -4px 4px;padding:6px 2px 6px 10px;box-sizing:border-box;border:none;border-radius:12px;cursor:pointer;overflow:hidden;font-size:14px;line-height:22px}",
865
+ "[class*=footerActions]:has(.sm-footerBtn-wide),[class*=footer-actions]:has(.sm-footerBtn-wide),[class*=footer]:has(> .sm-footerBtn-wide),[class*=footer] > div:has(> .sm-footerBtn-wide){flex-wrap:wrap;align-content:flex-start;height:auto}",
866
+ ".sm-footerBtn-wide{flex:1 1 100%;display:flex;align-items:center;gap:8px;width:calc(100% + 8px);height:34px;margin:4px -4px 4px;padding:6px 2px 6px 10px;box-sizing:border-box;border:none;border-radius:12px;cursor:pointer;overflow:hidden;font-size:14px;line-height:22px}",
866
867
  ".sm-footerBtn-wide:hover,.sm-footerBtn-wide:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
867
868
  ".sm-footerBtn-rail{width:36px;height:36px;padding:0;justify-content:center;border-radius:12px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:0;display:inline-flex;align-items:center}",
868
869
  ".sm-footerBtn-rail:hover,.sm-footerBtn-rail:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
@@ -2100,6 +2101,18 @@ function isTopmostDialog(z) {
2100
2101
  }
2101
2102
  }
2102
2103
 
2104
+ function getTargetSmSelect(target) {
2105
+ if (!target || typeof target.closest !== "function") return null;
2106
+ const direct = target.closest(SM_SELECT_SELECTOR);
2107
+ if (direct && direct.tagName === "SELECT") return direct;
2108
+ const container = target.closest(".sm-wsSortField, .sm-annotationPriority, .sm-viewField, .sm-moveSection, .sm-settingsRow, .sm-customSelectWrapper");
2109
+ if (container) {
2110
+ const sel = container.querySelector("select");
2111
+ if (sel && sel.tagName === "SELECT") return sel;
2112
+ }
2113
+ return null;
2114
+ }
2115
+
2103
2116
  function installCustomDropdownInterceptor() {
2104
2117
  if (typeof document === "undefined" || typeof document.addEventListener !== "function") {
2105
2118
  return () => {};
@@ -2113,24 +2126,8 @@ function isTopmostDialog(z) {
2113
2126
  if (activeCustomMenu && activeCustomMenu.contains(e.target)) return;
2114
2127
  if (e.target?.closest?.("button, input, textarea, a")) return;
2115
2128
 
2116
- let select = null;
2117
- if (e.target?.matches?.("select")) {
2118
- select = e.target;
2119
- } else if (e.target?.closest?.("select")) {
2120
- select = e.target.closest("select");
2121
- } else {
2122
- const container = e.target?.closest?.(".sm-wsSortField, .sm-annotationPriority, .sm-viewField, .sm-moveSection, .sm-settingsRow, .sm-customSelectWrapper");
2123
- if (container) {
2124
- select = container.querySelector("select");
2125
- } else {
2126
- const nearest = e.target?.closest?.(SM_SELECT_SELECTOR);
2127
- if (nearest && nearest.tagName === "SELECT") {
2128
- select = nearest;
2129
- }
2130
- }
2131
- }
2132
-
2133
- if (select && select.tagName === "SELECT") {
2129
+ const select = getTargetSmSelect(e.target);
2130
+ if (select) {
2134
2131
  e.preventDefault();
2135
2132
  e.stopPropagation();
2136
2133
  if (select.disabled) return;
@@ -2146,14 +2143,8 @@ function isTopmostDialog(z) {
2146
2143
 
2147
2144
  const onClick = (e) => {
2148
2145
  if (e.target?.closest?.("button, input, textarea, a")) return;
2149
- let select = null;
2150
- if (e.target?.matches?.("select")) {
2151
- select = e.target;
2152
- } else {
2153
- const container = e.target?.closest?.(".sm-wsSortField, .sm-annotationPriority, .sm-viewField, .sm-moveSection, .sm-settingsRow, .sm-customSelectWrapper");
2154
- if (container) select = container.querySelector("select");
2155
- }
2156
- if (select && select.tagName === "SELECT") {
2146
+ const select = getTargetSmSelect(e.target);
2147
+ if (select) {
2157
2148
  e.preventDefault();
2158
2149
  e.stopPropagation();
2159
2150
  }
@@ -2170,7 +2161,7 @@ function isTopmostDialog(z) {
2170
2161
  }
2171
2162
 
2172
2163
  const focused = document.activeElement;
2173
- const targetSelect = activeCustomSelect || (focused?.matches?.(SM_SELECT_SELECTOR) ? focused : null) || (focused?.closest?.(".sm-wsSortField, .sm-annotationPriority, .sm-viewField, .sm-moveSection, .sm-settingsRow")?.querySelector("select"));
2164
+ const targetSelect = activeCustomSelect || getTargetSmSelect(focused);
2174
2165
  if (!targetSelect || targetSelect.tagName !== "SELECT") return;
2175
2166
 
2176
2167
  if (e.key === "Enter" || e.key === " ") {
@@ -2196,7 +2187,14 @@ function isTopmostDialog(z) {
2196
2187
  }
2197
2188
  };
2198
2189
 
2199
- const onResizeOrScroll = () => {
2190
+ const onResizeOrScroll = (e) => {
2191
+ // Ignore scrolls that originate inside the open menu: the listener is bound on
2192
+ // window with capture=true, so scrolling the menu's own option list (wheel or
2193
+ // scrollbar drag) would otherwise close it immediately.
2194
+ // Reference: PR #37
2195
+ if (activeCustomMenu && e && e.type === "scroll" && e.target && typeof activeCustomMenu.contains === "function" && activeCustomMenu.contains(e.target)) {
2196
+ return;
2197
+ }
2200
2198
  if (activeCustomMenu) closeCustomSelect();
2201
2199
  };
2202
2200
 
@@ -3749,7 +3747,7 @@ function isTopmostDialog(z) {
3749
3747
  ));
3750
3748
  }
3751
3749
 
3752
- const CURRENT_VERSION = "0.7.1";
3750
+ const CURRENT_VERSION = "0.7.2";
3753
3751
 
3754
3752
  function compareSemver(v1, v2) {
3755
3753
  if (typeof v1 !== "string" || typeof v2 !== "string") return 0;
@@ -6856,6 +6854,7 @@ function isTopmostDialog(z) {
6856
6854
  exports.openCustomSelect = openCustomSelect;
6857
6855
  exports.closeCustomSelect = closeCustomSelect;
6858
6856
  exports.installCustomDropdownInterceptor = installCustomDropdownInterceptor;
6857
+ exports.getTargetSmSelect = getTargetSmSelect;
6859
6858
  exports.SM_SELECT_SELECTOR = SM_SELECT_SELECTOR;
6860
6859
  return module.exports;
6861
6860
  }
package/lib/index.js CHANGED
@@ -28,7 +28,7 @@ import { randomBytes } from "node:crypto";
28
28
  import { createAnnotationStore } from "./annotation-store.js";
29
29
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
30
30
  import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
31
- import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
+ import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
32
32
 
33
33
  export const name = "dsh-session-manager";
34
34
 
@@ -125,10 +125,16 @@ const scanSessionHeaders = async (persistence, dshHome) => {
125
125
  }
126
126
  const result = () => ({ headers, complete: listed && errors.length === 0, errors });
127
127
  // Skip known files before fopen; retain the orphan/raw-id compatibility path.
128
+ // Only add canonical session directories that do not start and end with "--"
129
+ // to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
128
130
  const knownDirs = new Set();
129
131
  for (const id of seen) {
130
- knownDirs.add(id);
131
- knownDirs.add(encodeSessionSegment(id));
132
+ try {
133
+ const canonical = encodeFsSessionSegment(id);
134
+ if (!canonical.startsWith("--") || !canonical.endsWith("--")) {
135
+ knownDirs.add(canonical);
136
+ }
137
+ } catch (_) {}
132
138
  }
133
139
  if (typeof dshHome !== "function") return result();
134
140
  const root = dshHome("sessions");
@@ -150,7 +156,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
150
156
  for (const filename of ARTIFACT_NAMES) {
151
157
  try {
152
158
  const header = await readSessionHeader(join(root, proj.name, entry.name, filename));
153
- if (![header.id, encodeSessionSegment(header.id)].includes(entry.name)) {
159
+ if (!candidateSessionDirs(header.id).includes(entry.name)) {
154
160
  throw new Error("会话目录与 header id 不一致");
155
161
  }
156
162
  if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
@@ -170,7 +176,7 @@ const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHea
170
176
 
171
177
  export function apply(ctx) {
172
178
  try {
173
- const buildId = "dsh-session-manager v0.7.1 (workspace-archive)";
179
+ const buildId = "dsh-session-manager v0.7.2 (workspace-archive)";
174
180
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
175
181
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
176
182
  } catch (_) { /* ignore logging failures */ }
@@ -1252,14 +1258,19 @@ ${rest}`, "utf8");
1252
1258
  catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
1253
1259
  for (const proj of projects) {
1254
1260
  if (!proj.isDirectory() || proj.isSymbolicLink()) continue;
1255
- for (const candidate of [encodeSessionSegment(sessionId), sessionId]) {
1261
+ for (const candidate of candidateSessionDirs(sessionId)) {
1256
1262
  const dir = join(root, proj.name, candidate);
1257
1263
  try { await assertSessionDirectory(root, dir, sessionId); }
1258
1264
  catch (error) { if (error.code === "ENOENT") continue; throw error; }
1259
1265
  for (const filename of ARTIFACT_NAMES) {
1260
1266
  try {
1261
1267
  const artifact = await read(join(dir, filename));
1262
- if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
1268
+ if (artifact.meta.id !== sessionId) {
1269
+ if (candidate === candidateSessionDirs(sessionId)[0]) {
1270
+ throw new Error("会话工件 header id 与请求不一致");
1271
+ }
1272
+ break;
1273
+ }
1263
1274
  return artifact;
1264
1275
  } catch (error) { if (error.code !== "ENOENT") throw error; }
1265
1276
  }
@@ -2013,7 +2024,7 @@ ${rest}`, "utf8");
2013
2024
  return send(res, 200, { ok: true, result });
2014
2025
  }
2015
2026
  if (req.method === "GET" && path === "/update/check") {
2016
- const currentVersion = adapter.pluginVersion() || "0.7.1";
2027
+ const currentVersion = adapter.pluginVersion() || "0.7.2";
2017
2028
  let latestVersion = currentVersion;
2018
2029
  let hasUpdate = false;
2019
2030
  try {
@@ -2111,4 +2122,4 @@ ${rest}`, "utf8");
2111
2122
  }), "session-manager: http api");
2112
2123
  }
2113
2124
 
2114
- export { listSessionHeaders, scanSessionHeaders, encodeSessionSegment, compareSemver };
2125
+ export { listSessionHeaders, scanSessionHeaders, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, compareSemver };
@@ -21,7 +21,7 @@ const MAX_HEADER_BYTES = 1024 * 1024;
21
21
 
22
22
  export function assertSessionId(id) {
23
23
  if (typeof id !== "string" || id.trim() === "" || id === "." || id === ".."
24
- || /[\\/\x00-\x1f<>:"|?*]/.test(id) || /[. ]$/.test(id)) {
24
+ || /[\\/\x00-\x1f<>"|?*]/.test(id) || /[. ]$/.test(id)) {
25
25
  throw Object.assign(new Error("sessionId 必须是合法的会话标识,不能包含路径或特殊目录名"), { code: "bad-request" });
26
26
  }
27
27
  return id;
@@ -46,12 +46,56 @@ export function parseSessionGenerationFromFilename(filename) {
46
46
  return match[1] !== undefined ? Number(match[1]) : 0;
47
47
  }
48
48
 
49
- export function encodeSessionSegment(id) {
49
+ /**
50
+ * Encode a session ID into its on-disk session directory name,
51
+ * matching DSH kernel's `encodeSegment` contract in
52
+ * packages/session/session-persistence-jsonl/src/format.ts:
53
+ * - "." -> "~002E"
54
+ * - ".." -> "~002E~002E"
55
+ * - Safe chars [A-Za-z0-9._-] (except "~") remain unchanged
56
+ * - Other characters (including ":", "~", non-ASCII) are escaped as ~XXXX (4-digit uppercase hex)
57
+ */
58
+ export function encodeFsSessionSegment(raw) {
59
+ if (typeof raw !== "string" || raw.length === 0) {
60
+ throw new Error("cannot encode an empty path segment");
61
+ }
62
+ if (raw === ".") return "~002E";
63
+ if (raw === "..") return "~002E~002E";
64
+ let out = "";
65
+ for (let i = 0; i < raw.length; i++) {
66
+ const code = raw.charCodeAt(i);
67
+ const ch = String.fromCharCode(code);
68
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
69
+ out += ch;
70
+ } else {
71
+ out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Decode an on-disk session directory name back to raw session ID.
79
+ */
80
+ export function decodeFsSessionSegment(encoded) {
81
+ if (typeof encoded !== "string" || encoded.length === 0) return encoded;
82
+ if (encoded === "~002E") return ".";
83
+ if (encoded === "~002E~002E") return "..";
84
+ return encoded.replace(/~([0-9A-Fa-f]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
85
+ }
86
+
87
+ /**
88
+ * Encode a project directory path (cwd) into its on-disk project directory key,
89
+ * matching DSH kernel's `projectKey` contract:
90
+ * Wrapping "--...--" markers with path separators collapsed into "-".
91
+ */
92
+ export function encodeProjectSegment(path) {
93
+ if (typeof path !== "string") return "--root--";
50
94
  let readable = "";
51
95
  let separatorRun = false;
52
- for (let i = 0; i < id.length; i++) {
53
- const ch = id[i];
54
- const code = id.charCodeAt(i);
96
+ for (let i = 0; i < path.length; i++) {
97
+ const ch = path[i];
98
+ const code = path.charCodeAt(i);
55
99
  if (ch === "/" || ch === "\\" || ch === ":") {
56
100
  if (!separatorRun) readable += "-";
57
101
  separatorRun = true;
@@ -66,6 +110,28 @@ export function encodeSessionSegment(id) {
66
110
  return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
67
111
  }
68
112
 
113
+ /**
114
+ * Legacy alias for `encodeProjectSegment`.
115
+ * @deprecated Use `encodeProjectSegment` for project cwd keys, or `encodeFsSessionSegment` for session directories.
116
+ */
117
+ export const encodeSessionSegment = encodeProjectSegment;
118
+
119
+ /**
120
+ * Return strictly verified candidate directory names for a given session ID.
121
+ * - Primary: DSH's true `encodeFsSessionSegment(id)` (e.g. "im~003Afeishu...")
122
+ * - Raw `id`: matches when raw ID equals encoded ID (common UUIDs), or if raw dir was used
123
+ * - Legacy projectKey format: transitional compatibility for `--id--` created by older plugin versions
124
+ */
125
+ export function candidateSessionDirs(id) {
126
+ assertSessionId(id);
127
+ const primary = encodeFsSessionSegment(id);
128
+ const candidates = [primary];
129
+ if (id !== primary) candidates.push(id);
130
+ const legacyProject = encodeProjectSegment(id);
131
+ if (legacyProject !== primary && legacyProject !== id) candidates.push(legacyProject);
132
+ return candidates;
133
+ }
134
+
69
135
  /** Only an actual <sessions>/<project>/<session> directory may be mutated.
70
136
  * The root itself may be configured through a symlink, but neither child may
71
137
  * be a symlink/junction. Check again immediately before destructive cleanup.
@@ -79,8 +145,9 @@ export async function assertSessionDirectory(root, directory, id, { allowMissing
79
145
  const target = resolve(directory);
80
146
  const rel = relative(base, target);
81
147
  const parts = rel.split(sep);
148
+ const validDirs = candidateSessionDirs(id);
82
149
  if (isAbsolute(rel) || parts.length !== 2 || parts.some(p => p === ".." || p === "." || p === "")
83
- || ![id, encodeSessionSegment(id)].includes(parts[1])) {
150
+ || !validDirs.includes(parts[1])) {
84
151
  throw new Error("会话目录超出允许范围,已停止文件操作");
85
152
  }
86
153
  const canonicalRoot = await fs.realpath(base);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-manager",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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": [