dsh-session-manager 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-session-manager contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # dsh-session-manager — 会话管理器(删除 + 归档管理)
2
+
3
+ 为 DeepSeek Harness Web 增加两件官方缺失的能力:
4
+
5
+ 1. **删除对话**(删除前二次确认):物理删除会话 —— 停止并销毁对应 agent、清空
6
+ 会话存储条目(所有标签页同步移除该行)、删除磁盘上的 JSONL 会话目录、
7
+ 清理工作区记账与归档集合。
8
+ 2. **归档管理**:移入归档(官方已有 `workspace.archiveSession`)与 **移出归档**
9
+ (官方 rc.6 没有 unarchive API,本插件在 host 端补齐,走 workspace registry
10
+ 自己的持久化队列,`host/archived-sessions-changed` 帧自动同步所有客户端)。
11
+
12
+ ## 特性
13
+
14
+ - 🗑 **删除会话**:头部垃圾桶按钮直接删除当前会话;管理面板每行可删。全部带二次确认
15
+ - 📦 **归档 / 移出归档**:头部归档菜单 + 管理面板逐行操作
16
+ - 📋 **会话管理面板**:全部 / 未归档 / 已归档 过滤(已归档会话侧边栏默认不可见,
17
+ 只有这里能找回),每行「打开 / 归档 / 移出归档 / 删除」
18
+ - 🏷 状态徽标:已归档 / 运行中 / 当前会话;相对时间与工作区目录
19
+ - ⚠️ 删除确认弹窗展示会话标题与“不可撤销”警告;会话运行中会提示“删除将立即中断它”
20
+
21
+ ## UI 入口
22
+
23
+ | 位置 | 内容 |
24
+ |---|---|
25
+ | 会话头部(当前会话标题旁) | 「归档 / 移出归档」与「删除会话…」(红色,二次确认弹窗)两个文字按钮 |
26
+ | 侧边栏底部 | 「会话管理」按钮:打开完整管理面板 |
27
+
28
+ ## 安装
29
+
30
+ ### 从 npm 安装(推荐)
31
+
32
+ ```powershell
33
+ dsh plugin --profile web add dsh-session-manager
34
+ ```
35
+
36
+ 重启 web 后生效(profile bundle 自动装配,`lib/` 即运行时产物,无需构建)。
37
+
38
+ ### 从 GitHub 安装(备用)
39
+
40
+ ```powershell
41
+ dsh plugin --profile web add github:hkkz9522/dsh-session-manager
42
+ ```
43
+
44
+ ### 本地开发 / 运行时注入
45
+
46
+ 克隆本仓库后,在已常驻 [dsh-super-injector](https://github.com/yjh051108/dsh-super-injector)
47
+ 的 web 会话中注入(注入即生效,免重启):
48
+
49
+ ```
50
+ dev_inject_plugin {"dir": "<仓库目录绝对路径>"}
51
+ ```
52
+
53
+ ## 卸载
54
+
55
+ - 若通过 `dsh plugin add` 装配:从 `~/.dsh/profiles/web/package.json` 的
56
+ `dependencies` 与 `dsh.profile.bundles` 中移除对应条目,重启。
57
+ - 若通过运行时注入安装:`dev_uninject_plugin {"name": "dsh-session-manager"}`
58
+
59
+ ## 兼容性(升级 DSH 后)
60
+
61
+ - **客户端 UI** 只使用官方插件面(slot 契约 `conversation.session.header.actions` /
62
+ `sidebar.footer.action`、标准工具包 `useSessions` / `useWorkspaces` / `t`、locale、
63
+ client bundle 格式),升级大概率无缝。
64
+ - **host 端** 不 import 任何 `@deepseek-ai` 包(仅 node 内置模块),升级不会在
65
+ 加载期失败;删除 / 移出归档用到了少数 rc.6 未公开的内部结构(已做防御性访问),
66
+ 若未来版本重构这些内部实现,会表现为运行时操作报错而非崩溃,按报错适配即可。
67
+ - `peerDependencies` 仅声明 `cordis` 范围,不硬编码 DSH 版本。
68
+ - 建议升级后自检一次:新建空白会话并删除(端到端 30 秒)。
69
+
70
+ ## 实现说明
71
+
72
+ - **host 端**(`lib/index.js`):cordis 插件,注入
73
+ `webServer / workspaceRegistry / sessions / agents / sessionPersistence`,
74
+ 注册两条 HTTP 端点:
75
+ - `POST /session-manager/api/delete { sessionId }`
76
+ - `POST /session-manager/api/unarchive { sessionId }`
77
+ - **client 端**(`lib/client.js`):loader factory 格式手写 bundle(无构建步骤),
78
+ 注册两个 slot:
79
+ - `conversation.session.header.actions`(每会话操作 + 删除确认)
80
+ - `sidebar.footer.action`(会话管理面板入口)
81
+
82
+ 删除 live 会话的顺序:`agent.cancel`(中断运行)→ `agent.scope.dispose`(安静销毁
83
+ agent fiber,3s 上限)→ 从 agents 注册表移除僵尸条目 → `sessions.flush` → 会话
84
+ store 条目 detach(触发 `session/disposed` → 各端移除行)→ 工作区记账清理 →
85
+ 归档集合清理 → 删除磁盘目录。
86
+
87
+ ## 风险与边界
88
+
89
+ - 删除是不可逆操作(文件物理删除),UI 已做二次确认。
90
+ - live 会话删除依赖 host 内部结构(会话 store / agent registry 的实例字段),
91
+ 未来 DSH 版本若重构这些内部实现可能需要同步适配(代码均做了防御性访问)。
92
+ - 已归档会话若其磁盘文件已被外部删除,移出归档只会把它放回列表(行可能不显示)。
93
+
94
+ ## 开源许可
95
+
96
+ 本项目基于 [MIT License](./LICENSE) 开源。
97
+
98
+ 你可以自由地使用、修改、复制、分发本项目(包括商业用途),但需保留版权声明与
99
+ 本许可文本;项目按“现状”提供,作者不对其适用性、可靠性或特定用途的适配性作任何
100
+ 明示或默示的担保。完整条款见 [LICENSE](./LICENSE)。
@@ -0,0 +1,5 @@
1
+ # dsh-session-manager bundle 层:插入会话管理器插件行。
2
+ - insert:
3
+ - id: dsh-session-manager
4
+ name: 'dsh-session-manager'
5
+ config: {}
package/lib/client.js ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * @dsh-session-manager — Client half.
3
+ *
4
+ * Adds two UI surfaces to the web app:
5
+ *
6
+ * 1. conversation.session.header.actions — a per-session menu on the open
7
+ * conversation's header: 归档 / 移出归档 / 删除(二次确认)/ 打开会话管理。
8
+ * 2. sidebar.footer.action — a "会话管理" footer button that opens the full
9
+ * management panel (all sessions, archived filter, per-row open / archive /
10
+ * unarchive / delete with confirmation).
11
+ *
12
+ * Archive uses the built-in `workspaces.archiveSession` wire API; unarchive and
13
+ * delete go through this plugin's own host HTTP endpoints
14
+ * (/session-manager/api/unarchive, /session-manager/api/delete).
15
+ *
16
+ * Written as the loader's factory-form bundle by hand (no build step), using
17
+ * React.createElement instead of JSX so the file ships as-is.
18
+ */
19
+ window.__ModuleLoader__.load({
20
+ id: "dsh-session-manager",
21
+ factory: (require) => {
22
+ var module = { exports: {} };
23
+ var exports = module.exports;
24
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
25
+
26
+ const React = require("react");
27
+ const {
28
+ useState,
29
+ useEffect,
30
+ useMemo,
31
+ useRef,
32
+ useSyncExternalStore
33
+ } = React;
34
+ const P = require("@deepseek-ai/dsh-client-ui-primitives");
35
+
36
+ const NS = "session-manager";
37
+ const API = "/session-manager/api";
38
+
39
+ // ---------------------------------------------------------------------
40
+ // Styles (theme-token driven; matches the dsh web design language)
41
+ // ---------------------------------------------------------------------
42
+ const css = [
43
+ ".sm-header{display:flex;align-items:center;gap:6px}",
44
+ ".sm-headerBtn{box-sizing:border-box;min-height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;align-items:center;gap:4px;padding:3px 10px;font-size:12px;line-height:18px;display:inline-flex;white-space:nowrap}",
45
+ ".sm-headerBtn:hover:not(:disabled),.sm-headerBtn:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}",
46
+ ".sm-headerBtn:disabled{opacity:.5;cursor:not-allowed}",
47
+ ".sm-headerBtn svg{flex:none}",
48
+ ".sm-headerDanger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}",
49
+ ".sm-headerDanger:hover:not(:disabled){background:rgba(217,78,68,.1)}",
50
+ ".sm-busy{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:28px;margin-left:4px}",
51
+ ".sm-footer{display:flex}",
52
+ ".sm-footerBtn{min-height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;border-radius:6px;align-items:center;gap:6px;padding:3px 8px;font-size:12px;line-height:18px;display:inline-flex}",
53
+ ".sm-footerBtn:hover,.sm-footerBtn:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}",
54
+ ".sm-footerBtn svg{flex:none}",
55
+ ".sm-panel{display:flex;flex-direction:column;gap:10px;min-height:280px;max-height:min(60vh,560px)}",
56
+ ".sm-filter{display:flex;gap:4px;flex:none}",
57
+ ".sm-filterBtn{cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:3px 12px;font-size:12px;line-height:18px}",
58
+ ".sm-filterBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
59
+ ".sm-filterBtn.sm-on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2);border-color:transparent}",
60
+ ".sm-list{display:flex;flex-direction:column;gap:4px;min-height:0;overflow:auto;flex:1}",
61
+ ".sm-row{box-sizing:border-box;display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px}",
62
+ ".sm-row:hover{background:var(--dsw-alias-interactive-bg-hover)}",
63
+ ".sm-rowCurrent{border-color:var(--dsw-alias-accent-primary,var(--dsw-alias-label-secondary))}",
64
+ ".sm-rowMain{min-width:0;display:flex;flex-direction:column;gap:2px;flex:1}",
65
+ ".sm-rowTitle{color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;display:flex;align-items:center;gap:6px;min-width:0}",
66
+ ".sm-rowTitle>span:first-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}",
67
+ ".sm-rowMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}",
68
+ ".sm-rowActions{display:flex;gap:4px;flex:none}",
69
+ ".sm-badge{flex:none;border-radius:999px;padding:0 6px;font-size:10px;line-height:16px}",
70
+ ".sm-badgeArchived{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l2)}",
71
+ ".sm-badgeRunning{color:var(--dsw-alias-state-warning-primary,#d97706);background:rgba(217,119,6,.12)}",
72
+ ".sm-badgeCurrent{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-fill-l2)}",
73
+ ".sm-danger{color:var(--dsw-alias-state-error-primary) !important;border-color:var(--dsw-alias-state-error-primary) !important}",
74
+ ".sm-dangerText{color:var(--dsw-alias-state-error-primary) !important}",
75
+ ".sm-warn{display:flex;align-items:flex-start;gap:8px;color:var(--dsw-alias-label-secondary);background:rgba(217,119,6,.1);border:1px solid rgba(217,119,6,.35);border-radius:8px;padding:8px 10px;font-size:12px;line-height:18px}",
76
+ ".sm-warn svg{flex:none;margin-top:1px}",
77
+ ".sm-error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;min-height:18px}",
78
+ ".sm-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;width:100%}",
79
+ ".sm-footError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;margin-right:auto}",
80
+ ".sm-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;text-align:center;padding:24px 0}"
81
+ ].join("");
82
+
83
+ const CSS_ID = "dsh-session-manager/session-manager";
84
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_ID) + "]") === null) {
85
+ const tag = document.createElement("style");
86
+ tag.dataset.plugin = "dsh-session-manager";
87
+ tag.dataset.pluginCss = CSS_ID;
88
+ tag.textContent = css;
89
+ document.head.appendChild(tag);
90
+ }
91
+
92
+ // ---------------------------------------------------------------------
93
+ // Locales
94
+ // ---------------------------------------------------------------------
95
+ const zh = {
96
+ "header.aria": "会话操作",
97
+ "menu.archive": "归档会话",
98
+ "menu.unarchive": "移出归档",
99
+ "menu.delete": "删除会话…",
100
+ "menu.manage": "打开会话管理…",
101
+ "footer.aria": "会话管理",
102
+ "footer.label": "会话管理",
103
+ "panel.title": "会话管理",
104
+ "panel.filter.all": "全部",
105
+ "panel.filter.active": "未归档",
106
+ "panel.filter.archived": "已归档",
107
+ "panel.empty": "暂无会话",
108
+ "panel.empty.archived": "归档为空",
109
+ "panel.archived": "已归档",
110
+ "panel.running": "运行中",
111
+ "panel.current": "当前",
112
+ "row.open": "打开",
113
+ "row.archive": "归档",
114
+ "row.unarchive": "移出归档",
115
+ "row.delete": "删除",
116
+ "confirm.delete.title": "删除会话",
117
+ "confirm.delete.desc": "会话「{title}」将被永久删除,包括其全部消息记录与磁盘文件,此操作不可撤销。",
118
+ "confirm.delete.running": "该会话正在运行,删除将立即中断它。",
119
+ "confirm.delete.confirm": "确认删除",
120
+ "confirm.cancel": "取消",
121
+ "busy.processing": "处理中…",
122
+ "error.operation": "操作失败:{message}"
123
+ };
124
+ const en = {
125
+ "header.aria": "Session actions",
126
+ "menu.archive": "Archive session",
127
+ "menu.unarchive": "Unarchive session",
128
+ "menu.delete": "Delete session…",
129
+ "menu.manage": "Open session manager…",
130
+ "footer.aria": "Session manager",
131
+ "footer.label": "Sessions",
132
+ "panel.title": "Session manager",
133
+ "panel.filter.all": "All",
134
+ "panel.filter.active": "Active",
135
+ "panel.filter.archived": "Archived",
136
+ "panel.empty": "No sessions",
137
+ "panel.empty.archived": "Archive is empty",
138
+ "panel.archived": "Archived",
139
+ "panel.running": "Running",
140
+ "panel.current": "Current",
141
+ "row.open": "Open",
142
+ "row.archive": "Archive",
143
+ "row.unarchive": "Unarchive",
144
+ "row.delete": "Delete",
145
+ "confirm.delete.title": "Delete session",
146
+ "confirm.delete.desc": "Session \"{title}\" will be permanently deleted, including all its messages and files on disk. This cannot be undone.",
147
+ "confirm.delete.running": "This session is running; deleting will interrupt it immediately.",
148
+ "confirm.delete.confirm": "Delete",
149
+ "confirm.cancel": "Cancel",
150
+ "busy.processing": "Working…",
151
+ "error.operation": "Operation failed: {message}"
152
+ };
153
+
154
+ // ---------------------------------------------------------------------
155
+ // Shared panel-open store (footer owns the modal; header opens it)
156
+ // ---------------------------------------------------------------------
157
+ const panelStore = {
158
+ open: false,
159
+ listeners: new Set(),
160
+ getSnapshot: () => panelStore.open,
161
+ subscribe: (fn) => {
162
+ panelStore.listeners.add(fn);
163
+ return () => panelStore.listeners.delete(fn);
164
+ },
165
+ set: (open) => {
166
+ if (panelStore.open === open) return;
167
+ panelStore.open = open;
168
+ for (const fn of [...panelStore.listeners]) fn();
169
+ }
170
+ };
171
+
172
+ // ---------------------------------------------------------------------
173
+ // Host API
174
+ // ---------------------------------------------------------------------
175
+ async function callApi(path, payload) {
176
+ const response = await fetch(API + path, {
177
+ method: "POST",
178
+ headers: { "content-type": "application/json" },
179
+ body: JSON.stringify(payload || {})
180
+ });
181
+ let data;
182
+ try {
183
+ data = await response.json();
184
+ } catch {
185
+ data = { ok: false, error: "HTTP " + response.status };
186
+ }
187
+ return data;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------
191
+ // Small helpers
192
+ // ---------------------------------------------------------------------
193
+ const h = (type, props, ...children) => React.createElement(type, props, ...children);
194
+
195
+ function formatRelative(ts, now) {
196
+ const diff = Math.max(0, now - ts);
197
+ const s = Math.floor(diff / 1000);
198
+ if (s < 60) return s + "s";
199
+ const m = Math.floor(s / 60);
200
+ if (m < 60) return m + "m";
201
+ const hr = Math.floor(m / 60);
202
+ if (hr < 24) return hr + "h";
203
+ const d = Math.floor(hr / 24);
204
+ if (d < 30) return d + "d";
205
+ const mo = Math.floor(d / 30);
206
+ if (mo < 12) return mo + "mo";
207
+ return Math.floor(mo / 12) + "y";
208
+ }
209
+
210
+ function shortPath(cwd) {
211
+ if (!cwd) return "";
212
+ const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
213
+ const last = parts[parts.length - 1] || "";
214
+ return last.length > 26 ? last.slice(0, 24) + "…" : last;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------
218
+ // Confirm dialog (delete needs explicit confirmation)
219
+ // ---------------------------------------------------------------------
220
+ function ConfirmDialog({ open, title, description, warning, confirmLabel, cancelLabel, busy, error, onCancel, onConfirm }) {
221
+ return h(P.Modal, {
222
+ open,
223
+ onClose: () => {
224
+ if (!busy) onCancel();
225
+ },
226
+ title,
227
+ description,
228
+ closeLabel: cancelLabel,
229
+ children: h("div", { className: "sm-panel" },
230
+ warning ? h("div", { className: "sm-warn" },
231
+ h(P.IconWarningOutline16, {}),
232
+ h("span", null, warning)
233
+ ) : null
234
+ ),
235
+ footer: h("div", { className: "sm-foot" },
236
+ h("span", { className: "sm-footError", role: "alert" }, error || null),
237
+ h(P.Button, { variant: "ghost", onClick: onCancel, disabled: busy }, cancelLabel),
238
+ h(P.Button, { variant: "primary", className: "sm-danger", onClick: onConfirm, disabled: busy }, confirmLabel)
239
+ )
240
+ });
241
+ }
242
+
243
+ // ---------------------------------------------------------------------
244
+ // Header actions (per open session): archive toggle + delete, as text
245
+ // pills. Uses only primitives proven to render in the shipped shell
246
+ // (Modal / IconArchiveOutline20 / IconWarningOutline16); no Menu, no
247
+ // uncertain icons — React #130 (undefined element type) killed the old
248
+ // icon/menu variant in the real browser.
249
+ // ---------------------------------------------------------------------
250
+ function HeaderAction({ sessionId, useSessions, useWorkspaces, t, onArchive, onUnarchive, onRemove }) {
251
+ const archivedIds = useWorkspaces((s) => s.archivedSessionIds);
252
+ const summary = useSessions((s) => s.byId[sessionId]);
253
+ const archived = useMemo(() => new Set(archivedIds), [archivedIds]);
254
+ const isArchived = archived.has(sessionId);
255
+ const [confirmOpen, setConfirmOpen] = useState(false);
256
+ const [busy, setBusy] = useState(false);
257
+ const [error, setError] = useState(null);
258
+
259
+ const run = async (fn) => {
260
+ if (busy) return;
261
+ setBusy(true);
262
+ setError(null);
263
+ try {
264
+ await fn();
265
+ } catch (e) {
266
+ setError(e instanceof Error ? e.message : String(e));
267
+ } finally {
268
+ setBusy(false);
269
+ }
270
+ };
271
+
272
+ return h("div", { className: "sm-header" },
273
+ h("button", {
274
+ type: "button",
275
+ className: "sm-headerBtn",
276
+ disabled: busy,
277
+ title: t(isArchived ? "menu.unarchive" : "menu.archive"),
278
+ onClick: (e) => {
279
+ e.stopPropagation();
280
+ run(() => isArchived ? onUnarchive(sessionId) : onArchive(sessionId));
281
+ }
282
+ },
283
+ h(P.IconArchiveOutline20, { size: 14 }),
284
+ h("span", null, t(isArchived ? "menu.unarchive" : "menu.archive"))
285
+ ),
286
+ h("button", {
287
+ type: "button",
288
+ className: "sm-headerBtn sm-headerDanger",
289
+ disabled: busy,
290
+ title: t("menu.delete"),
291
+ onClick: (e) => {
292
+ e.stopPropagation();
293
+ setConfirmOpen(true);
294
+ }
295
+ },
296
+ h("span", null, t("menu.delete"))
297
+ ),
298
+ busy ? h("span", { className: "sm-busy" }, t("busy.processing")) : null,
299
+ h(ConfirmDialog, {
300
+ open: confirmOpen,
301
+ title: t("confirm.delete.title"),
302
+ description: t("confirm.delete.desc", { title: summary?.displayTitle ?? sessionId }),
303
+ warning: summary?.running ? t("confirm.delete.running") : null,
304
+ confirmLabel: t("confirm.delete.confirm"),
305
+ cancelLabel: t("confirm.cancel"),
306
+ busy,
307
+ error: error ? t("error.operation", { message: error }) : null,
308
+ onCancel: () => setConfirmOpen(false),
309
+ onConfirm: () => run(async () => {
310
+ await onRemove(sessionId);
311
+ setConfirmOpen(false);
312
+ })
313
+ })
314
+ );
315
+ }
316
+
317
+ // ---------------------------------------------------------------------
318
+ // Session manager panel (full management surface)
319
+ // ---------------------------------------------------------------------
320
+ function SessionManagerPanel({ open, onClose, useSessions, useWorkspaces, t, onOpen, onArchive, onUnarchive, onRemove }) {
321
+ const list = useSessions((s) => s);
322
+ const archivedIds = useWorkspaces((s) => s.archivedSessionIds);
323
+ const [filter, setFilter] = useState("all");
324
+ const [confirm, setConfirm] = useState(null);
325
+ const [busyId, setBusyId] = useState(null);
326
+ const [error, setError] = useState(null);
327
+ const [now, setNow] = useState(() => Date.now());
328
+
329
+ useEffect(() => {
330
+ const timer = setInterval(() => setNow(Date.now()), 30000);
331
+ return () => clearInterval(timer);
332
+ }, []);
333
+
334
+ const archived = useMemo(() => new Set(archivedIds), [archivedIds]);
335
+
336
+ const rows = useMemo(() => {
337
+ const all = list.ids
338
+ .map((id) => list.byId[id])
339
+ .filter((s) => s !== void 0 && s.origin !== "subagent");
340
+ if (filter === "active") return all.filter((s) => !archived.has(s.id));
341
+ if (filter === "archived") return all.filter((s) => archived.has(s.id));
342
+ return all;
343
+ }, [list, archived, filter]);
344
+
345
+ const runRow = async (sessionId, fn) => {
346
+ if (busyId !== null) return;
347
+ setBusyId(sessionId);
348
+ setError(null);
349
+ try {
350
+ await fn();
351
+ } catch (e) {
352
+ setError(e instanceof Error ? e.message : String(e));
353
+ } finally {
354
+ setBusyId(null);
355
+ }
356
+ };
357
+
358
+ const filterBtn = (id, label) => h("button", {
359
+ type: "button",
360
+ key: id,
361
+ className: "sm-filterBtn" + (filter === id ? " sm-on" : ""),
362
+ onClick: () => setFilter(id)
363
+ }, label);
364
+
365
+ return h(P.Modal, {
366
+ open,
367
+ onClose,
368
+ title: t("panel.title"),
369
+ closeLabel: t("confirm.cancel"),
370
+ contentClassName: "sm-panel",
371
+ children: [
372
+ h("div", { className: "sm-filter", key: "filter" },
373
+ filterBtn("all", t("panel.filter.all")),
374
+ filterBtn("active", t("panel.filter.active")),
375
+ filterBtn("archived", t("panel.filter.archived"))
376
+ ),
377
+ error ? h("div", { className: "sm-error", role: "alert", key: "error" },
378
+ t("error.operation", { message: error })
379
+ ) : null,
380
+ rows.length === 0
381
+ ? h("div", { className: "sm-empty", key: "empty" },
382
+ filter === "archived" ? t("panel.empty.archived") : t("panel.empty")
383
+ )
384
+ : h("div", { className: "sm-list", key: "list" },
385
+ rows.map((s) => {
386
+ const isArchived = archived.has(s.id);
387
+ const isCurrent = s.id === list.current;
388
+ const busy = busyId === s.id;
389
+ return h("div", {
390
+ key: s.id,
391
+ className: "sm-row" + (isCurrent ? " sm-rowCurrent" : "")
392
+ },
393
+ h("div", { className: "sm-rowMain" },
394
+ h("div", { className: "sm-rowTitle" },
395
+ h("span", null, s.displayTitle || s.id),
396
+ isArchived ? h("span", { className: "sm-badge sm-badgeArchived" }, t("panel.archived")) : null,
397
+ s.running ? h("span", { className: "sm-badge sm-badgeRunning" }, t("panel.running")) : null,
398
+ isCurrent ? h("span", { className: "sm-badge sm-badgeCurrent" }, t("panel.current")) : null
399
+ ),
400
+ h("div", { className: "sm-rowMeta" },
401
+ shortPath(s.cwd) + (s.cwd ? " · " : "") + formatRelative(s.updatedAt, now)
402
+ )
403
+ ),
404
+ h("div", { className: "sm-rowActions" },
405
+ h(P.Button, { variant: "ghost", size: "sm", disabled: busy, onClick: () => onOpen(s.id) }, t("row.open")),
406
+ h(P.Button, {
407
+ variant: "ghost",
408
+ size: "sm",
409
+ disabled: busy,
410
+ onClick: () => runRow(s.id, () => isArchived ? onUnarchive(s.id) : onArchive(s.id))
411
+ }, t(isArchived ? "row.unarchive" : "row.archive")),
412
+ h(P.Button, {
413
+ variant: "ghost",
414
+ size: "sm",
415
+ className: "sm-dangerText",
416
+ disabled: busy,
417
+ onClick: () => setConfirm(s)
418
+ }, t("row.delete"))
419
+ )
420
+ );
421
+ })
422
+ )
423
+ ],
424
+ footer: h(ConfirmDialog, {
425
+ open: confirm !== null,
426
+ title: t("confirm.delete.title"),
427
+ description: t("confirm.delete.desc", { title: confirm?.displayTitle ?? confirm?.id ?? "" }),
428
+ warning: confirm?.running ? t("confirm.delete.running") : null,
429
+ confirmLabel: t("confirm.delete.confirm"),
430
+ cancelLabel: t("confirm.cancel"),
431
+ busy: busyId !== null,
432
+ error: null,
433
+ onCancel: () => setConfirm(null),
434
+ onConfirm: () => runRow(confirm.id, async () => {
435
+ await onRemove(confirm.id);
436
+ setConfirm(null);
437
+ })
438
+ })
439
+ });
440
+ }
441
+
442
+ // ---------------------------------------------------------------------
443
+ // Footer action (owns the management panel modal)
444
+ // ---------------------------------------------------------------------
445
+ function FooterAction({ wide, useSessions, useWorkspaces, t, onOpen, onArchive, onUnarchive, onRemove, onOpenPanel }) {
446
+ const open = useSyncExternalStore(panelStore.subscribe, panelStore.getSnapshot);
447
+ return h("div", { className: "sm-footer" },
448
+ h("button", {
449
+ type: "button",
450
+ className: "sm-footerBtn",
451
+ "aria-label": t("footer.aria"),
452
+ onClick: () => onOpenPanel()
453
+ },
454
+ h(P.IconArchiveOutline20, { size: 14 }),
455
+ wide ? h("span", null, t("footer.label")) : null
456
+ ),
457
+ h(SessionManagerPanel, {
458
+ open,
459
+ onClose: () => panelStore.set(false),
460
+ useSessions,
461
+ useWorkspaces,
462
+ t,
463
+ onOpen,
464
+ onArchive,
465
+ onUnarchive,
466
+ onRemove
467
+ })
468
+ );
469
+ }
470
+
471
+ // ---------------------------------------------------------------------
472
+ // Plugin entry
473
+ // ---------------------------------------------------------------------
474
+ const inject = ["slots", "sessions", "workspaces", "locale"];
475
+
476
+ function apply(ctx) {
477
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-manager: dictionaries");
478
+
479
+ // NOTE: the slot renderer SPREADS the inject factory's return value into
480
+ // the component's props as flat members (see renderEntry: ...injected).
481
+ // So these are delivered as onOpen/onArchive/... props, not under an
482
+ // `actions` namespace.
483
+ const actions = () => ({
484
+ onOpen: (sessionId) => {
485
+ ctx.sessions.open(sessionId);
486
+ },
487
+ onArchive: async (sessionId) => {
488
+ await ctx.workspaces.archiveSession(sessionId);
489
+ },
490
+ onUnarchive: async (sessionId) => {
491
+ const response = await callApi("/unarchive", { sessionId });
492
+ if (!response.ok) throw new Error(response.error || "unarchive failed");
493
+ },
494
+ onRemove: async (sessionId) => {
495
+ const response = await callApi("/delete", { sessionId });
496
+ if (!response.ok) throw new Error(response.error || "delete failed");
497
+ const current = ctx.sessions.list.getSnapshot().current;
498
+ if (current === sessionId) ctx.sessions.clear();
499
+ },
500
+ onOpenPanel: () => {
501
+ panelStore.set(true);
502
+ }
503
+ });
504
+
505
+ ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
506
+ name: "conversation.session.header.actions",
507
+ id: "session-manager-header",
508
+ order: 40,
509
+ locale: NS,
510
+ inject: () => actions()
511
+ }, HeaderAction));
512
+
513
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
514
+ name: "sidebar.footer.action",
515
+ id: "session-manager-footer",
516
+ order: 30,
517
+ locale: NS,
518
+ inject: () => actions()
519
+ }, FooterAction));
520
+ }
521
+
522
+ exports.apply = apply;
523
+ exports.inject = inject;
524
+ return module.exports;
525
+ }
526
+ });
package/lib/index.js ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @dsh-session-manager — Host half.
3
+ *
4
+ * DSH 0.1.0-rc.6 ships `workspace.archiveSession` (归档) but has NO session
5
+ * deletion and NO unarchive. This plugin fills both gaps:
6
+ *
7
+ * - POST /session-manager/api/delete { sessionId }
8
+ * Physically delete one session: cancel/dispose a live agent, detach the
9
+ * session store entry (emits session/disposed -> host/session-removed so
10
+ * every tab drops the row), flush + remove the JSONL artifact directory,
11
+ * remove workspace accounting, and remove the archive-set membership.
12
+ * - POST /session-manager/api/unarchive { sessionId }
13
+ * Remove one session from the registry-global archive set. The registry's
14
+ * own `domain/changed` -> `host/archived-sessions-changed` frame then
15
+ * refreshes every connected client.
16
+ *
17
+ * All mutations go through the workspace registry's own serialized
18
+ * `enqueueOperation` + `setState` durability path (the same one the built-in
19
+ * archiveSession uses), so a restart cannot resurrect a removed session.
20
+ */
21
+ import { rm } from "node:fs/promises";
22
+ import { dirname } from "node:path";
23
+
24
+ export const name = "dsh-session-manager";
25
+
26
+ export const inject = [
27
+ "webServer",
28
+ "workspaceRegistry",
29
+ "sessions",
30
+ "agents",
31
+ "sessionPersistence"
32
+ ];
33
+
34
+ const API_PREFIX = "/session-manager/api";
35
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
36
+
37
+ export function apply(ctx) {
38
+ const readBody = async (req) => {
39
+ const chunks = [];
40
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
41
+ return Buffer.concat(chunks).toString("utf8");
42
+ };
43
+
44
+ const send = (res, code, obj) => {
45
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
46
+ res.end(JSON.stringify(obj));
47
+ };
48
+
49
+ /** Remove one id from the registry-global archive set (durable, serialized). */
50
+ const unarchiveSession = async (sessionId) => {
51
+ const registry = ctx.workspaceRegistry;
52
+ await registry.enqueueOperation(async () => {
53
+ const state = registry.requireState();
54
+ if (!state.archivedSessionIds.includes(sessionId)) return;
55
+ await registry.setState({
56
+ ...state,
57
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
58
+ });
59
+ });
60
+ };
61
+
62
+ /** Detach one session from every workspace's ordered accounting. */
63
+ const detachFromWorkspaces = async (sessionId) => {
64
+ for (const entity of ctx.workspaceRegistry.list()) {
65
+ if (entity.sessionIds.includes(sessionId)) {
66
+ await entity.detachSession(sessionId);
67
+ }
68
+ }
69
+ };
70
+
71
+ /** Resolve the on-disk session directory (parent of its artifact), if any. */
72
+ const sessionDirOf = async (sessionId) => {
73
+ const persistence = ctx.get("sessionPersistence");
74
+ if (persistence === void 0) return void 0;
75
+ const headers = await persistence.list();
76
+ const meta = headers.find((header) => header.id === sessionId);
77
+ if (meta === void 0) return void 0;
78
+ const location = persistence.locate(meta);
79
+ if (location === void 0) return void 0;
80
+ return dirname(location.path);
81
+ };
82
+
83
+ /**
84
+ * Delete one session end to end. Returns a summary of what was torn down.
85
+ * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
86
+ */
87
+ const deleteSession = async (sessionId) => {
88
+ const session = ctx.sessions.get(sessionId);
89
+ const agent = ctx.agents.get(sessionId);
90
+
91
+ if (agent !== void 0) {
92
+ // Stop any running turn (disposed-kind suppresses re-wake).
93
+ agent.cancel({ kind: "disposed" });
94
+ // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
95
+ if (typeof agent.scope?.dispose === "function") {
96
+ await Promise.race([agent.scope.dispose(), sleep(3000)]);
97
+ }
98
+ // Drop the zombie from the registry so a later session.create/open with
99
+ // the same id cannot resurrect it.
100
+ try {
101
+ ctx.agents.store?.delete?.(sessionId);
102
+ } catch { /* best-effort */ }
103
+ }
104
+
105
+ if (session !== void 0) {
106
+ // Flush buffered events to disk first so the retirement drain is a no-op.
107
+ try {
108
+ await ctx.sessions.flush(session);
109
+ } catch { /* best-effort */ }
110
+ // Detach the session store entry: emits session/disposed, which the
111
+ // persistence write-path answers with a final drain, and the API proxy
112
+ // relays as host/session-removed so every connected client drops the row.
113
+ try {
114
+ const entry = ctx.sessions.store?.get?.(sessionId);
115
+ if (entry !== void 0 && typeof entry.detach === "function") {
116
+ entry.detach();
117
+ await sleep(200); // let the write-behind retirement settle
118
+ }
119
+ } catch { /* best-effort */ }
120
+ }
121
+
122
+ // Workspace accounting + archive-set membership.
123
+ await detachFromWorkspaces(sessionId);
124
+ await unarchiveSession(sessionId);
125
+
126
+ // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
127
+ const dir = await sessionDirOf(sessionId);
128
+ if (dir !== void 0) {
129
+ await rm(dir, { recursive: true, force: true });
130
+ }
131
+
132
+ return {
133
+ ok: true,
134
+ sessionId,
135
+ wasLive: session !== void 0 || agent !== void 0,
136
+ filesRemoved: dir !== void 0
137
+ };
138
+ };
139
+
140
+ ctx.effect(() => ctx.webServer.register({
141
+ kind: "prefix",
142
+ path: API_PREFIX,
143
+ handler: async (req, res) => {
144
+ try {
145
+ const url = new URL(req.url ?? "/", "http://localhost");
146
+ const path = url.pathname.startsWith(API_PREFIX)
147
+ ? url.pathname.slice(API_PREFIX.length) || "/"
148
+ : "/";
149
+ let body = {};
150
+ if (req.method === "POST") {
151
+ const raw = await readBody(req);
152
+ if (raw.trim() !== "") {
153
+ try {
154
+ body = JSON.parse(raw);
155
+ } catch {
156
+ return send(res, 400, { ok: false, error: "请求体不是合法 JSON" });
157
+ }
158
+ }
159
+ }
160
+ const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() !== ""
161
+ ? body.sessionId.trim()
162
+ : null;
163
+
164
+ if (req.method === "POST" && path === "/delete") {
165
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
166
+ try {
167
+ const result = await deleteSession(sessionId);
168
+ return send(res, 200, { ok: true, result });
169
+ } catch (error) {
170
+ ctx.logger.warn(`session-manager: delete "${sessionId}" failed: ${String(error)}`);
171
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
172
+ }
173
+ }
174
+
175
+ if (req.method === "POST" && path === "/unarchive") {
176
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
177
+ try {
178
+ await unarchiveSession(sessionId);
179
+ return send(res, 200, { ok: true, result: { sessionId } });
180
+ } catch (error) {
181
+ ctx.logger.warn(`session-manager: unarchive "${sessionId}" failed: ${String(error)}`);
182
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
183
+ }
184
+ }
185
+
186
+ return send(res, 404, { ok: false, error: `not found: ${req.method} ${path}` });
187
+ } catch (error) {
188
+ ctx.logger.warn(`session-manager: api error: ${String(error)}`);
189
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
190
+ }
191
+ }
192
+ }), "session-manager: http api");
193
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "dsh-session-manager",
3
+ "version": "0.1.0",
4
+ "description": "会话管理器:删除对话(删除前二次确认)+ 归档管理(移入/移出归档),Web UI 全面接管侧边栏会话列表与头部操作",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "plugin",
10
+ "session",
11
+ "archive"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/hkkz9522/dsh-session-manager.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/hkkz9522/dsh-session-manager/issues"
19
+ },
20
+ "homepage": "https://github.com/hkkz9522/dsh-session-manager",
21
+ "type": "module",
22
+ "main": "./lib/index.js",
23
+ "exports": {
24
+ ".": "./lib/index.js",
25
+ "./client": "./lib/client.js",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "cordis.patch.yml",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-runtime",
41
+ "@deepseek-ai/dsh-client-ui-slots"
42
+ ],
43
+ "platform": "web"
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "cordis": ">=4.0.0-rc <5"
48
+ }
49
+ }