dsh-session-curator 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -189
- package/README.zh-CN.md +15 -144
- package/lib/client.js +1325 -988
- package/lib/host/fs-utils.js +64 -0
- package/lib/host/manifest.js +22 -0
- package/lib/host/paths.js +47 -0
- package/lib/host/routes.js +113 -0
- package/lib/host/session-manager.js +230 -0
- package/lib/host/trash.js +190 -0
- package/lib/host/update.js +67 -0
- package/lib/index.js +17 -539
- package/package.json +11 -4
- package/scripts/activation.mjs +1 -1
- package/scripts/build.mjs +96 -0
- package/scripts/client.mjs +273 -0
- package/scripts/smoke.mjs +226 -25
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本比较与"有没有新版"的查询。
|
|
3
|
+
*
|
|
4
|
+
* 为什么放在**宿主**而不是客户端直接 fetch:客户端那侧要过 CORS / 可能的 CSP 和桌面端
|
|
5
|
+
* 网络策略,宿主本来就是普通 Node,顺便还能做超时与缓存。只读 GET,没有任何副作用。
|
|
6
|
+
* 这也是插件**唯一**的外发请求,别在别处再加。
|
|
7
|
+
*/
|
|
8
|
+
import { messageOf } from "./fs-utils.js";
|
|
9
|
+
import { LOCAL_VERSION, PLUGIN_NAME } from "./manifest.js";
|
|
10
|
+
const UPDATE_REGISTRY = "https://registry.npmjs.org";
|
|
11
|
+
/** 10 分钟内复用上次结果:打开配置页不该每次都打 registry。 */
|
|
12
|
+
const UPDATE_TTL_MS = 10 * 60 * 1000;
|
|
13
|
+
const UPDATE_TIMEOUT_MS = 5000;
|
|
14
|
+
let updateCache = null;
|
|
15
|
+
/**
|
|
16
|
+
* 宽松 semver 比较:只按数字段比,带 prerelease 后缀的算更低
|
|
17
|
+
* (`0.1.1` > `0.1.1-rc.1`,`0.2.0` > `0.1.9`)。够判断"有没有新版",不引依赖。
|
|
18
|
+
* 导出是为了让 `scripts/smoke.mjs` 直接单测它(浏览器里用不到)。
|
|
19
|
+
*/
|
|
20
|
+
export function compareVersions(left, right) {
|
|
21
|
+
const parse = (value) => {
|
|
22
|
+
const [core, ...rest] = String(value).replace(/^v/, "").split("-");
|
|
23
|
+
return { parts: (core ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease: rest.length > 0 };
|
|
24
|
+
};
|
|
25
|
+
const a = parse(left);
|
|
26
|
+
const b = parse(right);
|
|
27
|
+
for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
|
|
28
|
+
const difference = (a.parts[index] ?? 0) - (b.parts[index] ?? 0);
|
|
29
|
+
if (difference !== 0)
|
|
30
|
+
return difference > 0 ? 1 : -1;
|
|
31
|
+
}
|
|
32
|
+
if (a.prerelease === b.prerelease)
|
|
33
|
+
return 0;
|
|
34
|
+
return a.prerelease ? -1 : 1;
|
|
35
|
+
}
|
|
36
|
+
export async function checkForUpdate(force) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (force !== true && updateCache !== null && now - updateCache.at < UPDATE_TTL_MS)
|
|
39
|
+
return updateCache.value;
|
|
40
|
+
const base = { current: LOCAL_VERSION, latest: null, outdated: false, checkedAt: now };
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`${UPDATE_REGISTRY}/${PLUGIN_NAME}/latest`, {
|
|
44
|
+
headers: { accept: "application/json" },
|
|
45
|
+
signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(`HTTP ${response.status}`);
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
const version = body?.version;
|
|
51
|
+
const latest = typeof version === "string" && version !== "" ? version : null;
|
|
52
|
+
if (latest === null)
|
|
53
|
+
throw new Error("registry 没有返回 version 字段");
|
|
54
|
+
value = {
|
|
55
|
+
...base,
|
|
56
|
+
latest,
|
|
57
|
+
outdated: LOCAL_VERSION !== null && compareVersions(latest, LOCAL_VERSION) > 0,
|
|
58
|
+
checkedAt: Date.now()
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// 离线 / 超时 / 代理不通都走这里:界面照常显示版本号,只是没有更新提示。
|
|
63
|
+
value = { ...base, error: messageOf(error) };
|
|
64
|
+
}
|
|
65
|
+
updateCache = { at: Date.now(), value };
|
|
66
|
+
return value;
|
|
67
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-session-curator — host
|
|
2
|
+
* dsh-session-curator — host 半区入口。
|
|
3
3
|
*
|
|
4
4
|
* 提供三块能力:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* 1. 会话软删除:先把会话**藏起来**(进 registry-global 的归档集合 —— DSH 里唯一能让它
|
|
6
|
+
* 从侧边栏所有分组面消失的机制,否则它会以"未分组"的幽灵行留在那里),再把它从
|
|
7
|
+
* 工作区记账里摘掉(`Workspace.detachSession`),最后把
|
|
8
|
+
* `<DSH_HOME>/sessions/<slug>/<sessionId>/` 整个搬到
|
|
9
|
+
* `<DSH_HOME>/storages/session-manager/trash/<sessionId>/`。可完整恢复。
|
|
10
|
+
* ⚠️ 目录名 `session-manager` 是**故意保留的旧名**(见 host/paths.ts)。
|
|
9
11
|
* 2. 回收站管理:列出 / 恢复 / 永久删除。
|
|
10
12
|
* 3. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶,
|
|
11
13
|
* 以及直接删除。归档/取消归档/置顶全部走官方 `ctx.workspaceRegistry`。
|
|
@@ -13,544 +15,20 @@
|
|
|
13
15
|
* 对客户端暴露一组挂在共享 `/api` 通道下的精确 Fetch 路由(
|
|
14
16
|
* `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
|
|
15
17
|
* 前缀路由先做完,所以插件自己不需要任何鉴权代码。
|
|
18
|
+
*
|
|
19
|
+
* 源码在 `src/`(TypeScript),`lib/` 是构建产物(`npm run build`),DSH 加载的是后者。
|
|
16
20
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
export const name =
|
|
21
|
+
import { SessionManager } from "./host/session-manager.js";
|
|
22
|
+
import { registerRoutes } from "./host/routes.js";
|
|
23
|
+
import { PLUGIN_NAME } from "./host/manifest.js";
|
|
24
|
+
/** 插件名(DSH 的插件 id,同时也是 npm 包名)。 */
|
|
25
|
+
export const name = PLUGIN_NAME;
|
|
22
26
|
// `connection` 承载 HTTP 面;`webServer` 是硬依赖(Connection 只在 webServer 存在时
|
|
23
27
|
// 才挂载 `/api` 通道,没有它插件就不可达);`workspaceRegistry` 是归档/置顶/记账的来源。
|
|
24
28
|
export const inject = ["connection", "webServer", "workspaceRegistry"];
|
|
25
|
-
|
|
26
|
-
// ── DSH home 解析 ────────────────────────────────────────────────────────
|
|
27
|
-
// 这里刻意**不 import** `@deepseek-ai/dsh-home-paths`:那个包只在 DSH 自己的
|
|
28
|
-
// node_modules 里,而插件可能以 junction/symlink 形式挂在 profile 下 —— 此时 Node 会
|
|
29
|
-
// 按 realpath 解析裸标识符,从插件源码目录向上找不到它,插件就加载不了。语义与那个包
|
|
30
|
-
// 一致(`$DSH_HOME` 优先,否则 `~/.dsh`),几行就够,换来的是"插件放哪都能跑"。
|
|
31
|
-
function resolveDshHome() {
|
|
32
|
-
const configured = process.env.DSH_HOME;
|
|
33
|
-
if (typeof configured === "string" && configured.trim() !== "") {
|
|
34
|
-
const expanded = configured.trim();
|
|
35
|
-
if (expanded === "~") return homedir();
|
|
36
|
-
if (expanded.startsWith("~/") || expanded.startsWith("~\\")) return resolve(join(homedir(), expanded.slice(2)));
|
|
37
|
-
return resolve(expanded);
|
|
38
|
-
}
|
|
39
|
-
return resolve(join(homedir(), ".dsh"));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function dshHomePath(...segments) {
|
|
43
|
-
return join(resolveDshHome(), ...segments);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** DSH 的共享 API 通道:唯一带 Host/Origin 信任栅栏与浏览器会话鉴权的 HTTP 面。 */
|
|
47
|
-
const API_CHANNEL = "/api";
|
|
48
|
-
/** 频道下属于本插件的路径前缀(`/api/session-curator/<endpoint>`)。 */
|
|
49
|
-
const ROUTE_PREFIX = "session-curator";
|
|
50
|
-
/** 端点只读 / 只写,一律 POST(Connection 的精确路由按方法集合匹配)。 */
|
|
51
|
-
const ROUTE_METHODS = ["POST"];
|
|
52
|
-
/**
|
|
53
|
-
* 回收站根目录:`<DSH_HOME>/storages/session-manager/trash`。
|
|
54
|
-
*
|
|
55
|
-
* 这里的 `session-manager` 是**故意不改的旧名**:插件从 `dsh-session-manager` 改名成
|
|
56
|
-
* `dsh-session-curator` 时,回收站里可能还躺着用户已经删掉的会话(本机就有一个)——
|
|
57
|
-
* 改路径会把它们变成孤儿。数据目录用历史名、其余 id 用新名,是权衡后的结果。
|
|
58
|
-
*/
|
|
59
|
-
const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
|
|
60
|
-
const META_FILE = "__trash.json";
|
|
61
|
-
const PROJ_FILE = "__projcache.json";
|
|
62
|
-
|
|
63
|
-
// ── 文件系统小工具 ───────────────────────────────────────────────────────
|
|
64
|
-
function trashRoot() {
|
|
65
|
-
return dshHomePath(...TRASH_SUBPATH);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function sessionsRoot() {
|
|
69
|
-
return dshHomePath("sessions");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function projCacheFile(sessionId) {
|
|
73
|
-
return dshHomePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function messageOf(error) {
|
|
77
|
-
if (error instanceof Error) return error.message;
|
|
78
|
-
return String(error);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
|
|
82
|
-
async function moveDir(from, to) {
|
|
83
|
-
await fsp.mkdir(dirname(to), { recursive: true });
|
|
84
|
-
try {
|
|
85
|
-
await fsp.rename(from, to);
|
|
86
|
-
return;
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if (error?.code !== "EXDEV") throw error;
|
|
89
|
-
}
|
|
90
|
-
await fsp.cp(from, to, { recursive: true, force: true });
|
|
91
|
-
await fsp.rm(from, { recursive: true, force: true });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ── 会话管理器 ───────────────────────────────────────────────────────────
|
|
95
|
-
class SessionManager {
|
|
96
|
-
constructor(ctx) {
|
|
97
|
-
this.ctx = ctx;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
get registry() {
|
|
101
|
-
return this.ctx.workspaceRegistry;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── 只读投影 ─────────────────────────────────────────────────────────
|
|
105
|
-
/** <DSH_HOME>/sessions/<slug>/<sessionId> → { dir, slug } 索引。 */
|
|
106
|
-
async scanSessionDirs() {
|
|
107
|
-
const root = sessionsRoot();
|
|
108
|
-
const map = new Map();
|
|
109
|
-
let slugs;
|
|
110
|
-
try {
|
|
111
|
-
slugs = await fsp.readdir(root, { withFileTypes: true });
|
|
112
|
-
} catch {
|
|
113
|
-
return map;
|
|
114
|
-
}
|
|
115
|
-
for (const slug of slugs) {
|
|
116
|
-
if (!slug.isDirectory()) continue;
|
|
117
|
-
let ids;
|
|
118
|
-
try {
|
|
119
|
-
ids = await fsp.readdir(join(root, slug.name), { withFileTypes: true });
|
|
120
|
-
} catch {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
for (const id of ids) {
|
|
124
|
-
if (!id.isDirectory()) continue;
|
|
125
|
-
if (!map.has(id.name)) map.set(id.name, { dir: join(root, slug.name, id.name), slug: slug.name });
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return map;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** 投影缓存里的会话元数据(标题 / cwd / 创建时间)。缺文件时返回空字段。 */
|
|
132
|
-
async readProjection(sessionId) {
|
|
133
|
-
try {
|
|
134
|
-
const parsed = JSON.parse(await fsp.readFile(projCacheFile(sessionId), "utf8"));
|
|
135
|
-
const rows = parsed?.record?.rows;
|
|
136
|
-
const identity = parsed?.record?.identity;
|
|
137
|
-
const title = rows?.title?.val;
|
|
138
|
-
return {
|
|
139
|
-
title: typeof title === "string" && title !== "" ? title : null,
|
|
140
|
-
cwd: typeof identity?.cwd === "string" ? identity.cwd : null,
|
|
141
|
-
createdAt: typeof identity?.createdAt === "number" ? identity.createdAt : null
|
|
142
|
-
};
|
|
143
|
-
} catch {
|
|
144
|
-
return { title: null, cwd: null, createdAt: null };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** 只统计顶层文件:会话目录里就是那几份日志。 */
|
|
149
|
-
async dirStats(dir) {
|
|
150
|
-
let bytes = 0;
|
|
151
|
-
let files = 0;
|
|
152
|
-
let entries;
|
|
153
|
-
try {
|
|
154
|
-
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
155
|
-
} catch {
|
|
156
|
-
return { bytes: 0, files: 0 };
|
|
157
|
-
}
|
|
158
|
-
for (const entry of entries) {
|
|
159
|
-
if (!entry.isFile()) continue;
|
|
160
|
-
try {
|
|
161
|
-
const stat = await fsp.stat(join(dir, entry.name));
|
|
162
|
-
bytes += stat.size;
|
|
163
|
-
files += 1;
|
|
164
|
-
} catch {
|
|
165
|
-
// 竞态:文件刚好消失,忽略
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return { bytes, files };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
findWorkspaceOf(sessionId) {
|
|
172
|
-
for (const workspace of this.registry.list()) {
|
|
173
|
-
const ids = workspace.sessionIds;
|
|
174
|
-
if (Array.isArray(ids) && ids.includes(sessionId)) return workspace;
|
|
175
|
-
}
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async describeSession(sessionId, dirs) {
|
|
180
|
-
const info = dirs.get(sessionId);
|
|
181
|
-
const projection = await this.readProjection(sessionId);
|
|
182
|
-
const workspace = this.findWorkspaceOf(sessionId);
|
|
183
|
-
const stats = info ? await this.dirStats(info.dir) : { bytes: 0, files: 0 };
|
|
184
|
-
return {
|
|
185
|
-
sessionId,
|
|
186
|
-
title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
|
|
187
|
-
cwd: projection.cwd ?? (workspace ? workspace.path : null),
|
|
188
|
-
createdAt: projection.createdAt ?? null,
|
|
189
|
-
workspaceId: workspace ? String(workspace.id) : null,
|
|
190
|
-
workspaceTitle: workspace ? workspace.title : null,
|
|
191
|
-
slug: info ? info.slug : null,
|
|
192
|
-
onDisk: Boolean(info),
|
|
193
|
-
bytes: stats.bytes,
|
|
194
|
-
files: stats.files
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async listTrash() {
|
|
199
|
-
const root = trashRoot();
|
|
200
|
-
let entries;
|
|
201
|
-
try {
|
|
202
|
-
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
203
|
-
} catch {
|
|
204
|
-
return [];
|
|
205
|
-
}
|
|
206
|
-
const rows = [];
|
|
207
|
-
for (const entry of entries) {
|
|
208
|
-
if (!entry.isDirectory()) continue;
|
|
209
|
-
const dir = join(root, entry.name);
|
|
210
|
-
let meta = null;
|
|
211
|
-
try {
|
|
212
|
-
meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
|
|
213
|
-
} catch {
|
|
214
|
-
meta = null;
|
|
215
|
-
}
|
|
216
|
-
const stats = await this.dirStats(dir);
|
|
217
|
-
rows.push({
|
|
218
|
-
sessionId: entry.name,
|
|
219
|
-
title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
|
|
220
|
-
cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
|
|
221
|
-
slug: typeof meta?.slug === "string" ? meta.slug : null,
|
|
222
|
-
// 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
|
|
223
|
-
workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
|
|
224
|
-
workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
|
|
225
|
-
deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
|
|
226
|
-
bytes: stats.bytes,
|
|
227
|
-
files: stats.files,
|
|
228
|
-
restorable: typeof meta?.slug === "string" && meta.slug !== ""
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
rows.sort((left, right) => right.deletedAt - left.deletedAt);
|
|
232
|
-
return rows;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
|
|
236
|
-
async buildSnapshot() {
|
|
237
|
-
const registry = this.registry;
|
|
238
|
-
const dirs = await this.scanSessionDirs();
|
|
239
|
-
const archived = [];
|
|
240
|
-
for (const sessionId of registry.archivedSessionIds) {
|
|
241
|
-
archived.push(await this.describeSession(sessionId, dirs));
|
|
242
|
-
}
|
|
243
|
-
const pinned = [];
|
|
244
|
-
for (const sessionId of registry.pinnedSessionIds) {
|
|
245
|
-
pinned.push(await this.describeSession(sessionId, dirs));
|
|
246
|
-
}
|
|
247
|
-
const trashed = await this.listTrash();
|
|
248
|
-
let archivedBytes = 0;
|
|
249
|
-
for (const row of archived) archivedBytes += row.bytes;
|
|
250
|
-
let trashBytes = 0;
|
|
251
|
-
for (const row of trashed) trashBytes += row.bytes;
|
|
252
|
-
return {
|
|
253
|
-
dshHome: dshHomePath(),
|
|
254
|
-
trashDir: trashRoot(),
|
|
255
|
-
sessionsDir: sessionsRoot(),
|
|
256
|
-
archived,
|
|
257
|
-
pinned,
|
|
258
|
-
trashed,
|
|
259
|
-
totals: {
|
|
260
|
-
archived: archived.length,
|
|
261
|
-
archivedBytes,
|
|
262
|
-
pinned: pinned.length,
|
|
263
|
-
trashed: trashed.length,
|
|
264
|
-
trashBytes
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ── 变更操作 ─────────────────────────────────────────────────────────
|
|
270
|
-
snapshot() {
|
|
271
|
-
return this.buildSnapshot();
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
|
|
275
|
-
async each(ids, action) {
|
|
276
|
-
const results = [];
|
|
277
|
-
for (const sessionId of ids) {
|
|
278
|
-
try {
|
|
279
|
-
const extra = await action(sessionId);
|
|
280
|
-
results.push({ sessionId, ok: true, ...(extra ? { steps: extra } : {}) });
|
|
281
|
-
} catch (error) {
|
|
282
|
-
results.push({ sessionId, ok: false, reason: messageOf(error) });
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return { results, ...(await this.buildSnapshot()) };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** `stopActivity` 为 true 时先停掉会话正在跑的工作再归档。 */
|
|
289
|
-
archive(ids, stopActivity) {
|
|
290
|
-
return this.each(ids, (sessionId) => this.registry.archiveSession(sessionId, { stopActivity: Boolean(stopActivity) }));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
unarchive(ids) {
|
|
294
|
-
return this.each(ids, (sessionId) => this.registry.unarchiveSession(sessionId));
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
pin(ids) {
|
|
298
|
-
return this.each(ids, (sessionId) => this.registry.pinSession(sessionId));
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
unpin(ids) {
|
|
302
|
-
return this.each(ids, (sessionId) => this.registry.unpinSession(sessionId));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** 正在跑的会话不允许删除,与官方归档同一条活动判据。 */
|
|
306
|
-
async sessionActivity(sessionId) {
|
|
307
|
-
try {
|
|
308
|
-
const activity = await this.ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
|
|
309
|
-
return Array.isArray(activity) ? activity : [];
|
|
310
|
-
} catch {
|
|
311
|
-
return [];
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* 软删除:移出工作区记账 + 搬到回收站(含投影缓存)。
|
|
317
|
-
* 归档中的会话先取消归档,这样恢复后它能回到原来的位置。
|
|
318
|
-
*/
|
|
319
|
-
async trash(ids) {
|
|
320
|
-
await fsp.mkdir(trashRoot(), { recursive: true });
|
|
321
|
-
const dirs = await this.scanSessionDirs();
|
|
322
|
-
return this.each(ids, async (sessionId) => {
|
|
323
|
-
const steps = [];
|
|
324
|
-
if (sessionId === process.env.DSH_SESSION_ID) {
|
|
325
|
-
throw new Error("这是当前正在使用的会话,不能删除");
|
|
326
|
-
}
|
|
327
|
-
const activity = await this.sessionActivity(sessionId);
|
|
328
|
-
if (activity.length > 0) throw new Error("会话正在运行,请先停止它");
|
|
329
|
-
const info = dirs.get(sessionId);
|
|
330
|
-
if (!info) throw new Error("找不到会话日志目录(可能已被删除)");
|
|
331
|
-
|
|
332
|
-
if (this.registry.archivedSessionIds.includes(sessionId)) {
|
|
333
|
-
await this.registry.unarchiveSession(sessionId);
|
|
334
|
-
steps.push("unarchive");
|
|
335
|
-
}
|
|
336
|
-
// 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
|
|
337
|
-
// 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
|
|
338
|
-
if (this.registry.pinnedSessionIds.includes(sessionId)) {
|
|
339
|
-
await this.registry.unpinSession(sessionId);
|
|
340
|
-
steps.push("unpin");
|
|
341
|
-
}
|
|
342
|
-
const workspace = this.findWorkspaceOf(sessionId);
|
|
343
|
-
if (workspace) {
|
|
344
|
-
try {
|
|
345
|
-
await workspace.detachSession(sessionId);
|
|
346
|
-
steps.push("detach");
|
|
347
|
-
} catch (error) {
|
|
348
|
-
steps.push(`detach-failed:${messageOf(error)}`);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
const projection = await this.readProjection(sessionId);
|
|
352
|
-
|
|
353
|
-
const target = join(trashRoot(), sessionId);
|
|
354
|
-
await fsp.rm(target, { recursive: true, force: true });
|
|
355
|
-
await moveDir(info.dir, target);
|
|
356
|
-
steps.push("move");
|
|
357
|
-
|
|
358
|
-
const cached = projCacheFile(sessionId);
|
|
359
|
-
if (existsSync(cached)) {
|
|
360
|
-
try {
|
|
361
|
-
await fsp.rename(cached, join(target, PROJ_FILE));
|
|
362
|
-
steps.push("projcache");
|
|
363
|
-
} catch {
|
|
364
|
-
// 缓存搬不动不影响会话本身
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
await fsp.writeFile(
|
|
368
|
-
join(target, META_FILE),
|
|
369
|
-
JSON.stringify(
|
|
370
|
-
{
|
|
371
|
-
version: 1,
|
|
372
|
-
sessionId,
|
|
373
|
-
slug: info.slug,
|
|
374
|
-
title: projection.title,
|
|
375
|
-
cwd: projection.cwd,
|
|
376
|
-
workspaceId: workspace ? String(workspace.id) : null,
|
|
377
|
-
workspaceTitle: workspace ? workspace.title : null,
|
|
378
|
-
deletedAt: Date.now()
|
|
379
|
-
},
|
|
380
|
-
null,
|
|
381
|
-
2
|
|
382
|
-
),
|
|
383
|
-
"utf8"
|
|
384
|
-
);
|
|
385
|
-
return steps;
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** 从回收站恢复;尽力把会话重新挂回原工作区。 */
|
|
390
|
-
async restore(ids) {
|
|
391
|
-
const dirs = await this.scanSessionDirs();
|
|
392
|
-
return this.each(ids, async (sessionId) => {
|
|
393
|
-
const steps = [];
|
|
394
|
-
const source = join(trashRoot(), sessionId);
|
|
395
|
-
if (!existsSync(source)) throw new Error("回收站里没有这个会话");
|
|
396
|
-
let meta = null;
|
|
397
|
-
try {
|
|
398
|
-
meta = JSON.parse(await fsp.readFile(join(source, META_FILE), "utf8"));
|
|
399
|
-
} catch {
|
|
400
|
-
meta = null;
|
|
401
|
-
}
|
|
402
|
-
if (typeof meta?.slug !== "string" || meta.slug === "") {
|
|
403
|
-
throw new Error("缺少回收站元数据,无法确定原目录");
|
|
404
|
-
}
|
|
405
|
-
if (dirs.has(sessionId)) throw new Error("目标会话目录已存在,请先处理同名会话");
|
|
406
|
-
|
|
407
|
-
const target = join(sessionsRoot(), meta.slug, sessionId);
|
|
408
|
-
await moveDir(source, target);
|
|
409
|
-
steps.push("move");
|
|
410
|
-
|
|
411
|
-
const cached = join(target, PROJ_FILE);
|
|
412
|
-
if (existsSync(cached)) {
|
|
413
|
-
try {
|
|
414
|
-
await fsp.rename(cached, projCacheFile(sessionId));
|
|
415
|
-
steps.push("projcache");
|
|
416
|
-
} catch {
|
|
417
|
-
// 同上,非关键
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
try {
|
|
421
|
-
await fsp.rm(join(target, META_FILE), { force: true });
|
|
422
|
-
} catch {
|
|
423
|
-
// 元数据残留无害
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (typeof meta.cwd === "string" && meta.cwd !== "") {
|
|
427
|
-
try {
|
|
428
|
-
const workspace = await this.registry.resolveByPath(meta.cwd);
|
|
429
|
-
if (workspace) {
|
|
430
|
-
await workspace.attachSession(sessionId);
|
|
431
|
-
steps.push("attach");
|
|
432
|
-
}
|
|
433
|
-
} catch {
|
|
434
|
-
// 索引要等下次启动才会收录这个会话,挂靠失败不影响恢复
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return steps;
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** 永久删除(不可恢复)。 */
|
|
442
|
-
async purge(ids) {
|
|
443
|
-
return this.each(ids, async (sessionId) => {
|
|
444
|
-
const target = join(trashRoot(), sessionId);
|
|
445
|
-
if (!existsSync(target)) throw new Error("回收站里没有这个会话");
|
|
446
|
-
await fsp.rm(target, { recursive: true, force: true });
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// ── 传输 / 分发 ──────────────────────────────────────────────────────────
|
|
452
|
-
/**
|
|
453
|
-
* 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读):
|
|
454
|
-
*
|
|
455
|
-
* 它内部把路由交给 `owner.effect(() => owner.webServer.register(route))`,而那个
|
|
456
|
-
* `owner` 是 `this.ctx` —— **Connection 服务自己的 ctx**,不是读服务的插件 ctx。
|
|
457
|
-
* cordis 的服务调用会带 shadow(`Context[symbols.shadow]` 指向提供者作用域),所以
|
|
458
|
-
* 服务解析从"提供者的 fiber"开始;Connection 自己的 inject 只有 `["credentials"]`,
|
|
459
|
-
* 于是必然抛:
|
|
460
|
-
*
|
|
461
|
-
* cannot get property "webServer" without inject
|
|
462
|
-
* (@deepseek-ai/dsh-client-connection/lib/index.js:656)
|
|
463
|
-
*
|
|
464
|
-
* 结果:插件 fiber 直接变 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态
|
|
465
|
-
* 兜底,由 `@deepseek-ai/dsh-host-frontend-static` 对非 GET/HEAD 回 **HTTP 405** ——
|
|
466
|
-
* 就是 `transport failure for /session-manager/trash: HTTP 405`。
|
|
467
|
-
* 注意:**在插件自己的 inject 里加 `webServer` 修不了它**,因为报错的那个 ctx 不是插件的。
|
|
468
|
-
*
|
|
469
|
-
* 改走同样由 Connection 公布的 `ctx.connection.fetch.register()`:精确 Fetch 路由挂在
|
|
470
|
-
* 共享 `/api` 前缀下,`admit()`(Host/Origin/`sec-fetch-site` + 浏览器会话 cookie)与
|
|
471
|
-
* 请求体上限都仍由 Connection 的 `/api` 前缀路由先执行,插件依旧零鉴权代码。
|
|
472
|
-
*/
|
|
473
|
-
|
|
474
|
-
/** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
|
|
475
|
-
const ENDPOINTS = {
|
|
476
|
-
snapshot: (manager) => manager.snapshot(),
|
|
477
|
-
archive: (manager, payload) => manager.archive(payload.ids, payload.stopActivity),
|
|
478
|
-
unarchive: (manager, payload) => manager.unarchive(payload.ids),
|
|
479
|
-
pin: (manager, payload) => manager.pin(payload.ids),
|
|
480
|
-
unpin: (manager, payload) => manager.unpin(payload.ids),
|
|
481
|
-
trash: (manager, payload) => manager.trash(payload.ids),
|
|
482
|
-
restore: (manager, payload) => manager.restore(payload.ids),
|
|
483
|
-
purge: (manager, payload) => manager.purge(payload.ids)
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
/** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
|
|
487
|
-
function idsOf(payload) {
|
|
488
|
-
return Array.isArray(payload.ids) ? payload.ids.filter((value) => typeof value === "string" && value !== "") : [];
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* 一个端点处理器。返回 Connection 的固定信封:处理器自身的业务失败走
|
|
493
|
-
* `{ok:false, error}`,端点内部的逐项失败则留在 `value.results` 里。
|
|
494
|
-
*/
|
|
495
|
-
async function handle(manager, endpoint, payload) {
|
|
496
|
-
const run = ENDPOINTS[endpoint];
|
|
497
|
-
if (run === undefined) {
|
|
498
|
-
return { ok: false, error: { code: "session-curator/unknown-endpoint", message: `unknown endpoint ${endpoint}`, details: {} } };
|
|
499
|
-
}
|
|
500
|
-
const safe = payload !== null && typeof payload === "object" ? payload : {};
|
|
501
|
-
try {
|
|
502
|
-
return { ok: true, value: await run(manager, { ...safe, ids: idsOf(safe) }) };
|
|
503
|
-
} catch (error) {
|
|
504
|
-
return { ok: false, error: { code: "session-curator/error", message: messageOf(error), details: {} } };
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/** `{type:'server-response', rpcId, result}` —— 与 Connection 自己回的信封逐字一致。 */
|
|
509
|
-
function envelope(rpcId, result) {
|
|
510
|
-
return Response.json({ type: "server-response", rpcId, result });
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function badRequest(rpcId, message) {
|
|
514
|
-
// 与 Connection 的 `invalidEnvelopeResponse` 一致:坏信封也回 200 + 错误信封,
|
|
515
|
-
// 这样客户端读到的是人话,而不是一个传输层异常。
|
|
516
|
-
return envelope(rpcId ?? "invalid-request", {
|
|
517
|
-
ok: false,
|
|
518
|
-
error: { code: "session-curator/bad-request", message, details: {} }
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** `/api` 已在前面做完信任与鉴权,这里只校验信封、分发、回包。 */
|
|
523
|
-
async function respond(manager, endpoint, request) {
|
|
524
|
-
let body;
|
|
525
|
-
try {
|
|
526
|
-
body = await request.json();
|
|
527
|
-
} catch {
|
|
528
|
-
return badRequest(null, "body is not JSON");
|
|
529
|
-
}
|
|
530
|
-
const rpcId = body !== null && typeof body === "object" && typeof body.rpcId === "string" ? body.rpcId : null;
|
|
531
|
-
if (rpcId === null || body.type !== "client-request" || typeof body.method !== "string") {
|
|
532
|
-
return badRequest(rpcId, "invalid client-request message");
|
|
533
|
-
}
|
|
534
|
-
const target = `${ROUTE_PREFIX}/${endpoint}`;
|
|
535
|
-
if (body.method !== target) {
|
|
536
|
-
return badRequest(rpcId, `method ${JSON.stringify(body.method)} does not match endpoint ${JSON.stringify(target)}`);
|
|
537
|
-
}
|
|
538
|
-
return envelope(rpcId, await handle(manager, endpoint, body.payload));
|
|
539
|
-
}
|
|
540
|
-
|
|
541
29
|
export function apply(ctx) {
|
|
542
30
|
const manager = new SessionManager(ctx);
|
|
543
|
-
|
|
544
|
-
const fetchRoutes = ctx.connection.fetch;
|
|
545
|
-
for (const endpoint of Object.keys(ENDPOINTS)) {
|
|
546
|
-
const route = {
|
|
547
|
-
path: `${API_CHANNEL}/${ROUTE_PREFIX}/${endpoint}`,
|
|
548
|
-
methods: ROUTE_METHODS,
|
|
549
|
-
requestBody: "buffered",
|
|
550
|
-
fetch: (request) => respond(manager, endpoint, request)
|
|
551
|
-
};
|
|
552
|
-
// 注册挂在**插件自己的 fiber** 上(`registerFetchRoute` 用的是调用方的
|
|
553
|
-
// `owner.effect`),所以卸载插件时路由会一起摘掉。
|
|
554
|
-
ctx.effect(() => fetchRoutes.register(route), `dsh-session-curator: ${route.path}`);
|
|
555
|
-
}
|
|
31
|
+
registerRoutes(ctx, manager);
|
|
556
32
|
}
|
|
33
|
+
// 纯函数,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
|
|
34
|
+
export { compareVersions } from "./host/update.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-curator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DeepSeek Harness plugin: soft-delete sessions to a recoverable trash and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:会话软删除到可恢复的回收站,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -101,8 +101,10 @@
|
|
|
101
101
|
}
|
|
102
102
|
},
|
|
103
103
|
"scripts": {
|
|
104
|
-
"
|
|
105
|
-
"
|
|
104
|
+
"build": "node scripts/build.mjs",
|
|
105
|
+
"typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
|
|
106
|
+
"test": "npm run build && node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
|
|
107
|
+
"check": "npm run typecheck && node --check lib/index.js && node --check lib/client.js && node --check scripts/client.mjs",
|
|
106
108
|
"prepublishOnly": "npm test"
|
|
107
109
|
},
|
|
108
110
|
"files": [
|
|
@@ -114,5 +116,10 @@
|
|
|
114
116
|
"LICENSE",
|
|
115
117
|
"README.md",
|
|
116
118
|
"README.zh-CN.md"
|
|
117
|
-
]
|
|
119
|
+
],
|
|
120
|
+
"devDependencies": {
|
|
121
|
+
"@types/node": "^26.6.5",
|
|
122
|
+
"esbuild": "^0.28.2",
|
|
123
|
+
"typescript": "^5.9.3"
|
|
124
|
+
}
|
|
118
125
|
}
|
package/scripts/activation.mjs
CHANGED
|
@@ -115,7 +115,7 @@ function assertFetchRoute(route) {
|
|
|
115
115
|
if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
const ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge"];
|
|
118
|
+
const ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge", "check-update"];
|
|
119
119
|
const CHANNEL = "/api";
|
|
120
120
|
const PREFIX = "session-curator";
|
|
121
121
|
const ROUTE_PREFIX = `${CHANNEL}/${PREFIX}`;
|