@michengai/dsh-archive-manager 0.1.30 → 0.1.32

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
@@ -4,6 +4,19 @@
4
4
 
5
5
  Published release notes are retained below; new versions are added without removing earlier entries.
6
6
 
7
+ ## Unreleased
8
+
9
+ ## 0.1.32 - 2026-09-07
10
+
11
+ - Fixed leftover session directories after deletion (#16): remove the entire session-owned directory after validating the official JSONL layout, preserve shared/unknown-backend parent directories, and refuse deletion through directory links.
12
+ - Added regression coverage for directory and attachment cleanup, path encoding, shared-directory protection, and failed-deletion retries.
13
+ - Kept relative JSONL storage roots stable across host working-directory changes and added warnings when directory ownership cannot be verified.
14
+
15
+ ## 0.1.31 - 2026-09-07
16
+
17
+ - Added independent in-product update checks with automatic updates when a verified DSH update service is available and a profile-specific manual fallback otherwise.
18
+ - Removed the update-button dependency on `react-dom/client` so the client can load on Hosts that do not register that module id.
19
+
7
20
  ## 0.1.30 — 2026-09-04
8
21
 
9
22
  - Kept failed archived-session deletions retryable until their transcript artifact is removed.
@@ -4,6 +4,19 @@
4
4
 
5
5
  以下发布说明会持续保留;新增版本时不再删除较早记录。
6
6
 
7
+ ## 未发布
8
+
9
+ ## 0.1.32 - 2026-09-07
10
+
11
+ - 修复删除会话后遗留目录的问题(#16):校验官方 JSONL 存储布局后清理完整会话专属目录,保留共享目录和未知后端的父目录,并拒绝通过目录符号链接进行删除。
12
+ - 补充目录与附件清理、路径编码、共享目录保护及删除失败重试的回归测试。
13
+ - 修复宿主工作目录变化导致相对 JSONL 存储根路径失效的问题,并为无法验证目录归属的降级删除增加警告。
14
+
15
+ ## 0.1.31 - 2026-09-07
16
+
17
+ - 新增独立的插件内检查更新能力:仅在已验证的 DSH 更新服务可用时自动更新,其余环境提供与 Profile 对应的手工更新命令。
18
+ - 更新按钮图标不再依赖 `react-dom/client`,避免宿主未注册该模块 id 时客户端无法加载。
19
+
7
20
  ## 0.1.30 — 2026-09-04
8
21
 
9
22
  - 归档会话物理删除失败时保留可重试状态,直至转录工件删除成功。
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  - Archive a session from the sidebar session menu.
25
25
  - Archive every active chat in a workspace from its sidebar action menu.
26
- - Search archived chats, sort them by update time, creation time, or title, and filter them by workspace in **Settings → Archived**.
26
+ - Search archived chats, sort them by update time, creation time, or title, and filter them by workspace in **Settings → Archived sessions**.
27
27
  - Restore a session to its original workspace with **Unarchive**.
28
28
  - Restore or permanently delete every archived chat in a project group.
29
29
  - Restore all archived chats from the page header.
@@ -38,9 +38,9 @@ Open the sidebar session menu and choose **Archive session**:
38
38
 
39
39
  ![Archive a session from the session menu](assets/screenshots/archive-session-menu.png)
40
40
 
41
- Search, sort, filter by project, unarchive, or permanently delete chats in **Settings → Archived**:
41
+ Search, sort, filter by project, unarchive, or permanently delete chats in **Settings → Archived sessions**:
42
42
 
43
- ![Archived chats settings page](assets/screenshots/archived-sessions.png)
43
+ ![Archived sessions settings page](assets/screenshots/archived-sessions.png)
44
44
 
45
45
  ## DSH product ecosystem
46
46
 
@@ -85,7 +85,7 @@ Install the DSH plugin from source at https://github.com/MichengAI/dsh-archive-m
85
85
  | Codex | Send one of the sentences above to Codex and let it install locally. |
86
86
  | WorkBuddy | Send one of the sentences above to WorkBuddy; for a source install you can also paste `https://github.com/MichengAI/dsh-archive-manager`. |
87
87
 
88
- Codex and WorkBuddy only install the plugin. After that, open DSH Web and use **Settings → Archived**.
88
+ Codex and WorkBuddy only install the plugin. After that, open DSH Web and use **Settings → Archived sessions**.
89
89
 
90
90
  You can also run the same npm command yourself:
91
91
 
@@ -128,10 +128,14 @@ dsh --profile web --dump-config
128
128
 
129
129
  Restart DSH Web and hard-refresh the browser. `dsh plugin ... add .` reads the package metadata and `cordis.patch.yml`; do not install by copying `lib` directly.
130
130
 
131
+ ## Updates
132
+
133
+ The settings title shows the installed version and a **Check for updates** button. When a newer release is available, **Update automatically** runs only when the DSH CLI or Desktop update service is available; otherwise, the dialog provides a profile-specific manual command to copy and run.
134
+
131
135
  ## Usage
132
136
 
133
137
  1. Open the sidebar session menu and choose **Archive session**.
134
- 2. Open **Settings → Archived** to inspect sessions by workspace.
138
+ 2. Open **Settings → Archived sessions** to inspect sessions by workspace.
135
139
  3. Search by title, sort by update time, creation time, or title, or filter the list by project.
136
140
  4. Select **Unarchive** to restore one session, or select **Restore all** in the page header.
137
141
  5. Open a project heading's action menu to restore or delete all of that project's archived chats.
@@ -142,7 +146,10 @@ If the entry is missing after installation or upgrade, restart DSH Web and hard-
142
146
  ## Data handling limits
143
147
 
144
148
  - Deletion always requires confirmation.
145
- - It removes the session directory, workspace records, archive set, and projection cache.
149
+ - It removes workspace records, archive markers, and the projection cache. For the official JSONL backend, a validated layout also allows removal of the session-owned directory and its contents, including attachments. Other backends or unknown layouts only lose the located transcript artifact, never its parent directory.
150
+ - Project containers and storage roots are retained. Deletion refuses symbolic links or Windows junctions at the official layout's project/session directory levels and keeps the operation retryable.
151
+ - Layout validation uses the official backend's initialized absolute root, so a relative root remains stable across host working-directory changes. If that field is unavailable, only an absolute configured root is accepted. An unverified official JSONL layout emits a warning with the session ID and artifact path before falling back to artifact-only deletion.
152
+ - Directory validation is not a cross-process filesystem lock: do not concurrently move or replace storage directories or change directory links during deletion. Storage paths writable by untrusted processes are not a security isolation boundary.
146
153
  - A live session finishes writing before cleanup to prevent data truncation.
147
154
  - The plugin replaces DSH’s default workspace and projection services. Install through the DSH profile instead of manually composing the patch.
148
155
 
package/README.zh-CN.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  - 在侧栏会话菜单中选择「归档会话」。
25
25
  - 在工作区侧栏操作菜单中一键归档该工作区全部未归档聊天。
26
- - 在「设置 → 已归档」按工作区查看归档会话,并支持搜索、按更新时间/创建时间/标题排序和按项目筛选。
26
+ - 在「设置 → 归档会话」按工作区查看归档会话,并支持搜索、按更新时间/创建时间/标题排序和按项目筛选。
27
27
  - 安全取消归档,将会话恢复到原工作区位置。
28
28
  - 在项目分组中批量恢复或永久删除该项目的全部已归档聊天。
29
29
  - 在页面顶部一键恢复全部已归档聊天。
@@ -38,7 +38,7 @@
38
38
 
39
39
  ![从会话菜单归档会话](assets/screenshots/archive-session-menu.png)
40
40
 
41
- 在「设置 → 已归档」中搜索、排序、按项目筛选、取消归档或永久删除:
41
+ 在「设置 → 归档会话」中搜索、排序、按项目筛选、取消归档或永久删除:
42
42
 
43
43
  ![已归档聊天设置页面](assets/screenshots/archived-sessions.png)
44
44
 
@@ -85,7 +85,7 @@
85
85
  | Codex | 把上面其中一句发给 Codex,让它在本机执行安装。 |
86
86
  | WorkBuddy | 把上面其中一句发给 WorkBuddy;源码安装也可同时粘贴仓库地址 `https://github.com/MichengAI/dsh-archive-manager`。 |
87
87
 
88
- Codex 和 WorkBuddy 只负责代装;装好后仍要打开 DSH Web 使用「设置 → 已归档」。
88
+ Codex 和 WorkBuddy 只负责代装;装好后仍要打开 DSH Web 使用「设置 → 归档会话」。
89
89
 
90
90
  也可以自己执行同一条 npm 命令:
91
91
 
@@ -128,10 +128,14 @@ dsh --profile web --dump-config
128
128
 
129
129
  完成后重启 DSH Web 并硬刷新浏览器。`dsh plugin ... add .` 会读取当前目录的包信息和 `cordis.patch.yml`;不要改为直接复制 `lib` 目录。
130
130
 
131
+ ## 在线更新
132
+
133
+ 设置标题会显示当前版本和“检查更新”按钮。发现新版后,只有检测到 DSH CLI 或 Desktop 更新服务时才可使用“自动更新”;其他环境会在弹窗中提供可复制、与当前 Profile 对应的手工更新命令。
134
+
131
135
  ## 使用
132
136
 
133
137
  1. 在侧栏右键或打开会话菜单,选择「归档会话」。
134
- 2. 打开「设置 → 已归档」,按工作区查看归档会话。
138
+ 2. 打开「设置 → 归档会话」,按工作区查看归档会话。
135
139
  3. 按标题搜索,按更新时间、创建时间或标题排序,或按项目筛选列表。
136
140
  4. 点击「取消归档」恢复单个会话,或在顶部点击「全部恢复」。
137
141
  5. 打开项目标题右侧菜单,可恢复或删除该项目的全部已归档聊天。
@@ -142,7 +146,10 @@ dsh --profile web --dump-config
142
146
  ## 数据处理边界
143
147
 
144
148
  - 删除操作始终需要确认。
145
- - 删除会移除会话目录、工作区记录、归档集合和投影缓存。
149
+ - 删除会移除工作区记录、归档标记和投影缓存。官方 JSONL 后端在目录布局校验通过后,会一并删除会话专属目录及其中的附件等内容;其他后端或未知布局仅删除定位到的转录工件,不删除其父目录。
150
+ - 不清理项目分组目录或存储根目录。官方布局的项目/会话目录若为符号链接或 Windows junction,会拒绝删除并保留可重试状态。
151
+ - 布局校验优先使用官方后端初始化时确定的绝对根路径,相对路径配置不再受宿主工作目录变化影响;若无法取得该字段,则只接受绝对路径配置。官方 JSONL 布局校验失败时,会记录包含会话 ID 和工件路径的警告,再降级为仅删除工件。
152
+ - 目录校验不是跨进程文件锁:删除期间不要由其他进程迁移、替换存储目录或改写目录链接。插件不将可被不可信进程改写的存储路径视为安全隔离边界。
146
153
  - 正在写入的会话会在完成写入后清理,避免截断数据。
147
154
  - 本插件替换 DSH 默认的工作区和会话投影服务;请仅通过 DSH profile 安装,避免手工拼接补丁配置。
148
155
 
package/lib/client.js CHANGED
@@ -1,4 +1,295 @@
1
1
  (() => {
2
+ // src/plugin-update-ui.js
3
+ var UPDATE_HEADER = "x-michengai-plugin-update";
4
+ var STYLE_ID = "michengai-plugin-update-ui";
5
+ var CSS = `
6
+ .mpi-version{margin-left:8px;color:var(--dsw-alias-label-tertiary,#9da1aa);font-family:inherit;font-size:12px;font-weight:500;line-height:18px;letter-spacing:0;white-space:nowrap;vertical-align:baseline}.mpi-check{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:28px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,#4b4d52);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#b8bbc2);font:inherit;font-size:12px;font-weight:500;line-height:18px;white-space:nowrap;cursor:pointer}.mpi-check:hover{background:var(--dsw-alias-interactive-bg-hover,#3a3b3f);color:var(--dsw-alias-label-primary,#fff)}.mpi-check:focus-visible,.mpi-action:focus-visible,.mpi-dialog-close:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4f8cff);outline-offset:2px}.mpi-icon{display:inline-flex;flex:0 0 auto;width:16px;height:16px;align-items:center;justify-content:center;pointer-events:none}.mpi-icon svg{display:block;width:16px;height:16px}
7
+ .mpi-overlay{position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.62)}.mpi-dialog{position:relative;box-sizing:border-box;width:min(680px,100%);max-height:calc(100vh - 48px);overflow:auto;border:1px solid var(--dsw-alias-border-l2,#4b4d52);border-radius:14px;padding:22px;background:var(--dsw-alias-bg-layer-2,var(--dsw-specific-menu,#202124));color:var(--dsw-alias-label-primary,#fff);box-shadow:var(--dsw-shadow-lv3,0 16px 48px rgba(0,0,0,.24));font-family:inherit}.mpi-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.mpi-dialog h2{margin:0;font-size:18px;line-height:26px}.mpi-dialog-close{display:inline-flex;flex:0 0 28px;width:28px;height:28px;align-items:center;justify-content:center;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#b8bbc2);cursor:pointer}.mpi-dialog-close:hover{background:var(--dsw-alias-interactive-bg-hover,#3a3b3f);color:var(--dsw-alias-label-primary,#fff)}.mpi-intro{margin:8px 0 18px;color:var(--dsw-alias-label-secondary,#c2c4ca);font-size:13px;line-height:20px}.mpi-meta{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 18px;margin:0 0 16px;font-size:12px;line-height:18px}.mpi-meta dt{color:var(--dsw-alias-label-secondary,#c2c4ca)}.mpi-meta dd{margin:0;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.mpi-status{margin:0 0 18px;border-radius:7px;padding:12px 14px;background:var(--dsw-alias-bg-layer-3,var(--dsw-specific-menu-item-hover,#252527));font-size:13px;font-weight:600;line-height:20px}.mpi-status[data-kind=error]{color:var(--dsw-alias-state-error-primary,#ff6464)}.mpi-status[data-kind=success]{color:var(--dsw-alias-state-success-primary,#36d67a)}.mpi-manual{border-top:1px solid var(--dsw-alias-border-l2,#4b4d52);padding-top:16px}.mpi-manual h3{margin:0 0 6px;font-size:14px;line-height:20px}.mpi-manual p{margin:0 0 10px;color:var(--dsw-alias-label-secondary,#c2c4ca);font-size:12px;line-height:18px}.mpi-command{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2,#4b4d52);border-radius:7px;padding:10px;background:var(--dsw-alias-bg-layer-3,var(--dsw-specific-menu-item-hover,#252527))}.mpi-command code{min-width:0;flex:1;overflow:auto;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;line-height:18px;white-space:nowrap}.mpi-actions{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:18px}.mpi-actions-group{display:flex;gap:8px}.mpi-action{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;border:1px solid var(--dsw-alias-border-l2,#4b4d52);border-radius:7px;padding:6px 10px;background:transparent;color:inherit;font:inherit;font-size:12px;cursor:pointer}.mpi-action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#414247)}.mpi-action:disabled{cursor:not-allowed;opacity:.55}.mpi-primary{border-color:var(--dsw-alias-state-business-primary,#4f8cff);background:var(--dsw-alias-state-business-primary,#4f8cff);color:#fff}.mpi-progress{height:4px;margin-top:10px;overflow:hidden;border-radius:99px;background:var(--dsw-alias-border-l2,#4b4d52)}.mpi-progress::after{display:block;width:32%;height:100%;background:var(--dsw-alias-state-business-primary,#4f8cff);content:'';animation:mpi-wave 1.15s ease-in-out infinite}@keyframes mpi-wave{from{transform:translateX(-110%)}to{transform:translateX(330%)}}@media(max-width:560px){.mpi-overlay{padding:10px}.mpi-dialog{max-height:calc(100vh - 20px);padding:16px}.mpi-actions{align-items:stretch}.mpi-actions-group{justify-content:flex-end;flex-wrap:wrap}.mpi-meta{grid-template-columns:1fr;gap:2px}.mpi-meta dd{margin-bottom:6px}}
8
+ `;
9
+ var ZH = {
10
+ check: "\u68C0\u67E5\u66F4\u65B0",
11
+ update: "\u66F4\u65B0",
12
+ close: "\u5173\u95ED",
13
+ recheck: "\u91CD\u65B0\u68C0\u67E5",
14
+ auto: "\u81EA\u52A8\u66F4\u65B0",
15
+ updating: "\u6B63\u5728\u66F4\u65B0\u2026",
16
+ copy: "\u590D\u5236\u547D\u4EE4",
17
+ copied: "\u5DF2\u590D\u5236",
18
+ copyFailed: "\u590D\u5236\u5931\u8D25",
19
+ checking: "\u6B63\u5728\u68C0\u67E5\u66F4\u65B0\u2026",
20
+ latest: "\u5DF2\u662F\u6700\u65B0\u7248\u672C",
21
+ found: "\u53D1\u73B0\u65B0\u7248\u672C",
22
+ failed: "\u68C0\u67E5\u66F4\u65B0\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002",
23
+ current: "\u8FD0\u884C\u7248\u672C",
24
+ latestLabel: "\u6700\u65B0\u7248\u672C",
25
+ profile: "\u76EE\u6807 profile",
26
+ unknown: "\u672A\u77E5",
27
+ manual: "\u624B\u5DE5\u66F4\u65B0",
28
+ manualHint: "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u65F6\uFF0C\u53EF\u5728\u5F53\u524D DSH \u7EC8\u7AEF\u6267\u884C\u4EE5\u4E0B\u547D\u4EE4\uFF0C\u5B8C\u6210\u540E\u91CD\u542F DSH Web\u3002",
29
+ intro: "\u4EC5\u68C0\u67E5\u5E76\u66F4\u65B0\u5F53\u524D\u63D2\u4EF6\uFF0C\u4E0D\u4F1A\u8054\u52A8\u5B89\u88C5\u5176\u4ED6\u63D2\u4EF6\u3002",
30
+ restart: "\u66F4\u65B0\u5B8C\u6210\uFF0C\u8BF7\u91CD\u542F DSH Web\u3002",
31
+ restarting: "\u66F4\u65B0\u5B8C\u6210\uFF0C\u6B63\u5728\u91CD\u542F DSH Desktop\u2026",
32
+ unavailable: "\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u81EA\u52A8\u66F4\u65B0\uFF0C\u8BF7\u4F7F\u7528\u624B\u5DE5\u66F4\u65B0\u547D\u4EE4\u3002"
33
+ };
34
+ var EN = {
35
+ check: "Check for updates",
36
+ update: "Update",
37
+ close: "Close",
38
+ recheck: "Check again",
39
+ auto: "Update automatically",
40
+ updating: "Updating\u2026",
41
+ copy: "Copy command",
42
+ copied: "Copied",
43
+ copyFailed: "Copy failed",
44
+ checking: "Checking for updates\u2026",
45
+ latest: "You are up to date",
46
+ found: "New version available",
47
+ failed: "Could not check for updates. Try again later.",
48
+ current: "Running version",
49
+ latestLabel: "Latest version",
50
+ profile: "Target profile",
51
+ unknown: "Unknown",
52
+ manual: "Manual update",
53
+ manualHint: "If automatic update fails, run this command in the current DSH terminal, then restart DSH Web.",
54
+ intro: "Only this plugin is checked and updated. Other plugins are not changed.",
55
+ restart: "Update complete. Restart DSH Web.",
56
+ restarting: "Update complete. Restarting DSH Desktop\u2026",
57
+ unavailable: "Automatic update is unavailable. Use the manual command."
58
+ };
59
+ function strings() {
60
+ const lang = document.documentElement.lang.toLowerCase();
61
+ const settings = document.querySelector('[role="dialog"]')?.textContent ?? "";
62
+ return lang.startsWith("en") || settings.includes("Settings") && !settings.includes("\u8BBE\u7F6E") ? EN : ZH;
63
+ }
64
+ function ensureStyle() {
65
+ if (document.getElementById(STYLE_ID) !== null) return;
66
+ const style = document.createElement("style");
67
+ style.id = STYLE_ID;
68
+ style.textContent = CSS;
69
+ (document.head ?? document.documentElement).append(style);
70
+ }
71
+ function validPayload(value) {
72
+ if (value === null || typeof value !== "object") return false;
73
+ const item = value;
74
+ return typeof item.packageName === "string" && typeof item.currentVersion === "string" && typeof item.updateAvailable === "boolean" && typeof item.profileName === "string" && typeof item.canAutoUpdate === "boolean" && typeof item.latestCheckFailed === "boolean" && (item.latestVersion === void 0 || typeof item.latestVersion === "string");
75
+ }
76
+ async function requestStatus(endpoint, method, signal) {
77
+ const signalOption = signal === void 0 ? {} : { signal };
78
+ const response = await fetch(endpoint, method === "GET" ? { cache: "no-store", ...signalOption } : {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json", [UPDATE_HEADER]: "1" },
81
+ body: "{}",
82
+ ...signalOption
83
+ });
84
+ const value = await response.json();
85
+ if (!response.ok || !validPayload(value)) throw new Error(typeof value.error === "string" ? value.error : strings().failed);
86
+ return value;
87
+ }
88
+ function manualPluginUpdateCommand(profileName, packageName, version) {
89
+ const profile = profileName.trim() === "" ? "" : ` --profile ${profileName.trim()}`;
90
+ return `dsh plugin${profile} add ${packageName}@${version} --registry=https://registry.npmjs.org/`;
91
+ }
92
+ function handlePluginUpdateEscape(event, close) {
93
+ if (event.key !== "Escape") return false;
94
+ event.preventDefault();
95
+ event.stopPropagation();
96
+ event.stopImmediatePropagation();
97
+ close();
98
+ return true;
99
+ }
100
+ function observePluginUpdate(options) {
101
+ if (typeof document === "undefined" || document.body === null) return () => {
102
+ };
103
+ ensureStyle();
104
+ const controller = new AbortController();
105
+ let payload;
106
+ let overlay;
107
+ let frame;
108
+ const setButtonContent = (button, label, iconName) => {
109
+ const icon = options.createIcon(iconName);
110
+ icon.classList.add("mpi-icon");
111
+ icon.setAttribute("aria-hidden", "true");
112
+ const text = document.createElement("span");
113
+ text.dataset.mpiLabel = "";
114
+ text.textContent = label;
115
+ button.replaceChildren(icon, text);
116
+ };
117
+ const setButtonLabel = (button, label) => {
118
+ const text = button.querySelector("[data-mpi-label]");
119
+ if (text === null) button.textContent = label;
120
+ else text.textContent = label;
121
+ };
122
+ const applyControls = () => {
123
+ const row = document.querySelector(options.titleRowSelector);
124
+ if (row === null) return;
125
+ const heading = row.querySelector("h1,h2");
126
+ if (heading !== null && payload !== void 0) {
127
+ let version = heading.querySelector(`.mpi-version[data-package="${options.packageName}"]`);
128
+ if (version === null) {
129
+ version = document.createElement("span");
130
+ version.className = "mpi-version";
131
+ version.dataset.package = options.packageName;
132
+ heading.append(version);
133
+ }
134
+ const versionLabel = `v${payload.currentVersion}`;
135
+ if (version.textContent !== versionLabel) version.textContent = versionLabel;
136
+ }
137
+ const links = row.querySelector(options.linksSelector);
138
+ if (links === null || links.querySelector(`[data-mpi-check="${options.packageName}"]`) !== null) return;
139
+ const button = document.createElement("button");
140
+ button.type = "button";
141
+ button.className = "mpi-check";
142
+ button.dataset.mpiCheck = options.packageName;
143
+ setButtonContent(button, strings().check, "refresh");
144
+ button.addEventListener("click", openDialog);
145
+ links.append(button);
146
+ };
147
+ const load = async () => {
148
+ payload = await requestStatus(options.endpoint, "GET", controller.signal);
149
+ applyControls();
150
+ return payload;
151
+ };
152
+ const closeDialog = () => {
153
+ overlay?.remove();
154
+ overlay = void 0;
155
+ };
156
+ function openDialog() {
157
+ closeDialog();
158
+ const text = strings();
159
+ overlay = document.createElement("div");
160
+ overlay.className = "mpi-overlay";
161
+ const dialog = document.createElement("section");
162
+ dialog.className = "mpi-dialog";
163
+ dialog.setAttribute("role", "dialog");
164
+ dialog.setAttribute("aria-modal", "true");
165
+ dialog.innerHTML = `<header class="mpi-head"><h2></h2><button type="button" class="mpi-dialog-close" data-action="close"></button></header><p class="mpi-intro"></p><dl class="mpi-meta"><dt></dt><dd data-role="current"></dd><dt></dt><dd data-role="latest"></dd><dt></dt><dd data-role="profile"></dd></dl><div class="mpi-status" role="status"></div><div class="mpi-progress" hidden></div><section class="mpi-manual"><h3></h3><p></p><div class="mpi-command"><code></code><button type="button" class="mpi-action" data-action="copy"></button></div></section><footer class="mpi-actions"><div class="mpi-actions-group"><button type="button" class="mpi-action" data-action="check"></button><button type="button" class="mpi-action mpi-primary" data-action="update"></button></div></footer>`;
166
+ const name = document.documentElement.lang.toLowerCase().startsWith("en") ? options.enName : options.zhName;
167
+ dialog.querySelector("h2").textContent = `${name} ${text.update}`;
168
+ dialog.querySelector(".mpi-intro").textContent = text.intro;
169
+ const terms = dialog.querySelectorAll("dt");
170
+ terms[0].textContent = text.current;
171
+ terms[1].textContent = text.latestLabel;
172
+ terms[2].textContent = text.profile;
173
+ dialog.querySelector(".mpi-manual h3").textContent = text.manual;
174
+ dialog.querySelector(".mpi-manual p").textContent = text.manualHint;
175
+ const status = dialog.querySelector(".mpi-status");
176
+ const progress = dialog.querySelector(".mpi-progress");
177
+ const command = dialog.querySelector(".mpi-command code");
178
+ const close = dialog.querySelector("[data-action=close]");
179
+ const check = dialog.querySelector("[data-action=check]");
180
+ const update = dialog.querySelector("[data-action=update]");
181
+ const copy = dialog.querySelector("[data-action=copy]");
182
+ setButtonContent(close, text.close, "close");
183
+ close.querySelector("[data-mpi-label]")?.remove();
184
+ close.setAttribute("aria-label", text.close);
185
+ close.title = text.close;
186
+ setButtonContent(check, text.recheck, "refresh");
187
+ setButtonContent(update, text.auto, "download");
188
+ setButtonContent(copy, text.copy, "copy");
189
+ let busy = false;
190
+ const setMessage = (message, kind = "") => {
191
+ status.textContent = message;
192
+ status.dataset.kind = kind;
193
+ };
194
+ const setBusy = (value) => {
195
+ busy = value;
196
+ check.disabled = value;
197
+ copy.disabled = value;
198
+ update.disabled = value || payload?.canAutoUpdate !== true || payload.updateAvailable !== true;
199
+ progress.hidden = !value;
200
+ };
201
+ const render = () => {
202
+ dialog.querySelector("[data-role=current]").textContent = payload === void 0 ? text.unknown : `v${payload.currentVersion}`;
203
+ dialog.querySelector("[data-role=latest]").textContent = payload?.latestVersion === void 0 ? text.unknown : `v${payload.latestVersion}`;
204
+ dialog.querySelector("[data-role=profile]").textContent = payload?.profileName ?? text.unknown;
205
+ command.textContent = manualPluginUpdateCommand(payload?.profileName ?? "", options.packageName, payload?.latestVersion ?? "latest");
206
+ update.disabled = busy || payload?.canAutoUpdate !== true || payload.updateAvailable !== true;
207
+ if (payload === void 0) setMessage(text.checking);
208
+ else if (payload.latestCheckFailed) setMessage(text.failed, "error");
209
+ else if (payload.updateAvailable) setMessage(`${text.found}: v${payload.latestVersion ?? text.unknown}`);
210
+ else setMessage(text.latest, "success");
211
+ if (payload !== void 0 && !payload.canAutoUpdate && payload.updateAvailable) setMessage(text.unavailable);
212
+ };
213
+ const checkNow = async () => {
214
+ if (busy) return;
215
+ setBusy(true);
216
+ setMessage(text.checking);
217
+ try {
218
+ await load();
219
+ setBusy(false);
220
+ render();
221
+ } catch (error) {
222
+ setBusy(false);
223
+ setMessage(error instanceof Error ? error.message : text.failed, "error");
224
+ }
225
+ };
226
+ const updateNow = async () => {
227
+ if (busy) return;
228
+ setBusy(true);
229
+ setButtonLabel(update, text.updating);
230
+ setMessage(text.updating);
231
+ try {
232
+ payload = await requestStatus(options.endpoint, "POST", controller.signal);
233
+ applyControls();
234
+ render();
235
+ setMessage(payload.autoReload === true ? text.restarting : text.restart, "success");
236
+ } catch (error) {
237
+ setMessage(error instanceof Error ? error.message : text.failed, "error");
238
+ } finally {
239
+ setButtonLabel(update, text.auto);
240
+ setBusy(false);
241
+ }
242
+ };
243
+ close.addEventListener("click", closeDialog);
244
+ check.addEventListener("click", () => {
245
+ void checkNow();
246
+ });
247
+ update.addEventListener("click", () => {
248
+ void updateNow();
249
+ });
250
+ copy.addEventListener("click", () => {
251
+ void navigator.clipboard?.writeText(command.textContent ?? "").then(() => {
252
+ setButtonLabel(copy, text.copied);
253
+ setTimeout(() => {
254
+ setButtonLabel(copy, text.copy);
255
+ }, 1400);
256
+ }).catch(() => {
257
+ setButtonLabel(copy, text.copyFailed);
258
+ });
259
+ });
260
+ overlay.addEventListener("click", (event) => {
261
+ if (event.target === overlay) closeDialog();
262
+ });
263
+ overlay.addEventListener("keydown", (event) => {
264
+ handlePluginUpdateEscape(event, closeDialog);
265
+ }, true);
266
+ overlay.append(dialog);
267
+ document.body.append(overlay);
268
+ render();
269
+ close.focus();
270
+ void checkNow();
271
+ }
272
+ const observer = new MutationObserver(() => {
273
+ if (frame !== void 0) return;
274
+ frame = window.requestAnimationFrame(() => {
275
+ frame = void 0;
276
+ applyControls();
277
+ });
278
+ });
279
+ observer.observe(document.body, { childList: true, subtree: true });
280
+ applyControls();
281
+ void load().catch(() => {
282
+ });
283
+ return () => {
284
+ controller.abort();
285
+ observer.disconnect();
286
+ closeDialog();
287
+ if (frame !== void 0) window.cancelAnimationFrame(frame);
288
+ document.querySelectorAll(`[data-mpi-check="${options.packageName}"],.mpi-version[data-package="${options.packageName}"]`).forEach((node) => node.remove());
289
+ };
290
+ }
291
+
292
+ // src/client.js
2
293
  window.__ModuleLoader__.load({
3
294
  id: "@michengai/dsh-archive-manager",
4
295
  factory: (require2) => {
@@ -16,6 +307,32 @@
16
307
  let react_jsx_runtime = require2("react/jsx-runtime");
17
308
  let react = require2("react");
18
309
  let _deepseek_ai_dsh_client_ui_primitives = require2("@deepseek-ai/dsh-client-ui-primitives");
310
+ const UPDATE_ICON_PATHS = {
311
+ refresh: ["M13.5 5.5V2.5m0 0h-3m3 0-2.1 2.1A5.5 5.5 0 1 0 13.2 12"],
312
+ download: ["M8 2v8m0 0 3-3m-3 3-3-3M3 13v2h10v-2"],
313
+ copy: ["M5 5h8v8H5z", "M3 3h8"],
314
+ close: ["m4 4 8 8M12 4 4 12"]
315
+ };
316
+ function createPluginUpdateIcon(name) {
317
+ const element = document.createElement("span");
318
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
319
+ svg.setAttribute("viewBox", "0 0 16 16");
320
+ svg.setAttribute("width", "16");
321
+ svg.setAttribute("height", "16");
322
+ svg.setAttribute("fill", "none");
323
+ svg.setAttribute("stroke", "currentColor");
324
+ svg.setAttribute("stroke-width", "1.5");
325
+ svg.setAttribute("stroke-linecap", "round");
326
+ svg.setAttribute("stroke-linejoin", "round");
327
+ svg.setAttribute("aria-hidden", "true");
328
+ for (const d of UPDATE_ICON_PATHS[name]) {
329
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
330
+ path.setAttribute("d", d);
331
+ svg.append(path);
332
+ }
333
+ element.append(svg);
334
+ return element;
335
+ }
19
336
  const sessionIdSchema = {
20
337
  parse(value) {
21
338
  if (typeof value !== "string" || value.length === 0) throw new TypeError(`sessionId must be a non-empty string, got ${String(value)}`);
@@ -3180,9 +3497,9 @@
3180
3497
  "menu.addWorkspace": "\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026",
3181
3498
  "menu.unarchive": "\u53D6\u6D88\u5F52\u6863",
3182
3499
  "menu.deleteSession": "\u5220\u9664\u4F1A\u8BDD",
3183
- "archived.badge": "\u5DF2\u5F52\u6863",
3500
+ "archived.badge": "\u5F52\u6863\u4F1A\u8BDD",
3184
3501
  "archived.notOpenable": "\u5DF2\u5F52\u6863\uFF0C\u53D6\u6D88\u5F52\u6863\u540E\u53EF\u7EE7\u7EED\u5BF9\u8BDD",
3185
- "archives.title": "\u5DF2\u5F52\u6863\u7684\u804A\u5929",
3502
+ "archives.title": "\u5F52\u6863\u4F1A\u8BDD",
3186
3503
  "archives.description": "\u7BA1\u7406\u5DF2\u5F52\u6863\u7684\u4F1A\u8BDD\u3002",
3187
3504
  "archives.viewProject": "GitHub",
3188
3505
  "archives.feedback": "\u95EE\u9898\u53CD\u9988",
@@ -3310,9 +3627,9 @@
3310
3627
  "menu.addWorkspace": "Add workspace\u2026",
3311
3628
  "menu.unarchive": "Unarchive",
3312
3629
  "menu.deleteSession": "Delete session",
3313
- "archived.badge": "Archived",
3630
+ "archived.badge": "Archived sessions",
3314
3631
  "archived.notOpenable": "This session is archived. Unarchive it to continue the conversation.",
3315
- "archives.title": "Archived chats",
3632
+ "archives.title": "Archived sessions",
3316
3633
  "archives.description": "Manage archived sessions.",
3317
3634
  "archives.viewProject": "GitHub",
3318
3635
  "archives.feedback": "Issues",
@@ -3589,6 +3906,15 @@
3589
3906
  zh,
3590
3907
  en
3591
3908
  }), "dsh-archive-manager: dictionaries");
3909
+ ctx.effect(() => observePluginUpdate({
3910
+ endpoint: "/api/michengai/dsh-archive-manager/update",
3911
+ packageName: "@michengai/dsh-archive-manager",
3912
+ titleRowSelector: ".dsham_settingsTitleRow",
3913
+ linksSelector: ".dsham_settingsLinks",
3914
+ zhName: "\u5F52\u6863\u4F1A\u8BDD",
3915
+ enName: "Archived sessions",
3916
+ createIcon: createPluginUpdateIcon
3917
+ }), "dsh-archive-manager: plugin update ui");
3592
3918
  const uiWorkspaceAt = () => ctx.get("uiWorkspace");
3593
3919
  const searchSessions = async (query, signal) => {
3594
3920
  const result = await ctx.sessions.search(query, signal);
package/lib/index.js CHANGED
@@ -1,5 +1,13 @@
1
- function apply() {
1
+ import { registerPluginUpdater } from "./plugin-updater.js";
2
+ const inject = ["webServer"];
3
+ function apply(ctx) {
4
+ return registerPluginUpdater(ctx, {
5
+ endpoint: "/api/michengai/dsh-archive-manager/update",
6
+ packageName: "@michengai/dsh-archive-manager",
7
+ manifestUrl: new URL("../package.json", import.meta.url)
8
+ });
2
9
  }
3
10
  export {
4
- apply
11
+ apply,
12
+ inject
5
13
  };
@@ -0,0 +1,257 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ const PLUGIN_UPDATE_HEADER = "x-michengai-plugin-update";
8
+ const PLUGIN_UPDATE_IPC = "apply-plugin-updates";
9
+ function header(request, name) {
10
+ const value = request.headers?.[name];
11
+ return Array.isArray(value) ? value[0] : value;
12
+ }
13
+ function isLoopbackAddress(value) {
14
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, "");
15
+ return address === "localhost" || address === "localhost." || address === "::1" || address?.startsWith("127.") === true || address?.startsWith("::ffff:127.") === true;
16
+ }
17
+ function isTrustedUpdateRequest(request) {
18
+ if (header(request, PLUGIN_UPDATE_HEADER) !== "1") return false;
19
+ if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
20
+ const site = header(request, "sec-fetch-site");
21
+ if (site !== void 0 && site !== "same-origin") return false;
22
+ const origin = header(request, "origin");
23
+ const host = header(request, "host");
24
+ if (origin === void 0 || host === void 0) return false;
25
+ try {
26
+ const url = new URL(origin);
27
+ return (url.protocol === "http:" || url.protocol === "https:") && isLoopbackAddress(url.hostname) && url.host === host;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+ function validProfileName(value) {
33
+ return typeof value === "string" && value !== "" && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\") && !/[\0-\x1f\x7f]/.test(value);
34
+ }
35
+ function profileNameFromArgv(argv) {
36
+ for (let index = 2; index < argv.length; index += 1) {
37
+ if (argv[index] === "--profile") return argv[index + 1];
38
+ if (argv[index]?.startsWith("--profile=")) return argv[index].slice("--profile=".length);
39
+ }
40
+ return argv[2] === "web" ? "web" : void 0;
41
+ }
42
+ function isDshCliEntry(entry, manifest, packageRoot) {
43
+ if (typeof manifest !== "object" || manifest === null) return false;
44
+ if (manifest.name !== "@deepseek-ai/dsh") return false;
45
+ const bin = typeof manifest.bin === "string" ? manifest.bin : typeof manifest.bin === "object" && manifest.bin !== null ? manifest.bin.dsh : void 0;
46
+ return typeof bin === "string" && bin !== "" && !isAbsolute(bin) && resolve(packageRoot, bin) === resolve(entry);
47
+ }
48
+ function cliEntry() {
49
+ const value = process.argv[1];
50
+ if (value === void 0 || value === "") return void 0;
51
+ const entry = value.startsWith("file:") ? fileURLToPath(value) : resolve(process.cwd(), value);
52
+ if (!existsSync(entry)) return void 0;
53
+ for (let directory = dirname(entry); ; ) {
54
+ const manifestPath = resolve(directory, "package.json");
55
+ if (existsSync(manifestPath)) {
56
+ try {
57
+ if (isDshCliEntry(entry, JSON.parse(readFileSync(manifestPath, "utf8")), directory)) return entry;
58
+ } catch {
59
+ }
60
+ }
61
+ const parent = dirname(directory);
62
+ if (parent === directory) return void 0;
63
+ directory = parent;
64
+ }
65
+ }
66
+ function runtime(ctx) {
67
+ const profiles = ctx.get?.("desktopProfiles");
68
+ const desktopPnpm = ctx.get?.("desktopPnpm");
69
+ if (profiles?.current !== void 0) {
70
+ const current = profiles.current;
71
+ if (!validProfileName(current.name) || typeof current.dir !== "string" || !isAbsolute(current.dir)) {
72
+ throw new Error("\u5F53\u524D Desktop Profile \u4FE1\u606F\u65E0\u6548\uFF0C\u8BF7\u91CD\u542F\u540E\u91CD\u8BD5\u3002");
73
+ }
74
+ return { profileName: current.name, profileDir: resolve(current.dir), ...typeof desktopPnpm?.runPlugin === "function" ? { desktopPnpm } : {} };
75
+ }
76
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR ?? resolve(homedir(), ".dsh", "profiles", "web"));
77
+ const selected = profileNameFromArgv(process.argv);
78
+ const profileName = validProfileName(selected) ? selected : validProfileName(basename(profileDir)) ? basename(profileDir) : "web";
79
+ const entry = cliEntry();
80
+ return { profileName, profileDir, ...entry === void 0 ? {} : { cliEntry: entry } };
81
+ }
82
+ function parseSemver(value) {
83
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
84
+ if (match === null) return void 0;
85
+ return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4]?.split(".") ?? [] };
86
+ }
87
+ function isNewerVersion(currentValue, candidateValue) {
88
+ const current = parseSemver(currentValue);
89
+ const candidate = parseSemver(candidateValue);
90
+ if (current === void 0 || candidate === void 0) return false;
91
+ for (let index = 0; index < 3; index += 1) {
92
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
93
+ }
94
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
95
+ }
96
+ function comparePrerelease(left, right) {
97
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
98
+ const length = Math.max(left.length, right.length);
99
+ for (let index = 0; index < length; index += 1) {
100
+ const a = left[index];
101
+ const b = right[index];
102
+ if (a === void 0 || b === void 0) return a === b ? 0 : a === void 0 ? -1 : 1;
103
+ if (a === b) continue;
104
+ const aNumeric = /^\d+$/.test(a);
105
+ const bNumeric = /^\d+$/.test(b);
106
+ if (aNumeric && bNumeric) {
107
+ const aNumber = BigInt(a);
108
+ const bNumber = BigInt(b);
109
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
110
+ continue;
111
+ }
112
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
113
+ return a > b ? 1 : -1;
114
+ }
115
+ return 0;
116
+ }
117
+ let latestCache;
118
+ async function latestVersion(packageName) {
119
+ if (latestCache?.packageName === packageName && Date.now() < latestCache.expiresAt) return latestCache.version;
120
+ try {
121
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, { signal: AbortSignal.timeout(8e3) });
122
+ if (!response.ok) return void 0;
123
+ const value = await response.json();
124
+ if (typeof value.version !== "string" || value.version === "") return void 0;
125
+ latestCache = { packageName, version: value.version, expiresAt: Date.now() + 5 * 6e4 };
126
+ return value.version;
127
+ } catch {
128
+ return void 0;
129
+ }
130
+ }
131
+ async function currentVersion(manifestUrl) {
132
+ const value = JSON.parse(await readFile(manifestUrl, "utf8"));
133
+ if (typeof value.version !== "string" || value.version === "") throw new Error("\u65E0\u6CD5\u8BFB\u53D6\u5F53\u524D\u63D2\u4EF6\u7248\u672C\u3002");
134
+ return value.version;
135
+ }
136
+ async function status(options, target) {
137
+ const current = await currentVersion(options.manifestUrl);
138
+ const latest = await latestVersion(options.packageName);
139
+ return {
140
+ packageName: options.packageName,
141
+ currentVersion: current,
142
+ ...latest === void 0 ? {} : { latestVersion: latest },
143
+ latestCheckFailed: latest === void 0,
144
+ updateAvailable: latest !== void 0 && isNewerVersion(current, latest),
145
+ profileName: target.profileName,
146
+ canAutoUpdate: target.desktopPnpm !== void 0 || target.cliEntry !== void 0
147
+ };
148
+ }
149
+ async function runCliInstall(target, packageSpec) {
150
+ if (target.cliEntry === void 0) throw new Error("\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u81EA\u52A8\u66F4\u65B0\uFF0C\u8BF7\u4F7F\u7528\u624B\u5DE5\u66F4\u65B0\u547D\u4EE4\u3002");
151
+ await new Promise((resolvePromise, reject) => {
152
+ const child = spawn(process.execPath, [target.cliEntry, "plugin", "--profile", target.profileName, "add", "--config.minimumReleaseAge=0", packageSpec, "--registry=https://registry.npmjs.org/"], {
153
+ cwd: target.profileDir,
154
+ windowsHide: true,
155
+ stdio: ["ignore", "pipe", "pipe"],
156
+ env: { ...process.env, NO_COLOR: "1" }
157
+ });
158
+ let detail = "";
159
+ child.stdout?.on("data", (chunk) => {
160
+ detail = (detail + String(chunk)).slice(-4e3);
161
+ });
162
+ child.stderr?.on("data", (chunk) => {
163
+ detail = (detail + String(chunk)).slice(-4e3);
164
+ });
165
+ const timer = setTimeout(() => {
166
+ child.kill();
167
+ reject(new Error("\u66F4\u65B0\u8D85\u65F6\uFF0C\u8BF7\u6539\u7528\u624B\u5DE5\u66F4\u65B0\u3002"));
168
+ }, 10 * 6e4);
169
+ child.once("error", (error) => {
170
+ clearTimeout(timer);
171
+ reject(error);
172
+ });
173
+ child.once("exit", (code) => {
174
+ clearTimeout(timer);
175
+ if (code === 0) resolvePromise();
176
+ else reject(new Error(detail.trim() || `\u66F4\u65B0\u8FDB\u7A0B\u9000\u51FA\u7801 ${String(code)}`));
177
+ });
178
+ });
179
+ }
180
+ async function install(target, packageSpec) {
181
+ if (target.desktopPnpm === void 0) return runCliInstall(target, packageSpec);
182
+ const handle = target.desktopPnpm.runPlugin(["add", "--config.minimumReleaseAge=0", packageSpec, "--registry=https://registry.npmjs.org/"], target.profileDir);
183
+ const result = await handle.done;
184
+ if (result.exitCode !== 0) throw new Error(`\u66F4\u65B0\u8FDB\u7A0B\u9000\u51FA\u7801 ${String(result.exitCode)}\u3002`);
185
+ }
186
+ function json(response, statusCode, value) {
187
+ response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
188
+ response.end(JSON.stringify(value));
189
+ }
190
+ function publicError(error) {
191
+ const message = error instanceof Error ? error.message : "\u66F4\u65B0\u6682\u4E0D\u53EF\u7528\u3002";
192
+ return /[A-Za-z]:[\\/]|\/(?:home|root|Users|var|tmp)\//.test(message) ? "\u66F4\u65B0\u5931\u8D25\uFF0C\u8BF7\u67E5\u770B\u670D\u52A1\u7AEF\u65E5\u5FD7\u3002" : message;
193
+ }
194
+ function registerPluginUpdater(ctx, options) {
195
+ const host = ctx;
196
+ let installing = false;
197
+ return host.webServer.register({
198
+ kind: "exact",
199
+ path: options.endpoint,
200
+ handler: async (request, response) => {
201
+ try {
202
+ const target = runtime(host);
203
+ if (request.method === "GET" || request.method === "HEAD") {
204
+ const payload = await status(options, target);
205
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
206
+ response.end(request.method === "HEAD" ? void 0 : JSON.stringify(payload));
207
+ return;
208
+ }
209
+ if (request.method !== "POST") {
210
+ response.writeHead(405, { allow: "GET, HEAD, POST" });
211
+ response.end();
212
+ return;
213
+ }
214
+ if (!isTrustedUpdateRequest(request)) {
215
+ json(response, 403, { error: "\u5DF2\u62D2\u7EDD\u975E\u672C\u673A\u540C\u6E90\u66F4\u65B0\u8BF7\u6C42\u3002" });
216
+ return;
217
+ }
218
+ if (installing) {
219
+ json(response, 409, { error: "\u5F53\u524D\u63D2\u4EF6\u6B63\u5728\u66F4\u65B0\uFF0C\u8BF7\u7A0D\u5019\u3002" });
220
+ return;
221
+ }
222
+ const before = await status(options, target);
223
+ if (before.latestVersion === void 0) {
224
+ json(response, 503, { error: "\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\u6700\u65B0\u7248\u672C\u3002" });
225
+ return;
226
+ }
227
+ if (!before.updateAvailable) {
228
+ json(response, 200, before);
229
+ return;
230
+ }
231
+ installing = true;
232
+ try {
233
+ await install(target, `${options.packageName}@${before.latestVersion}`);
234
+ } finally {
235
+ installing = false;
236
+ }
237
+ const notifyParent = target.desktopPnpm === void 0 && typeof process.send === "function";
238
+ const autoReload = target.desktopPnpm !== void 0 || notifyParent;
239
+ json(response, 200, { ...before, updatedVersion: before.latestVersion, restartRequired: true, autoReload });
240
+ if (notifyParent) setTimeout(() => {
241
+ process.send?.(PLUGIN_UPDATE_IPC);
242
+ }, 150).unref?.();
243
+ } catch (error) {
244
+ ctx.logger.warn(`plugin updater failed: ${String(error)}`);
245
+ json(response, 503, { error: publicError(error) });
246
+ }
247
+ }
248
+ });
249
+ }
250
+ export {
251
+ PLUGIN_UPDATE_HEADER,
252
+ PLUGIN_UPDATE_IPC,
253
+ isDshCliEntry,
254
+ isNewerVersion,
255
+ isTrustedUpdateRequest,
256
+ registerPluginUpdater
257
+ };
package/lib/workspace.js CHANGED
@@ -1,10 +1,29 @@
1
- import { rm } from "node:fs/promises";
1
+ import { lstat, rm } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
2
3
  import {
3
4
  WorkspaceRegistry
4
5
  } from "@deepseek-ai/dsh-workspace";
5
6
  import { bindTypertRemote, Remote } from "@deepseek-ai/dsh-typert-protocol";
6
7
  import { sessionDir } from "@deepseek-ai/dsh-spill-local";
7
8
  import { trackTombstone } from "./tombstone.js";
9
+ function jsonlSessionDirectory(persistence, header, location) {
10
+ if (persistence.name !== "session-persistence-jsonl" || location.kind !== "jsonl") return;
11
+ const root = persistence.root ?? persistence.config?.root;
12
+ if (typeof root !== "string" || !isAbsolute(root) || !isAbsolute(location.path)) return;
13
+ if (!["session.jsonl", "session.jsonl.zstd"].includes(basename(location.path))) return;
14
+ if (typeof header.id !== "string" || header.id.length === 0) return;
15
+ const encode = (text) => text.replace(/[^A-Za-z0-9._-]/g, (ch) => `~${ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")}`);
16
+ const segment = header.id === "." ? "~002E" : header.id === ".." ? "~002E~002E" : encode(header.id);
17
+ let project = "_no-cwd";
18
+ if (header.cwd !== void 0) {
19
+ if (typeof header.cwd !== "string" || header.cwd.length === 0) return;
20
+ const readable = encode(header.cwd.replace(/[\\/:]+/g, "-")).replace(/^-+/, "") || "root";
21
+ project = `--${readable.slice(0, 251)}--`;
22
+ }
23
+ const directory = join(resolve(root), project, segment);
24
+ if (location.path !== join(directory, basename(location.path))) return;
25
+ return directory;
26
+ }
8
27
  function markRemoteMethod(instance, method) {
9
28
  const context = {
10
29
  private: false,
@@ -577,7 +596,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
577
596
  this.ctx.logger.warn(`archive-manager: could not publish removal for stored session "${sessionId}": ${String(error)}`);
578
597
  }
579
598
  }
580
- /** 删除后端定位到的会话转录工件,绝不推导或删除其父目录。 */
599
+ /** 官方 JSONL 已知布局清理会话专属目录;其他后端只删除定位到的工件。 */
581
600
  async removeTranscriptDirectory(sessionId) {
582
601
  const persistence = this.ctx.get("sessionPersistence");
583
602
  if (persistence === void 0 || typeof persistence.locate !== "function") {
@@ -588,10 +607,28 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
588
607
  if (location === void 0 || typeof location.path !== "string") {
589
608
  throw new Error(`cannot delete session "${sessionId}": the session persistence backend could not resolve its transcript artifact`);
590
609
  }
610
+ let target = { path: location.path, kind: "transcript artifact" };
591
611
  try {
592
- await rm(location.path, { recursive: true, force: true });
612
+ const directory = jsonlSessionDirectory(persistence, header, location);
613
+ if (directory !== void 0) {
614
+ target = { path: directory, kind: "session directory" };
615
+ for (const path of [dirname(directory), directory]) {
616
+ let stat;
617
+ try {
618
+ stat = await lstat(path);
619
+ } catch (error) {
620
+ if (error?.code === "ENOENT") continue;
621
+ throw error;
622
+ }
623
+ if (stat.isSymbolicLink()) throw new Error(`refusing to delete through symbolic link "${path}"`);
624
+ if (!stat.isDirectory()) throw new Error(`expected session storage directory "${path}"`);
625
+ }
626
+ } else if (persistence.name === "session-persistence-jsonl") {
627
+ this.ctx.logger.warn(`archive-manager: session "${sessionId}": JSONL directory ownership could not be verified; falling back to artifact-only deletion at "${location.path}" (parent directory retained)`);
628
+ }
629
+ await rm(target.path, { recursive: true, force: true });
593
630
  } catch (error) {
594
- const message = `cannot delete session "${sessionId}": transcript artifact "${location.path}" remains before bookkeeping cleanup`;
631
+ const message = `cannot delete session "${sessionId}": cleanup of ${target.kind} "${target.path}" failed; bookkeeping retained for retry`;
595
632
  const detail = `${message}: ${String(error)}`;
596
633
  this.ctx.logger.warn(`archive-manager: ${detail}`);
597
634
  throw new Error(detail, { cause: error });
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@michengai/dsh-archive-manager",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "NPM-installable DSH Web plugin for managing archived sessions.",
5
5
  "type": "module",
6
- "packageManager": "pnpm@11.21.0",
6
+ "packageManager": "pnpm@11.22.0",
7
7
  "engines": {
8
8
  "node": "^22.19.0 || >=24.0.0"
9
9
  },
@@ -43,6 +43,7 @@
43
43
  "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.5 <0.2.0",
44
44
  "@deepseek-ai/dsh-client-ui-sidebar": ">=0.1.0-rc.5 <0.2.0",
45
45
  "@deepseek-ai/dsh-client-ui-workspace": ">=0.1.0-rc.5 <0.2.0",
46
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.5 <0.2.0",
46
47
  "@deepseek-ai/dsh-session": ">=0.1.0-rc.5 <0.2.0",
47
48
  "@deepseek-ai/dsh-session-projection-cache": ">=0.1.0-rc.5 <0.2.0",
48
49
  "@deepseek-ai/dsh-spill-local": ">=0.1.0-rc.5 <0.2.0",
@@ -70,6 +71,7 @@
70
71
  "@deepseek-ai/dsh-client-ui-sidebar": "0.1.2-rc.1",
71
72
  "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
72
73
  "@deepseek-ai/dsh-client-ui-workspace": "0.1.2-rc.1",
74
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
73
75
  "@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
74
76
  "@deepseek-ai/dsh-scope": "0.1.2-rc.1",
75
77
  "@deepseek-ai/dsh-session": "0.1.2-rc.1",