dsh-session-manager 0.4.11 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,110 +1,158 @@
1
- # dsh-session-manager — session manager for DeepSeek Harness
2
-
3
- English | [中文](README.zh.md)
4
-
5
- [![npm version](https://img.shields.io/npm/v/dsh-session-manager)](https://www.npmjs.com/package/dsh-session-manager)
6
- [![GitHub](https://img.shields.io/badge/GitHub-repository-blue)](https://github.com/hkkz9522/dsh-session-manager)
7
- [![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
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
-
10
- A DeepSeek Harness (DSH) Web plugin for session management: delete sessions, archive sessions, move sessions across workspaces, and migrate a session's Agent preset. Suggestions are welcome on GitHub.
11
-
12
- ## Features
13
-
14
- - **Archive / unarchive** sessions.
15
- - **Delete sessions** with an explicit irreversible-action confirmation.
16
- - **Move to workspace**: preserves history, title, archive state, and derived-session relationships, and rewrites the session's working directory to the target workspace.
17
- - **Migrate Agent preset**: change the preset on demand. Typical use case: when the original preset was renamed or removed and the session can no longer resume, you can repair that session.
18
- - **Session manager panel**: browse active and archived sessions in the sidebar, and run Open, Archive / Unarchive, Move, Migrate preset, or Delete on each row.
19
- - The current session's title area offers Archive / Unarchive, Move to workspace, and a red Delete session button.
20
- - Each dialog button (Move, Migrate preset, Delete, plus the Session manager toggle) closes its own popup when clicked a second time, matching the built-in title-area buttons.
21
-
22
- ## Where to find the UI
23
-
24
- - **Session title area (right side):** Archive / Unarchive, Move to workspace, Delete session.
25
- - **Sidebar footer → Session manager:** browse all sessions (including archived ones) and operate on each one.
26
-
27
- ## Agent preset migration
28
-
29
- Use this when a session can no longer resume because its original preset no longer exists, for example after removing a custom preset such as `router-standard`.
30
-
31
- 1. Open **Session manager**.
32
- 2. Locate the session and select **Migrate preset**.
33
- 3. Choose one of the currently available target presets and confirm.
34
-
35
- The plugin determines the session's effective preset from its latest `agent-preset/selected` event when present; otherwise it uses the session header. It then rewrites that event in place (or appends a fresh one if the session has never recorded a selection), so the migration is durable and the prior entry remains visible in the event log as history. For a live session, the new event is appended in memory via `Session.append()` and flushed to disk via `SessionStore.flush()`; the api-gateway's chat panel sees the new preset on the next event fold.
36
-
37
- > A preset migration changes session metadata only. It does not alter message history, files, or the selected workspace.
38
-
39
- ## Install
40
-
41
- The plugin is listed in [dsh-market](https://github.com/dsh-market/dsh-market) and [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin), and can be installed directly from the **Plugin Marketplace** inside DSH.
42
-
43
- ### From dsh-market
44
-
45
- ```powershell
46
- dsh plugin --profile web add npm:dsh-session-manager
47
- ```
48
-
49
- ### From GitHub
50
-
51
- ```powershell
52
- dsh plugin --profile web add github:hkkz9522/dsh-session-manager
53
- ```
54
-
55
- Restart DSH Web after installation. If the browser still holds an older client bundle, force refresh with `Ctrl+Shift+R`.
56
-
57
- ### Local development / runtime injection
58
-
59
- ```text
60
- dev_inject_plugin {"dir": "<absolute path to this repository>"}
61
- ```
62
-
63
- ## Safety and behavior
64
-
65
- - **Deletion is permanent**, so the UI always asks for confirmation.
66
- - Move and Migrate preset do **not** tear down the live agent or session. They keep the in-memory session/agent alive, write the new artifact in place, update the in-memory session header to point at the new cwd (move) or append the new event (migrate), and refresh the workspace registry. The api-gateway's chat panel therefore stays "available" without a manual refresh.
67
- - Move rewrites the session's stored `cwd`; subsequent tool calls run in the target workspace.
68
- - Subagent sessions and transient blank-session placeholders are excluded from Delete, Move, and Migrate preset.
69
- - The move path encodes the artifact in the backend's own physical layout (zstd frames with a one-header-line first frame, or plain JSONL), matching DSH's own writer. The migrate path rewrites the relevant event in place at the existing file.
70
-
71
- ## Compatibility
72
-
73
- | Plugin version | Verified DSH version |
1
+ # dsh-session-manager — session manager for DeepSeek Harness
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ [![npm version](https://img.shields.io/npm/v/dsh-session-manager)](https://www.npmjs.com/package/dsh-session-manager)
6
+ [![GitHub](https://img.shields.io/badge/GitHub-repository-blue)](https://github.com/hkkz9522/dsh-session-manager)
7
+ [![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
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
+
10
+ DSH Web session manager: delete, archive, move across workspaces, migrate preset; favorites, review-later, search, sort, priority, add tags and notes (manual / semi-automated). Suggestions are welcome on GitHub.
11
+
12
+ ## Features
13
+
14
+ ### Session lifecycle
15
+
16
+ - **Archive / unarchive** sessions.
17
+ - **Delete sessions** with an explicit irreversible-action confirmation. Deletion is rejected for subagent sessions and transient blank placeholders.
18
+ - **Move to workspace**: preserves history, title, archive state, and derived-session relationships, and rewrites the session's working directory (`cwd`) to the target workspace. The move updates the live writer's header in place so any pending tool calls keep landing on the new path.
19
+ - **Migrate Agent preset**: change the preset on demand. Typical use case: when the original preset was renamed or removed and the session can no longer resume, you can repair that session. The migration rewrites the latest `agent-preset/selected` event (or the session header if no such event exists) without altering message history.
20
+
21
+ ### Session manager panel (sidebar)
22
+
23
+ - Browse active and archived sessions, switch workspaces, and filter, sort, search across the list.
24
+ - Open a session directly from a row, or click a tag chip to filter the list to that tag.
25
+ - Per-row actions: **Open**, **Archive / Unarchive**, **Move**, **Migrate preset**, **Delete**.
26
+ - Each popup dialog (Move / Migrate preset / Delete / the panel itself) toggles closed when its trigger is clicked a second time, matching the built-in title-area buttons.
27
+
28
+ ### Search, filters, and sorting
29
+
30
+ - Case-insensitive title and session-ID search; whitespace is trimmed. Message history is never read.
31
+ - Combine a workspace selector (All / Ungrouped / specific) with the archive filter (All / Active / Archived).
32
+ - Combine favorite/review flags, tag and priority filters; sort by recently updated (default), least recently updated, newest created, oldest created, or **priority (1 → 5)**.
33
+ - See matching/total counts and reset all view controls together. These controls only affect the manager panel — workspace membership, archive state, and the native sidebar ordering are untouched.
34
+ - Failed workspace loads can be retried without losing search, sort state.
35
+
36
+ ### Favorites, review flags, tags, notes and priority
37
+
38
+ - Favorite / Review / **Tags/Notes** / priority controls are reachable from both the **title bar** (current session) and the **manager panel** (every row).
39
+ - Favorites and review flags are manual — independent of archive / running state, never cleared automatically.
40
+ - Priority is a dropdown **1 Highest, 2 High, 3 Normal, 4 Low, 5 Lowest** with **3 (Normal) as the default**; the manager row and title bar always show a P1–P5 badge. Legacy `null` priorities are normalized to 3.
41
+ - Tags: up to 20 per session, 32 characters each. Both English `,` and Chinese `,` are separators, whitespace is trimmed, duplicates are merged case-insensitively.
42
+ - Notes: plain multiline text, up to 2000 characters.
43
+ - Tags, notes and the AI **paste** textarea all share the same `sm-noteInput` style and `rows: 3` height (60px min-height), so the three input boxes line up visually.
44
+ - The "Tags/Notes" editor also surfaces **Copy Prompt** / **Import** controls for AI-assisted tagging (see below).
45
+ - Annotations are stored as plain text in `dsh-session-manager/annotations.v1.json` under the DSH home, keyed by session ID. They do not rewrite history or enter model context automatically. Move / Migrate preserve them; Delete cleans them up (and reports cleanup failures separately).
46
+ - Both UI surfaces share live state. Same-origin browser tabs receive change notifications via `BroadcastChannel`; refocusing or reopening the manager refreshes data.
47
+ - Saves are atomic, use a cross-process lock, and never silently overwrite another editor: revision conflicts surface a "load latest" prompt. Unsaved drafts survive a save failure.
48
+ - A crash-left `annotations.v1.lock` is not forcibly removed; verify no writer is active before handling it.
49
+
50
+ ### AI-assisted tagging (manual, opt-in)
51
+
52
+ The **Tags/Notes** editor has two extra buttons above the paste box. Neither calls a model automatically — both keep you in control:
53
+
54
+ - **复制 Prompt** / **Copy Prompt** copies a structured prompt (Chinese or English, matched to the active UI language) to the clipboard. Paste it into the current conversation to ask the model to generate tags / note / priority within the plugin's limits.
55
+ - **导入** / **Import** reads the clipboard, extracts the first JSON object (tolerating Markdown fences, conversational wrappers, smart quotes, stray backslashes and a leading BOM), validates it against the same limits, and populates the editor fields. Oversized notes are truncated; invalid tags / priority are dropped with reasons. Importing into a dirty draft asks for confirmation first. If parsing still fails, the error message includes the actual `JSON.parse` position from each recovery attempt so you can see exactly which character broke it.
56
+
57
+ The prompt templates and import parser live in `lib/clipboard-parser.js` and are bundled into the client; no build step or network call is required.
58
+
59
+ ### Current session title bar
60
+
61
+ The right side of the title area offers:
62
+
63
+ - **Archive / Unarchive** the current session.
64
+ - **Move to workspace** with a workspace picker.
65
+ - A red **Delete session** button with confirmation.
66
+
67
+ The same buttons appear in the manager row.
68
+ ## Agent preset migration
69
+
70
+ Use this when a session can no longer resume because its original preset no longer exists, for example after removing a custom preset such as `router-standard`.
71
+
72
+ 1. Open **Session manager**.
73
+ 2. Locate the session and select **Migrate preset**.
74
+ 3. Choose one of the currently available target presets and confirm.
75
+
76
+ The plugin determines the session's effective preset from its latest `agent-preset/selected` event when present; otherwise it uses the session header. It then rewrites that event in place (or appends a fresh one if the session has never recorded a selection), so the migration is durable and the prior entry remains visible in the event log as history. For a live session, the new event is appended in memory via `Session.append()` and flushed to disk via `SessionStore.flush()`; the api-gateway's chat panel sees the new preset on the next event fold.
77
+
78
+ > A preset migration changes session metadata only. It does not alter message history, files, or the selected workspace.
79
+
80
+ ## Install
81
+
82
+ The plugin is listed in [dsh-market](https://github.com/dsh-market/dsh-market) and [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin), and can be installed directly from the **Plugin Marketplace** inside DSH.
83
+
84
+ ### From dsh-market
85
+
86
+ ```powershell
87
+ dsh plugin --profile web add npm:dsh-session-manager
88
+ ```
89
+
90
+ ### From GitHub
91
+
92
+ ```powershell
93
+ dsh plugin --profile web add github:hkkz9522/dsh-session-manager
94
+ ```
95
+
96
+ Restart DSH Web after installation. If the browser still holds an older client bundle, force refresh with `Ctrl+Shift+R`.
97
+
98
+ ### Local development / runtime injection
99
+
100
+ ```text
101
+ dev_inject_plugin {"dir": "<absolute path to this repository>"}
102
+ ```
103
+
104
+ ## Safety and behavior
105
+
106
+ - **Deletion is permanent**, so the UI always asks for confirmation. The API checks the session ID, directory boundary and artifact header before deletion; traversal, symlinks and junctions are refused.
107
+ - Move and preset migration retain the live session/agent; deletion cancels and disposes it. Move updates the stored cwd and the existing live writer's header.
108
+ - The management list hides subagent sessions and the move API rejects them. Blank sessions without a persisted artifact cannot be moved.
109
+ - Cold rewrites preserve the artifact's stored format rather than forcing a v2 → v3 upgrade. Moves and rewrites refuse corrupt/truncated Zstd logs or JSONL logs with incomplete final lines instead of publishing partial history.
110
+ - Preset migration separates backup, publication and rollback. If rollback fails, recovery files are retained and their paths are included in the error; do not remove them.
111
+ - Incomplete startup scans skip workspace reconciliation. Complete scans preserve live sessions and membership added during the scan.
112
+ - Plugin mutations are serialized per session and request bodies are limited to 64 KiB. This queue supplements, rather than replaces, DSH's persistence coordination.
113
+
114
+ ## Compatibility
115
+
116
+ | Plugin version | Verified DSH version |
74
117
  | --- | --- |
118
+ | 0.5.1 | v0.1.6-alpha.2 |
75
119
  | 0.4.11 | v0.1.5-rc.2 |
76
- | 0.4.10 | v0.1.5-rc.1 |
77
- | 0.4.9 | v0.1.5-rc.1 |
78
- | 0.4.7 | v0.1.5-rc.1 |
79
- | 0.4.4 | 0.1.3-alpha.2 |
80
- | 0.4.1 | 0.1.3-alpha.2 |
81
- | 0.4.0 | v0.1.2-rc.1 |
82
- | 0.1.2 | v0.1.0-rc.7 |
83
- | 0.1.1 | v0.1.0-rc.7 |
84
- | 0.1.0 | v0.1.0-rc.7 |
85
-
86
- The plugin is a Cordis plugin and declares `cordis: ">=4.0.0-rc <5"` as its peer dependency.
87
-
88
- ## Development
89
-
90
- - `lib/index.js` is the host-side ESM plugin; `lib/client.js` is the Web client bundle. No build step is required.
91
- - Before submitting changes, run:
92
-
93
- ```powershell
94
- node --check lib/client.js
95
- node --check lib/index.js
96
- node --test test/*.test.mjs
97
- node scripts/smoke-test.mjs
98
- git diff --check
99
- npm pack --dry-run
100
- ```
101
-
102
- Release history is in [CHANGELOG.md](CHANGELOG.md).
103
-
104
- ## Acknowledgments
105
-
106
- Thanks to everyone who installs and uses dsh-session-manager, and to the people who file issues and open pull requests to help improve it. This plugin is listed in [dsh-market](https://github.com/dsh-market/dsh-market) and [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin). Suggestions and feedback are welcome.
107
-
108
- ## License
109
-
110
- [MIT](LICENSE)
120
+ | 0.4.10 | v0.1.5-rc.1 |
121
+ | 0.4.9 | v0.1.5-rc.1 |
122
+ | 0.4.7 | v0.1.5-rc.1 |
123
+ | 0.4.4 | 0.1.3-alpha.2 |
124
+ | 0.4.1 | 0.1.3-alpha.2 |
125
+ | 0.4.0 | v0.1.2-rc.1 |
126
+ | 0.1.2 | v0.1.0-rc.7 |
127
+ | 0.1.1 | v0.1.0-rc.7 |
128
+ | 0.1.0 | v0.1.0-rc.7 |
129
+
130
+ Requires Node.js 22.15+ (22.x) or 24+ for built-in Zstd support.
131
+
132
+ The plugin is a Cordis plugin and declares `cordis: ">=4.0.0-rc <5"` as its peer dependency.
133
+
134
+ ## Development
135
+
136
+ - `lib/index.js` is the host-side ESM plugin; `lib/client.js` is the Web client bundle. No build step is required.
137
+ - Before submitting changes, run:
138
+
139
+ ```powershell
140
+ npm run check
141
+ npm test
142
+ npm run check:package
143
+ git diff --check
144
+ ```
145
+
146
+ Tests use isolated temporary directories and the real plugin entry point, never real sessions. CI runs these checks on Windows/Linux with Node 22.15.0/24.
147
+
148
+ Optional integration check: run `node scripts/smoke-test.mjs` against a running test instance of DSH Web. This contacts a real service and is not part of the default unit test suite.
149
+
150
+ Release history is in [CHANGELOG.md](CHANGELOG.md).
151
+
152
+ ## Acknowledgments
153
+
154
+ Thanks to everyone who installs and uses dsh-session-manager, and to the people who file issues and open pull requests to help improve it. This plugin is listed in [dsh-market](https://github.com/dsh-market/dsh-market) and [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin). Suggestions and feedback are welcome.
155
+
156
+ ## License
157
+
158
+ [MIT](LICENSE)
package/README.zh.md CHANGED
@@ -7,17 +7,64 @@
7
7
  [![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
8
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
9
9
 
10
- 用于在 DeepSeek Harness(DSH)Web 中进行会话管理,包括:删除会话、归档会话、跨工作区移动会话、迁移会话的 Agent 预设。欢迎至 GitHub 提意见。
10
+ DSH Web 会话管理:删除、归档、跨工作区移动、迁移预设;收藏、待看、搜索、排序、设置优先级、添加(手动/半自动)标签和备注。欢迎至 GitHub 提意见。
11
11
 
12
12
  ## 功能
13
13
 
14
+ ### 会话生命周期
15
+
14
16
  - **归档 / 移出归档**会话。
15
- - **删除会话**:带不可逆操作的二次确认。
16
- - **移动至工作区**:保留历史、标题、归档状态和派生会话关系,同时把会话的 `cwd` 更新为目标工作区。
17
- - **迁移 Agent 预设**:按需修改。典型工况:当原预设被改名或删除,导致会话无法恢复时,可修复该会话。
18
- - **会话管理窗口**:在侧边栏中浏览未归档和已归档会话,并对每一行执行打开、归档 / 移出归档、移动、迁移预设、删除。
19
- - 当前会话标题区域提供归档 / 移出归档、移动至工作区和红色的删除会话按钮。
20
- - 弹窗按钮(移动、迁移预设、删除,以及"会话管理"入口)再次点击会关闭对应弹窗,与标题栏原生按钮行为一致。
17
+ - **删除会话**:不可逆操作的二次确认。subagent 会话和临时空白会话占位会被拒绝。
18
+ - **移动至工作区**:保留历史、标题、归档状态和派生会话关系,同时把会话的 `cwd` 更新为目标工作区。移动会就地更新 live writer 的 header,使挂起的工具调用继续落到新路径。
19
+ - **迁移 Agent 预设**:按需修改。典型工况:当原预设被改名或删除,导致会话无法恢复时,可修复该会话。迁移会就地重写最后一条 `agent-preset/selected` 事件(若从未记录选择事件则修改会话 header),不会改写历史消息。
20
+
21
+ ### 会话管理窗口(侧边栏)
22
+
23
+ - 浏览未归档和已归档会话、切换工作区、在列表中筛选 / 排序 / 搜索。
24
+ - 直接从某一行打开会话,或点击标签直接按该标签筛选。
25
+ - 每行操作:**打开**、**归档 / 移出归档**、**移动**、**迁移预设**、**删除**。
26
+ - 各弹窗(移动 / 迁移预设 / 删除 / 面板本身)在触发按钮再次点击时会切换关闭,与标题栏原生按钮的行为一致。
27
+
28
+ ### 搜索、筛选和排序
29
+
30
+ - 不区分大小写的标题和会话 ID 搜索,自动去除首尾空格;不读取聊天历史。
31
+ - 工作区下拉(全部 / 未分组 / 具体工作区)可与归档状态筛选(全部 / 未归档 / 已归档)叠加。
32
+ - 可叠加收藏 / 待回看、标签和优先级筛选;排序支持最近更新(默认)、最早更新、最新创建、最早创建以及**优先级(1 → 5)**。
33
+ - 显示匹配 / 总数,并提供"重置筛选"。这些控件只影响管理窗口,不改变会话归属、归档状态或原生侧边栏顺序。
34
+ - 工作区加载失败时可重试,标题 / ID 搜索和更新时间排序仍可使用。
35
+
36
+ ### 收藏、待回看、标签、备注与优先级
37
+
38
+ - **标题栏**(当前会话)和 **管理窗口**(每一行)都提供收藏 / 待回看 / **标签 / 备注** / 优先级操作入口。
39
+ - 收藏是长期标记,待回看是手动提醒;不会随归档或会话结束自动清除。
40
+ - 优先级下拉:**1 最高、2 高、3 普通、4 低、5 最低**,**默认 3(普通)**;管理行 / 标题栏始终显示 P1–P5 徽标。旧数据中的 `null` 优先级归一化为 3。
41
+ - 标签:每个会话最多 20 个,每个最多 32 字符;英文 `,` 与中文 `,` 都是分隔符,首尾空白被去除,重复标签按大小写不敏感合并。
42
+ - 备注:最多 2000 字符的多行纯文本。
43
+ - 标签、备注、AI 粘贴三个输入框使用相同的 `sm-noteInput` 样式与 `rows: 3` 高度(60px min-height),三个字段在视觉上对齐。
44
+ - "标签 / 备注" 编辑窗口内还提供 **复制 Prompt** / **导入** 两个按钮,用于 AI 辅助整理(见下)。
45
+ - 标记明文保存在 DSH home 下的 `dsh-session-manager/annotations.v1.json`,按会话 ID 关联;不写入 JSONL/Zstd 历史,也不自动发送给模型。移动 / 迁移预设会保留标记;删除会话后会清理对应标记(清理失败会单独提示)。
46
+ - 标题栏和管理窗口实时共享状态;同源浏览器标签页通过 `BroadcastChannel` 通知同步,重新获得焦点或打开管理窗口也会刷新数据。
47
+ - 保存采用原子写入并使用跨进程锁;版本冲突时保留草稿,要求显式"载入最新内容"。保存失败不会关闭编辑窗口或丢弃草稿。
48
+ - 异常退出遗留的 `annotations.v1.lock` 不会被自动强行删除;应在确认没有进程写入后再处理。
49
+
50
+ ### AI 整理(手动、可选)
51
+
52
+ **标签/备注** 编辑窗口在标准"取消 / 保存标记"按钮之外,还多了两个按钮,位于粘贴输入框上方。两者都不会自动调用模型,是否发送完全由你决定:
53
+
54
+ - **复制 Prompt** / **Copy Prompt**:把结构化 Prompt(中文或英文,跟随当前界面语言)复制到剪贴板。粘贴到当前对话中,要求模型按本插件的限制生成标签 / 备注 / 优先级(最多 20 个标签、每个 ≤ 32 字符、备注 ≤ 2000 字符、优先级 1–5 默认 3)。
55
+ - **导入** / **Import**:读取剪贴板,提取首个 JSON 对象(可识别 Markdown 代码块、对话包裹、智能引号、孤立反斜杠和开头 BOM),按相同规则校验后填入编辑窗口。如果当前有未保存的修改,会先询问是否覆盖再继续。超限的标签会被丢弃、超长的备注会被截断,所有调整都会在状态消息中列出,确认后再保存。如果仍然解析失败,错误信息会附带每一次修复尝试中 `JSON.parse` 给出的具体位置(原始 / 修复引号反斜杠 / 扫描对象 / 扫描对象+修复),方便定位坏掉的字符。
56
+
57
+ Prompt 模板和导入解析逻辑位于 `lib/clipboard-parser.js`,直接打包进客户端,无需额外构建步骤,也不会发起任何网络请求。
58
+
59
+ ### 当前会话标题栏
60
+
61
+ 标题栏右侧提供:
62
+
63
+ - **归档 / 移出归档** 当前会话。
64
+ - **移动至工作区**,弹窗选择目标工作区。
65
+ - 红色的 **删除会话** 按钮,带确认。
66
+
67
+ 同样的按钮在管理窗口每一行也可用。
21
68
 
22
69
  ## UI 入口
23
70
 
@@ -32,7 +79,7 @@
32
79
 
33
80
  例如:当会话无法恢复,报错表明原 Agent 预设不存在时(例如删掉了 `router-standard`),可以使用迁移功能。
34
81
 
35
- 插件会读取最后一条 `agent-preset/selected` 事件中的有效预设(若不存在则读取会话 header),然后就地重写该事件(若会话从未记录过选择事件则追加新事件)——这一做法是持久的,旧事件保留在日志中作为历史。对 live session,新事件通过 `Session.append()` 追加到内存,再通过 `SessionStore.flush()` 刷到磁盘;api-gateway 的聊天面板在下次事件折叠时即可看到新预设。
82
+ 插件会读取最后一条 `agent-preset/selected` 事件中的有效预设(若不存在则读取会话 header)。冷会话会重写最后一条选择事件;从未记录选择事件时修改 header。正常的 live session 通过 `Session.append()` 追加选择事件,再通过 `SessionStore.flush()` 刷到磁盘;api-gateway 的聊天面板在下次事件折叠时即可看到新预设。
36
83
 
37
84
  > 迁移预设只会修改会话元数据,不会改写历史消息、文件或当前工作区。
38
85
 
@@ -62,26 +109,31 @@ dev_inject_plugin {"dir": "<本仓库的绝对路径>"}
62
109
 
63
110
  ## 安全与行为说明
64
111
 
65
- - **删除不可恢复**,因此界面始终要求确认。
66
- - 移动和迁移预设会先 quiesce 该会话的 live agent(取消运行 + 释放 scope + 移出 SessionStore),即使聊天标签页还开着也能成功;侧边栏会自动刷新。
67
- - 移动会改写会话保存的 `cwd`,之后的工具调用将在目标工作区执行。
68
- - subagent 会话和临时空白会话占位不会参与删除、移动或迁移预设。
69
- - 持久化改写走 DSH 自身的 `open/create/append/flush` 接口,编解码链在内部处理 v2 → v3 格式迁移,写出的工件对当前 DSH 版本始终合法。
112
+ - **删除不可恢复**,因此界面始终要求确认。删除前校验会话 ID、目录边界和工件 header;不允许通过路径穿越、符号链接或 junction 操作其他目录。
113
+ - 移动和迁移预设保留 live session / agent;删除才会取消运行并释放会话。移动会更新保存的 cwd 和 live writer 的 header。
114
+ - 会话管理列表隐藏 subagent 会话,移动接口也拒绝 subagent;尚未落盘的空白会话不能跨工作区移动。
115
+ - 冷会话重写保留原工件格式,不强制升级 v2 → v3。损坏或截断的 Zstd 日志、缺少完整尾行的 JSONL 会拒绝移动/重写,不会把部分历史当作完整日志保存。
116
+ - 迁移预设按“备份 → 发布 → 回滚”分阶段处理。如果回滚失败,会保留恢复文件并在错误中报告路径;不要删除这些文件。
117
+ - 启动扫描不完整时跳过工作区归属修复;完整扫描也不会清除仍在内存中或扫描期间新加入的会话。
118
+ - 同一会话的插件写操作按顺序执行;请求体限制为 64 KiB。该队列不替代 DSH 自身的持久化写入协调。
70
119
 
71
120
  ## 兼容性
72
121
 
73
- | 插件版本 | 已验证 DSH 版本 |
74
- | --- | --- |
75
- | 0.4.11 | v0.1.5-rc.2 |
76
- | 0.4.10 | v0.1.5-rc.1 |
77
- | 0.4.9 | v0.1.5-rc.1 |
78
- | 0.4.7 | v0.1.5-rc.1 |
79
- | 0.4.4 | 0.1.3-alpha.2 |
80
- | 0.4.1 | 0.1.3-alpha.2 |
81
- | 0.4.0 | v0.1.2-rc.1 |
82
- | 0.1.2 | v0.1.0-rc.7 |
83
- | 0.1.1 | v0.1.0-rc.7 |
84
- | 0.1.0 | v0.1.0-rc.7 |
122
+ | 插件版本 | 已验证 DSH 版本 |
123
+ | ------ | ------------- |
124
+ | 0.5.1 | v0.1.6-alpha.2 |
125
+ | 0.4.11 | v0.1.5-rc.2 |
126
+ | 0.4.10 | v0.1.5-rc.1 |
127
+ | 0.4.9 | v0.1.5-rc.1 |
128
+ | 0.4.7 | v0.1.5-rc.1 |
129
+ | 0.4.4 | 0.1.3-alpha.2 |
130
+ | 0.4.1 | 0.1.3-alpha.2 |
131
+ | 0.4.0 | v0.1.2-rc.1 |
132
+ | 0.1.2 | v0.1.0-rc.7 |
133
+ | 0.1.1 | v0.1.0-rc.7 |
134
+ | 0.1.0 | v0.1.0-rc.7 |
135
+
136
+ 运行时要求 Node.js 22.15+(22.x)或 24+,以提供内置 Zstd 支持。
85
137
 
86
138
  本插件是 Cordis 插件,peer dependency 为 `cordis: ">=4.0.0-rc <5"`。
87
139
 
@@ -91,14 +143,16 @@ dev_inject_plugin {"dir": "<本仓库的绝对路径>"}
91
143
  - 提交修改前请运行:
92
144
 
93
145
  ```powershell
94
- node --check lib/client.js
95
- node --check lib/index.js
96
- node --test test/*.test.mjs
97
- node scripts/smoke-test.mjs
146
+ npm run check
147
+ npm test
148
+ npm run check:package
98
149
  git diff --check
99
- npm pack --dry-run
100
150
  ```
101
151
 
152
+ 测试使用隔离临时目录和真实插件入口,不操作真实会话。CI 在 Windows/Linux、Node 22.15.0/24 上执行相同检查。
153
+
154
+ 可选集成检查:在 DSH Web 已运行的测试环境中执行 `node scripts/smoke-test.mjs`;它会请求实际服务,不属于默认单元测试。
155
+
102
156
  发布记录见 [CHANGELOG.md](CHANGELOG.md)。
103
157
 
104
158
  ## 致谢
@@ -0,0 +1,159 @@
1
+ /** User annotations live outside session artifacts: no history rewriting, no
2
+ * workspace changes, and no model calls. Atomic publication + an advisory
3
+ * inter-process lock protect independent browser/profile updates. */
4
+ import * as fs from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import { randomBytes } from "node:crypto";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { assertSessionId, writeTempFile } from "./session-files.js";
9
+
10
+ export const DEFAULT_ANNOTATION = Object.freeze({ favorite: false, reviewLater: false, tags: Object.freeze([]), note: "", priority: null, revision: 0 });
11
+ export const ANNOTATION_LIMITS = Object.freeze({ tags: 20, tagLength: 32, noteLength: 2000 });
12
+ const FIELDS = new Set(["favorite", "reviewLater", "tags", "note", "priority"]);
13
+ const fail = (message, code = "bad-request") => Object.assign(new Error(message), { code });
14
+ const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
15
+
16
+ export function normalizeAnnotationPatch(patch) {
17
+ if (!isRecord(patch) || Object.keys(patch).length === 0) throw fail("标记更新不能为空");
18
+ const out = {};
19
+ for (const key of Object.keys(patch)) {
20
+ if (!FIELDS.has(key)) throw fail("未知的标记字段: " + key);
21
+ const value = patch[key];
22
+ if (key === "favorite" || key === "reviewLater") {
23
+ if (typeof value !== "boolean") throw fail("收藏和待回看必须是布尔值");
24
+ out[key] = value;
25
+ } else if (key === "priority") {
26
+ if (value !== null && (!Number.isInteger(value) || value < 1 || value > 5)) throw fail("优先级必须为 1–5 的整数,或未设置(null)");
27
+ out.priority = value;
28
+ } else if (key === "note") {
29
+ if (typeof value !== "string" || value.length > ANNOTATION_LIMITS.noteLength || value.includes("\0")) throw fail("备注最多 2000 字符,不能包含空字符");
30
+ out.note = value;
31
+ } else {
32
+ if (!Array.isArray(value)) throw fail("标签必须是数组");
33
+ const tags = []; const seen = new Set();
34
+ for (const item of value) {
35
+ if (typeof item !== "string") throw fail("标签必须是文本");
36
+ const tag = item.trim();
37
+ if (!tag) continue;
38
+ if (tag.length > ANNOTATION_LIMITS.tagLength || /[,,\x00-\x1f]/.test(tag)) throw fail("每个标签最多 32 字符,不能包含逗号或控制字符");
39
+ const key = tag.toLowerCase();
40
+ if (!seen.has(key)) { tags.push(tag); seen.add(key); }
41
+ }
42
+ if (tags.length > ANNOTATION_LIMITS.tags) throw fail("每个会话最多 20 个标签");
43
+ out.tags = tags;
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ export function createAnnotationStore(directory, { io = fs, lockWaitMs = 1500 } = {}) {
50
+ if (typeof directory !== "string" || !directory) throw fail("无法定位会话标记目录", "annotations-unavailable");
51
+ const root = resolve(directory);
52
+ const path = join(root, "annotations.v1.json");
53
+ const lockPath = join(root, "annotations.v1.lock");
54
+ let queue = Promise.resolve();
55
+ const enqueue = operation => {
56
+ const result = queue.then(operation);
57
+ queue = result.catch(() => {});
58
+ return result;
59
+ };
60
+ const read = async () => {
61
+ let content;
62
+ try {
63
+ const parent = await io.lstat(root);
64
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("会话标记目录不能是符号链接", "annotations-unavailable");
65
+ const info = await io.lstat(path);
66
+ if (!info.isFile() || info.isSymbolicLink()) throw fail("会话标记文件必须是普通文件", "annotations-unavailable");
67
+ content = await io.readFile(path, "utf8");
68
+ } catch (error) {
69
+ if (error.code === "ENOENT") return { version: 1, sessions: {} };
70
+ throw error;
71
+ }
72
+ try {
73
+ const state = JSON.parse(content);
74
+ if (state?.version !== 1 || !isRecord(state.sessions)) throw new Error("schema");
75
+ for (const [id, item] of Object.entries(state.sessions)) {
76
+ assertSessionId(id);
77
+ if (!isRecord(item) || !Number.isSafeInteger(item.revision) || item.revision < 1) throw new Error("revision");
78
+ const { revision, updatedAt, ...fields } = item;
79
+ if (Object.keys(fields).length !== FIELDS.size || typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) throw new Error("fields");
80
+ normalizeAnnotationPatch(fields);
81
+ }
82
+ return state;
83
+ } catch {
84
+ // Never include note text or JSON parser excerpts in HTTP errors/logs.
85
+ throw fail("会话标记文件损坏或版本不兼容,已停止写入;请先备份并检查 " + path, "annotations-corrupt");
86
+ }
87
+ };
88
+ const withLock = async operation => {
89
+ await io.mkdir(root, { recursive: true, mode: 0o700 });
90
+ const parent = await io.lstat(root);
91
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("会话标记目录不能是符号链接", "annotations-unavailable");
92
+ const token = JSON.stringify({ pid: process.pid, token: randomBytes(12).toString("hex") });
93
+ const deadline = Date.now() + lockWaitMs;
94
+ let handle;
95
+ while (!handle) {
96
+ try { handle = await io.open(lockPath, "wx", 0o600); }
97
+ catch (error) {
98
+ if (error.code !== "EEXIST") throw error;
99
+ if (Date.now() >= deadline) throw fail("会话标记正由其他进程写入,请稍后重试;若进程异常退出,请确认无写入后检查锁文件 " + lockPath, "annotations-busy");
100
+ await delay(Math.min(25, Math.max(1, deadline - Date.now())));
101
+ }
102
+ }
103
+ let stamped = false;
104
+ try {
105
+ await handle.writeFile(token);
106
+ stamped = true;
107
+ return await operation();
108
+ } finally {
109
+ await handle.close();
110
+ // Never unlink a lock that somebody replaced while we were working.
111
+ if (!stamped || await io.readFile(lockPath, "utf8").catch(() => undefined) === token) await io.rm(lockPath, { force: true });
112
+ }
113
+ };
114
+ const publish = async state => {
115
+ const temp = await writeTempFile(path, JSON.stringify(state, null, 2) + "\n", io);
116
+ try {
117
+ // Replace directly: a failed rename leaves the old file intact; readers
118
+ // never see a backup/publication gap or a half-written JSON document.
119
+ await io.rename(temp, path);
120
+ } catch (error) {
121
+ try { await io.rm(temp, { force: true }); } catch { /* uncommitted temp only */ }
122
+ throw error;
123
+ }
124
+ };
125
+ return {
126
+ path,
127
+ async list() { return (await read()).sessions; },
128
+ update(id, patch, expectedRevision) {
129
+ assertSessionId(id);
130
+ const normalized = normalizeAnnotationPatch(patch);
131
+ if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) throw fail("标记版本号无效");
132
+ return enqueue(() => withLock(async () => {
133
+ const state = await read();
134
+ const previous = Object.hasOwn(state.sessions, id) ? state.sessions[id] : DEFAULT_ANNOTATION;
135
+ if (expectedRevision !== undefined && expectedRevision !== previous.revision) {
136
+ throw fail("标记已被其他窗口修改,请载入最新内容后再保存;当前输入尚未丢弃", "annotation-conflict");
137
+ }
138
+ if (Object.keys(normalized).every(key => JSON.stringify(previous[key]) === JSON.stringify(normalized[key]))) return { ...previous };
139
+ const next = { ...previous, ...normalized, revision: previous.revision + 1, updatedAt: Date.now() };
140
+ if (!Number.isSafeInteger(next.revision)) throw fail("标记版本号已超出范围", "annotations-unavailable");
141
+ await publish({ version: 1, sessions: { ...state.sessions, [id]: next } });
142
+ return next;
143
+ }));
144
+ },
145
+ async remove(id) {
146
+ assertSessionId(id);
147
+ // A plugin that has never used annotations should not create a directory
148
+ // just because an ordinary session was deleted.
149
+ if (!Object.hasOwn((await read()).sessions, id)) return false;
150
+ return enqueue(() => withLock(async () => {
151
+ const state = await read();
152
+ if (!Object.hasOwn(state.sessions, id)) return false;
153
+ const sessions = { ...state.sessions }; delete sessions[id];
154
+ await publish({ version: 1, sessions });
155
+ return true;
156
+ }));
157
+ },
158
+ };
159
+ }