dsh-session-manager 0.6.3 → 0.6.4

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/lib/index.js CHANGED
@@ -1,1335 +1,1562 @@
1
- /**
2
- * @dsh-session-manager — Host half.
3
- *
4
- * Cordis plugin that fills three gaps in DSH:
5
- * - delete / unarchive (DSH ships `workspace.archiveSession` but not its inverse);
6
- * - cross-workspace move (DSH indexes workspace membership off the session
7
- * header's immutable `cwd`, so a real move must re-home the artifact);
8
- * - per-conversation Agent-preset migration (rewrite the effective preset
9
- * in place so the next event fold sees the new value).
10
- *
11
- * Live preset updates use the DSH session store. Cold rewrites and moves
12
- * validate the durable JSONL/Zstd artifact and preserve its stored format.
13
- * File mutations are restricted to verified session directories.
14
- *
15
- * The move and migrate paths do NOT tear down the live agent or session.
16
- * The in-memory session stays alive, the file is rewritten in place under
17
- * a new cwd (move) or a new event is appended to the existing log
18
- * (migrate), and the workspace registry is updated. The api-gateway's
19
- * chat panel therefore remains "available" throughout; the user does not
20
- * have to refresh.
21
- *
22
- * The compatibility adapter normalizes service access and list snapshots.
23
- * Optional live-writer/coordinator internals are guarded before mutation.
24
- */
25
- import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
26
- import { basename, dirname, join } from "node:path";
27
- import { randomBytes } from "node:crypto";
28
- import { createAnnotationStore } from "./annotation-store.js";
29
- import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
30
- import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
-
32
- export const name = "dsh-session-manager";
33
-
34
- export const inject = [
35
- "webServer",
36
- "workspaceRegistry",
37
- "sessions",
38
- "agents",
39
- "sessionPersistence",
40
- "agentPresets"
41
- ];
42
-
43
- /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
44
- * logger never throws while reporting a throw. */
45
- function safeJson(value) { try { return JSON.stringify(value); } catch { return "[unserializable]"; } }
46
- /** Like safeJson but used as the user-facing error string in HTTP responses. */
47
- function safeErrorMessage(error) {
48
- if (error === null || error === undefined) return `<nullish:${typeof error}>`;
49
- if (typeof error === "string") return error;
50
- if (typeof error === "object") {
51
- if (typeof error.message === "string" && error.message.length > 0) return error.message;
52
- if (typeof error.code === "string" && error.code.length > 0) return `[code=${error.code}]`;
53
- }
54
- try { return JSON.stringify(error); } catch { return String(error); }
55
- }
56
-
57
- const API_PREFIX = "/session-manager/api";
58
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
- function liveWriterOf(persistence, sessionId) {
60
- const writers = persistence?.tracker?.writers;
61
- return typeof writers?.get === "function" ? writers.get(sessionId) : undefined;
62
- }
63
-
64
- /**
65
- * Retarget the existing live JSONL writer after its artifact is relocated.
66
- * The agent loop retains the original SessionHandle until the agent is
67
- * disposed; replacing the tracker entry with a newly opened handle leaves
68
- * that retained handle stale and can make later teardown delete the new
69
- * tracker entry. The compiled DSH handle exposes a writable runtime
70
- * header property even though the TypeScript declaration is readonly.
71
- * Mutating that property keeps the same handle, queue, cursor, and lease.
72
- */
73
- function rebindLiveWriter(persistence, sessionId, newHeader) {
74
- const writer = liveWriterOf(persistence, sessionId);
75
- if (writer === undefined || writer === null) {
76
- throw new Error("当前 DSH 运行时未暴露可重绑定的 live persistence writer,无法安全迁移运行中的会话;请先关闭会话后重试");
77
- }
78
- if (typeof writer.header !== "object" || writer.header === null) {
79
- throw new Error("当前持久化后端的 live writer 不支持安全重绑定;请先关闭会话后重试");
80
- }
81
- const reboundHeader = Object.freeze({ ...newHeader });
82
- writer.header = reboundHeader;
83
- return writer;
84
- }
85
-
86
-
87
- /** List every persisted session header visible to the configured persistence
88
- * backend, supplemented by raw-id sessions written by older DSH builds.
89
- *
90
- * Performance (issue #6): the disk loop previously opened and zstd-decoded
91
- * every candidate file in <dshHome>/sessions/<proj>/<dir>/ even when the
92
- * persistence layer had already returned those ids. On a healthy index
93
- * that wasted O(N * file_size) read+inflate per call -- the reporter
94
- * measured +17s CPU / +254 MB read per DSH startup on an 80-session /
95
- * 249 MB library. We now pre-build a `knownDirs` set from the persisted
96
- * ids (both `[id]` and `[encodeSessionSegment(id)]`) and skip matching
97
- * directories before any fopen. Orphan raw-id directories that the
98
- * index does not know about still fall through to the file read so the
99
- * disk fallback semantics are preserved -- the orphan is the whole
100
- * reason this loop exists.
101
- *
102
- * @param {object|undefined} persistence DSH's sessionPersistence service
103
- * @param {((segment: string) => string)|undefined} dshHome Resolver for
104
- * DSH home sub-paths; only "sessions" is read.
105
- */
106
- const scanSessionHeaders = async (persistence, dshHome) => {
107
- const seen = new Set();
108
- const headers = [];
109
- const errors = [];
110
- let listed = false;
111
- if (typeof persistence?.list === "function") {
112
- try {
113
- const rows = await persistence.list();
114
- if (!Array.isArray(rows)) throw new Error("会话列表返回了无效数据");
115
- for (const row of rows) {
116
- const header = normalizeSessionHeader(row);
117
- if (!header) { errors.push(new Error("会话列表包含无效 header")); continue; }
118
- try { assertSessionId(header.id); }
119
- catch (error) { errors.push(error); continue; }
120
- if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
121
- }
122
- listed = true;
123
- } catch (error) { errors.push(error); }
124
- }
125
- const result = () => ({ headers, complete: listed && errors.length === 0, errors });
126
- // Skip known files before fopen; retain the orphan/raw-id compatibility path.
127
- const knownDirs = new Set();
128
- for (const id of seen) {
129
- knownDirs.add(id);
130
- knownDirs.add(encodeSessionSegment(id));
131
- }
132
- if (typeof dshHome !== "function") return result();
133
- const root = dshHome("sessions");
134
- let projects;
135
- try { projects = await readdir(root, { withFileTypes: true }); }
136
- catch (error) { errors.push(error); return result(); }
137
- listed = true;
138
- for (const proj of projects) {
139
- if (proj.isSymbolicLink()) { errors.push(new Error("跳过符号链接项目目录")); continue; }
140
- if (!proj.isDirectory()) continue;
141
- let dirs;
142
- try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
143
- catch (error) { errors.push(error); continue; }
144
- for (const entry of dirs) {
145
- if (entry.isSymbolicLink()) { errors.push(new Error("跳过符号链接会话目录")); continue; }
146
- if (!entry.isDirectory()) continue;
147
- if (knownDirs.has(entry.name)) continue;
148
- let found = false;
149
- for (const filename of ARTIFACT_NAMES) {
150
- try {
151
- const header = await readSessionHeader(join(root, proj.name, entry.name, filename));
152
- if (![header.id, encodeSessionSegment(header.id)].includes(entry.name)) {
153
- throw new Error("会话目录与 header id 不一致");
154
- }
155
- if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
156
- found = true;
157
- break;
158
- } catch (error) {
159
- if (error.code !== "ENOENT") { errors.push(error); break; }
160
- }
161
- }
162
- if (!found) errors.push(new Error("无法完整读取会话目录: " + join(root, proj.name, entry.name)));
163
- }
164
- }
165
- return result();
166
- };
167
-
168
- const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
169
-
170
- export function apply(ctx) {
171
- try {
172
- const buildId = "dsh-session-manager v0.5.2+release-17 (clearSelection-keeps-bulk-mode-on)";
173
- if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
174
- else if (typeof console !== "undefined" && console?.info) console.info(buildId);
175
- } catch (_) { /* ignore logging failures */ }
176
- const adapter = createDshAdapter(ctx);
177
- let annotationStore;
178
- const annotations = () => {
179
- if (!annotationStore) {
180
- const home = adapter.homePath;
181
- if (typeof home !== "function") throw Object.assign(new Error("会话标记存储不可用"), { code: "annotations-unavailable" });
182
- annotationStore = createAnnotationStore(home("dsh-session-manager"));
183
- }
184
- return annotationStore;
185
- };
186
- const assertAnnotatable = id => {
187
- const registry = adapter.workspaceRegistry;
188
- if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
189
- || adapter.workspaceList().some(ws => (ws.record?.sessionIds ?? ws.sessionIds ?? []).includes(id))) return;
190
- throw Object.assign(new Error("会话不存在或尚未加载,请先打开该会话后重试"), { code: "session-not-found" });
191
- };
192
- const operations = new Map();
193
- const mutate = (id, operation) => {
194
- const pending = (operations.get(id) ?? Promise.resolve()).then(operation);
195
- const settled = pending.catch(() => {}).finally(() => {
196
- if (operations.get(id) === settled) operations.delete(id);
197
- });
198
- operations.set(id, settled);
199
- return pending;
200
- };
201
- const readBody = async (req) => {
202
- const chunks = [];
203
- let size = 0;
204
- for await (const chunk of req) {
205
- const bytes = Buffer.from(chunk);
206
- size += bytes.length;
207
- if (size > 64 * 1024) throw Object.assign(new Error("请求体超过 64 KiB"), { code: "body-too-large" });
208
- chunks.push(bytes);
209
- }
210
- return Buffer.concat(chunks).toString("utf8");
211
- };
212
-
213
- const send = (res, code, obj) => {
214
- res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
215
- res.end(JSON.stringify(obj));
216
- };
217
-
218
- /** Remove one id from the registry-global archive set (durable, serialized). */
219
- const unarchiveSession = async (sessionId) => {
220
- const registry = ctx.workspaceRegistry;
221
- await registry.enqueueOperation(async () => {
222
- const state = registry.requireState();
223
- if (!state.archivedSessionIds.includes(sessionId)) return;
224
- await registry.setState({
225
- ...state,
226
- archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
227
- });
228
- });
229
- };
230
-
231
- /** Add one id to the registry-global archive set (durable, serialized).
232
- * Mirror of unarchiveSession; both touch workspaceRegistry state directly
233
- * because the host-side ctx.workspaces service is not available inside
234
- * the per-request dispatch (Cordis throws "cannot get property
235
- * 'workspaces' without inject" otherwise). */
236
- const archiveSession = async (sessionId) => {
237
- const registry = ctx.workspaceRegistry;
238
- await registry.enqueueOperation(async () => {
239
- const state = registry.requireState();
240
- if (state.archivedSessionIds.includes(sessionId)) return;
241
- await registry.setState({
242
- ...state,
243
- archivedSessionIds: [...state.archivedSessionIds, sessionId]
244
- });
245
- });
246
- };
247
-
248
- /** Detach one session from every workspace's ordered accounting. */
249
- const detachFromWorkspaces = async (sessionId) => {
250
- for (const entity of ctx.workspaceRegistry.list()) {
251
- if (entity.sessionIds.includes(sessionId)) {
252
- await entity.detachSession(sessionId);
253
- }
254
- }
255
- };
256
-
257
- /** Ordered workspace projection for the client. */
258
- const listWorkspaces = () => {
259
- return ctx.workspaceRegistry.list().map((entity) => {
260
- const rawIds = Array.isArray(entity.record?.sessionIds)
261
- ? [...entity.record.sessionIds]
262
- : [...entity.sessionIds];
263
- return {
264
- id: entity.id,
265
- name: entity.title || entity.id,
266
- title: entity.title || entity.id,
267
- path: entity.path,
268
- sessionCount: rawIds.length,
269
- sessionIds: rawIds
270
- };
271
-
272
- });
273
- };
274
-
275
-
276
- /**
277
- * Encode the moved artifact in the backend's own physical layout:
278
- * plain JSONL, or zstd frames whose FIRST frame is exactly the header
279
- * line (the reader's assertZstdHeaderFrame / readFirstZstdLine contract).
280
- */
281
- const encodeArtifact = async (headerLine, rest, isZstd) => {
282
- if (!isZstd) return Buffer.from(`${headerLine}
283
- ${rest}`, "utf8");
284
- const { zstdCompress } = await import("node:zlib");
285
- if (typeof zstdCompress !== "function") {
286
- throw new Error("当前 Node 运行时没有 zstd 支持,无法迁移 zstd 编码的会话工件");
287
- }
288
- const compress = (buffer) => new Promise((resolve, reject) => {
289
- zstdCompress(buffer, (error, output) => error == null ? resolve(output) : reject(error));
290
- });
291
- const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
292
- if (rest === "") return headerFrame;
293
- const bodyFrame = await compress(Buffer.from(rest, "utf8"));
294
- return Buffer.concat([headerFrame, bodyFrame]);
295
- };
296
-
297
- /**
298
- * TRUE cross-workspace move. See the module docstring for the full design:
299
- * re-home the stored cwd, migrate the artifact, then swap accounting.
300
- * Throws an Error with a readable message on every failure path; the
301
- * artifact is restored (or its location reported) whenever a mid-move
302
- * step fails, so the session never ends up in a half-moved state.
303
- */
304
- const moveSession = async (sessionId, targetWorkspaceId) => {
305
- assertSessionId(sessionId);
306
- const registry = ctx.workspaceRegistry;
307
- const persistence = adapter.persistence;
308
- const dshHomePath = adapter.homePath;
309
- if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
310
- if (typeof persistence.locate !== "function") {
311
- throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
312
- }
313
-
314
- const target = registry.list().find((entity) => entity.id === targetWorkspaceId);
315
- if (target === void 0) {
316
- const error = new Error(`目标工作区不存在: ${targetWorkspaceId}`);
317
- error.code = "workspace-not-found";
318
- throw error;
319
- }
320
- const targetPath = target.path;
321
-
322
- // Fast path: the common case -- the session is already in the
323
- // persistence index -- costs one .list() + .find(). The previous
324
- // code called listSessionHeaders() unconditionally, which (before
325
- // the issue #6 fix) re-read every file in the session library even
326
- // when the index already had the answer. We still fall through to
327
- // the disk fallback for raw-id orphans persistence.list() cannot
328
- // surface (older DSH builds wrote plain-id directories that the
329
- // index may not know about).
330
- let storedHeader;
331
- if (persistence !== void 0 && typeof persistence.list === "function") {
332
- try {
333
- const headers = (await persistence.list()).map(normalizeSessionHeader);
334
- storedHeader = headers.find((header) =>
335
- header !== void 0 && typeof header.id === "string" && header.id === sessionId
336
- );
337
- } catch { /* fall through to disk fallback below */ }
338
- }
339
- if (storedHeader === void 0) {
340
- const storedHeaders = await listSessionHeaders(persistence, dshHomePath);
341
- storedHeader = storedHeaders.find((header) => header !== void 0 && header.id === sessionId);
342
- }
343
- if (storedHeader === void 0) {
344
- const error = new Error(
345
- `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
346
- );
347
- error.code = "session-not-found";
348
- throw error;
349
- }
350
- if (storedHeader.origin === "subagent") {
351
- const error = new Error("子代理(subagent)会话不支持跨工作区移动");
352
- error.code = "subagent-unsupported";
353
- throw error;
354
- }
355
- if (storedHeader.cwd !== void 0) {
356
- let currentCanonical;
357
- try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
358
- if (currentCanonical === targetPath) {
359
- return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
360
- }
361
- }
362
-
363
- const liveSession = ctx.sessions.get(sessionId);
364
- if (liveSession !== void 0 && liveWriterOf(persistence, sessionId) === undefined) {
365
- throw new Error("当前 DSH 运行时未暴露可重绑定的 live persistence writer,无法安全迁移运行中的会话;请先关闭会话后重试");
366
- }
367
- let liveAgent;
368
- try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
369
- // The 0.4.6+ move path is no-teardown: the live agent/session stays
370
- // alive throughout. We only rewrite the on-disk artifact in place and
371
- // update the in-memory session header to point at the new cwd; the
372
- // api-gateway's chat panel keeps the in-memory session as active.
373
- // The persistence coordinator's per-id serialize() (if available) wraps
374
- // the rewrite so the write-behind cannot append to the hidden file.
375
- // client UI never has to re-init. Just flush pending events to
376
- // disk (still at OLD cwd), then atomically rename the artifact to
377
- // NEW cwd. The persistence coordinator's serialize() inside the
378
- // move block holds the per-id lock, so the write-behind can't append
379
- // to the hidden file. The agent's UI keeps showing the same in-memory
380
- // session -- no client state flip, no half-initialized resume, no
381
- // "session unavailable" symptoms.
382
- if (liveSession !== void 0) {
383
- try { await ctx.sessions.flush(liveSession); } catch { /* best-effort */ }
384
- }
385
-
386
- // No teardown -- the agent and session are still live and that's
387
- // intentional. Move straight to file re-home.
388
- const coordinator = persistence.coordinator;
389
- const serialize = typeof coordinator?.serialize === "function"
390
- ? (operation) => coordinator.serialize(sessionId, operation)
391
- : (operation) => operation();
392
-
393
- const result = await serialize(async () => {
394
- const raw = await readSessionArtifact(sessionId, undefined, { storedHeader });
395
- if (raw === void 0) throw new Error("读取会话工件失败:未找到会话文件");
396
- const newlineAt = raw.content.indexOf("\n");
397
- if (newlineAt === -1) throw new Error("会话工件缺少头行,数据可能损坏");
398
- const rest = raw.content.slice(newlineAt + 1);
399
- let oldHeader;
400
- try { oldHeader = JSON.parse(raw.content.slice(0, newlineAt)); }
401
- catch (error) { throw new Error(`会话工件头行无法解析: ${String(error)}`); }
402
- if (oldHeader.id !== sessionId) throw new Error("会话工件头行 id 与请求不符,拒绝移动");
403
-
404
- // Keep the stored generation in sync with the filename selected by
405
- // persistence.locate(). Rewriting a v2 header to v0 while locating it
406
- // through the same v2 backend creates a v2 filename with v0 content,
407
- // which makes DSH reject the session on the next startup.
408
- const newHeader = { ...oldHeader, cwd: targetPath };
409
- const oldPath = raw.path ?? persistence.locate(raw.meta)?.path;
410
- const newLocation = persistence.locate(newHeader);
411
- if (typeof oldPath !== "string" || newLocation === void 0) {
412
- throw new Error("持久化后端无法定位会话工件路径");
413
- }
414
- const newPath = newLocation.path;
415
- // Keep the stored generation in sync with the filename selected by
416
- // persistence.locate(). persistence.locate() always returns the
417
- // SESSION_FORMAT_VERSION path (DSH 0.1.7+ ships v4 by default),
418
- // regardless of oldHeader.version. Without the stamp below, the
419
- // rewritten file is `session.v4.jsonl.zstd` carrying a v0/v3 header,
420
- // which JsonlSessionPersistence.listArtifacts rejects on the next
421
- // startup (`session generation filename identifies v4, but its
422
- // header identifies v0`), breaking persistence.list() and crashing
423
- // the workspace registry while it boots. (issue #18)
424
- const expectedVersion = parseSessionGenerationFromFilename(basename(newPath));
425
- if (typeof expectedVersion === "number") newHeader.version = expectedVersion;
426
- const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
427
- await assertSessionDirectory(root, dirname(oldPath), sessionId);
428
- await assertSessionDirectory(root, dirname(newPath), sessionId, { allowMissing: true });
429
- try {
430
- await lstat(newPath);
431
- throw new Error("目标位置已有会话工件,拒绝覆盖");
432
- } catch (error) { if (error.code !== "ENOENT") throw error; }
433
- if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
434
- const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
435
- await mkdir(dirname(newPath), { recursive: true });
436
- const tempNew = await writeTempFile(newPath, bytes);
437
- const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
438
- try {
439
- await rename(oldPath, oldHidden);
440
- } catch (error) {
441
- await rm(tempNew, { force: true });
442
- throw new Error(`移动失败(无法隐藏旧会话工件,已取消,会话保持原状): ${String(error)}`);
443
- }
444
- try {
445
- await rename(tempNew, newPath);
446
- } catch (error) {
447
- try { await rename(oldHidden, oldPath); }
448
- catch (rollbackError) {
449
- throw new AggregateError([error, rollbackError], "移动发布失败且回滚失败;原始会话保留在 " + oldHidden + ",新文件保留在 " + tempNew);
450
- }
451
- try { await rm(tempNew, { force: true }); } catch { /* original restored */ }
452
- throw new Error("移动发布失败,原始会话已恢复: " + safeErrorMessage(error), { cause: error });
453
- }
454
-
455
- const state = coordinator?.states?.get?.(sessionId);
456
- if (state?.owner !== void 0 && liveSession !== void 0 && state.owner !== liveSession) {
457
- await rm(newPath, { force: true });
458
- await rename(oldHidden, oldPath);
459
- throw new Error(`会话 ${sessionId} 的持久化 owner 与当前 live session 不一致,已回滚`);
460
- }
461
- const oldStateMeta = state?.meta;
462
- const oldLiveHeader = liveSession?.header;
463
- const liveWriter = liveSession === void 0 ? void 0 : liveWriterOf(persistence, sessionId);
464
- const oldWriterHeader = liveWriter?.header;
465
- const oldIndexedHeader = registry.headers?.get?.(sessionId);
466
- const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
467
- const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
468
- const fromWorkspaceIds = registry.list()
469
- .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
470
- .map((entity) => entity.id);
471
- let targetAttached = false;
472
- const detachedFrom = [];
473
-
474
- try {
475
- if (state !== void 0) {
476
- state.meta = { ...state.meta, ...newHeader, cwd: targetPath };
477
- state.materialized = true;
478
- }
479
- coordinator?.preparations?.invalidate?.(sessionId);
480
- if (liveSession !== void 0) {
481
- liveSession.header = Object.freeze({ ...newHeader });
482
- rebindLiveWriter(persistence, sessionId, newHeader);
483
- }
484
- registry.headers?.set?.(sessionId, { ...newHeader });
485
- registry.sessionPaths?.set?.(sessionId, targetPath);
486
- registry.invalidSessionPaths?.delete?.(sessionId);
487
-
488
- await registry.enqueueOperation(async () => {
489
- for (const id of fromWorkspaceIds) {
490
- const entity = registry.list().find((candidate) => candidate.id === id);
491
- if (entity === void 0) continue;
492
- await entity.detachSession(sessionId);
493
- detachedFrom.push(id);
494
- }
495
- await target.attachSession(sessionId);
496
- targetAttached = true;
497
- });
498
- } catch (error) {
499
- if (state !== void 0 && oldStateMeta !== void 0) state.meta = oldStateMeta;
500
- coordinator?.preparations?.invalidate?.(sessionId);
501
- if (liveSession !== void 0 && oldLiveHeader !== void 0) liveSession.header = oldLiveHeader;
502
- if (liveWriter !== void 0 && oldWriterHeader !== void 0) liveWriter.header = oldWriterHeader;
503
- if (oldIndexedHeader === void 0) registry.headers?.delete?.(sessionId);
504
- else registry.headers?.set?.(sessionId, oldIndexedHeader);
505
- if (oldIndexedPath === void 0) registry.sessionPaths?.delete?.(sessionId);
506
- else registry.sessionPaths?.set?.(sessionId, oldIndexedPath);
507
- if (oldInvalidPath === void 0) registry.invalidSessionPaths?.delete?.(sessionId);
508
- else registry.invalidSessionPaths?.set?.(sessionId, oldInvalidPath);
509
-
510
- const rollbackErrors = [];
511
- try { if (targetAttached) await target.detachSession(sessionId); } catch (e) { rollbackErrors.push(e); }
512
- for (const id of detachedFrom) {
513
- try {
514
- const entity = registry.list().find((candidate) => candidate.id === id);
515
- if (entity !== void 0) await entity.attachSession(sessionId);
516
- } catch (e) { rollbackErrors.push(e); }
517
- }
518
- try { await rm(newPath, { force: true }); } catch (e) { rollbackErrors.push(e); }
519
- try { await rename(oldHidden, oldPath); } catch (e) { rollbackErrors.push(e); }
520
- if (rollbackErrors.length > 0) {
521
- throw new AggregateError([error, ...rollbackErrors], "移动失败且回滚不完整");
522
- }
523
- throw error;
524
- }
525
-
526
- // rebindLiveWriter() above retargeted the existing writer before
527
- // cleanup, so subsequent routed events use the target cwd without
528
- // replacing the handle retained by the live agent.
529
- try { await rm(oldHidden, { force: true }); } catch { /* best-effort */ }
530
- try {
531
- await assertSessionDirectory(root, dirname(oldPath), sessionId);
532
- await rm(dirname(oldPath), { recursive: true, force: true });
533
- } catch (error) { ctx.logger.warn(`session-manager: old directory retained: ${String(error)}`); }
534
-
535
- return {
536
- ok: true,
537
- sessionId,
538
- moved: true,
539
- fromWorkspaceIds,
540
- toWorkspaceId: target.id,
541
- toWorkspaceTitle: target.title || target.id,
542
- artifactFrom: oldPath,
543
- artifactTo: newPath,
544
- wasLive: liveSession !== void 0 || liveAgent !== void 0
545
- };
546
- });
547
-
548
- const cache = ctx.get("sessionProjectionCache");
549
- if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
550
- try { await cache.coldSnapshot(sessionId); }
551
- catch (error) { ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`); }
552
- }
553
- // The agent is still live (no teardown). The session's in-memory
554
- // header was rewritten above and the workspace registry updated;
555
- // future writes go through persistence.locate() against the new
556
- // cwd, so the file at the new location accumulates new events.
557
- // Re-resuming the agent here re-creates a half-initialized
558
- // Session/agent pair and leaves the client UI in a "session
559
- // unavailable" state (agent/status is only emitted on PHASE
560
- // CHANGES, never on agent construction). Leaving the live
561
- // agent/session in place avoids the broken UI entirely.
562
- ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}" (in-place file rename, live agent retained)`);
563
- return result;
564
- };
565
- /* === preset-migration (session-manager v0.3.0) === */
566
- const NL = String.fromCharCode(10);
567
-
568
- const scanSelectedEvents = (content) => {
569
- const out = [];
570
- const newlineAt = content.indexOf(NL);
571
- const after = newlineAt === -1 ? content : content.slice(newlineAt + 1);
572
- for (const line of after.split(NL)) {
573
- if (line.length === 0) continue;
574
- let obj;
575
- try { obj = JSON.parse(line); } catch { continue; }
576
- if (obj !== null && typeof obj === "object" && obj.type === "agent-preset/selected" && obj.data !== void 0 && typeof obj.data.agentPreset === "string") {
577
- out.push({ agentPreset: obj.data.agentPreset, time: obj.time, seq: obj.seq });
578
- }
579
- }
580
- return out;
581
- };
582
-
583
- const scanPreset = async (filter) => {
584
- // Pull the full preset roster so the UI shows every available preset,
585
- // not just the ones currently used by some session.
586
- let allPresetIds = [];
587
- try {
588
- const ap = ctx.get("agentPresets");
589
- if (ap !== void 0 && typeof ap.list === "function") {
590
- const list = await ap.list();
591
- for (const p of (list || [])) {
592
- if (p && typeof p.id === "string" && p.id.length > 0) allPresetIds.push(p.id);
593
- }
594
- }
595
- } catch { /* ignore -- fall back to used presets only */ }
596
-
597
- const persistence = adapter.persistence;
598
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
599
- // Enumeration normalizes backend snapshots and retains the legacy
600
- // raw-id disk fallback. Incomplete reads never authorize reconciliation.
601
- let headers = [];
602
- try {
603
- headers = await listSessionHeaders(persistence, adapter.homePath);
604
- } catch (probeError) {
605
- throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
606
- }
607
- let allowedIds = null;
608
- const workspaceId = filter !== void 0 && typeof filter === "object" ? filter.id : void 0;
609
- if (typeof workspaceId === "string" && workspaceId !== "") {
610
- const registry = ctx.get("workspaceRegistry");
611
- if (registry !== void 0 && typeof registry.list === "function") {
612
- const all = registry.list();
613
- const ws = all.find((entity) => entity.id === workspaceId);
614
- if (ws === void 0) {
615
- const error = new Error("workspace not found: " + workspaceId);
616
- error.code = "workspace-not-found";
617
- throw error;
618
- }
619
- allowedIds = new Set();
620
- const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
621
- if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
622
- }
623
- }
624
- const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
625
- ? filter.sessionId
626
- : "";
627
- if (requestedSessionId !== "") assertSessionId(requestedSessionId);
628
- const out = [];
629
- for (const header of headers) {
630
- if (requestedSessionId !== "" && header.id !== requestedSessionId) continue;
631
- if (allowedIds !== null && !allowedIds.has(header.id)) continue;
632
- if (header.origin === "subagent") continue;
633
- const raw = await readSessionArtifact(header.id, undefined, { storedHeader: header });
634
- if (raw === void 0) continue;
635
- const newlineAt = raw.content.indexOf(NL);
636
- if (newlineAt === -1) continue;
637
- let headerObj;
638
- try { headerObj = JSON.parse(raw.content.slice(0, newlineAt)); } catch { continue; }
639
- let selected = scanSelectedEvents(raw.content);
640
- const live = ctx.sessions.get(header.id);
641
- if (live !== void 0) {
642
- const liveEvents = Array.isArray(live.events)
643
- ? live.events
644
- : Array.isArray(live.log) ? live.log : [];
645
- const liveSelected = liveEvents
646
- .filter((event) => event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string")
647
- .map((event) => ({ agentPreset: event.data.agentPreset, time: event.time, seq: event.seq }));
648
- if (liveSelected.length > selected.length) selected = liveSelected;
649
- }
650
- const finalPreset = selected.length > 0 ? selected[selected.length - 1].agentPreset : headerObj.agentPreset;
651
- out.push({ sessionId: header.id, cwd: header.cwd, origin: header.origin, headerPreset: headerObj.agentPreset, selectedEvents: selected, finalPreset });
652
- }
653
- // Targets come only from the live roster. A deleted preset can still be
654
- // displayed as this session's current value, but must not be offered as
655
- // a destination.
656
- return { rows: out, availablePresets: Array.from(new Set(allPresetIds)) };
657
- };
658
-
659
- /**
660
- * Rewrite the effective preset of one conversation in place on disk.
661
- * The 0.4.6+ flow is no-teardown: when a live session is in store we
662
- * append the new agent-preset/selected event via Session.append() and
663
- * flush through SessionStore.flush(); for cold sessions we rewrite
664
- * the last matching event directly on the existing artifact. In both
665
- * cases the live agent/session is left intact, the api-gateway chat
666
- * panel stays available, and the projection picks up the new value
667
- * on the next event fold.
668
- */
669
- /** Cold-path preset migration: read disk, rewrite, publish. Works whether
670
- * or not the session is currently in the coordinator, and does not depend on
671
- * `coordinator.states` carrying a fresh `meta.cwd`. */
672
- const applyMigrationToArtifact = async (args) => {
673
- const sessionId = typeof args.sessionId === "string" ? args.sessionId : "";
674
- const toPreset = typeof args.toPreset === "string" ? args.toPreset : "";
675
- const liveSession = (args !== void 0 && typeof args === "object") ? args.liveSession : void 0;
676
- const persistence = adapter.persistence;
677
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
678
- const coordinator = persistence.coordinator;
679
- const serialize = typeof coordinator?.serialize === "function"
680
- ? (operation) => coordinator.serialize(sessionId, operation)
681
- : (operation) => operation();
682
- return await serialize(async () => {
683
- const raw = await readSessionArtifact(sessionId);
684
- if (raw === void 0) throw new Error(`session "${sessionId}" has no artifact`);
685
- const lines = raw.content.split(NL);
686
- if (lines.length < 2) throw new Error("session artifact has no header line");
687
- let header;
688
- try { header = JSON.parse(lines[0]); } catch { throw new Error("session header parse failed"); }
689
- if (header.id !== sessionId) throw new Error("session header id mismatch");
690
-
691
- let selectedIndex = -1;
692
- let selectedPreset;
693
- for (let index = 1; index < lines.length; index += 1) {
694
- if (lines[index] === "") continue;
695
- let event;
696
- try { event = JSON.parse(lines[index]); } catch { continue; }
697
- if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
698
- selectedIndex = index;
699
- selectedPreset = event.data.agentPreset;
700
- }
701
- }
702
- const oldPreset = selectedIndex >= 0 ? selectedPreset : header.agentPreset;
703
- if (oldPreset === toPreset) {
704
- return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
705
- }
706
-
707
- if (selectedIndex >= 0) {
708
- const event = JSON.parse(lines[selectedIndex]);
709
- event.data = { ...event.data, agentPreset: toPreset };
710
- lines[selectedIndex] = JSON.stringify(event);
711
- } else {
712
- header.agentPreset = toPreset;
713
- lines[0] = JSON.stringify(header);
714
- }
715
-
716
- const artifactPath = raw.path ?? persistence.locate?.(raw.meta)?.path;
717
- if (typeof artifactPath !== "string") throw new Error("cannot locate session artifact");
718
- const replacementContent = lines.join(NL);
719
- const firstNewline = replacementContent.indexOf(NL);
720
- const bytes = await encodeArtifact(
721
- replacementContent.slice(0, firstNewline),
722
- replacementContent.slice(firstNewline + 1),
723
- artifactPath.endsWith(".zstd")
724
- );
725
- const home = adapter.homePath;
726
- await assertSessionDirectory(typeof home === "function" ? home("sessions") : undefined, dirname(artifactPath), sessionId);
727
- const published = await replaceSessionFile(artifactPath, bytes);
728
- if (published.backupPath) ctx.logger.warn("session-manager: retained backup " + published.backupPath);
729
-
730
- const state = coordinator?.states?.get?.(sessionId);
731
- if (state !== void 0 && selectedIndex < 0) state.meta = { ...state.meta, agentPreset: toPreset };
732
- coordinator?.preparations?.invalidate?.(sessionId);
733
- // Mirror the disk rewrite into the in-memory live session: the cold path
734
- // bypasses the persistence write controller (which is intentionally
735
- // skipped when its state is stale), so the session header, projection,
736
- // and downstream agent composition would otherwise keep reporting the old
737
- // preset. Update both the header and the in-memory event log so DSH's
738
- // `resolveSessionPreset` and any projection subscribers observe the new
739
- // preset from the same source.
740
- if (liveSession !== void 0) {
741
- try {
742
- const nextHeader = { ...(liveSession.header ?? {}), agentPreset: toPreset };
743
- try { liveSession.header = Object.freeze(nextHeader); }
744
- catch { liveSession.header = nextHeader; }
745
- } catch { /* live session may have been retired mid-flight */ }
746
- // Append the new selection to the live log so the session/store
747
- // observers (agent-presets, projections, ctx) fire their normal
748
- // `session/event` + `agent-preset/selected` pipeline. We append
749
- // unconditionally (not only when the disk had no prior selection)
750
- // because the in-memory projection reads the latest appended event
751
- // and the cold path deliberately skipped the persistence write
752
- // controller; the appended event will be persisted on the next
753
- // normal flush, keeping disk and memory consistent.
754
- if (typeof liveSession.append === "function") {
755
- try { liveSession.append("agent-preset/selected", { agentPreset: toPreset }); } catch { /* best-effort */ }
756
- }
757
- }
758
- return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
759
- });
760
- };
761
- /** Rewrite exactly one conversation's effective Agent preset. */
762
- const migratePreset = async (opts) => {
763
- const sessionId = typeof opts.sessionId === "string" ? opts.sessionId.trim() : "";
764
- const toPreset = typeof opts.toPreset === "string" ? opts.toPreset.trim() : "";
765
- if (sessionId === "") { const e = new Error("sessionId required"); e.code = "bad-request"; throw e; }
766
- if (toPreset === "") { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
767
-
768
- const presets = ctx.get("agentPresets");
769
- if (presets === void 0 || typeof presets.list !== "function") {
770
- throw new Error("agentPresets service unavailable");
771
- }
772
- const roster = await presets.list();
773
- const target = (roster || []).find((preset) => preset && preset.id === toPreset);
774
- if (target === void 0) {
775
- const available = (roster || []).map((preset) => preset?.id).filter(Boolean);
776
- throw new Error(`Agent 预设 "${toPreset}" 不存在(可用:${available.join(", ")})`);
777
- }
778
- if (target.broken !== void 0) throw new Error(`Agent 预设 "${toPreset}" 不可用:${target.broken}`);
779
-
780
- const liveSession = ctx.sessions.get(sessionId);
781
- let liveAgent;
782
- try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
783
- // Resolve persistence up front so the live path can read the coordinator
784
- // without tripping JavaScript's temporal dead zone on `const persistence`.
785
- const persistence = adapter.persistence;
786
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
787
-
788
- if (liveSession !== void 0) {
789
- const coordinator = persistence.coordinator;
790
- const states = coordinator?.states;
791
- const beforeState = states?.get?.(sessionId);
792
- const liveCwd = liveSession.header?.cwd;
793
- // A just-completed moveSession mutates `liveSession.header.cwd` in place,
794
- // but the persistence coordinator's tracked state may not see the new cwd
795
- // until its serialize chain drains. If we hand the migration to the
796
- // coordinator now, its `state.meta.cwd` may still be the pre-move cwd and
797
- // appendBatch will open the old (now deleted) artifact path, producing the
798
- // "ENOENT ... session.v2.jsonl.zstd" regression. Falling back to the cold
799
- // path is always correct: the disk copy already reflects the new cwd.
800
- const stateStale = beforeState === void 0
801
- || beforeState.meta?.cwd === void 0
802
- || (liveCwd !== void 0 && beforeState.meta.cwd !== liveCwd);
803
- if (stateStale) {
804
- const raw = await readSessionArtifact(sessionId);
805
- if (raw !== void 0) {
806
- const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
807
- if (result.migrated) { try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ } }
808
- ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" via cold path (state was stale, live cwd=${String(liveCwd)}, state cwd=${String(beforeState?.meta?.cwd)})`);
809
- return { sessionId, migrated: true, oldPreset: result.oldPreset, newPreset: toPreset };
810
- }
811
- // No artifact yet; fall through to the in-memory + flush path below,
812
- // which materializes the empty file using the live cwd.
813
- }
814
- let oldPreset = liveSession.header?.agentPreset;
815
- // SessionStore implementations from older DSH builds may expose the
816
- // history as `log` rather than the newer `events` snapshot getter.
817
- // Do not dereference `.length` on an absent compatibility surface.
818
- const liveEvents = Array.isArray(liveSession.events)
819
- ? liveSession.events
820
- : Array.isArray(liveSession.log) ? liveSession.log : [];
821
- for (let index = liveEvents.length - 1; index >= 0; index -= 1) {
822
- const event = liveEvents[index];
823
- if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
824
- oldPreset = event.data.agentPreset;
825
- break;
826
- }
827
- }
828
- if (oldPreset === toPreset) {
829
- return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
830
- }
831
- if (liveAgent !== void 0 && typeof presets?.recompose === "function" && liveAgent.ctx) {
832
- try { await presets.recompose(liveAgent.ctx, toPreset); } catch { /* best-effort */ }
833
- }
834
- liveSession.append("agent-preset/selected", { agentPreset: toPreset });
835
- await ctx.sessions.flush(liveSession);
836
- ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" from "${String(oldPreset)}" to "${toPreset}"`);
837
- return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
838
- }
839
-
840
- // A session absent from SessionStore is cold. If an older plugin left its
841
- // exact Session object in the coordinator, retire that proven orphan by
842
- // the coordinator's own drain path instead of deleting owner bookkeeping.
843
- const coordinator = persistence.coordinator;
844
- let tracked = coordinator?.states?.get?.(sessionId);
845
- if (tracked?.owner !== void 0 && ctx.sessions.get(sessionId) === void 0) {
846
- coordinator.retire?.(tracked.owner);
847
- const retirement = coordinator.retirements?.get?.(sessionId);
848
- if (retirement !== void 0) await retirement;
849
- tracked = coordinator.states?.get?.(sessionId);
850
- if (tracked?.owner !== void 0) {
851
- throw new Error(`会话 ${sessionId} 仍有未释放的持久化 owner,已停止迁移以保护数据`);
852
- }
853
- }
854
-
855
- const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
856
-
857
- if (result.migrated) {
858
- try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ }
859
- ctx.logger.info(`session-manager: migrated cold preset for "${sessionId}" from "${String(result.oldPreset)}" to "${toPreset}"`);
860
- }
861
- return result;
862
- };
863
-
864
- /** Read one session artifact verbatim, falling back to direct disk access when the
865
- * configured persistence backend cannot see the id (e.g. an older DSH build
866
- * wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
867
- */
868
- const readSessionArtifact = async (sessionId, signal, { headerOnly = false, storedHeader } = {}) => {
869
- assertSessionId(sessionId);
870
- const persistence = adapter.persistence;
871
- const dshHomePath = adapter.homePath;
872
- const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
873
- const read = async (path) => headerOnly
874
- ? { meta: await readSessionHeader(path), path }
875
- : readSessionFile(path);
876
- // Use cheap index metadata as a location hint, never a decoded readRaw
877
- // payload. Move/preset scans can reuse headers they already enumerated.
878
- let header = storedHeader;
879
- if (header === undefined && typeof persistence?.list === "function") {
880
- try {
881
- header = (await persistence.list()).map(normalizeSessionHeader).find(item => item?.id === sessionId);
882
- } catch (error) { if (signal?.aborted) throw error; }
883
- }
884
- if (header !== undefined && typeof persistence?.locate === "function") {
885
- const path = persistence.locate(header)?.path;
886
- if (typeof path === "string") {
887
- try {
888
- await assertSessionDirectory(root, dirname(path), sessionId);
889
- const artifact = await read(path);
890
- if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
891
- return artifact;
892
- } catch (error) { if (error.code !== "ENOENT") throw error; }
893
- }
894
- }
895
- if (typeof root !== "string") throw new Error("无法定位并验证会话工件目录");
896
- let projects;
897
- try { projects = await readdir(root, { withFileTypes: true }); }
898
- catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
899
- for (const proj of projects) {
900
- if (!proj.isDirectory() || proj.isSymbolicLink()) continue;
901
- for (const candidate of [encodeSessionSegment(sessionId), sessionId]) {
902
- const dir = join(root, proj.name, candidate);
903
- try { await assertSessionDirectory(root, dir, sessionId); }
904
- catch (error) { if (error.code === "ENOENT") continue; throw error; }
905
- for (const filename of ARTIFACT_NAMES) {
906
- try {
907
- const artifact = await read(join(dir, filename));
908
- if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
909
- return artifact;
910
- } catch (error) { if (error.code !== "ENOENT") throw error; }
911
- }
912
- }
913
- }
914
- return undefined;
915
- };
916
-
917
- const sessionDirOf = async (sessionId) => {
918
- // Require a matching artifact header instead of deleting an arbitrary
919
- // directory merely because its name matches user input.
920
- const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
921
- return artifact === undefined ? undefined : dirname(artifact.path);
922
- };
923
-
924
- /**
925
- * Delete one session end to end. Returns a summary of what was torn down.
926
- * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
927
- */
928
- const deleteSession = async (sessionId) => {
929
- assertSessionId(sessionId);
930
- // Resolve/validate before cancelling the agent or changing registry state.
931
- let dir = await sessionDirOf(sessionId);
932
- const session = ctx.sessions.get(sessionId);
933
- // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
934
- let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
935
-
936
- if (agent !== void 0) {
937
- // Stop any running turn (disposed-kind suppresses re-wake).
938
- agent.cancel({ kind: "disposed" });
939
- // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
940
- if (typeof agent.scope?.dispose === "function") {
941
- await Promise.race([agent.scope.dispose(), sleep(3000)]);
942
- }
943
- // Drop the zombie from the registry so a later session.create/open with
944
- // the same id cannot resurrect it.
945
- try {
946
- ctx.agents.store?.delete?.(sessionId);
947
- } catch { /* best-effort */ }
948
- }
949
-
950
- let detached = false;
951
- if (session !== void 0) {
952
- // Flush buffered events to disk first so the retirement drain is a no-op.
953
- try {
954
- await ctx.sessions.flush(session);
955
- } catch { /* best-effort */ }
956
- // Detach the session store entry: emits session/disposed, which the
957
- // persistence write-path answers with a final drain, and the API proxy
958
- // relays as host/session-removed so every connected client drops the row.
959
- try {
960
- const entry = ctx.sessions.store?.get?.(sessionId);
961
- if (entry !== void 0 && typeof entry.detach === "function") {
962
- entry.detach();
963
- await sleep(200); // let the write-behind retirement settle
964
- detached = true;
965
- }
966
- } catch { /* best-effort */ }
967
- }
968
- // Some sessions have a stored artifact but no live store row (e.g.
969
- // already-scoped scope torn down, or never attached). Without a live
970
- // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
971
- // entry.detach() never fires — so the apiproxy never queues a
972
- // host/session-removed frame, every connected client keeps showing the
973
- // session row, and the row collapses into the ungrouped section the
974
- // moment host/workspace-changed drops its workspace membership. Emit
975
- // session/disposed explicitly so every connected client drops the row.
976
- if (!detached) {
977
- try {
978
- ctx.emit("session/disposed", { id: sessionId });
979
- } catch (_) { /* best-effort */ }
980
- }
981
-
982
- // A blank live session may have materialized its first artifact during
983
- // flush/retirement. Re-resolve after teardown rather than leaking that file.
984
- dir = await sessionDirOf(sessionId);
985
-
986
- // Workspace accounting + archive-set membership.
987
- await detachFromWorkspaces(sessionId);
988
- await unarchiveSession(sessionId);
989
-
990
- // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
991
- if (dir !== void 0) {
992
- await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
993
- await rm(dir, { recursive: true, force: true });
994
- }
995
-
996
- let warning;
997
- try { await annotations().remove(sessionId); }
998
- catch (error) {
999
- warning = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
1000
- ctx.logger.warn("session-manager: " + warning);
1001
- }
1002
- return {
1003
- ok: true,
1004
- ...(warning ? { warning } : {}),
1005
- sessionId,
1006
- wasLive: session !== void 0 || agent !== void 0,
1007
- filesRemoved: dir !== void 0
1008
- };
1009
- };
1010
-
1011
- /**
1012
- * On plugin boot, refold every persisted session projection cache
1013
- * row from disk. Sessions moved before coldSnapshot was wired into
1014
- * moveSession would otherwise keep their pre-move {createdAt, cwd}
1015
- * identity and let the cold list path fall back to basename(cwd),
1016
- * surfacing the workspace title (for example "DSH") where the
1017
- * session's own title was expected. The sweep is one-shot and
1018
- * idempotent: a row whose identity already matches is a cheap
1019
- * pass-through refold, and a stale one triggers the unrelated-identity
1020
- * full re-read path.
1021
- */
1022
- ctx.effect(async () => {
1023
- const cache = adapter.projectionCache;
1024
- const persistence = adapter.persistence;
1025
- const registry = adapter.workspaceRegistry;
1026
- if (typeof cache?.coldSnapshot !== "function" || typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1027
- try {
1028
- // Remember only ids that existed before the async scan. Concurrently
1029
- // attached ids must never be removed using this older disk snapshot.
1030
- const initialIds = new Map(registry.list().map(ws => [ws.id, new Set(ws.record?.sessionIds ?? [])]));
1031
- const scan = await scanSessionHeaders(persistence, adapter.homePath);
1032
- if (!scan.complete) {
1033
- ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
1034
- return;
1035
- }
1036
- const headers = scan.headers;
1037
- const validIds = new Set(headers.map(header => header.id));
1038
- for (const header of headers) {
1039
- try { await cache.coldSnapshot(header.id); }
1040
- catch (error) { ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error)); }
1041
- }
1042
- let removed = 0;
1043
- let added = 0;
1044
- // Read current state *inside* its queue, and publish an immutable copy.
1045
- // A failed setState must not mutate the registry's live state object.
1046
- await registry.enqueueOperation(async () => {
1047
- const state = registry.requireState();
1048
- const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
1049
- const assignedIds = new Set((state.workspaces ?? []).flatMap(ws => ws.sessionIds ?? []));
1050
- const workspaces = (state.workspaces ?? []).map(ws => {
1051
- const original = ws.sessionIds ?? [];
1052
- const sessionIds = original.filter(id => validIds.has(id)
1053
- || !initialIds.get(ws.id)?.has(id) || adapter.getSession(id) !== undefined);
1054
- const removedHere = original.length - sessionIds.length;
1055
- removed += removedHere;
1056
- for (const header of headers) {
1057
- if (header.cwd === undefined || header.cwd !== pathById.get(ws.id) || assignedIds.has(header.id)) continue;
1058
- sessionIds.unshift(header.id);
1059
- assignedIds.add(header.id);
1060
- added += 1;
1061
- }
1062
- if (sessionIds.length === original.length && sessionIds.every((id, i) => id === original[i])) return ws;
1063
- return { ...ws, sessionIds, updatedAt: new Date().toISOString() };
1064
- });
1065
- if (removed || added) await registry.setState({ ...state, workspaces });
1066
- });
1067
- if (removed || added) ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
1068
- } catch (error) {
1069
- ctx.logger.warn('session-manager: post-boot reconcile failed: ' + safeErrorMessage(error));
1070
- }
1071
- }, "session-manager: post-boot re-fold stale projection cache rows");
1072
-
1073
- /** Build a normalized annotation patch from one bulk action + payload.
1074
- * Returns null when the payload is missing or malformed so the caller can
1075
- * surface a 400 instead of letting the store throw on a bad value.
1076
- */
1077
- const annotationPatchFromBatchAction = (action, payload) => {
1078
- if (!payload || typeof payload !== "object") return null;
1079
- if (action === "favorite" || action === "unfavorite") return { favorite: action === "favorite" };
1080
- if (action === "review" || action === "unreview") return { reviewLater: action === "review" };
1081
- if (action === "set-priority") {
1082
- const p = Number(payload.priority);
1083
- if (!Number.isInteger(p) || p < 1 || p > 5) return null;
1084
- return { priority: p };
1085
- }
1086
- if (action === "add-tags" || action === "remove-tags") {
1087
- if (!Array.isArray(payload.tags)) return null;
1088
- return action === "add-tags" ? { tags: payload.tags } : { tags: [] };
1089
- }
1090
- return null;
1091
- };
1092
-
1093
- /** Run one batch action against a single session id. Annotation-affecting
1094
- * actions resolve the annotation accessor from the surrounding closure
1095
- * so it can never be silently undefined. Session-management actions reuse
1096
- * the existing helpers verbatim so move/preset-migrate validation and
1097
- * live-session handling stay identical to the per-row buttons.
1098
- */
1099
- const runBatchAction = async ({ action, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter }) => {
1100
- const annotationsAccessor = annotations;
1101
- if (action === "favorite" || action === "unfavorite" || action === "review" || action === "unreview" || action === "set-priority" || action === "add-tags" || action === "remove-tags") {
1102
- const patch = annotationPatchFromBatchAction(action, payload);
1103
- if (patch === null) {
1104
- const e = new Error("invalid batch payload for " + action); e.code = "bad-request"; throw e;
1105
- }
1106
- try {
1107
- await annotationsAccessor().batchUpdate([sessionId], patch);
1108
- } catch (error) {
1109
- throw error;
1110
- }
1111
- return;
1112
- }
1113
- if (action === "archive") { await mutate(sessionId, () => archiveSession(sessionId)); return; }
1114
- if (action === "unarchive") { await mutate(sessionId, () => unarchiveSession(sessionId)); return; }
1115
- if (action === "delete") { await mutate(sessionId, () => deleteSession(sessionId)); return; }
1116
- if (action === "move") {
1117
- const targetWorkspaceId = typeof payload?.targetWorkspaceId === "string" ? payload.targetWorkspaceId.trim() : "";
1118
- if (!targetWorkspaceId) { const e = new Error("targetWorkspaceId required"); e.code = "bad-request"; throw e; }
1119
- await mutate(sessionId, () => moveSession(sessionId, targetWorkspaceId));
1120
- return;
1121
- }
1122
- if (action === "preset-migrate") {
1123
- const toPreset = typeof payload?.toPreset === "string" ? payload.toPreset.trim() : "";
1124
- if (!toPreset) { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
1125
- await mutate(sessionId, () => migratePreset({ sessionId, toPreset }));
1126
- return;
1127
- }
1128
- const snapshot = adapter.workspaceRegistry?.headers?.get?.(sessionId)
1129
- ?? adapter.sessions?.list?.()?.find(s => s?.id === sessionId)?.header
1130
- ?? null;
1131
- const isSubagent = snapshot?.origin === "subagent";
1132
- const isBlank = snapshot?.blank === true;
1133
- if (isSubagent) { const e = new Error("subagent"); e.code = "subagent-skip"; throw e; }
1134
- if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
1135
- };
1136
-
1137
- ctx.effect(() => ctx.webServer.register({ kind: "prefix",
1138
- path: API_PREFIX,
1139
- handler: async (req, res) => {
1140
- try {
1141
- const url = new URL(req.url ?? "/", "http://localhost");
1142
- const path = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) || "/" : "/";
1143
- let body = {};
1144
- if (req.method === "POST") {
1145
- const raw = await readBody(req);
1146
- if (raw.trim() !== "") {
1147
- try { body = JSON.parse(raw); }
1148
- catch { return send(res, 400, { ok: false, error: "请求体不是合法 JSON" }); }
1149
- }
1150
- }
1151
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
1152
- return send(res, 400, { ok: false, error: "请求体必须是 JSON 对象" });
1153
- }
1154
- const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
1155
- if (req.method === "GET" && path === "/annotations") {
1156
- return send(res, 200, { ok: true, result: { annotations: await annotations().list() } });
1157
- }
1158
- if (req.method === "POST" && path === "/annotations") {
1159
- assertSessionId(sessionId);
1160
- const annotation = await mutate(sessionId, async () => {
1161
- assertAnnotatable(sessionId);
1162
- return annotations().update(sessionId, body.patch, body.expectedRevision);
1163
- });
1164
- return send(res, 200, { ok: true, result: { sessionId, annotation } });
1165
- }
1166
- if (req.method === "POST" && ["/delete", "/unarchive", "/move", "/preset-migrate"].includes(path)) {
1167
- assertSessionId(sessionId);
1168
- let result;
1169
- if (path === "/delete") result = await mutate(sessionId, () => deleteSession(sessionId));
1170
- else if (path === "/unarchive") {
1171
- await mutate(sessionId, () => unarchiveSession(sessionId));
1172
- result = { sessionId };
1173
- } else if (path === "/move") {
1174
- const targetWorkspaceId = typeof body.targetWorkspaceId === "string" ? body.targetWorkspaceId.trim() : "";
1175
- if (!targetWorkspaceId) return send(res, 400, { ok: false, error: "targetWorkspaceId 必填" });
1176
- result = await mutate(sessionId, () => moveSession(sessionId, targetWorkspaceId));
1177
- } else {
1178
- const toPreset = typeof body.toPreset === "string" ? body.toPreset.trim() : "";
1179
- if (!toPreset) return send(res, 400, { ok: false, error: "toPreset 必填" });
1180
- result = await mutate(sessionId, () => migratePreset({ sessionId, toPreset }));
1181
- }
1182
- return send(res, 200, { ok: true, result });
1183
- }
1184
- if (req.method === "POST" && path === "/batch") {
1185
- // Per-action body shape (each action validates its own payload):
1186
- // archive : no payload
1187
- // unarchive : no payload
1188
- // delete : no payload
1189
- // move : { targetWorkspaceId }
1190
- // preset-migrate: { toPreset }
1191
- // favorite : no payload (defaults to true)
1192
- // unfavorite : no payload
1193
- // review : no payload
1194
- // unreview : no payload
1195
- // set-priority : { priority: 1..5 }
1196
- // add-tags : { tags: string[] }
1197
- // remove-tags : { tags: string[] }
1198
- const rawAction = typeof body.action === "string" ? body.action : "";
1199
- const rawIds = Array.isArray(body.sessionIds) ? body.sessionIds : [];
1200
- const ids = Array.from(new Set(rawIds.filter(id => typeof id === "string" && id.trim() !== "").map(id => id.trim())));
1201
- for (const id of ids) assertSessionId(id);
1202
- if (ids.length === 0) return send(res, 400, { ok: false, error: "sessionIds 必须是非空数组", code: "bad-request" });
1203
- if (ids.length > 200) return send(res, 400, { ok: false, error: "单次批量最多 200 条,请缩小筛选范围后重试", code: "batch-too-large" });
1204
- const payload = body.payload && typeof body.payload === "object" ? body.payload : {};
1205
- const BATCH_ACTIONS = new Set(["archive", "unarchive", "delete", "move", "preset-migrate", "favorite", "unfavorite", "review", "unreview", "set-priority", "add-tags", "remove-tags"]);
1206
- if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
1207
- const items = [];
1208
- let success = 0, failed = 0, skipped = 0;
1209
- for (const sessionId of ids) {
1210
- try {
1211
- await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
1212
- items.push({ sessionId, status: "success" });
1213
- success++;
1214
- } catch (error) {
1215
- const code = error && typeof error.code === "string" ? error.code : undefined;
1216
- if (code === "subagent-skip" || code === "blank-skip") {
1217
- items.push({ sessionId, status: "skipped", reason: code });
1218
- skipped++;
1219
- } else {
1220
- items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
1221
- failed++;
1222
- }
1223
- }
1224
- }
1225
- return send(res, 200, { ok: true, result: {
1226
- summary: { total: ids.length, success, failed, skipped },
1227
- items,
1228
- aborted: false,
1229
- } });
1230
- }
1231
- if (req.method === "GET" && path === "/workspaces") {
1232
- const sessionCreatedAt = Object.fromEntries(adapter.cachedSessionHeaders()
1233
- .filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
1234
- .map(header => [header.id, header.createdAt]));
1235
- return send(res, 200, { ok: true, result: { workspaces: listWorkspaces(), sessionCreatedAt } });
1236
- }
1237
- if (req.method === "GET" && path === "/preset-scan") {
1238
- const sessionId = url.searchParams.get("sessionId") || "";
1239
- if (sessionId) assertSessionId(sessionId);
1240
- const result = await scanPreset({ id: url.searchParams.get("workspaceId") || "", sessionId });
1241
- return send(res, 200, { ok: true, result });
1242
- }
1243
- if (req.method === "GET" && path === "/update/check") {
1244
- const currentVersion = adapter.pluginVersion() || "0.6.3";
1245
- let latestVersion = currentVersion;
1246
- let hasUpdate = false;
1247
- try {
1248
- const requestedRegistry = url.searchParams.get("registry")?.trim();
1249
- const registry = (requestedRegistry && /^https?:\/\//i.test(requestedRegistry))
1250
- ? requestedRegistry
1251
- : (process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org");
1252
- const base = registry.endsWith("/") ? registry : registry + "/";
1253
- const pkgUrl = new URL("dsh-session-manager/latest", base).toString();
1254
- const controller = new AbortController();
1255
- const timer = setTimeout(() => controller.abort(), 6000);
1256
- try {
1257
- const response = await fetch(pkgUrl, {
1258
- signal: controller.signal,
1259
- headers: { "accept": "application/json" }
1260
- });
1261
- if (response.ok) {
1262
- const data = await response.json();
1263
- if (data && typeof data.version === "string") {
1264
- latestVersion = data.version;
1265
- }
1266
- }
1267
- } finally {
1268
- clearTimeout(timer);
1269
- }
1270
- hasUpdate = compareSemver(latestVersion, currentVersion) > 0;
1271
- return send(res, 200, {
1272
- ok: true,
1273
- result: { currentVersion, latestVersion, hasUpdate }
1274
- });
1275
- } catch (err) {
1276
- return send(res, 200, {
1277
- ok: false,
1278
- code: "update-check-failed",
1279
- error: "检查更新失败: " + safeErrorMessage(err),
1280
- result: { currentVersion, latestVersion: currentVersion, hasUpdate: false }
1281
- });
1282
- }
1283
- }
1284
- if (req.method === "POST" && path === "/update/install") {
1285
- const rawVersion = typeof body?.version === "string" ? body.version.trim() : "";
1286
- if (rawVersion && !/^[a-zA-Z0-9_.-]+$/.test(rawVersion)) {
1287
- return send(res, 400, { ok: false, code: "bad-request", error: "Invalid version format" });
1288
- }
1289
- const targetVersion = rawVersion || "latest";
1290
- const spec = "dsh-session-manager@" + targetVersion;
1291
- const registry = typeof body?.registry === "string" && /^https?:\/\//i.test(body.registry.trim()) ? body.registry.trim() : "";
1292
- const pm = adapter.pluginManager;
1293
- if (pm && typeof pm.installBundle === "function") {
1294
- try {
1295
- const prevRegistry = process.env.NPM_CONFIG_REGISTRY;
1296
- if (registry) {
1297
- process.env.NPM_CONFIG_REGISTRY = registry;
1298
- }
1299
- let outcome;
1300
- try {
1301
- outcome = await (registry ? pm.installBundle(spec, { registry }) : pm.installBundle(spec));
1302
- } finally {
1303
- if (registry) {
1304
- if (prevRegistry !== undefined) {
1305
- process.env.NPM_CONFIG_REGISTRY = prevRegistry;
1306
- } else {
1307
- delete process.env.NPM_CONFIG_REGISTRY;
1308
- }
1309
- }
1310
- }
1311
- const application = outcome?.application || "restart-required";
1312
- return send(res, 200, { ok: true, result: { application, spec } });
1313
- } catch (err) {
1314
- return send(res, 500, { ok: false, code: "install-failed", error: safeErrorMessage(err) });
1315
- }
1316
- }
1317
- return send(res, 501, {
1318
- ok: false,
1319
- code: "plugin-manager-unavailable",
1320
- error: "当前宿主环境未提供 PluginManager 安装服务,请通过 DSH 插件市场或命令行更新"
1321
- });
1322
- }
1323
- return send(res, 404, { ok: false, error: "not found: " + req.method + " " + path });
1324
- } catch (error) {
1325
- ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
1326
- const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
1327
- : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
1328
- : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
1329
- return send(res, status, { ok: false, error: safeErrorMessage(error), ...(typeof error?.code === "string" ? { code: error.code } : {}) });
1330
- }
1331
- }
1332
- }), "session-manager: http api");
1333
- }
1334
-
1335
- export { listSessionHeaders, scanSessionHeaders, encodeSessionSegment, compareSemver };
1
+ /**
2
+ * @dsh-session-manager — Host half.
3
+ *
4
+ * Cordis plugin that fills three gaps in DSH:
5
+ * - delete / unarchive (DSH ships `workspace.archiveSession` but not its inverse);
6
+ * - cross-workspace move (DSH indexes workspace membership off the session
7
+ * header's immutable `cwd`, so a real move must re-home the artifact);
8
+ * - per-conversation Agent-preset migration (rewrite the effective preset
9
+ * in place so the next event fold sees the new value).
10
+ *
11
+ * Live preset updates use the DSH session store. Cold rewrites and moves
12
+ * validate the durable JSONL/Zstd artifact and preserve its stored format.
13
+ * File mutations are restricted to verified session directories.
14
+ *
15
+ * The move and migrate paths do NOT tear down the live agent or session.
16
+ * The in-memory session stays alive, the file is rewritten in place under
17
+ * a new cwd (move) or a new event is appended to the existing log
18
+ * (migrate), and the workspace registry is updated. The api-gateway's
19
+ * chat panel therefore remains "available" throughout; the user does not
20
+ * have to refresh.
21
+ *
22
+ * The compatibility adapter normalizes service access and list snapshots.
23
+ * Optional live-writer/coordinator internals are guarded before mutation.
24
+ */
25
+ import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
26
+ import { basename, dirname, join } from "node:path";
27
+ import { randomBytes } from "node:crypto";
28
+ import { createAnnotationStore } from "./annotation-store.js";
29
+ import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
30
+ import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
31
+
32
+ export const name = "dsh-session-manager";
33
+
34
+ export const inject = [
35
+ "webServer",
36
+ "workspaceRegistry",
37
+ "sessions",
38
+ "agents",
39
+ "sessionPersistence",
40
+ "agentPresets"
41
+ ];
42
+
43
+ /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
44
+ * logger never throws while reporting a throw. */
45
+ function safeJson(value) { try { return JSON.stringify(value); } catch { return "[unserializable]"; } }
46
+ /** Like safeJson but used as the user-facing error string in HTTP responses. */
47
+ function safeErrorMessage(error) {
48
+ if (error === null || error === undefined) return `<nullish:${typeof error}>`;
49
+ if (typeof error === "string") return error;
50
+ if (typeof error === "object") {
51
+ if (typeof error.message === "string" && error.message.length > 0) return error.message;
52
+ if (typeof error.code === "string" && error.code.length > 0) return `[code=${error.code}]`;
53
+ }
54
+ try { return JSON.stringify(error); } catch { return String(error); }
55
+ }
56
+
57
+ const API_PREFIX = "/session-manager/api";
58
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
59
+ function liveWriterOf(persistence, sessionId) {
60
+ const writers = persistence?.tracker?.writers;
61
+ return typeof writers?.get === "function" ? writers.get(sessionId) : undefined;
62
+ }
63
+
64
+ /**
65
+ * Retarget the existing live JSONL writer after its artifact is relocated.
66
+ * The agent loop retains the original SessionHandle until the agent is
67
+ * disposed; replacing the tracker entry with a newly opened handle leaves
68
+ * that retained handle stale and can make later teardown delete the new
69
+ * tracker entry. The compiled DSH handle exposes a writable runtime
70
+ * header property even though the TypeScript declaration is readonly.
71
+ * Mutating that property keeps the same handle, queue, cursor, and lease.
72
+ */
73
+ function rebindLiveWriter(persistence, sessionId, newHeader) {
74
+ const writer = liveWriterOf(persistence, sessionId);
75
+ if (writer === undefined || writer === null) {
76
+ throw new Error("当前 DSH 运行时未暴露可重绑定的 live persistence writer,无法安全迁移运行中的会话;请先关闭会话后重试");
77
+ }
78
+ if (typeof writer.header !== "object" || writer.header === null) {
79
+ throw new Error("当前持久化后端的 live writer 不支持安全重绑定;请先关闭会话后重试");
80
+ }
81
+ const reboundHeader = Object.freeze({ ...newHeader });
82
+ writer.header = reboundHeader;
83
+ return writer;
84
+ }
85
+
86
+
87
+ /** List every persisted session header visible to the configured persistence
88
+ * backend, supplemented by raw-id sessions written by older DSH builds.
89
+ *
90
+ * Performance (issue #6): the disk loop previously opened and zstd-decoded
91
+ * every candidate file in <dshHome>/sessions/<proj>/<dir>/ even when the
92
+ * persistence layer had already returned those ids. On a healthy index
93
+ * that wasted O(N * file_size) read+inflate per call -- the reporter
94
+ * measured +17s CPU / +254 MB read per DSH startup on an 80-session /
95
+ * 249 MB library. We now pre-build a `knownDirs` set from the persisted
96
+ * ids (both `[id]` and `[encodeSessionSegment(id)]`) and skip matching
97
+ * directories before any fopen. Orphan raw-id directories that the
98
+ * index does not know about still fall through to the file read so the
99
+ * disk fallback semantics are preserved -- the orphan is the whole
100
+ * reason this loop exists.
101
+ *
102
+ * @param {object|undefined} persistence DSH's sessionPersistence service
103
+ * @param {((segment: string) => string)|undefined} dshHome Resolver for
104
+ * DSH home sub-paths; only "sessions" is read.
105
+ */
106
+ const scanSessionHeaders = async (persistence, dshHome) => {
107
+ const seen = new Set();
108
+ const headers = [];
109
+ const errors = [];
110
+ let listed = false;
111
+ if (typeof persistence?.list === "function") {
112
+ try {
113
+ const rows = await persistence.list();
114
+ if (!Array.isArray(rows)) throw new Error("会话列表返回了无效数据");
115
+ for (const row of rows) {
116
+ const header = normalizeSessionHeader(row);
117
+ if (!header) { errors.push(new Error("会话列表包含无效 header")); continue; }
118
+ try { assertSessionId(header.id); }
119
+ catch (error) { errors.push(error); continue; }
120
+ if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
121
+ }
122
+ listed = true;
123
+ } catch (error) { errors.push(error); }
124
+ }
125
+ const result = () => ({ headers, complete: listed && errors.length === 0, errors });
126
+ // Skip known files before fopen; retain the orphan/raw-id compatibility path.
127
+ const knownDirs = new Set();
128
+ for (const id of seen) {
129
+ knownDirs.add(id);
130
+ knownDirs.add(encodeSessionSegment(id));
131
+ }
132
+ if (typeof dshHome !== "function") return result();
133
+ const root = dshHome("sessions");
134
+ let projects;
135
+ try { projects = await readdir(root, { withFileTypes: true }); }
136
+ catch (error) { errors.push(error); return result(); }
137
+ listed = true;
138
+ for (const proj of projects) {
139
+ if (proj.isSymbolicLink()) { errors.push(new Error("跳过符号链接项目目录")); continue; }
140
+ if (!proj.isDirectory()) continue;
141
+ let dirs;
142
+ try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
143
+ catch (error) { errors.push(error); continue; }
144
+ for (const entry of dirs) {
145
+ if (entry.isSymbolicLink()) { errors.push(new Error("跳过符号链接会话目录")); continue; }
146
+ if (!entry.isDirectory()) continue;
147
+ if (knownDirs.has(entry.name)) continue;
148
+ let found = false;
149
+ for (const filename of ARTIFACT_NAMES) {
150
+ try {
151
+ const header = await readSessionHeader(join(root, proj.name, entry.name, filename));
152
+ if (![header.id, encodeSessionSegment(header.id)].includes(entry.name)) {
153
+ throw new Error("会话目录与 header id 不一致");
154
+ }
155
+ if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
156
+ found = true;
157
+ break;
158
+ } catch (error) {
159
+ if (error.code !== "ENOENT") { errors.push(error); break; }
160
+ }
161
+ }
162
+ if (!found) errors.push(new Error("无法完整读取会话目录: " + join(root, proj.name, entry.name)));
163
+ }
164
+ }
165
+ return result();
166
+ };
167
+
168
+ const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
169
+
170
+ export function apply(ctx) {
171
+ try {
172
+ const buildId = "dsh-session-manager v0.5.2+release-17 (clearSelection-keeps-bulk-mode-on)";
173
+ if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
174
+ else if (typeof console !== "undefined" && console?.info) console.info(buildId);
175
+ } catch (_) { /* ignore logging failures */ }
176
+ const adapter = createDshAdapter(ctx);
177
+ let annotationStore;
178
+ const annotations = () => {
179
+ if (!annotationStore) {
180
+ const home = adapter.homePath;
181
+ if (typeof home !== "function") throw Object.assign(new Error("会话标记存储不可用"), { code: "annotations-unavailable" });
182
+ annotationStore = createAnnotationStore(home("dsh-session-manager"));
183
+ }
184
+ return annotationStore;
185
+ };
186
+ const assertAnnotatable = id => {
187
+ const registry = adapter.workspaceRegistry;
188
+ if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
189
+ || adapter.workspaceList().some(ws => (ws.record?.sessionIds ?? ws.sessionIds ?? []).includes(id))) return;
190
+ throw Object.assign(new Error("会话不存在或尚未加载,请先打开该会话后重试"), { code: "session-not-found" });
191
+ };
192
+ const operations = new Map();
193
+ const mutate = (id, operation) => {
194
+ const pending = (operations.get(id) ?? Promise.resolve()).then(operation);
195
+ const settled = pending.catch(() => {}).finally(() => {
196
+ if (operations.get(id) === settled) operations.delete(id);
197
+ });
198
+ operations.set(id, settled);
199
+ return pending;
200
+ };
201
+ const readBody = async (req) => {
202
+ const chunks = [];
203
+ let size = 0;
204
+ for await (const chunk of req) {
205
+ const bytes = Buffer.from(chunk);
206
+ size += bytes.length;
207
+ if (size > 64 * 1024) throw Object.assign(new Error("请求体超过 64 KiB"), { code: "body-too-large" });
208
+ chunks.push(bytes);
209
+ }
210
+ return Buffer.concat(chunks).toString("utf8");
211
+ };
212
+
213
+ const send = (res, code, obj) => {
214
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
215
+ res.end(JSON.stringify(obj));
216
+ };
217
+
218
+ /** Remove one id from the registry-global archive set (durable, serialized). */
219
+ const unarchiveSession = async (sessionId) => {
220
+ const ui = adapter.uiWorkspace;
221
+ if (ui && typeof ui.unarchiveSession === "function") {
222
+ await ui.unarchiveSession(sessionId);
223
+ return;
224
+ }
225
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
226
+ if (registry && typeof registry.unarchiveSession === "function") {
227
+ await registry.unarchiveSession(sessionId);
228
+ return;
229
+ }
230
+ if (!registry || typeof registry.enqueueOperation !== "function" || typeof registry.requireState !== "function" || typeof registry.setState !== "function") {
231
+ const error = new Error(`cannot unarchive session '${sessionId}': workspaceRegistry service is unavailable`);
232
+ error.code = "workspace-registry-unavailable";
233
+ throw error;
234
+ }
235
+ await registry.enqueueOperation(async () => {
236
+ const state = registry.requireState();
237
+ if (!state.archivedSessionIds.includes(sessionId)) return;
238
+ await registry.setState({
239
+ ...state,
240
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
241
+ });
242
+ });
243
+ };
244
+
245
+ /** Add one id to the registry-global archive set (durable, serialized).
246
+ * Prefers official uiWorkspace or workspaceRegistry services without bypassing rejections.
247
+ * Default preserves running agents (stopActivity: false). When active, archive fails unless explicitly told to stop.
248
+ * Direct write fallback mirrors official DSH semantics: checks activity when stopActivity !== true,
249
+ * updates archived/pinned state, and stops activity after writing when requested.
250
+ * If equivalent semantics cannot be guaranteed (e.g. stop requested but no stop interface available),
251
+ * safely rejects without modifying registry state. */
252
+ const archiveSession = async (sessionId, options = {}) => {
253
+ const stopActivity = options.stopActivity === true;
254
+ const ui = adapter.uiWorkspace;
255
+ if (ui && typeof ui.archiveSession === "function") {
256
+ await ui.archiveSession(sessionId, { stopActivity });
257
+ return;
258
+ }
259
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
260
+ if (registry && typeof registry.archiveSession === "function") {
261
+ await registry.archiveSession(sessionId, { stopActivity });
262
+ return;
263
+ }
264
+ if (!registry || typeof registry.enqueueOperation !== "function" || typeof registry.requireState !== "function" || typeof registry.setState !== "function") {
265
+ const error = new Error(`cannot archive session '${sessionId}': workspaceRegistry service is unavailable`);
266
+ error.code = "workspace-registry-unavailable";
267
+ throw error;
268
+ }
269
+
270
+ if (stopActivity) {
271
+ const hasStopFn = typeof registry.stopSessionActivity === "function" || typeof ctx.parallel === "function";
272
+ if (!hasStopFn) {
273
+ const error = new Error(`cannot archive session '${sessionId}': no valid stop activity interface available`);
274
+ error.code = "stop-activity-unavailable";
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ await registry.enqueueOperation(async () => {
280
+ const state = registry.requireState();
281
+ if (state.archivedSessionIds.includes(sessionId)) return;
282
+
283
+ if (typeof registry.sessionKnown === "function") {
284
+ const known = await registry.sessionKnown(sessionId);
285
+ if (!known) {
286
+ const error = new Error(`cannot archive session '${sessionId}': session not found`);
287
+ error.code = "workspace/unknown-session";
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ if (!stopActivity) {
293
+ const liveSession = ctx.sessions?.get?.(sessionId);
294
+ const liveAgent = ctx.agents?.get?.(sessionId);
295
+ const activeTurn = liveSession?.activeTurn;
296
+ const agentRunning = liveAgent?.status === "running" || liveAgent?.busy === true;
297
+ if (activeTurn !== undefined || agentRunning) {
298
+ const kinds = [
299
+ ...(activeTurn !== undefined ? ["active turn"] : []),
300
+ ...(agentRunning ? ["agent running"] : [])
301
+ ].join(", ");
302
+ const error = new Error(`cannot archive session '${sessionId}': the session is active (${kinds})`);
303
+ error.code = "workspace/session-active";
304
+ throw error;
305
+ }
306
+
307
+ if (typeof ctx.waterfall !== "function") {
308
+ const error = new Error(`cannot archive session '${sessionId}': activity inspection interface (ctx.waterfall) is unavailable`);
309
+ error.code = "activity-inspection-unavailable";
310
+ throw error;
311
+ }
312
+
313
+ let waterfallActivities;
314
+ try {
315
+ waterfallActivities = await ctx.waterfall("workspace/session-activity", { sessionId }, () => Promise.resolve([]));
316
+ } catch (err) {
317
+ const error = new Error(`cannot archive session '${sessionId}': failed to inspect session activity: ${safeErrorMessage(err)}`, { cause: err });
318
+ error.code = "activity-check-failed";
319
+ throw error;
320
+ }
321
+
322
+ if (Array.isArray(waterfallActivities) && waterfallActivities.length > 0) {
323
+ const kinds = waterfallActivities.map(entry => entry?.kind || "task").join(", ");
324
+ const error = new Error(`cannot archive session '${sessionId}': the session is active (${kinds})`);
325
+ error.code = "workspace/session-active";
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ await registry.setState({
331
+ ...state,
332
+ archivedSessionIds: [...state.archivedSessionIds, sessionId],
333
+ pinnedSessionIds: (state.pinnedSessionIds ?? []).filter((id) => id !== sessionId)
334
+ });
335
+
336
+ if (stopActivity) {
337
+ if (typeof registry.stopSessionActivity === "function") {
338
+ await registry.stopSessionActivity(sessionId);
339
+ } else if (typeof ctx.parallel === "function") {
340
+ try {
341
+ await ctx.parallel("workspace/session-stop", { sessionId });
342
+ } catch (err) {
343
+ ctx.logger?.warn?.(`workspace: stopping session '${sessionId}' for archive failed: ${String(err)}`);
344
+ }
345
+ }
346
+ }
347
+ });
348
+ };
349
+
350
+ /** Detach one session from every workspace's ordered accounting. */
351
+ const detachFromWorkspaces = async (sessionId) => {
352
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
353
+ for (const entity of registry.list()) {
354
+ if (entity.sessionIds.includes(sessionId)) {
355
+ await entity.detachSession(sessionId);
356
+ }
357
+ }
358
+ };
359
+
360
+ /** Ordered workspace projection for the client. */
361
+ const listWorkspaces = () => {
362
+ return ctx.workspaceRegistry.list().map((entity) => {
363
+ const rawIds = Array.isArray(entity.record?.sessionIds)
364
+ ? [...entity.record.sessionIds]
365
+ : [...entity.sessionIds];
366
+ return {
367
+ id: entity.id,
368
+ name: entity.title || entity.id,
369
+ title: entity.title || entity.id,
370
+ path: entity.path,
371
+ sessionCount: rawIds.length,
372
+ sessionIds: rawIds
373
+ };
374
+
375
+ });
376
+ };
377
+
378
+
379
+ /**
380
+ * Encode the moved artifact in the backend's own physical layout:
381
+ * plain JSONL, or zstd frames whose FIRST frame is exactly the header
382
+ * line (the reader's assertZstdHeaderFrame / readFirstZstdLine contract).
383
+ */
384
+ const encodeArtifact = async (headerLine, rest, isZstd) => {
385
+ if (!isZstd) return Buffer.from(`${headerLine}
386
+ ${rest}`, "utf8");
387
+ const { zstdCompress } = await import("node:zlib");
388
+ if (typeof zstdCompress !== "function") {
389
+ throw new Error("当前 Node 运行时没有 zstd 支持,无法迁移 zstd 编码的会话工件");
390
+ }
391
+ const compress = (buffer) => new Promise((resolve, reject) => {
392
+ zstdCompress(buffer, (error, output) => error == null ? resolve(output) : reject(error));
393
+ });
394
+ const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
395
+ if (rest === "") return headerFrame;
396
+ const bodyFrame = await compress(Buffer.from(rest, "utf8"));
397
+ return Buffer.concat([headerFrame, bodyFrame]);
398
+ };
399
+
400
+ /**
401
+ * Resolve the exact inheritedEventCount for a session.
402
+ * Unseeded sessions strictly require 0.
403
+ * For seeded sessions, the official SessionHeader specification does NOT store
404
+ * inheritedEventCount. We resolve it from:
405
+ * 1. Live session object (session.inheritedEventCount)
406
+ * 2. Persistence live writer or handle (writer.inheritedEventCount, handle.inheritedEventCount via open("read"))
407
+ * 3. Parsed event stream: the index of the FINAL { type: "session/end-seed", data: { inherited: true } } marker event
408
+ * 4. Fallback to header.inheritedEventCount if present (e.g. test fixture/mocks)
409
+ * If isSeeded is true but inheritedEventCount cannot be determined, returns undefined to prevent
410
+ * poisoning the projection cache with an invalid 0 cut.
411
+ */
412
+ const resolveInheritedEventCount = async ({ sessionId, header, events, persistence, liveSession }) => {
413
+ if (!header?.isSeeded) return 0;
414
+ if (typeof liveSession?.inheritedEventCount === "number" && liveSession.inheritedEventCount >= 0) {
415
+ return liveSession.inheritedEventCount;
416
+ }
417
+ const writer = liveWriterOf(persistence, sessionId);
418
+ if (typeof writer?.inheritedEventCount === "number" && writer.inheritedEventCount >= 0) {
419
+ return writer.inheritedEventCount;
420
+ }
421
+ if (persistence && typeof persistence.open === "function") {
422
+ let handle;
423
+ try {
424
+ handle = await persistence.open(sessionId, "read");
425
+ if (typeof handle?.inheritedEventCount === "number" && handle.inheritedEventCount >= 0) {
426
+ return handle.inheritedEventCount;
427
+ }
428
+ } catch { /* best-effort */ }
429
+ finally {
430
+ try { await handle?.close?.(); } catch {}
431
+ }
432
+ }
433
+ if (Array.isArray(events) && events.length > 0) {
434
+ for (let i = events.length - 1; i >= 0; i--) {
435
+ const e = events[i];
436
+ if (e?.type === "session/end-seed" && (e?.data?.inherited === true || e?.inherited === true)) {
437
+ return i;
438
+ }
439
+ }
440
+ }
441
+ if (typeof header?.inheritedEventCount === "number" && header.inheritedEventCount >= 0) {
442
+ return header.inheritedEventCount;
443
+ }
444
+ return undefined;
445
+ };
446
+
447
+ /**
448
+ * TRUE cross-workspace move. See the module docstring for the full design:
449
+ * re-home the stored cwd, migrate the artifact, then swap accounting.
450
+ * Throws an Error with a readable message on every failure path; the
451
+ * artifact is restored (or its location reported) whenever a mid-move
452
+ * step fails, so the session never ends up in a half-moved state.
453
+ */
454
+ const moveSession = async (sessionId, targetWorkspaceId) => {
455
+ assertSessionId(sessionId);
456
+ const registry = ctx.workspaceRegistry;
457
+ const persistence = adapter.persistence;
458
+ const dshHomePath = adapter.homePath;
459
+ if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
460
+ if (typeof persistence.locate !== "function") {
461
+ throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
462
+ }
463
+
464
+ const target = registry.list().find((entity) => entity.id === targetWorkspaceId);
465
+ if (target === void 0) {
466
+ const error = new Error(`目标工作区不存在: ${targetWorkspaceId}`);
467
+ error.code = "workspace-not-found";
468
+ throw error;
469
+ }
470
+ const targetPath = target.path;
471
+
472
+ // Fast path: the common case -- the session is already in the
473
+ // persistence index -- costs one .list() + .find(). The previous
474
+ // code called listSessionHeaders() unconditionally, which (before
475
+ // the issue #6 fix) re-read every file in the session library even
476
+ // when the index already had the answer. We still fall through to
477
+ // the disk fallback for raw-id orphans persistence.list() cannot
478
+ // surface (older DSH builds wrote plain-id directories that the
479
+ // index may not know about).
480
+ let storedHeader;
481
+ if (persistence !== void 0 && typeof persistence.list === "function") {
482
+ try {
483
+ const headers = (await persistence.list()).map(normalizeSessionHeader);
484
+ storedHeader = headers.find((header) =>
485
+ header !== void 0 && typeof header.id === "string" && header.id === sessionId
486
+ );
487
+ } catch { /* fall through to disk fallback below */ }
488
+ }
489
+ if (storedHeader === void 0) {
490
+ const storedHeaders = await listSessionHeaders(persistence, dshHomePath);
491
+ storedHeader = storedHeaders.find((header) => header !== void 0 && header.id === sessionId);
492
+ }
493
+ if (storedHeader === void 0) {
494
+ const error = new Error(
495
+ `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
496
+ );
497
+ error.code = "session-not-found";
498
+ throw error;
499
+ }
500
+ if (storedHeader.origin === "subagent") {
501
+ const error = new Error("子代理(subagent)会话不支持跨工作区移动");
502
+ error.code = "subagent-unsupported";
503
+ throw error;
504
+ }
505
+ if (storedHeader.cwd !== void 0) {
506
+ let currentCanonical;
507
+ try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
508
+ if (currentCanonical === targetPath) {
509
+ return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
510
+ }
511
+ }
512
+
513
+ const liveSession = ctx.sessions.get(sessionId);
514
+ if (liveSession !== void 0 && liveWriterOf(persistence, sessionId) === undefined) {
515
+ throw new Error("当前 DSH 运行时未暴露可重绑定的 live persistence writer,无法安全迁移运行中的会话;请先关闭会话后重试");
516
+ }
517
+ let liveAgent;
518
+ try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
519
+ // The 0.4.6+ move path is no-teardown: the live agent/session stays
520
+ // alive throughout. We only rewrite the on-disk artifact in place and
521
+ // update the in-memory session header to point at the new cwd; the
522
+ // api-gateway's chat panel keeps the in-memory session as active.
523
+ // The persistence coordinator's per-id serialize() (if available) wraps
524
+ // the rewrite so the write-behind cannot append to the hidden file.
525
+ // client UI never has to re-init. Just flush pending events to
526
+ // disk (still at OLD cwd), then atomically rename the artifact to
527
+ // NEW cwd. The persistence coordinator's serialize() inside the
528
+ // move block holds the per-id lock, so the write-behind can't append
529
+ // to the hidden file. The agent's UI keeps showing the same in-memory
530
+ // session -- no client state flip, no half-initialized resume, no
531
+ // "session unavailable" symptoms.
532
+ if (liveSession !== void 0) {
533
+ try { await ctx.sessions.flush(liveSession); } catch { /* best-effort */ }
534
+ }
535
+
536
+ // No teardown -- the agent and session are still live and that's
537
+ // intentional. Move straight to file re-home.
538
+ const coordinator = persistence.coordinator;
539
+ const serialize = typeof coordinator?.serialize === "function"
540
+ ? (operation) => coordinator.serialize(sessionId, operation)
541
+ : (operation) => operation();
542
+
543
+ let movedArtifact = null;
544
+ const result = await serialize(async () => {
545
+ const raw = await readSessionArtifact(sessionId, undefined, { storedHeader });
546
+ if (raw === void 0) throw new Error("读取会话工件失败:未找到会话文件");
547
+ const newlineAt = raw.content.indexOf("\n");
548
+ if (newlineAt === -1) throw new Error("会话工件缺少头行,数据可能损坏");
549
+ const rest = raw.content.slice(newlineAt + 1);
550
+ let oldHeader;
551
+ try { oldHeader = JSON.parse(raw.content.slice(0, newlineAt)); }
552
+ catch (error) { throw new Error(`会话工件头行无法解析: ${String(error)}`); }
553
+ if (oldHeader.id !== sessionId) throw new Error("会话工件头行 id 与请求不符,拒绝移动");
554
+
555
+ // Keep the stored generation in sync with the filename selected by
556
+ // persistence.locate(). Rewriting a v2 header to v0 while locating it
557
+ // through the same v2 backend creates a v2 filename with v0 content,
558
+ // which makes DSH reject the session on the next startup.
559
+ const newHeader = { ...oldHeader, cwd: targetPath };
560
+ const oldPath = raw.path ?? persistence.locate(raw.meta)?.path;
561
+ const newLocation = persistence.locate(newHeader);
562
+ if (typeof oldPath !== "string" || newLocation === void 0) {
563
+ throw new Error("持久化后端无法定位会话工件路径");
564
+ }
565
+ const newPath = newLocation.path;
566
+ // Keep the stored generation in sync with the filename selected by
567
+ // persistence.locate(). persistence.locate() always returns the
568
+ // SESSION_FORMAT_VERSION path (DSH 0.1.7+ ships v4 by default),
569
+ // regardless of oldHeader.version. Without the stamp below, the
570
+ // rewritten file is `session.v4.jsonl.zstd` carrying a v0/v3 header,
571
+ // which JsonlSessionPersistence.listArtifacts rejects on the next
572
+ // startup (`session generation filename identifies v4, but its
573
+ // header identifies v0`), breaking persistence.list() and crashing
574
+ // the workspace registry while it boots. (issue #18)
575
+ const expectedVersion = parseSessionGenerationFromFilename(basename(newPath));
576
+ if (typeof expectedVersion === "number") newHeader.version = expectedVersion;
577
+ const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
578
+ await assertSessionDirectory(root, dirname(oldPath), sessionId);
579
+ await assertSessionDirectory(root, dirname(newPath), sessionId, { allowMissing: true });
580
+ try {
581
+ await lstat(newPath);
582
+ throw new Error("目标位置已有会话工件,拒绝覆盖");
583
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
584
+ if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
585
+ const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
586
+ await mkdir(dirname(newPath), { recursive: true });
587
+ const tempNew = await writeTempFile(newPath, bytes);
588
+ const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
589
+ try {
590
+ await rename(oldPath, oldHidden);
591
+ } catch (error) {
592
+ await rm(tempNew, { force: true });
593
+ throw new Error(`移动失败(无法隐藏旧会话工件,已取消,会话保持原状): ${String(error)}`);
594
+ }
595
+ try {
596
+ await rename(tempNew, newPath);
597
+ } catch (error) {
598
+ try { await rename(oldHidden, oldPath); }
599
+ catch (rollbackError) {
600
+ throw new AggregateError([error, rollbackError], "移动发布失败且回滚失败;原始会话保留在 " + oldHidden + ",新文件保留在 " + tempNew);
601
+ }
602
+ try { await rm(tempNew, { force: true }); } catch { /* original restored */ }
603
+ throw new Error("移动发布失败,原始会话已恢复: " + safeErrorMessage(error), { cause: error });
604
+ }
605
+
606
+ const state = coordinator?.states?.get?.(sessionId);
607
+ if (state?.owner !== void 0 && liveSession !== void 0 && state.owner !== liveSession) {
608
+ await rm(newPath, { force: true });
609
+ await rename(oldHidden, oldPath);
610
+ throw new Error(`会话 ${sessionId} 的持久化 owner 与当前 live session 不一致,已回滚`);
611
+ }
612
+ const oldStateMeta = state?.meta;
613
+ const oldLiveHeader = liveSession?.header;
614
+ const liveWriter = liveSession === void 0 ? void 0 : liveWriterOf(persistence, sessionId);
615
+ const oldWriterHeader = liveWriter?.header;
616
+ const oldIndexedHeader = registry.headers?.get?.(sessionId);
617
+ const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
618
+ const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
619
+ const fromWorkspaceIds = registry.list()
620
+ .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
621
+ .map((entity) => entity.id);
622
+ let targetAttached = false;
623
+ const detachedFrom = [];
624
+
625
+ try {
626
+ if (state !== void 0) {
627
+ state.meta = { ...state.meta, ...newHeader, cwd: targetPath };
628
+ state.materialized = true;
629
+ }
630
+ coordinator?.preparations?.invalidate?.(sessionId);
631
+ if (liveSession !== void 0) {
632
+ liveSession.header = Object.freeze({ ...newHeader });
633
+ rebindLiveWriter(persistence, sessionId, newHeader);
634
+ }
635
+ registry.headers?.set?.(sessionId, { ...newHeader });
636
+ registry.sessionPaths?.set?.(sessionId, targetPath);
637
+ registry.invalidSessionPaths?.delete?.(sessionId);
638
+
639
+ await registry.enqueueOperation(async () => {
640
+ for (const id of fromWorkspaceIds) {
641
+ const entity = registry.list().find((candidate) => candidate.id === id);
642
+ if (entity === void 0) continue;
643
+ await entity.detachSession(sessionId);
644
+ detachedFrom.push(id);
645
+ }
646
+ await target.attachSession(sessionId);
647
+ targetAttached = true;
648
+ });
649
+ } catch (error) {
650
+ if (state !== void 0 && oldStateMeta !== void 0) state.meta = oldStateMeta;
651
+ coordinator?.preparations?.invalidate?.(sessionId);
652
+ if (liveSession !== void 0 && oldLiveHeader !== void 0) liveSession.header = oldLiveHeader;
653
+ if (liveWriter !== void 0 && oldWriterHeader !== void 0) liveWriter.header = oldWriterHeader;
654
+ if (oldIndexedHeader === void 0) registry.headers?.delete?.(sessionId);
655
+ else registry.headers?.set?.(sessionId, oldIndexedHeader);
656
+ if (oldIndexedPath === void 0) registry.sessionPaths?.delete?.(sessionId);
657
+ else registry.sessionPaths?.set?.(sessionId, oldIndexedPath);
658
+ if (oldInvalidPath === void 0) registry.invalidSessionPaths?.delete?.(sessionId);
659
+ else registry.invalidSessionPaths?.set?.(sessionId, oldInvalidPath);
660
+
661
+ const rollbackErrors = [];
662
+ try { if (targetAttached) await target.detachSession(sessionId); } catch (e) { rollbackErrors.push(e); }
663
+ for (const id of detachedFrom) {
664
+ try {
665
+ const entity = registry.list().find((candidate) => candidate.id === id);
666
+ if (entity !== void 0) await entity.attachSession(sessionId);
667
+ } catch (e) { rollbackErrors.push(e); }
668
+ }
669
+ try { await rm(newPath, { force: true }); } catch (e) { rollbackErrors.push(e); }
670
+ try { await rename(oldHidden, oldPath); } catch (e) { rollbackErrors.push(e); }
671
+ if (rollbackErrors.length > 0) {
672
+ throw new AggregateError([error, ...rollbackErrors], "移动失败且回滚不完整");
673
+ }
674
+ throw error;
675
+ }
676
+
677
+ // rebindLiveWriter() above retargeted the existing writer before
678
+ // cleanup, so subsequent routed events use the target cwd without
679
+ // replacing the handle retained by the live agent.
680
+ try { await rm(oldHidden, { force: true }); } catch { /* best-effort */ }
681
+ try {
682
+ await assertSessionDirectory(root, dirname(oldPath), sessionId);
683
+ await rm(dirname(oldPath), { recursive: true, force: true });
684
+ } catch (error) { ctx.logger.warn(`session-manager: old directory retained: ${String(error)}`); }
685
+
686
+ movedArtifact = { header: newHeader, rest };
687
+
688
+ return {
689
+ ok: true,
690
+ sessionId,
691
+ moved: true,
692
+ fromWorkspaceIds,
693
+ toWorkspaceId: target.id,
694
+ toWorkspaceTitle: target.title || target.id,
695
+ artifactFrom: oldPath,
696
+ artifactTo: newPath,
697
+ wasLive: liveSession !== void 0 || liveAgent !== void 0
698
+ };
699
+ });
700
+
701
+ const cache = ctx.get("sessionProjectionCache") ?? adapter.projectionCache;
702
+ if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
703
+ try {
704
+ const isThreeArg = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
705
+ const header = movedArtifact?.header;
706
+ const events = [];
707
+ if (typeof movedArtifact?.rest === "string") {
708
+ for (const line of movedArtifact.rest.split("\n")) {
709
+ if (!line.trim()) continue;
710
+ try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
711
+ }
712
+ }
713
+ const inheritedEventCount = await resolveInheritedEventCount({
714
+ sessionId,
715
+ header,
716
+ events,
717
+ persistence,
718
+ liveSession
719
+ });
720
+ if (header?.isSeeded && inheritedEventCount === undefined) {
721
+ ctx.logger.warn(`session-manager: skipping post-move cache refresh for seeded session "${sessionId}": cannot determine inheritedEventCount`);
722
+ } else if (isThreeArg) {
723
+ await cache.coldSnapshot(header, inheritedEventCount, events);
724
+ } else {
725
+ try {
726
+ await cache.coldSnapshot(sessionId);
727
+ } catch (error) {
728
+ if (String(error).includes("SessionLogOffset") && header) {
729
+ await cache.coldSnapshot(header, inheritedEventCount, events);
730
+ } else {
731
+ throw error;
732
+ }
733
+ }
734
+ }
735
+ } catch (error) {
736
+ ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
737
+ }
738
+ }
739
+ // The agent is still live (no teardown). The session's in-memory
740
+ // header was rewritten above and the workspace registry updated;
741
+ // future writes go through persistence.locate() against the new
742
+ // cwd, so the file at the new location accumulates new events.
743
+ // Re-resuming the agent here re-creates a half-initialized
744
+ // Session/agent pair and leaves the client UI in a "session
745
+ // unavailable" state (agent/status is only emitted on PHASE
746
+ // CHANGES, never on agent construction). Leaving the live
747
+ // agent/session in place avoids the broken UI entirely.
748
+ ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}" (in-place file rename, live agent retained)`);
749
+ return result;
750
+ };
751
+ /* === preset-migration (session-manager v0.3.0) === */
752
+ const NL = String.fromCharCode(10);
753
+
754
+ const scanSelectedEvents = (content) => {
755
+ const out = [];
756
+ const newlineAt = content.indexOf(NL);
757
+ const after = newlineAt === -1 ? content : content.slice(newlineAt + 1);
758
+ for (const line of after.split(NL)) {
759
+ if (line.length === 0) continue;
760
+ let obj;
761
+ try { obj = JSON.parse(line); } catch { continue; }
762
+ if (obj !== null && typeof obj === "object" && obj.type === "agent-preset/selected" && obj.data !== void 0 && typeof obj.data.agentPreset === "string") {
763
+ out.push({ agentPreset: obj.data.agentPreset, time: obj.time, seq: obj.seq });
764
+ }
765
+ }
766
+ return out;
767
+ };
768
+
769
+ const scanPreset = async (filter) => {
770
+ // Pull the full preset roster so the UI shows every available preset,
771
+ // not just the ones currently used by some session.
772
+ let allPresetIds = [];
773
+ try {
774
+ const ap = ctx.get("agentPresets");
775
+ if (ap !== void 0 && typeof ap.list === "function") {
776
+ const list = await ap.list();
777
+ for (const p of (list || [])) {
778
+ if (p && typeof p.id === "string" && p.id.length > 0) allPresetIds.push(p.id);
779
+ }
780
+ }
781
+ } catch { /* ignore -- fall back to used presets only */ }
782
+
783
+ const persistence = adapter.persistence;
784
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
785
+ // Enumeration normalizes backend snapshots and retains the legacy
786
+ // raw-id disk fallback. Incomplete reads never authorize reconciliation.
787
+ let headers = [];
788
+ try {
789
+ headers = await listSessionHeaders(persistence, adapter.homePath);
790
+ } catch (probeError) {
791
+ throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
792
+ }
793
+ let allowedIds = null;
794
+ const workspaceId = filter !== void 0 && typeof filter === "object" ? filter.id : void 0;
795
+ if (typeof workspaceId === "string" && workspaceId !== "") {
796
+ const registry = ctx.get("workspaceRegistry");
797
+ if (registry !== void 0 && typeof registry.list === "function") {
798
+ const all = registry.list();
799
+ const ws = all.find((entity) => entity.id === workspaceId);
800
+ if (ws === void 0) {
801
+ const error = new Error("workspace not found: " + workspaceId);
802
+ error.code = "workspace-not-found";
803
+ throw error;
804
+ }
805
+ allowedIds = new Set();
806
+ const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
807
+ if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
808
+ }
809
+ }
810
+ const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
811
+ ? filter.sessionId
812
+ : "";
813
+ if (requestedSessionId !== "") assertSessionId(requestedSessionId);
814
+ const out = [];
815
+ for (const header of headers) {
816
+ if (requestedSessionId !== "" && header.id !== requestedSessionId) continue;
817
+ if (allowedIds !== null && !allowedIds.has(header.id)) continue;
818
+ if (header.origin === "subagent") continue;
819
+ const raw = await readSessionArtifact(header.id, undefined, { storedHeader: header });
820
+ if (raw === void 0) continue;
821
+ const newlineAt = raw.content.indexOf(NL);
822
+ if (newlineAt === -1) continue;
823
+ let headerObj;
824
+ try { headerObj = JSON.parse(raw.content.slice(0, newlineAt)); } catch { continue; }
825
+ let selected = scanSelectedEvents(raw.content);
826
+ const live = ctx.sessions.get(header.id);
827
+ if (live !== void 0) {
828
+ const liveEvents = Array.isArray(live.events)
829
+ ? live.events
830
+ : Array.isArray(live.log) ? live.log : [];
831
+ const liveSelected = liveEvents
832
+ .filter((event) => event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string")
833
+ .map((event) => ({ agentPreset: event.data.agentPreset, time: event.time, seq: event.seq }));
834
+ if (liveSelected.length > selected.length) selected = liveSelected;
835
+ }
836
+ const finalPreset = selected.length > 0 ? selected[selected.length - 1].agentPreset : headerObj.agentPreset;
837
+ out.push({ sessionId: header.id, cwd: header.cwd, origin: header.origin, headerPreset: headerObj.agentPreset, selectedEvents: selected, finalPreset });
838
+ }
839
+ // Targets come only from the live roster. A deleted preset can still be
840
+ // displayed as this session's current value, but must not be offered as
841
+ // a destination.
842
+ return { rows: out, availablePresets: Array.from(new Set(allPresetIds)) };
843
+ };
844
+
845
+ /**
846
+ * Rewrite the effective preset of one conversation in place on disk.
847
+ * The 0.4.6+ flow is no-teardown: when a live session is in store we
848
+ * append the new agent-preset/selected event via Session.append() and
849
+ * flush through SessionStore.flush(); for cold sessions we rewrite
850
+ * the last matching event directly on the existing artifact. In both
851
+ * cases the live agent/session is left intact, the api-gateway chat
852
+ * panel stays available, and the projection picks up the new value
853
+ * on the next event fold.
854
+ */
855
+ /** Cold-path preset migration: read disk, rewrite, publish. Works whether
856
+ * or not the session is currently in the coordinator, and does not depend on
857
+ * `coordinator.states` carrying a fresh `meta.cwd`. */
858
+ const applyMigrationToArtifact = async (args) => {
859
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId : "";
860
+ const toPreset = typeof args.toPreset === "string" ? args.toPreset : "";
861
+ const liveSession = (args !== void 0 && typeof args === "object") ? args.liveSession : void 0;
862
+ const persistence = adapter.persistence;
863
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
864
+ const coordinator = persistence.coordinator;
865
+ const serialize = typeof coordinator?.serialize === "function"
866
+ ? (operation) => coordinator.serialize(sessionId, operation)
867
+ : (operation) => operation();
868
+ return await serialize(async () => {
869
+ const raw = await readSessionArtifact(sessionId);
870
+ if (raw === void 0) throw new Error(`session "${sessionId}" has no artifact`);
871
+ const lines = raw.content.split(NL);
872
+ if (lines.length < 2) throw new Error("session artifact has no header line");
873
+ let header;
874
+ try { header = JSON.parse(lines[0]); } catch { throw new Error("session header parse failed"); }
875
+ if (header.id !== sessionId) throw new Error("session header id mismatch");
876
+
877
+ let selectedIndex = -1;
878
+ let selectedPreset;
879
+ for (let index = 1; index < lines.length; index += 1) {
880
+ if (lines[index] === "") continue;
881
+ let event;
882
+ try { event = JSON.parse(lines[index]); } catch { continue; }
883
+ if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
884
+ selectedIndex = index;
885
+ selectedPreset = event.data.agentPreset;
886
+ }
887
+ }
888
+ const oldPreset = selectedIndex >= 0 ? selectedPreset : header.agentPreset;
889
+ if (oldPreset === toPreset) {
890
+ return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
891
+ }
892
+
893
+ if (selectedIndex >= 0) {
894
+ const event = JSON.parse(lines[selectedIndex]);
895
+ event.data = { ...event.data, agentPreset: toPreset };
896
+ lines[selectedIndex] = JSON.stringify(event);
897
+ } else {
898
+ header.agentPreset = toPreset;
899
+ lines[0] = JSON.stringify(header);
900
+ }
901
+
902
+ const artifactPath = raw.path ?? persistence.locate?.(raw.meta)?.path;
903
+ if (typeof artifactPath !== "string") throw new Error("cannot locate session artifact");
904
+ const replacementContent = lines.join(NL);
905
+ const firstNewline = replacementContent.indexOf(NL);
906
+ const bytes = await encodeArtifact(
907
+ replacementContent.slice(0, firstNewline),
908
+ replacementContent.slice(firstNewline + 1),
909
+ artifactPath.endsWith(".zstd")
910
+ );
911
+ const home = adapter.homePath;
912
+ await assertSessionDirectory(typeof home === "function" ? home("sessions") : undefined, dirname(artifactPath), sessionId);
913
+ const published = await replaceSessionFile(artifactPath, bytes);
914
+ if (published.backupPath) ctx.logger.warn("session-manager: retained backup " + published.backupPath);
915
+
916
+ const state = coordinator?.states?.get?.(sessionId);
917
+ if (state !== void 0 && selectedIndex < 0) state.meta = { ...state.meta, agentPreset: toPreset };
918
+ coordinator?.preparations?.invalidate?.(sessionId);
919
+ // Mirror the disk rewrite into the in-memory live session: the cold path
920
+ // bypasses the persistence write controller (which is intentionally
921
+ // skipped when its state is stale), so the session header, projection,
922
+ // and downstream agent composition would otherwise keep reporting the old
923
+ // preset. Update both the header and the in-memory event log so DSH's
924
+ // `resolveSessionPreset` and any projection subscribers observe the new
925
+ // preset from the same source.
926
+ if (liveSession !== void 0) {
927
+ try {
928
+ const nextHeader = { ...(liveSession.header ?? {}), agentPreset: toPreset };
929
+ try { liveSession.header = Object.freeze(nextHeader); }
930
+ catch { liveSession.header = nextHeader; }
931
+ } catch { /* live session may have been retired mid-flight */ }
932
+ // Append the new selection to the live log so the session/store
933
+ // observers (agent-presets, projections, ctx) fire their normal
934
+ // `session/event` + `agent-preset/selected` pipeline. We append
935
+ // unconditionally (not only when the disk had no prior selection)
936
+ // because the in-memory projection reads the latest appended event
937
+ // and the cold path deliberately skipped the persistence write
938
+ // controller; the appended event will be persisted on the next
939
+ // normal flush, keeping disk and memory consistent.
940
+ if (typeof liveSession.append === "function") {
941
+ try { liveSession.append("agent-preset/selected", { agentPreset: toPreset }); } catch { /* best-effort */ }
942
+ }
943
+ }
944
+ return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
945
+ });
946
+ };
947
+ /** Rewrite exactly one conversation's effective Agent preset. */
948
+ const migratePreset = async (opts) => {
949
+ const sessionId = typeof opts.sessionId === "string" ? opts.sessionId.trim() : "";
950
+ const toPreset = typeof opts.toPreset === "string" ? opts.toPreset.trim() : "";
951
+ if (sessionId === "") { const e = new Error("sessionId required"); e.code = "bad-request"; throw e; }
952
+ if (toPreset === "") { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
953
+
954
+ const presets = ctx.get("agentPresets");
955
+ if (presets === void 0 || typeof presets.list !== "function") {
956
+ throw new Error("agentPresets service unavailable");
957
+ }
958
+ const roster = await presets.list();
959
+ const target = (roster || []).find((preset) => preset && preset.id === toPreset);
960
+ if (target === void 0) {
961
+ const available = (roster || []).map((preset) => preset?.id).filter(Boolean);
962
+ throw new Error(`Agent 预设 "${toPreset}" 不存在(可用:${available.join(", ")})`);
963
+ }
964
+ if (target.broken !== void 0) throw new Error(`Agent 预设 "${toPreset}" 不可用:${target.broken}`);
965
+
966
+ const liveSession = ctx.sessions.get(sessionId);
967
+ let liveAgent;
968
+ try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
969
+ // Resolve persistence up front so the live path can read the coordinator
970
+ // without tripping JavaScript's temporal dead zone on `const persistence`.
971
+ const persistence = adapter.persistence;
972
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
973
+
974
+ if (liveSession !== void 0) {
975
+ const coordinator = persistence.coordinator;
976
+ const states = coordinator?.states;
977
+ const beforeState = states?.get?.(sessionId);
978
+ const liveCwd = liveSession.header?.cwd;
979
+ // A just-completed moveSession mutates `liveSession.header.cwd` in place,
980
+ // but the persistence coordinator's tracked state may not see the new cwd
981
+ // until its serialize chain drains. If we hand the migration to the
982
+ // coordinator now, its `state.meta.cwd` may still be the pre-move cwd and
983
+ // appendBatch will open the old (now deleted) artifact path, producing the
984
+ // "ENOENT ... session.v2.jsonl.zstd" regression. Falling back to the cold
985
+ // path is always correct: the disk copy already reflects the new cwd.
986
+ const stateStale = beforeState === void 0
987
+ || beforeState.meta?.cwd === void 0
988
+ || (liveCwd !== void 0 && beforeState.meta.cwd !== liveCwd);
989
+ if (stateStale) {
990
+ const raw = await readSessionArtifact(sessionId);
991
+ if (raw !== void 0) {
992
+ const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
993
+ if (result.migrated) { try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ } }
994
+ ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" via cold path (state was stale, live cwd=${String(liveCwd)}, state cwd=${String(beforeState?.meta?.cwd)})`);
995
+ return { sessionId, migrated: true, oldPreset: result.oldPreset, newPreset: toPreset };
996
+ }
997
+ // No artifact yet; fall through to the in-memory + flush path below,
998
+ // which materializes the empty file using the live cwd.
999
+ }
1000
+ let oldPreset = liveSession.header?.agentPreset;
1001
+ // SessionStore implementations from older DSH builds may expose the
1002
+ // history as `log` rather than the newer `events` snapshot getter.
1003
+ // Do not dereference `.length` on an absent compatibility surface.
1004
+ const liveEvents = Array.isArray(liveSession.events)
1005
+ ? liveSession.events
1006
+ : Array.isArray(liveSession.log) ? liveSession.log : [];
1007
+ for (let index = liveEvents.length - 1; index >= 0; index -= 1) {
1008
+ const event = liveEvents[index];
1009
+ if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
1010
+ oldPreset = event.data.agentPreset;
1011
+ break;
1012
+ }
1013
+ }
1014
+ if (oldPreset === toPreset) {
1015
+ return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
1016
+ }
1017
+ if (liveAgent !== void 0 && typeof presets?.recompose === "function" && liveAgent.ctx) {
1018
+ try { await presets.recompose(liveAgent.ctx, toPreset); } catch { /* best-effort */ }
1019
+ }
1020
+ liveSession.append("agent-preset/selected", { agentPreset: toPreset });
1021
+ await ctx.sessions.flush(liveSession);
1022
+ ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" from "${String(oldPreset)}" to "${toPreset}"`);
1023
+ return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
1024
+ }
1025
+
1026
+ // A session absent from SessionStore is cold. If an older plugin left its
1027
+ // exact Session object in the coordinator, retire that proven orphan by
1028
+ // the coordinator's own drain path instead of deleting owner bookkeeping.
1029
+ const coordinator = persistence.coordinator;
1030
+ let tracked = coordinator?.states?.get?.(sessionId);
1031
+ if (tracked?.owner !== void 0 && ctx.sessions.get(sessionId) === void 0) {
1032
+ coordinator.retire?.(tracked.owner);
1033
+ const retirement = coordinator.retirements?.get?.(sessionId);
1034
+ if (retirement !== void 0) await retirement;
1035
+ tracked = coordinator.states?.get?.(sessionId);
1036
+ if (tracked?.owner !== void 0) {
1037
+ throw new Error(`会话 ${sessionId} 仍有未释放的持久化 owner,已停止迁移以保护数据`);
1038
+ }
1039
+ }
1040
+
1041
+ const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
1042
+
1043
+ if (result.migrated) {
1044
+ try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ }
1045
+ ctx.logger.info(`session-manager: migrated cold preset for "${sessionId}" from "${String(result.oldPreset)}" to "${toPreset}"`);
1046
+ }
1047
+ return result;
1048
+ };
1049
+
1050
+ /** Read one session artifact verbatim, falling back to direct disk access when the
1051
+ * configured persistence backend cannot see the id (e.g. an older DSH build
1052
+ * wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
1053
+ */
1054
+ const readSessionArtifact = async (sessionId, signal, { headerOnly = false, storedHeader } = {}) => {
1055
+ assertSessionId(sessionId);
1056
+ const persistence = adapter.persistence;
1057
+ const dshHomePath = adapter.homePath;
1058
+ const root = typeof dshHomePath === "function" ? dshHomePath("sessions") : undefined;
1059
+ const read = async (path) => headerOnly
1060
+ ? { meta: await readSessionHeader(path), path }
1061
+ : readSessionFile(path);
1062
+ // Use cheap index metadata as a location hint, never a decoded readRaw
1063
+ // payload. Move/preset scans can reuse headers they already enumerated.
1064
+ let header = storedHeader;
1065
+ if (header === undefined && typeof persistence?.list === "function") {
1066
+ try {
1067
+ header = (await persistence.list()).map(normalizeSessionHeader).find(item => item?.id === sessionId);
1068
+ } catch (error) { if (signal?.aborted) throw error; }
1069
+ }
1070
+ if (header !== undefined && typeof persistence?.locate === "function") {
1071
+ const path = persistence.locate(header)?.path;
1072
+ if (typeof path === "string") {
1073
+ try {
1074
+ await assertSessionDirectory(root, dirname(path), sessionId);
1075
+ const artifact = await read(path);
1076
+ if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
1077
+ return artifact;
1078
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
1079
+ }
1080
+ }
1081
+ if (typeof root !== "string") throw new Error("无法定位并验证会话工件目录");
1082
+ let projects;
1083
+ try { projects = await readdir(root, { withFileTypes: true }); }
1084
+ catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
1085
+ for (const proj of projects) {
1086
+ if (!proj.isDirectory() || proj.isSymbolicLink()) continue;
1087
+ for (const candidate of [encodeSessionSegment(sessionId), sessionId]) {
1088
+ const dir = join(root, proj.name, candidate);
1089
+ try { await assertSessionDirectory(root, dir, sessionId); }
1090
+ catch (error) { if (error.code === "ENOENT") continue; throw error; }
1091
+ for (const filename of ARTIFACT_NAMES) {
1092
+ try {
1093
+ const artifact = await read(join(dir, filename));
1094
+ if (artifact.meta.id !== sessionId) throw new Error("会话工件 header id 与请求不一致");
1095
+ return artifact;
1096
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
1097
+ }
1098
+ }
1099
+ }
1100
+ return undefined;
1101
+ };
1102
+
1103
+ const sessionDirOf = async (sessionId) => {
1104
+ // Require a matching artifact header instead of deleting an arbitrary
1105
+ // directory merely because its name matches user input.
1106
+ const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
1107
+ return artifact === undefined ? undefined : dirname(artifact.path);
1108
+ };
1109
+
1110
+ /**
1111
+ * Delete one session end to end. Returns a summary of what was torn down.
1112
+ * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
1113
+ */
1114
+ const deleteSession = async (sessionId) => {
1115
+ assertSessionId(sessionId);
1116
+ // Resolve/validate before cancelling the agent or changing registry state.
1117
+ let dir = await sessionDirOf(sessionId);
1118
+ const session = ctx.sessions.get(sessionId);
1119
+ // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
1120
+ let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
1121
+
1122
+ if (agent !== void 0) {
1123
+ // Stop any running turn (disposed-kind suppresses re-wake).
1124
+ agent.cancel({ kind: "disposed" });
1125
+ // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
1126
+ if (typeof agent.scope?.dispose === "function") {
1127
+ await Promise.race([agent.scope.dispose(), sleep(3000)]);
1128
+ }
1129
+ // Drop the zombie from the registry so a later session.create/open with
1130
+ // the same id cannot resurrect it.
1131
+ try {
1132
+ ctx.agents.store?.delete?.(sessionId);
1133
+ } catch { /* best-effort */ }
1134
+ }
1135
+
1136
+ let detached = false;
1137
+ if (session !== void 0) {
1138
+ // Flush buffered events to disk first so the retirement drain is a no-op.
1139
+ try {
1140
+ await ctx.sessions.flush(session);
1141
+ } catch { /* best-effort */ }
1142
+ // Detach the session store entry: emits session/disposed, which the
1143
+ // persistence write-path answers with a final drain, and the API proxy
1144
+ // relays as host/session-removed so every connected client drops the row.
1145
+ try {
1146
+ const entry = ctx.sessions.store?.get?.(sessionId);
1147
+ if (entry !== void 0 && typeof entry.detach === "function") {
1148
+ entry.detach();
1149
+ await sleep(200); // let the write-behind retirement settle
1150
+ detached = true;
1151
+ }
1152
+ } catch { /* best-effort */ }
1153
+ }
1154
+ // Some sessions have a stored artifact but no live store row (e.g.
1155
+ // already-scoped scope torn down, or never attached). Without a live
1156
+ // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
1157
+ // entry.detach() never fires — so the apiproxy never queues a
1158
+ // host/session-removed frame, every connected client keeps showing the
1159
+ // session row, and the row collapses into the ungrouped section the
1160
+ // moment host/workspace-changed drops its workspace membership. Emit
1161
+ // session/disposed explicitly so every connected client drops the row.
1162
+ if (!detached) {
1163
+ try {
1164
+ ctx.emit("session/disposed", { id: sessionId });
1165
+ } catch (_) { /* best-effort */ }
1166
+ }
1167
+
1168
+ // A blank live session may have materialized its first artifact during
1169
+ // flush/retirement. Re-resolve after teardown rather than leaking that file.
1170
+ dir = await sessionDirOf(sessionId);
1171
+
1172
+ // Workspace accounting + archive-set membership.
1173
+ await detachFromWorkspaces(sessionId);
1174
+ await unarchiveSession(sessionId);
1175
+
1176
+ // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1177
+ if (dir !== void 0) {
1178
+ await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
1179
+ await rm(dir, { recursive: true, force: true });
1180
+ }
1181
+
1182
+ let warning;
1183
+ try { await annotations().remove(sessionId); }
1184
+ catch (error) {
1185
+ warning = "会话已删除,但独立标记清理失败: " + safeErrorMessage(error);
1186
+ ctx.logger.warn("session-manager: " + warning);
1187
+ }
1188
+ return {
1189
+ ok: true,
1190
+ ...(warning ? { warning } : {}),
1191
+ sessionId,
1192
+ wasLive: session !== void 0 || agent !== void 0,
1193
+ filesRemoved: dir !== void 0
1194
+ };
1195
+ };
1196
+
1197
+ /**
1198
+ * On plugin boot, refold every persisted session projection cache
1199
+ * row from disk. Sessions moved before coldSnapshot was wired into
1200
+ * moveSession would otherwise keep their pre-move {createdAt, cwd}
1201
+ * identity and let the cold list path fall back to basename(cwd),
1202
+ * surfacing the workspace title (for example "DSH") where the
1203
+ * session's own title was expected. The sweep is one-shot and
1204
+ * idempotent: a row whose identity already matches is a cheap
1205
+ * pass-through refold, and a stale one triggers the unrelated-identity
1206
+ * full re-read path.
1207
+ */
1208
+ ctx.effect(async () => {
1209
+ const cache = adapter.projectionCache;
1210
+ const persistence = adapter.persistence;
1211
+ const registry = adapter.workspaceRegistry;
1212
+ if (typeof cache?.coldSnapshot !== "function" || typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1213
+ try {
1214
+ // Remember only ids that existed before the async scan. Concurrently
1215
+ // attached ids must never be removed using this older disk snapshot.
1216
+ const initialIds = new Map(registry.list().map(ws => [ws.id, new Set(ws.record?.sessionIds ?? [])]));
1217
+ const scan = await scanSessionHeaders(persistence, adapter.homePath);
1218
+ if (!scan.complete) {
1219
+ ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
1220
+ return;
1221
+ }
1222
+ const headers = scan.headers;
1223
+ const validIds = new Set(headers.map(header => header.id));
1224
+ const isThreeArgCache = typeof cache.cachedSnapshot === "function" || cache.coldSnapshot.length >= 3;
1225
+ if (isThreeArgCache) {
1226
+ for (const header of headers) {
1227
+ try {
1228
+ // Fast path: cachedSnapshot performs an in-memory lifecycle check (formatVersion, cwd, createdAt, isSeeded).
1229
+ // If the cache record is already present and matches, skip disk I/O and projection fold.
1230
+ if (typeof cache.cachedSnapshot === "function" && cache.cachedSnapshot(header) !== undefined) {
1231
+ continue;
1232
+ }
1233
+ const raw = await readSessionArtifact(header.id, undefined, { storedHeader: header });
1234
+ if (!raw || typeof raw.content !== "string") continue;
1235
+ const newlineAt = raw.content.indexOf("\n");
1236
+ const rest = newlineAt === -1 ? "" : raw.content.slice(newlineAt + 1);
1237
+ const events = [];
1238
+ if (rest) {
1239
+ for (const line of rest.split("\n")) {
1240
+ if (!line.trim()) continue;
1241
+ try { events.push(JSON.parse(line)); } catch { /* ignore torn line */ }
1242
+ }
1243
+ }
1244
+ const inheritedEventCount = await resolveInheritedEventCount({
1245
+ sessionId: header.id,
1246
+ header,
1247
+ events,
1248
+ persistence,
1249
+ liveSession: adapter.getSession(header.id)
1250
+ });
1251
+ if (header.isSeeded && inheritedEventCount === undefined) {
1252
+ ctx.logger.warn('session-manager: skipping cache refold for seeded session "' + header.id + '": cannot determine inheritedEventCount');
1253
+ continue;
1254
+ }
1255
+ await cache.coldSnapshot(header, inheritedEventCount, events);
1256
+ } catch (error) {
1257
+ ctx.logger.warn('session-manager: post-boot cache refold failed for "' + header.id + '": ' + safeErrorMessage(error));
1258
+ }
1259
+ }
1260
+ } else {
1261
+ for (const header of headers) {
1262
+ try { await cache.coldSnapshot(header.id); }
1263
+ catch (error) {
1264
+ if (String(error).includes("SessionLogOffset")) break;
1265
+ ctx.logger.warn('session-manager: post-boot cache refold failed: ' + safeErrorMessage(error));
1266
+ }
1267
+ }
1268
+ }
1269
+ let removed = 0;
1270
+ let added = 0;
1271
+ // Read current state *inside* its queue, and publish an immutable copy.
1272
+ // A failed setState must not mutate the registry's live state object.
1273
+ await registry.enqueueOperation(async () => {
1274
+ const state = registry.requireState();
1275
+ const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
1276
+ const assignedIds = new Set((state.workspaces ?? []).flatMap(ws => ws.sessionIds ?? []));
1277
+ const workspaces = (state.workspaces ?? []).map(ws => {
1278
+ const original = ws.sessionIds ?? [];
1279
+ const sessionIds = original.filter(id => validIds.has(id)
1280
+ || !initialIds.get(ws.id)?.has(id) || adapter.getSession(id) !== undefined);
1281
+ const removedHere = original.length - sessionIds.length;
1282
+ removed += removedHere;
1283
+ for (const header of headers) {
1284
+ if (header.cwd === undefined || header.cwd !== pathById.get(ws.id) || assignedIds.has(header.id)) continue;
1285
+ sessionIds.unshift(header.id);
1286
+ assignedIds.add(header.id);
1287
+ added += 1;
1288
+ }
1289
+ if (sessionIds.length === original.length && sessionIds.every((id, i) => id === original[i])) return ws;
1290
+ return { ...ws, sessionIds, updatedAt: new Date().toISOString() };
1291
+ });
1292
+ if (removed || added) await registry.setState({ ...state, workspaces });
1293
+ });
1294
+ if (removed || added) ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
1295
+ } catch (error) {
1296
+ ctx.logger.warn('session-manager: post-boot reconcile failed: ' + safeErrorMessage(error));
1297
+ }
1298
+ }, "session-manager: post-boot re-fold stale projection cache rows");
1299
+
1300
+ /** Build a normalized annotation patch from one bulk action + payload.
1301
+ * Returns null when the payload is missing or malformed so the caller can
1302
+ * surface a 400 instead of letting the store throw on a bad value.
1303
+ */
1304
+ const annotationPatchFromBatchAction = (action, payload) => {
1305
+ if (!payload || typeof payload !== "object") return null;
1306
+ if (action === "favorite" || action === "unfavorite") return { favorite: action === "favorite" };
1307
+ if (action === "review" || action === "unreview") return { reviewLater: action === "review" };
1308
+ if (action === "set-priority") {
1309
+ const p = Number(payload.priority);
1310
+ if (!Number.isInteger(p) || p < 1 || p > 5) return null;
1311
+ return { priority: p };
1312
+ }
1313
+ if (action === "add-tags" || action === "remove-tags") {
1314
+ if (!Array.isArray(payload.tags)) return null;
1315
+ return action === "add-tags" ? { tags: payload.tags } : { tags: [] };
1316
+ }
1317
+ return null;
1318
+ };
1319
+
1320
+ /** Run one batch action against a single session id. Annotation-affecting
1321
+ * actions resolve the annotation accessor from the surrounding closure
1322
+ * so it can never be silently undefined. Session-management actions reuse
1323
+ * the existing helpers verbatim so move/preset-migrate validation and
1324
+ * live-session handling stay identical to the per-row buttons.
1325
+ */
1326
+ const runBatchAction = async ({ action, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter }) => {
1327
+ const annotationsAccessor = annotations;
1328
+ if (action === "favorite" || action === "unfavorite" || action === "review" || action === "unreview" || action === "set-priority" || action === "add-tags" || action === "remove-tags") {
1329
+ const patch = annotationPatchFromBatchAction(action, payload);
1330
+ if (patch === null) {
1331
+ const e = new Error("invalid batch payload for " + action); e.code = "bad-request"; throw e;
1332
+ }
1333
+ try {
1334
+ await annotationsAccessor().batchUpdate([sessionId], patch);
1335
+ } catch (error) {
1336
+ throw error;
1337
+ }
1338
+ return;
1339
+ }
1340
+ if (action === "archive") { await mutate(sessionId, () => archiveSession(sessionId, { stopActivity: payload?.stopActivity === true })); return; }
1341
+ if (action === "unarchive") { await mutate(sessionId, () => unarchiveSession(sessionId)); return; }
1342
+ if (action === "delete") { await mutate(sessionId, () => deleteSession(sessionId)); return; }
1343
+ if (action === "move") {
1344
+ const targetWorkspaceId = typeof payload?.targetWorkspaceId === "string" ? payload.targetWorkspaceId.trim() : "";
1345
+ if (!targetWorkspaceId) { const e = new Error("targetWorkspaceId required"); e.code = "bad-request"; throw e; }
1346
+ await mutate(sessionId, () => moveSession(sessionId, targetWorkspaceId));
1347
+ return;
1348
+ }
1349
+ if (action === "preset-migrate") {
1350
+ const toPreset = typeof payload?.toPreset === "string" ? payload.toPreset.trim() : "";
1351
+ if (!toPreset) { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
1352
+ await mutate(sessionId, () => migratePreset({ sessionId, toPreset }));
1353
+ return;
1354
+ }
1355
+ const snapshot = adapter.workspaceRegistry?.headers?.get?.(sessionId)
1356
+ ?? adapter.sessions?.list?.()?.find(s => s?.id === sessionId)?.header
1357
+ ?? null;
1358
+ const isSubagent = snapshot?.origin === "subagent";
1359
+ const isBlank = snapshot?.blank === true;
1360
+ if (isSubagent) { const e = new Error("subagent"); e.code = "subagent-skip"; throw e; }
1361
+ if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
1362
+ };
1363
+
1364
+ ctx.effect(() => ctx.webServer.register({ kind: "prefix",
1365
+ path: API_PREFIX,
1366
+ handler: async (req, res) => {
1367
+ try {
1368
+ const url = new URL(req.url ?? "/", "http://localhost");
1369
+ const path = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) || "/" : "/";
1370
+ let body = {};
1371
+ if (req.method === "POST") {
1372
+ const raw = await readBody(req);
1373
+ if (raw.trim() !== "") {
1374
+ try { body = JSON.parse(raw); }
1375
+ catch { return send(res, 400, { ok: false, error: "请求体不是合法 JSON" }); }
1376
+ }
1377
+ }
1378
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
1379
+ return send(res, 400, { ok: false, error: "请求体必须是 JSON 对象" });
1380
+ }
1381
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
1382
+ if (req.method === "GET" && path === "/annotations") {
1383
+ return send(res, 200, { ok: true, result: { annotations: await annotations().list() } });
1384
+ }
1385
+ if (req.method === "POST" && path === "/annotations") {
1386
+ assertSessionId(sessionId);
1387
+ const annotation = await mutate(sessionId, async () => {
1388
+ assertAnnotatable(sessionId);
1389
+ return annotations().update(sessionId, body.patch, body.expectedRevision);
1390
+ });
1391
+ return send(res, 200, { ok: true, result: { sessionId, annotation } });
1392
+ }
1393
+ if (req.method === "POST" && ["/delete", "/unarchive", "/move", "/preset-migrate"].includes(path)) {
1394
+ assertSessionId(sessionId);
1395
+ let result;
1396
+ if (path === "/delete") result = await mutate(sessionId, () => deleteSession(sessionId));
1397
+ else if (path === "/unarchive") {
1398
+ await mutate(sessionId, () => unarchiveSession(sessionId));
1399
+ result = { sessionId };
1400
+ } else if (path === "/move") {
1401
+ const targetWorkspaceId = typeof body.targetWorkspaceId === "string" ? body.targetWorkspaceId.trim() : "";
1402
+ if (!targetWorkspaceId) return send(res, 400, { ok: false, error: "targetWorkspaceId 必填" });
1403
+ result = await mutate(sessionId, () => moveSession(sessionId, targetWorkspaceId));
1404
+ } else {
1405
+ const toPreset = typeof body.toPreset === "string" ? body.toPreset.trim() : "";
1406
+ if (!toPreset) return send(res, 400, { ok: false, error: "toPreset 必填" });
1407
+ result = await mutate(sessionId, () => migratePreset({ sessionId, toPreset }));
1408
+ }
1409
+ return send(res, 200, { ok: true, result });
1410
+ }
1411
+ if (req.method === "POST" && path === "/batch") {
1412
+ // Per-action body shape (each action validates its own payload):
1413
+ // archive : no payload
1414
+ // unarchive : no payload
1415
+ // delete : no payload
1416
+ // move : { targetWorkspaceId }
1417
+ // preset-migrate: { toPreset }
1418
+ // favorite : no payload (defaults to true)
1419
+ // unfavorite : no payload
1420
+ // review : no payload
1421
+ // unreview : no payload
1422
+ // set-priority : { priority: 1..5 }
1423
+ // add-tags : { tags: string[] }
1424
+ // remove-tags : { tags: string[] }
1425
+ const rawAction = typeof body.action === "string" ? body.action : "";
1426
+ const rawIds = Array.isArray(body.sessionIds) ? body.sessionIds : [];
1427
+ const ids = Array.from(new Set(rawIds.filter(id => typeof id === "string" && id.trim() !== "").map(id => id.trim())));
1428
+ for (const id of ids) assertSessionId(id);
1429
+ if (ids.length === 0) return send(res, 400, { ok: false, error: "sessionIds 必须是非空数组", code: "bad-request" });
1430
+ if (ids.length > 200) return send(res, 400, { ok: false, error: "单次批量最多 200 条,请缩小筛选范围后重试", code: "batch-too-large" });
1431
+ const payload = body.payload && typeof body.payload === "object" ? body.payload : {};
1432
+ const BATCH_ACTIONS = new Set(["archive", "unarchive", "delete", "move", "preset-migrate", "favorite", "unfavorite", "review", "unreview", "set-priority", "add-tags", "remove-tags"]);
1433
+ if (!BATCH_ACTIONS.has(rawAction)) return send(res, 400, { ok: false, error: "action 不支持: " + rawAction, code: "bad-request" });
1434
+ const items = [];
1435
+ let success = 0, failed = 0, skipped = 0;
1436
+ for (const sessionId of ids) {
1437
+ try {
1438
+ await runBatchAction({ action: rawAction, sessionId, payload, mutate, deleteSession, unarchiveSession, moveSession, migratePreset, adapter });
1439
+ items.push({ sessionId, status: "success" });
1440
+ success++;
1441
+ } catch (error) {
1442
+ const code = error && typeof error.code === "string" ? error.code : undefined;
1443
+ if (code === "subagent-skip" || code === "blank-skip") {
1444
+ items.push({ sessionId, status: "skipped", reason: code });
1445
+ skipped++;
1446
+ } else {
1447
+ items.push({ sessionId, status: "failed", code, error: safeErrorMessage(error) });
1448
+ failed++;
1449
+ }
1450
+ }
1451
+ }
1452
+ return send(res, 200, { ok: true, result: {
1453
+ summary: { total: ids.length, success, failed, skipped },
1454
+ items,
1455
+ aborted: false,
1456
+ } });
1457
+ }
1458
+ if (req.method === "GET" && path === "/workspaces") {
1459
+ const sessionCreatedAt = Object.fromEntries(adapter.cachedSessionHeaders()
1460
+ .filter(header => typeof header.createdAt === "number" || typeof header.createdAt === "string")
1461
+ .map(header => [header.id, header.createdAt]));
1462
+ return send(res, 200, { ok: true, result: { workspaces: listWorkspaces(), sessionCreatedAt } });
1463
+ }
1464
+ if (req.method === "GET" && path === "/preset-scan") {
1465
+ const sessionId = url.searchParams.get("sessionId") || "";
1466
+ if (sessionId) assertSessionId(sessionId);
1467
+ const result = await scanPreset({ id: url.searchParams.get("workspaceId") || "", sessionId });
1468
+ return send(res, 200, { ok: true, result });
1469
+ }
1470
+ if (req.method === "GET" && path === "/update/check") {
1471
+ const currentVersion = adapter.pluginVersion() || "0.6.4";
1472
+ let latestVersion = currentVersion;
1473
+ let hasUpdate = false;
1474
+ try {
1475
+ const requestedRegistry = url.searchParams.get("registry")?.trim();
1476
+ const registry = (requestedRegistry && /^https?:\/\//i.test(requestedRegistry))
1477
+ ? requestedRegistry
1478
+ : (process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org");
1479
+ const base = registry.endsWith("/") ? registry : registry + "/";
1480
+ const pkgUrl = new URL("dsh-session-manager/latest", base).toString();
1481
+ const controller = new AbortController();
1482
+ const timer = setTimeout(() => controller.abort(), 6000);
1483
+ try {
1484
+ const response = await fetch(pkgUrl, {
1485
+ signal: controller.signal,
1486
+ headers: { "accept": "application/json" }
1487
+ });
1488
+ if (response.ok) {
1489
+ const data = await response.json();
1490
+ if (data && typeof data.version === "string") {
1491
+ latestVersion = data.version;
1492
+ }
1493
+ }
1494
+ } finally {
1495
+ clearTimeout(timer);
1496
+ }
1497
+ hasUpdate = compareSemver(latestVersion, currentVersion) > 0;
1498
+ return send(res, 200, {
1499
+ ok: true,
1500
+ result: { currentVersion, latestVersion, hasUpdate }
1501
+ });
1502
+ } catch (err) {
1503
+ return send(res, 200, {
1504
+ ok: false,
1505
+ code: "update-check-failed",
1506
+ error: "检查更新失败: " + safeErrorMessage(err),
1507
+ result: { currentVersion, latestVersion: currentVersion, hasUpdate: false }
1508
+ });
1509
+ }
1510
+ }
1511
+ if (req.method === "POST" && path === "/update/install") {
1512
+ const rawVersion = typeof body?.version === "string" ? body.version.trim() : "";
1513
+ if (rawVersion && !/^[a-zA-Z0-9_.-]+$/.test(rawVersion)) {
1514
+ return send(res, 400, { ok: false, code: "bad-request", error: "Invalid version format" });
1515
+ }
1516
+ const targetVersion = rawVersion || "latest";
1517
+ const spec = "dsh-session-manager@" + targetVersion;
1518
+ const registry = typeof body?.registry === "string" && /^https?:\/\//i.test(body.registry.trim()) ? body.registry.trim() : "";
1519
+ const pm = adapter.pluginManager;
1520
+ if (pm && typeof pm.installBundle === "function") {
1521
+ try {
1522
+ const prevRegistry = process.env.NPM_CONFIG_REGISTRY;
1523
+ if (registry) {
1524
+ process.env.NPM_CONFIG_REGISTRY = registry;
1525
+ }
1526
+ let outcome;
1527
+ try {
1528
+ outcome = await (registry ? pm.installBundle(spec, { registry }) : pm.installBundle(spec));
1529
+ } finally {
1530
+ if (registry) {
1531
+ if (prevRegistry !== undefined) {
1532
+ process.env.NPM_CONFIG_REGISTRY = prevRegistry;
1533
+ } else {
1534
+ delete process.env.NPM_CONFIG_REGISTRY;
1535
+ }
1536
+ }
1537
+ }
1538
+ const application = outcome?.application || "restart-required";
1539
+ return send(res, 200, { ok: true, result: { application, spec } });
1540
+ } catch (err) {
1541
+ return send(res, 500, { ok: false, code: "install-failed", error: safeErrorMessage(err) });
1542
+ }
1543
+ }
1544
+ return send(res, 501, {
1545
+ ok: false,
1546
+ code: "plugin-manager-unavailable",
1547
+ error: "当前宿主环境未提供 PluginManager 安装服务,请通过 DSH 插件市场或命令行更新"
1548
+ });
1549
+ }
1550
+ return send(res, 404, { ok: false, error: "not found: " + req.method + " " + path });
1551
+ } catch (error) {
1552
+ ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
1553
+ const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
1554
+ : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
1555
+ : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
1556
+ return send(res, status, { ok: false, error: safeErrorMessage(error), ...(typeof error?.code === "string" ? { code: error.code } : {}) });
1557
+ }
1558
+ }
1559
+ }), "session-manager: http api");
1560
+ }
1561
+
1562
+ export { listSessionHeaders, scanSessionHeaders, encodeSessionSegment, compareSemver };