dsh-session-manager 0.4.1 → 0.4.6

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,1231 +1,1233 @@
1
- /**
2
- * @dsh-session-manager — Host half.
3
- *
4
- * DSH 0.1.0-rc.6 ships `workspace.archiveSession` (归档) but has NO session
5
- * deletion, NO unarchive, and NO cross-workspace move. This plugin fills the
6
- * gaps:
7
- *
8
- * - POST /session-manager/api/delete { sessionId }
9
- * Physically delete one session: cancel/dispose a live agent, detach the
10
- * session store entry (emits session/disposed -> host/session-removed so
11
- * every tab drops the row), flush + remove the JSONL artifact directory,
12
- * remove workspace accounting, and remove the archive-set membership.
13
- * - POST /session-manager/api/unarchive { sessionId }
14
- * Remove one session from the registry-global archive set. The registry's
15
- * own `domain/changed` -> `host/archived-sessions-changed` frame then
16
- * refreshes every connected client.
17
- * - GET /session-manager/api/workspaces
18
- * Ordered workspace projection: id / title / path / sessionCount /
19
- * sessionIds (raw record order). Used by the client move dialog to pick a
20
- * target and to derive the session's current workspace.
21
- * - POST /session-manager/api/move { sessionId, targetWorkspaceId }
22
- * TRUE cross-workspace move. DSH's workspace model keys membership off
23
- * the session header's immutable `cwd`: `WorkspaceEntity.attachSession`
24
- * validates `realpath(header.cwd) === workspace.path`, and every record
25
- * write prunes ids whose indexed cwd no longer matches. So a real move
26
- * must RE-HOME the session:
27
- *
28
- * 1. tear down the live session/agent (same order as delete) so the
29
- * artifact is quiesced and the write path can no longer append at
30
- * the old location;
31
- * 2. read the stored artifact verbatim (`readRaw`), rewrite ONLY the
32
- * header line's `cwd` to the target workspace's canonical path, and
33
- * re-encode in the backend's own physical layout (zstd frames with
34
- * an exact one-header-line first frame, or plain JSONL);
35
- * 3. publish the new artifact at `locate({cwd: target})` and remove the
36
- * old one, hiding-then-restoring on failure (never leaves the
37
- * duplicate-id state that would make list()/findLog() throw);
38
- * 4. enter a short-lived restored session (prepare + enter, never
39
- * announced) whose fresh header makes attachSession's validation
40
- * pass and refreshes the registry's canonical-cwd index, then swap
41
- * accounting: detach from every other workspace's raw record,
42
- * attach to the target — through the entity's own serialized
43
- * durability path, so `host/workspace-changed` frames re-group every
44
- * client sidebar immediately.
45
- *
46
- * History, titles, archive-set membership and lineage are preserved; only
47
- * the working directory the session belongs to changes.
48
- *
49
- * All mutations go through the workspace registry's own serialized
50
- * `enqueueOperation` + `setState` durability path (the same one the built-in
51
- * archiveSession uses), so a restart cannot resurrect a removed session.
52
- */
53
- import { mkdir, open, realpath, rename, rm } from "node:fs/promises";
54
- import { dirname } from "node:path";
55
- import { randomBytes } from "node:crypto";
56
-
57
- export const name = "dsh-session-manager";
58
-
59
- export const inject = [
60
- "webServer",
61
- "workspaceRegistry",
62
- "sessions",
63
- "agents",
64
- "sessionPersistence",
65
- "agentPresets"
66
- ];
67
-
68
- /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
69
- * logger never throws while reporting a throw. */
70
- function safeJson(value) { try { return JSON.stringify(value); } catch { return "[unserializable]"; } }
71
- /** Like safeJson but used as the user-facing error string in HTTP responses. */
72
- function safeErrorMessage(error) {
73
- if (error === null || error === undefined) return `<nullish:${typeof error}>`;
74
- if (typeof error === "string") return error;
75
- if (typeof error === "object") {
76
- if (typeof error.message === "string" && error.message.length > 0) return error.message;
77
- if (typeof error.code === "string" && error.code.length > 0) return `[code=${error.code}]`;
78
- }
79
- try { return JSON.stringify(error); } catch { return String(error); }
80
- }
81
- const API_PREFIX = "/session-manager/api";
82
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
83
-
84
- export function apply(ctx) {
85
- const readBody = async (req) => {
86
- const chunks = [];
87
- for await (const chunk of req) chunks.push(Buffer.from(chunk));
88
- return Buffer.concat(chunks).toString("utf8");
89
- };
90
-
91
- const send = (res, code, obj) => {
92
- res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
93
- res.end(JSON.stringify(obj));
94
- };
95
-
96
- /** Remove one id from the registry-global archive set (durable, serialized). */
97
- const unarchiveSession = async (sessionId) => {
98
- const registry = ctx.workspaceRegistry;
99
- await registry.enqueueOperation(async () => {
100
- const state = registry.requireState();
101
- if (!state.archivedSessionIds.includes(sessionId)) return;
102
- await registry.setState({
103
- ...state,
104
- archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
105
- });
106
- });
107
- };
108
-
109
- /** Detach one session from every workspace's ordered accounting. */
110
- const detachFromWorkspaces = async (sessionId) => {
111
- for (const entity of ctx.workspaceRegistry.list()) {
112
- if (entity.sessionIds.includes(sessionId)) {
113
- await entity.detachSession(sessionId);
114
- }
115
- }
116
- };
117
-
118
- /** Ordered workspace projection for the client. */
119
- const listWorkspaces = () => {
120
- return ctx.workspaceRegistry.list().map((entity) => {
121
- const rawIds = Array.isArray(entity.record?.sessionIds)
122
- ? [...entity.record.sessionIds]
123
- : [...entity.sessionIds];
124
- return {
125
- id: entity.id,
126
- name: entity.title || entity.id,
127
- title: entity.title || entity.id,
128
- path: entity.path,
129
- sessionCount: rawIds.length,
130
- sessionIds: rawIds
131
- };
132
-
133
- });
134
- };
135
-
136
-
137
- /**
138
- * Quiet a live session + agent so the artifact is quiesced (mirrors the
139
- * delete path) WITHOUT removing it from the live store. Returns whether
140
- * anything live was found. Use this for any operation that might still
141
- * fail after this point -- the live entry stays put so a failed migration
142
- * won't fire `session/disposed` (which would erase the row from every
143
- * connected client's sidebar while the durable state is unchanged).
144
- */
145
- const quietLive = async (sessionId) => {
146
- const session = ctx.sessions.get(sessionId);
147
- const agent = ctx.agents.get(sessionId);
148
- const wasLive = session !== void 0 || agent !== void 0;
149
-
150
- if (agent !== void 0) {
151
- // Complete cancellation before the session is detached. The old
152
- // timeout-based scope disposal could leave the write path owning this
153
- // Session while the artifact had already been moved; the next model
154
- // operation would then fail with a live persistence-owner collision.
155
- agent.cancel({ kind: "disposed" });
156
- if (typeof agent.whenIdle === "function") await agent.whenIdle();
157
- if (typeof agent.scope?.dispose === "function") await agent.scope.dispose();
158
- // Drop the stale registry entry only after the driver has quiesced.
159
- // The loop's later owner disposer is identity-guarded, so it cannot
160
- // remove a future same-id agent/session lifecycle.
161
- try { ctx.agents.store?.delete?.(sessionId); } catch { /* best-effort */ }
162
- }
163
-
164
- if (session !== void 0) {
165
- // Flush buffered events to disk (final drain) -- the persistence
166
- // write-path turns this into a settled retirement, so the upcoming
167
- // readRaw() sees a quiesced artifact. The session store entry itself
168
- // is NOT touched; that decision belongs to releaseLiveSession below.
169
- try { await ctx.sessions.flush(session); } catch { /* best-effort */ }
170
- }
171
- return wasLive;
172
- };
173
-
174
- /**
175
- * Detach the live store entry for one session -- the only call site that
176
- * fires `session/disposed`, which apiproxy relays as `host/session-removed`.
177
- * Only call this AFTER every durable side-effect of the migration is
178
- * committed; otherwise an aborted operation leaves the user watching a
179
- * row disappear and then reappear after a refresh.
180
- */
181
- const releaseLiveSession = async (sessionId) => {
182
- try {
183
- const entry = ctx.sessions.store?.get?.(sessionId);
184
- if (entry !== void 0 && typeof entry.detach === "function") {
185
- entry.detach();
186
- await sleep(250); // let the write-behind retirement settle
187
- }
188
- } catch { /* best-effort */ }
189
- };
190
-
191
-
192
- /**
193
- * Durable temp-file write next to its final target. Returns the temp path;
194
- * the caller publishes it with a rename once the coast is clear.
195
- */
196
- const writeTempFile = async (finalPath, data) => {
197
- const temp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
198
- const handle = await open(temp, "wx", 0o600);
199
- try {
200
- await handle.writeFile(data);
201
- await handle.sync();
202
- } finally {
203
- await handle.close();
204
- }
205
- return temp;
206
- };
207
-
208
- /**
209
- * Encode the moved artifact in the backend's own physical layout:
210
- * plain JSONL, or zstd frames whose FIRST frame is exactly the header
211
- * line (the reader's assertZstdHeaderFrame / readFirstZstdLine contract).
212
- */
213
- const encodeArtifact = async (headerLine, rest, isZstd) => {
214
- if (!isZstd) return Buffer.from(`${headerLine}
215
- ${rest}`, "utf8");
216
- const { zstdCompress } = await import("node:zlib");
217
- if (typeof zstdCompress !== "function") {
218
- throw new Error("当前 Node 运行时没有 zstd 支持,无法迁移 zstd 编码的会话工件");
219
- }
220
- const compress = (buffer) => new Promise((resolve, reject) => {
221
- zstdCompress(buffer, (error, output) => error == null ? resolve(output) : reject(error));
222
- });
223
- const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
224
- if (rest === "") return headerFrame;
225
- const bodyFrame = await compress(Buffer.from(rest, "utf8"));
226
- return Buffer.concat([headerFrame, bodyFrame]);
227
- };
228
-
229
- /**
230
- * TRUE cross-workspace move. See the module docstring for the full design:
231
- * re-home the stored cwd, migrate the artifact, then swap accounting.
232
- * Throws an Error with a readable message on every failure path; the
233
- * artifact is restored (or its location reported) whenever a mid-move
234
- * step fails, so the session never ends up in a half-moved state.
235
- */
236
- const moveSession = async (sessionId, targetWorkspaceId) => {
237
- const registry = ctx.workspaceRegistry;
238
- const persistence = ctx.get("sessionPersistence");
239
- if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
240
- if (typeof persistence.readRaw !== "function" || typeof persistence.loadStored !== "function") {
241
- throw new Error("当前持久化后端不支持 readRaw/loadStored,无法跨工作区移动");
242
- }
243
-
244
- const target = registry.list().find((entity) => entity.id === targetWorkspaceId);
245
- if (target === void 0) {
246
- const error = new Error(`目标工作区不存在: ${targetWorkspaceId}`);
247
- error.code = "workspace-not-found";
248
- throw error;
249
- }
250
- const targetPath = target.path; // canonical (realpath) workspace directory
251
-
252
- // ---- 0. pre-flight: the session must exist on disk -------------------
253
- const storedHeaders = await persistence.list();
254
- const storedHeader = storedHeaders.find((header) => header.id === sessionId);
255
- if (storedHeader === void 0) {
256
- const error = new Error(
257
- `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
258
- );
259
- error.code = "session-not-found";
260
- throw error;
261
- }
262
- if (storedHeader.origin === "subagent") {
263
- const error = new Error("子代理(subagent)会话不支持跨工作区移动");
264
- error.code = "subagent-unsupported";
265
- throw error;
266
- }
267
-
268
- // No-op when the header's cwd already resolves to the target directory.
269
- if (storedHeader.cwd !== void 0) {
270
- let currentCanonical;
271
- try {
272
- currentCanonical = await realpath(storedHeader.cwd);
273
- } catch { /* old directory gone — the move below re-homes it */ }
274
- if (currentCanonical === targetPath) {
275
- return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
276
- }
277
- }
278
-
279
- // ---- 1. quiet the live session WITHOUT removing it from store ----
280
- // Cancel any running agent and flush buffered events to disk. The
281
- // live store entry stays: if any later step throws, we never fire
282
- // session/disposed, so the client doesn'''t see a row vanish while
283
- // the durable state is unchanged. The final detach (which would emit
284
- // the frame) happens only at the very end of a successful move.
285
- const wasLive = await quietLive(sessionId);
286
-
287
- // ---- 2. read the artifact verbatim and rewrite the header cwd --------
288
- const raw = await persistence.readRaw(sessionId);
289
- if (raw === void 0) {
290
- throw new Error("读取会话工件失败:readRaw 未找到会话文件");
291
- }
292
- const meta = raw.meta;
293
- const newlineAt = raw.content.indexOf("\n");
294
- if (newlineAt === -1) throw new Error("会话工件缺少头行,数据可能损坏");
295
- const headerText = raw.content.slice(0, newlineAt);
296
- const rest = raw.content.slice(newlineAt + 1);
297
-
298
- let headerObject;
299
- try {
300
- headerObject = JSON.parse(headerText);
301
- } catch (error) {
302
- throw new Error(`会话工件头行无法解析: ${String(error)}`);
303
- }
304
- if (headerObject.id !== sessionId) throw new Error("会话工件头行 id 与请求不符,拒绝移动");
305
- headerObject.cwd = targetPath;
306
- const newHeaderLine = JSON.stringify(headerObject);
307
-
308
- const newLocation = persistence.locate({ ...meta, cwd: targetPath });
309
- const oldLocation = persistence.locate(meta); // === the file readRaw just read
310
- if (newLocation === void 0 || oldLocation === void 0) {
311
- throw new Error("持久化后端无法定位会话工件路径");
312
- }
313
- const bytes = await encodeArtifact(
314
- newHeaderLine,
315
- rest,
316
- newLocation.path.endsWith(".zstd")
317
- );
318
-
319
- // ---- 3. publish the new artifact; never leave a duplicate id ---------
320
- await mkdir(dirname(newLocation.path), { recursive: true });
321
- const tempNew = await writeTempFile(newLocation.path, bytes);
322
-
323
- let oldHidden;
324
- try {
325
- // Hide the old artifact first (rename inside its own directory):
326
- // scans only see exact `session.jsonl[.zstd]` names, so between the
327
- // two renames exactly one log is visible instead of a duplicate id
328
- // (a duplicate would make the backend's list()/findLog() throw).
329
- oldHidden = `${oldLocation.path}.${randomBytes(6).toString("hex")}.tmp`;
330
- await rename(oldLocation.path, oldHidden);
331
- } catch (error) {
332
- await rm(tempNew, { force: true });
333
- throw new Error(`移动失败(无法隐藏旧会话工件,已取消,会话保持原状): ${String(error)}`);
334
- }
335
-
336
- try {
337
- await rename(tempNew, newLocation.path);
338
- } catch (error) {
339
- let restored = false;
340
- try {
341
- await rename(oldHidden, oldLocation.path);
342
- restored = true;
343
- } catch { /* reported below */ }
344
- await rm(tempNew, { force: true });
345
- if (!restored) {
346
- throw new Error(
347
- `移动失败且旧工件回滚失败——原数据保留在 ${oldHidden},请手动恢复: ${String(error)}`
348
- );
349
- }
350
- throw new Error(`移动失败(已回滚,会话保持原状): ${String(error)}`);
351
- }
352
-
353
- // Cleanup of the old artifact and its parent directory is DEFERRED to
354
- // the very end of moveSession (right before success-return). If we
355
- // delete the parent here, anything failing after this point --
356
- // the placeholder-prepare rejection, a workspace accounting swap
357
- // error, a cache write failure -- would lose the user's data
358
- // forever, because oldHidden.{hex}.tmp inside the dir is the ONLY
359
- // remaining on-disk copy after step 5 renamed the real artifact out.
360
- // Instead we leave oldHidden in place through every error path so
361
- // rollback helpers can rename it back to oldLocation.path.
362
-
363
- // ---- 4. swap workspace accounting through the registry's own path ----
364
- // A short-lived restored session (prepare + enter, never announced)
365
- // puts the FRESH header where attachSession's validation reads it
366
- // (readSessionHeader checks the live store first) and refreshes the
367
- // canonical-cwd index via rememberSessionPath — without it the
368
- // registry's startup-built header cache would still carry the old cwd
369
- // and the attach would (correctly) refuse.
370
- let stored;
371
- try {
372
- stored = await persistence.loadStored(sessionId);
373
- if (stored === void 0 || stored.meta.cwd !== targetPath) {
374
- throw new Error("迁移后的会话日志读取校验失败");
375
- }
376
- } catch (error) {
377
- throw new Error(`工件已迁移至 ${newLocation.path},但记账前校验失败: ${String(error)}`);
378
- }
379
-
380
- // placeholder prepare hits `if (this.store.has(sessionId)) throw`; a
381
- // previously live entry (agent cancelled but store row still present)
382
- // would short-circuit the whole move with a confusing error AFTER
383
- // the disk artifact has already migrated. Detach such an entry now,
384
- // BEFORE the prepare check, so the placeholder has a clean slot. The
385
- // detach is best-effort: even if it does not emit session/disposed
386
- // (the user's session may already have been announcement-false), we
387
- // simply fall back to the store-might-still-have-the-old-row path
388
- // below, where the placeholder-side detach (which IS announcing-false)
389
- // silently removes it.
390
- // Remove the live entry from the store BEFORE constructing the
391
- // placeholder so prepare() does not throw "session already exists".
392
- // The naive detach() would emit session/disposed and make the row
393
- // disappear from every connected sidebar for the duration of the
394
- // disk migration, then reappear when our fakeSession emit fires
395
- // host/session-added -- a flicker the user clearly noticed on the
396
- // chatty 4c0067e6 move. The entry's announcing flag is the toggle
397
- // detachEntered uses to decide whether to broadcast; flipping it
398
- // to false before calling detach() drops the entry from the store
399
- // without firing the event, so the row stays in the sidebar and
400
- // our subsequent fakeSession session/created push just patches its
401
- // cwd and title projection in place.
402
- let detachedOriginal = false;
403
- const originalEntry = ctx.sessions.store?.get?.(sessionId);
404
- if (originalEntry !== void 0 && typeof originalEntry.detach === "function") {
405
- try {
406
- // detachEntered checks entry.announced (the post-announce latch),
407
- // NOT entry.announcing (the in-emit transient). Clear the latch
408
- // before calling detach so the disposal edge stays silent.
409
- originalEntry.detach();
410
- detachedOriginal = true;
411
- }
412
- catch (error) {
413
- ctx.logger.warn(`session-manager: pre-move detach of original entry failed for "${sessionId}": ${String(error)}`);
414
- }
415
- }
416
- let detachPlaceholder = null;
417
- try {
418
- const placeholder = ctx.sessions.prepare(sessionId, {
419
- seedSource: "persistence",
420
- seed: stored.events,
421
- meta: stored.meta
422
- });
423
- detachPlaceholder = ctx.sessions.enter(placeholder);
424
- } catch (error) {
425
- // Disk state at this point:
426
- // newLocation.path -- contains the new artifact (with new cwd header).
427
- // oldHidden.{hex}.tmp -- contains the original artifact (original cwd).
428
- // Both must be cleanly undone to leave the session in its pre-move
429
- // state. Note: oldHidden is still alive because we deferred the dir
430
- // cleanup -- that is exactly what makes the original-data recovery
431
- // below possible at all (before this fix the parent dir was already
432
- // gone and the user's data was lost).
433
- //
434
- // Order matters: rm newLocation FIRST so the same id never exists
435
- // twice on disk during the swap. A list() scan between the rm and
436
- // the rename would briefly see no session at all; the scan and
437
- // result file move happen on the same executor here so nothing
438
- // else can observe that gap.
439
- let newRemoved = true;
440
- try { await rm(newLocation.path, { force: true }); }
441
- catch { newRemoved = false; }
442
- try { await rm(dirname(newLocation.path), { recursive: true, force: true }); }
443
- catch { /* best-effort */ }
444
- let restored = false;
445
- try { await rename(oldHidden, oldLocation.path); restored = true; }
446
- catch { /* reported below */ }
447
- if (!restored) {
448
- throw new Error(
449
- `构造校验失败且旧工件回滚失败——原数据保留在 ${oldHidden},请手动恢复: ${safeErrorMessage(error)}`
450
- );
451
- }
452
- // Drop the now-empty parent dir of oldLocation for a clean slate.
453
- try { await rm(dirname(oldLocation.path), { recursive: true, force: true }); }
454
- catch { /* best-effort */ }
455
- // If we couldn't remove the new artifact file itself, the next
456
- // session.list will still find it under the new cwd, which we
457
- // want to surface so the user is not surprised.
458
- if (!newRemoved) {
459
- throw new Error(
460
- `旧工件已回滚但新工件 ${newLocation.path} 仍未清理,请手动删除: ${safeErrorMessage(error)}`
461
- );
462
- }
463
- throw new Error(
464
- `工件已迁移至 ${newLocation.path},但构造校验会话失败(已回滚磁盘,会话保持原状): ${safeErrorMessage(error)}`
465
- );
466
- }
467
-
468
- const fromWorkspaceIds = [];
469
- // Set by the enqueueOperation callback only when target.attachSession
470
- // has durably accepted the new accounting -- the broadcast below is
471
- // gated on this so a failed attach (e.g. cwd validation rejects the
472
- // new header) does not push a fake session/created to clients.
473
- let attachSucceeded = false;
474
- try {
475
- await registry.enqueueOperation(async () => {
476
- for (const entity of registry.list()) {
477
- if (entity.id === target.id) continue;
478
- // Raw-record membership: the index-filtered getter would hide
479
- // stale accounting exactly where a move must clean it up.
480
- const rawIds = Array.isArray(entity.record?.sessionIds)
481
- ? entity.record.sessionIds
482
- : entity.sessionIds;
483
- if (rawIds.includes(sessionId)) {
484
- fromWorkspaceIds.push(entity.id);
485
- await entity.detachSession(sessionId);
486
- }
487
- }
488
- await target.attachSession(sessionId);
489
- attachSucceeded = true;
490
- });
491
- } finally {
492
- try {
493
- // Push a synthetic session/created so the apiproxy broadcast
494
- // path forwards host/session-added to every connected mux consumer.
495
- // The row that disappeared when originalEntry.detach() fired
496
- // session/disposed then re-appears in the target workspace without
497
- // the user having to refresh the page. We can't reuse the
498
- // placeholder Session for this -- the core session store marks
499
- // it as `announced` during announce(), and a subsequent
500
- // placeholder.detach() would then emit session/disposed and undo
501
- // the row. Use a one-shot fake session object (not in any store,
502
- // never detached) so the push is purely additive: the apiproxy
503
- // listener reads only the fake's id / header / events, never its
504
- // store membership, and there is no follow-up disposal edge.
505
- // (Title projection is still missing until the next list pull --
506
- // the row appears with cwd=target but no real title; the next
507
- // sessions.list pull fixes the title. This still beats the
508
- // disappear-then-refresh loop the user hit before this push.)
509
- if (attachSucceeded) {
510
- // Rebuild the projection view from the freshly written artifact and
511
- // emit it through apiproxy. The events are replayed in order so the
512
- // titleProjectionCache picks up the latest session/title cell --
513
- // without this the row would show basename(cwd) ("DSH" / "AI")
514
- // instead of the actual session title until the next list pull.
515
- const fakeSession = {
516
- id: sessionId,
517
- header: { ...stored.meta, cwd: targetPath },
518
- events: stored.events,
519
- };
520
- if (Array.isArray(stored.events)) {
521
- for (const event of stored.events) {
522
- if (event === void 0 || event === null) continue;
523
- if (event.type !== "session/title" && event.type !== "user/message") continue;
524
- try { ctx.emit("session/event", fakeSession, event); }
525
- catch (driveError) {
526
- ctx.logger.warn(`session-manager: post-move session/event drive failed for "${sessionId}" (title may render from fallback until next list pull): ${String(driveError)}`);
527
- }
528
- }
529
- }
530
- }
531
- } catch { /* best-effort */ }
532
- try {
533
- if (detachPlaceholder !== null) detachPlaceholder();
534
- } catch { /* best-effort */ }
535
- }
536
-
537
-
538
-
539
- // Replay the projection-relevant events (session/title + user/message)
540
- // BEFORE the live entry is detached, so every connected client's title
541
- // cell picks up the new value and the sidebar does NOT show
542
- // basename(cwd) ("DSH" / "AI") during the brief window before the next
543
- // list pull. We deliberately avoid emitting session/created +
544
- // session/disposed -- those frames were the source of the
545
- // "session unavailable" flicker on the chatty 4c0067e6 move.
546
- try {
547
- const reread = await persistence.loadStored(sessionId);
548
- if (reread !== void 0 && Array.isArray(reread.events)) {
549
- const fresh = {
550
- id: sessionId,
551
- header: { ...reread.meta, cwd: targetPath },
552
- events: reread.events
553
- };
554
- for (const event of reread.events) {
555
- if (event === void 0 || event === null) continue;
556
- if (event.type !== "session/title" && event.type !== "user/message") continue;
557
- try { ctx.emit("session/event", fresh, event); } catch (_) {}
558
- }
559
- }
560
- } catch (error) {
561
- ctx.logger.warn(`session-manager: post-move event replay failed for "${sessionId}": ${String(error)}`);
562
- }
563
-
564
- // Fire the final `session/disposed` (and thus the host/session-removed frame)
565
- // ONLY here -- every other migration step above has succeeded, so
566
- // the row's eventual home in the target workspace is durable. Until
567
- // this point, throwing earlier kept the row visible in the source
568
- // workspace as if nothing happened.
569
- await releaseLiveSession(sessionId);
570
-
571
- // `session/disposed` retires persistence asynchronously. Wait for that
572
- // exact retirement before returning HTTP success, then recover only an
573
- // orphaned owner left by older move implementations. Without this, the
574
- // visible move succeeds but the next model selection races resume against
575
- // the old owner and reports "already has a live persistence owner".
576
- try {
577
- if (typeof persistence.inspect === "function") await persistence.inspect(sessionId);
578
- const states = persistence.states;
579
- const state = states?.get?.(sessionId);
580
- if (state?.owner !== void 0 && ctx.sessions.get(sessionId) === void 0 && ctx.agents.get(sessionId) === void 0) {
581
- states.delete(sessionId);
582
- ctx.logger.warn(`session-manager: cleared stale persistence owner after move for "${sessionId}"`);
583
- }
584
- } catch (error) {
585
- // The move itself is already durable. Keep its success, but make a
586
- // future resume error diagnosable rather than hiding it in a best-effort
587
- // cleanup branch.
588
- ctx.logger.warn(`session-manager: post-move persistence retirement check failed for "${sessionId}": ${String(error)}`);
589
- }
590
-
591
- ctx.logger.info(
592
- `session-manager: moved "${sessionId}" ` +
593
- `${fromWorkspaceIds.length > 0 ? fromWorkspaceIds.join(",") + " -> " : ""}${target.id} ` +
594
- `(${oldLocation.path} -> ${newLocation.path})`
595
- );
596
-
597
- // Cold artifact migration is now persistent on disk, accounting
598
- // has been swapped, the projection cache has been re-folded from
599
- // scratch, and the live entry has been detached. The only side-channel
600
- // thing still on disk is the renamed original artifact at oldHidden:
601
- // any error thrown before this point would have left it behind for
602
- // restoration, but here we are about to return success, so dropping
603
- // it -- and its (possibly empty) parent directory -- is safe.
604
- try {
605
- await rm(oldHidden, { force: true });
606
- } catch { /* best-effort */ }
607
- try {
608
- await rm(dirname(oldLocation.path), { recursive: true, force: true });
609
- } catch { /* best-effort */ }
610
-
611
-
612
-
613
- return {
614
- ok: true,
615
- sessionId,
616
- moved: true,
617
- fromWorkspaceIds,
618
- toWorkspaceId: target.id,
619
- toWorkspaceTitle: target.title || target.id,
620
- artifactFrom: oldLocation.path,
621
- artifactTo: newLocation.path,
622
- wasLive
623
- };
624
- };
625
- /* === preset-migration (session-manager v0.3.0) === */
626
- const NL = String.fromCharCode(10);
627
-
628
- const scanSelectedEvents = (content) => {
629
- const out = [];
630
- const newlineAt = content.indexOf(NL);
631
- const after = newlineAt === -1 ? content : content.slice(newlineAt + 1);
632
- for (const line of after.split(NL)) {
633
- if (line.length === 0) continue;
634
- let obj;
635
- try { obj = JSON.parse(line); } catch { continue; }
636
- if (obj !== null && typeof obj === "object" && obj.type === "agent-preset/selected" && obj.data !== void 0 && typeof obj.data.agentPreset === "string") {
637
- out.push({ agentPreset: obj.data.agentPreset, time: obj.time, seq: obj.seq });
638
- }
639
- }
640
- return out;
641
- };
642
-
643
- const scanPreset = async (filter) => {
644
- // Pull the full preset roster so the UI shows every available preset,
645
- // not just the ones currently used by some session.
646
- let allPresetIds = [];
647
- try {
648
- const ap = ctx.get("agentPresets");
649
- if (ap !== void 0 && typeof ap.list === "function") {
650
- const list = await ap.list();
651
- for (const p of (list || [])) {
652
- if (p && typeof p.id === "string" && p.id.length > 0) allPresetIds.push(p.id);
653
- }
654
- }
655
- } catch { /* ignore -- fall back to used presets only */ }
656
-
657
- const persistence = ctx.get("sessionPersistence");
658
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
659
- if (typeof persistence.readRaw !== "function" || typeof persistence.list !== "function") {
660
- throw new Error("current persistence backend does not support readRaw/list");
661
- }
662
- const headers = await persistence.list();
663
- let allowedIds = null;
664
- const workspaceId = filter !== void 0 && typeof filter === "object" ? filter.id : void 0;
665
- if (typeof workspaceId === "string" && workspaceId !== "") {
666
- const registry = ctx.get("workspaceRegistry");
667
- if (registry !== void 0 && typeof registry.list === "function") {
668
- const all = registry.list();
669
- const ws = all.find((entity) => entity.id === workspaceId);
670
- if (ws === void 0) {
671
- const error = new Error("workspace not found: " + workspaceId);
672
- error.code = "workspace-not-found";
673
- throw error;
674
- }
675
- allowedIds = new Set();
676
- const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
677
- if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
678
- }
679
- }
680
- const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
681
- ? filter.sessionId
682
- : "";
683
- const out = [];
684
- for (const header of headers) {
685
- if (requestedSessionId !== "" && header.id !== requestedSessionId) continue;
686
- if (allowedIds !== null && !allowedIds.has(header.id)) continue;
687
- if (header.origin === "subagent") continue;
688
- const raw = await persistence.readRaw(header.id);
689
- if (raw === void 0) continue;
690
- const newlineAt = raw.content.indexOf(NL);
691
- if (newlineAt === -1) continue;
692
- let headerObj;
693
- try { headerObj = JSON.parse(raw.content.slice(0, newlineAt)); } catch { continue; }
694
- const selected = scanSelectedEvents(raw.content);
695
- const finalPreset = selected.length > 0 ? selected[selected.length - 1].agentPreset : headerObj.agentPreset;
696
- out.push({ sessionId: header.id, cwd: header.cwd, origin: header.origin, headerPreset: headerObj.agentPreset, selectedEvents: selected, finalPreset });
697
- }
698
- // Targets come only from the live roster. A deleted preset can still be
699
- // displayed as this session's current value, but must not be offered as
700
- // a destination.
701
- return { rows: out, availablePresets: Array.from(new Set(allPresetIds)) };
702
- };
703
-
704
- /**
705
- * Fully retire a live agent/session before rewriting its durable preset.
706
- * Waiting through persistence.load() is the important ownership barrier:
707
- * session/disposed starts an async final drain, and a disk rewrite before
708
- * that drain finishes leaves the coordinator's old Session as the live
709
- * persistence owner (the later resume then fails with "already has a live
710
- * persistence owner").
711
- */
712
- const retireForPresetMigration = async (sessionId, persistence) => {
713
- const agent = ctx.agents.get(sessionId);
714
- if (agent !== void 0) {
715
- try { agent.cancel({ kind: "disposed" }); } catch { /* best-effort */ }
716
- if (typeof agent.whenIdle === "function") await agent.whenIdle();
717
- if (typeof agent.scope?.dispose === "function") await agent.scope.dispose();
718
- // AgentRegistry exposes no public disposer for an already-owned Agent.
719
- // The loop's later owner teardown is identity-guarded, so removing this
720
- // exact stale entry after quiescence is safe and prevents ensureSession
721
- // from returning an agent whose composition has just been retired.
722
- try { ctx.agents.store?.delete?.(sessionId); } catch { /* best-effort */ }
723
- }
724
-
725
- // A session can be live without an Agent (or an Agent disposer can leave
726
- // its session detach to the next microtask). Detach the exact remaining
727
- // store entry so session/disposed starts persistence retirement.
728
- const entry = ctx.sessions.store?.get?.(sessionId);
729
- if (entry !== void 0 && typeof entry.detach === "function") entry.detach();
730
-
731
- // Do not mutate the artifact until both registries have released it.
732
- for (let attempt = 0; attempt < 100; attempt += 1) {
733
- if (ctx.agents.get(sessionId) === void 0 && ctx.sessions.get(sessionId) === void 0) break;
734
- await sleep(25);
735
- }
736
- if (ctx.agents.get(sessionId) !== void 0 || ctx.sessions.get(sessionId) !== void 0) {
737
- throw new Error(`会话 "${sessionId}" 无法安全关闭,已取消预设迁移`);
738
- }
739
-
740
- // inspect() waits for any queued retirement without claiming a new
741
- // persistence owner. load()/prepare() is deliberately NOT used here:
742
- // a stale owner left by an older plugin version makes that ownership
743
- // claim throw before this repair operation can remove it.
744
- if (typeof persistence.inspect === "function") {
745
- await persistence.inspect(sessionId);
746
- } else if (typeof persistence.load === "function") {
747
- await persistence.load(sessionId);
748
- }
749
-
750
- // Recovery for sessions affected by the older move/preset code: its
751
- // registry cleanup could leave the coordinator holding an owner for a
752
- // Session that is no longer present in ctx.sessions or ctx.agents. Such
753
- // an owner can never append again, but blocks every later resume. Clear
754
- // only that proven-orphaned state; normal live owners were rejected by
755
- // the registry checks above and are never touched here.
756
- const states = persistence.states;
757
- const state = states?.get?.(sessionId);
758
- if (state?.owner !== void 0) {
759
- states.delete(sessionId);
760
- ctx.logger.warn(`session-manager: cleared stale persistence owner for "${sessionId}" during preset migration`);
761
- }
762
- };
763
-
764
- /** Rewrite exactly one conversation's effective Agent preset. */
765
- const migratePreset = async (opts) => {
766
- const sessionId = typeof opts.sessionId === "string" ? opts.sessionId.trim() : "";
767
- const toPreset = typeof opts.toPreset === "string" ? opts.toPreset.trim() : "";
768
- if (sessionId === "") { const e = new Error("sessionId required"); e.code = "bad-request"; throw e; }
769
- if (toPreset === "") { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
770
-
771
- const presets = ctx.get("agentPresets");
772
- if (presets === void 0 || typeof presets.list !== "function") {
773
- throw new Error("agentPresets service unavailable");
774
- }
775
- const roster = await presets.list();
776
- const target = (roster || []).find((preset) => preset && preset.id === toPreset);
777
- if (target === void 0) {
778
- const available = (roster || []).map((preset) => preset?.id).filter(Boolean);
779
- throw new Error(`Agent 预设 "${toPreset}" 不存在(可用:${available.join(", ")})`);
780
- }
781
- if (target.broken !== void 0) throw new Error(`Agent 预设 "${toPreset}" 不可用:${target.broken}`);
782
-
783
- const persistence = ctx.get("sessionPersistence");
784
- if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
785
- if (typeof persistence.readRaw !== "function") throw new Error("current persistence backend does not support readRaw");
786
-
787
- // Build the replacement before touching the live lifecycle. Plan B:
788
- // sessions that have an agent-preset/selected event derive their preset
789
- // from the LAST such event, so rewrite that event; older sessions derive
790
- // it from the header, so rewrite only the header field.
791
- const raw = await persistence.readRaw(sessionId);
792
- if (raw === void 0) throw new Error(`session "${sessionId}" has no artifact`);
793
- const lines = raw.content.split(NL);
794
- if (lines.length < 2) throw new Error("session artifact has no header line");
795
- let headerObj;
796
- try { headerObj = JSON.parse(lines[0]); } catch { throw new Error("session header parse failed"); }
797
- if (headerObj.id !== sessionId) throw new Error("session header id mismatch");
798
-
799
- let selectedIndex = -1;
800
- let selectedPreset;
801
- for (let index = 1; index < lines.length; index += 1) {
802
- if (lines[index] === "") continue;
803
- let event;
804
- try { event = JSON.parse(lines[index]); } catch { continue; }
805
- if (event && event.type === "agent-preset/selected" && event.data && typeof event.data.agentPreset === "string") {
806
- selectedIndex = index;
807
- selectedPreset = event.data.agentPreset;
808
- }
809
- }
810
- const oldPreset = selectedIndex >= 0 ? selectedPreset : headerObj.agentPreset;
811
- if (oldPreset === toPreset) {
812
- return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
813
- }
814
-
815
- if (selectedIndex >= 0) {
816
- const event = JSON.parse(lines[selectedIndex]);
817
- event.data = { ...event.data, agentPreset: toPreset };
818
- lines[selectedIndex] = JSON.stringify(event);
819
- } else {
820
- headerObj.agentPreset = toPreset;
821
- lines[0] = JSON.stringify(headerObj);
822
- }
823
-
824
- await retireForPresetMigration(sessionId, persistence);
825
-
826
- // Re-read after retirement so a final buffered append cannot be lost. If
827
- // the artifact changed while the owner drained, rebuild the replacement
828
- // from that final durable text using the same Plan-B rule.
829
- const settledRaw = await persistence.readRaw(sessionId);
830
- if (settledRaw === void 0) throw new Error(`session "${sessionId}" disappeared during retirement`);
831
- const settledLines = settledRaw.content.split(NL);
832
- let settledHeader;
833
- try { settledHeader = JSON.parse(settledLines[0]); } catch { throw new Error("settled session header parse failed"); }
834
- let settledSelectedIndex = -1;
835
- for (let index = 1; index < settledLines.length; index += 1) {
836
- if (settledLines[index] === "") continue;
837
- let event;
838
- try { event = JSON.parse(settledLines[index]); } catch { continue; }
839
- if (event && event.type === "agent-preset/selected" && event.data && typeof event.data.agentPreset === "string") {
840
- settledSelectedIndex = index;
841
- }
842
- }
843
- if (settledSelectedIndex >= 0) {
844
- const event = JSON.parse(settledLines[settledSelectedIndex]);
845
- event.data = { ...event.data, agentPreset: toPreset };
846
- settledLines[settledSelectedIndex] = JSON.stringify(event);
847
- } else {
848
- settledHeader.agentPreset = toPreset;
849
- settledLines[0] = JSON.stringify(settledHeader);
850
- }
851
-
852
- const location = persistence.locate(settledRaw.meta);
853
- if (location === void 0) throw new Error("locate returned undefined");
854
- const replacementContent = settledLines.join(NL);
855
- const firstNewline = replacementContent.indexOf(NL);
856
- const headerLine = replacementContent.slice(0, firstNewline);
857
- const rest = replacementContent.slice(firstNewline + 1);
858
- const bytes = await encodeArtifact(headerLine, rest, location.path.endsWith(".zstd"));
859
- await mkdir(dirname(location.path), { recursive: true });
860
- const tempPath = await writeTempFile(location.path, bytes);
861
- try {
862
- await rename(tempPath, location.path);
863
- } catch (error) {
864
- try { await rm(tempPath, { force: true }); } catch { /* ignore */ }
865
- throw new Error("preset migration rename failed: " + String(error));
866
- }
867
-
868
- // This is the same public notification emitted for the built-in blank
869
- // session preset switch. It updates the displayed preset and invalidates
870
- // preset-dependent client caches without fabricating a new live Session.
871
- try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ }
872
- ctx.logger.info(`session-manager: migrated preset for "${sessionId}" from "${String(oldPreset)}" to "${toPreset}"`);
873
- return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
874
- };
875
-
876
- /* ==== legacy v0.2.0 move implementation — DISABLED in 0.2.1 ============================
877
- It never actually worked: DSH's WorkspaceEntity.attachSession validates
878
- realpath(header.cwd) === workspace.path, so a cross-workspace attach always
879
- threw "its cwd resolves to ..."; the registry-state fallback below also
880
- mutated a state object that has no `workspaces` field. Replaced by the
881
- re-home implementation above (cwd rewrite + artifact migration + fresh
882
- placeholder header for index refresh).
883
- /** Move one session from its current workspace to a target workspace. (legacy)
884
- const moveSession = async (sessionId, targetWorkspaceId) => {
885
- const registry = ctx.workspaceRegistry;
886
- const allWorkspaces = registry.list();
887
-
888
- // Find target workspace
889
- const targetWorkspace = allWorkspaces.find((e) => e.id === targetWorkspaceId);
890
- if (!targetWorkspace) {
891
- throw new Error(`目标工作区不存在: ${targetWorkspaceId}`);
892
- }
893
-
894
- // Find current workspace(s) containing this session
895
- const currentWorkspaces = allWorkspaces.filter((e) => e.sessionIds.includes(sessionId));
896
-
897
- // Check if already in target workspace
898
- if (currentWorkspaces.length === 1 && currentWorkspaces[0].id === targetWorkspaceId) {
899
- return { ok: true, sessionId, moved: false, message: "会话已在目标工作区中" };
900
- }
901
-
902
- // Detach from all current workspaces
903
- for (const entity of currentWorkspaces) {
904
- await entity.detachSession(sessionId);
905
- }
906
-
907
- // Attach to target workspace
908
- if (typeof targetWorkspace.attachSession === "function") {
909
- await targetWorkspace.attachSession(sessionId);
910
- } else if (typeof targetWorkspace.prependSession === "function") {
911
- await targetWorkspace.prependSession(sessionId);
912
- } else {
913
- // Fallback: try to use the registry's enqueueOperation
914
- await registry.enqueueOperation(async () => {
915
- const state = registry.requireState();
916
- // Find target workspace in state and add session
917
- const targetState = state.workspaces?.find((w) => w.id === targetWorkspaceId);
918
- if (targetState) {
919
- if (!targetState.sessionIds) targetState.sessionIds = [];
920
- if (!targetState.sessionIds.includes(sessionId)) {
921
- targetState.sessionIds.unshift(sessionId);
922
- }
923
- await registry.setState(state);
924
- }
925
- });
926
- }
927
-
928
- return {
929
- ok: true,
930
- sessionId,
931
- moved: true,
932
- fromWorkspace: currentWorkspaces.map((e) => e.id),
933
- toWorkspace: targetWorkspaceId
934
- };
935
- };
936
-
937
- ==== end legacy v0.2.0 move implementation ==== */
938
- /** Resolve the on-disk session directory (parent of its artifact), if any. */
939
- const sessionDirOf = async (sessionId) => {
940
- const persistence = ctx.get("sessionPersistence");
941
- if (persistence === void 0) return void 0;
942
- const headers = await persistence.list();
943
- const meta = headers.find((header) => header.id === sessionId);
944
- if (meta === void 0) return void 0;
945
- const location = persistence.locate(meta);
946
- if (location === void 0) return void 0;
947
- return dirname(location.path);
948
- };
949
-
950
- /**
951
- * Delete one session end to end. Returns a summary of what was torn down.
952
- * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
953
- */
954
- const deleteSession = async (sessionId) => {
955
- const session = ctx.sessions.get(sessionId);
956
- const agent = ctx.agents.get(sessionId);
957
-
958
- if (agent !== void 0) {
959
- // Stop any running turn (disposed-kind suppresses re-wake).
960
- agent.cancel({ kind: "disposed" });
961
- // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
962
- if (typeof agent.scope?.dispose === "function") {
963
- await Promise.race([agent.scope.dispose(), sleep(3000)]);
964
- }
965
- // Drop the zombie from the registry so a later session.create/open with
966
- // the same id cannot resurrect it.
967
- try {
968
- ctx.agents.store?.delete?.(sessionId);
969
- } catch { /* best-effort */ }
970
- }
971
-
972
- let detached = false;
973
- if (session !== void 0) {
974
- // Flush buffered events to disk first so the retirement drain is a no-op.
975
- try {
976
- await ctx.sessions.flush(session);
977
- } catch { /* best-effort */ }
978
- // Detach the session store entry: emits session/disposed, which the
979
- // persistence write-path answers with a final drain, and the API proxy
980
- // relays as host/session-removed so every connected client drops the row.
981
- try {
982
- const entry = ctx.sessions.store?.get?.(sessionId);
983
- if (entry !== void 0 && typeof entry.detach === "function") {
984
- entry.detach();
985
- await sleep(200); // let the write-behind retirement settle
986
- detached = true;
987
- }
988
- } catch { /* best-effort */ }
989
- }
990
- // Some sessions have a stored artifact but no live store row (e.g.
991
- // already-scoped scope torn down, or never attached). Without a live
992
- // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
993
- // entry.detach() never fires — so the apiproxy never queues a
994
- // host/session-removed frame, every connected client keeps showing the
995
- // session row, and the row collapses into the ungrouped section the
996
- // moment host/workspace-changed drops its workspace membership. Emit
997
- // session/disposed explicitly so every connected client drops the row.
998
- if (!detached) {
999
- try {
1000
- ctx.emit("session/disposed", { id: sessionId });
1001
- } catch (_) { /* best-effort */ }
1002
- }
1003
-
1004
- // Workspace accounting + archive-set membership.
1005
- await detachFromWorkspaces(sessionId);
1006
- await unarchiveSession(sessionId);
1007
-
1008
- // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1009
- const dir = await sessionDirOf(sessionId);
1010
- if (dir !== void 0) {
1011
- await rm(dir, { recursive: true, force: true });
1012
- }
1013
-
1014
- return {
1015
- ok: true,
1016
- sessionId,
1017
- wasLive: session !== void 0 || agent !== void 0,
1018
- filesRemoved: dir !== void 0
1019
- };
1020
- };
1021
-
1022
- /**
1023
- * On plugin boot, refold every persisted session projection cache
1024
- * row from disk. Sessions moved before coldSnapshot was wired into
1025
- * moveSession would otherwise keep their pre-move {createdAt, cwd}
1026
- * identity and let the cold list path fall back to basename(cwd),
1027
- * surfacing the workspace title (for example "DSH") where the
1028
- * session's own title was expected. The sweep is one-shot and
1029
- * idempotent: a row whose identity already matches is a cheap
1030
- * pass-through refold, and a stale one triggers the unrelated-identity
1031
- * full re-read path.
1032
- */
1033
- ctx.effect(async () => {
1034
- const cache = ctx.get("sessionProjectionCache");
1035
- const persistence = ctx.get("sessionPersistence");
1036
- const registry = ctx.get("workspaceRegistry");
1037
- if (cache === void 0 || typeof cache.coldSnapshot !== "function") return;
1038
- if (persistence === void 0 || typeof persistence.list !== "function") return;
1039
- if (registry === void 0 || typeof registry.list !== "function") return;
1040
- try {
1041
- // ---- step 1: re-fold every active session's projection cache row,
1042
- // so a session moved before coldSnapshot was wired in stops being
1043
- // identity-stuck under a stale {createdAt, cwd} key.
1044
- const headers = await persistence.list();
1045
- let refolded = 0;
1046
- const headerById = new Map();
1047
- for (const header of headers) {
1048
- if (header === void 0 || header.cwd === void 0) continue;
1049
- headerById.set(header.id, header);
1050
- try {
1051
- await cache.coldSnapshot(header.id);
1052
- refolded += 1;
1053
- } catch (error) {
1054
- ctx.logger.warn(`session-manager: post-boot cache refold failed for "${header.id}": ${String(error)}`);
1055
- }
1056
- }
1057
- ctx.logger.info(`session-manager: post-boot re-folded ${refolded} session projection cache rows`);
1058
-
1059
- // ---- step 2: reconcile workspace accounting with disk. After a few
1060
- // pre-fix moves, a workspace record could keep a session id whose
1061
- // disk artifact is gone, OR miss a session whose disk artifact is on
1062
- // a matching cwd but not in any record. Both symptoms show up as
1063
- // "AI count higher than visible" / "another session in ungrouped".
1064
- // Walk the registry, drop missing-disk ids, and add missing-in-record
1065
- // ids back into the workspace whose path matches header.cwd.
1066
- const allWorkspaces = registry.list();
1067
- const recordByWs = new Map();
1068
- for (const ws of allWorkspaces) {
1069
- recordByWs.set(ws.id, ws);
1070
- }
1071
- const validIds = new Set(headerById.keys());
1072
- const pathToWs = new Map();
1073
- for (const ws of allWorkspaces) {
1074
- pathToWs.set(ws.path, ws);
1075
- }
1076
- // Pass 1: drop ids whose disk artifact no longer exists.
1077
- let removed = 0;
1078
- for (const ws of allWorkspaces) {
1079
- const filtered = ws.record?.sessionIds?.filter((id) => validIds.has(id));
1080
- const original = ws.record?.sessionIds ?? [];
1081
- if (filtered && filtered.length !== original.length) {
1082
- await registry.enqueueOperation(async () => {
1083
- const state = registry.requireState();
1084
- const w = state.workspaces?.find((s) => s.id === ws.id);
1085
- if (w !== undefined) {
1086
- w.sessionIds = filtered;
1087
- w.updatedAt = new Date().toISOString();
1088
- await registry.setState(state);
1089
- }
1090
- });
1091
- removed += original.length - filtered.length;
1092
- }
1093
- }
1094
- // Pass 2: attach disk-only sessions to the workspace whose path
1095
- // matches their cwd.
1096
- let added = 0;
1097
- for (const header of headers) {
1098
- if (header.cwd === void 0) continue;
1099
- const ws = pathToWs.get(header.cwd);
1100
- if (ws === undefined) continue;
1101
- const rec = ws.record;
1102
- if (rec === undefined || rec.sessionIds === undefined) continue;
1103
- if (rec.sessionIds.includes(header.id)) continue;
1104
- await registry.enqueueOperation(async () => {
1105
- const state = registry.requireState();
1106
- const w = state.workspaces?.find((s) => s.id === ws.id);
1107
- if (w !== undefined && w.sessionIds !== undefined && !w.sessionIds.includes(header.id)) {
1108
- w.sessionIds = [header.id, ...w.sessionIds];
1109
- w.updatedAt = new Date().toISOString();
1110
- await registry.setState(state);
1111
- added += 1;
1112
- }
1113
- });
1114
- }
1115
- if (removed > 0 || added > 0) {
1116
- ctx.logger.info(`session-manager: post-boot workspace reconcile removed ${removed} orphans, attached ${added} disk-only sessions`);
1117
- }
1118
- } catch (error) {
1119
- ctx.logger.warn(`session-manager: post-boot reconcile failed: ${String(error)}`);
1120
- }
1121
- }, "session-manager: post-boot re-fold stale projection cache rows");
1122
-
1123
- ctx.effect(() => ctx.webServer.register({
1124
- kind: "prefix",
1125
- path: API_PREFIX,
1126
- handler: async (req, res) => {
1127
- try {
1128
- const url = new URL(req.url ?? "/", "http://localhost");
1129
- const path = url.pathname.startsWith(API_PREFIX)
1130
- ? url.pathname.slice(API_PREFIX.length) || "/"
1131
- : "/";
1132
- let body = {};
1133
- if (req.method === "POST") {
1134
- const raw = await readBody(req);
1135
- if (raw.trim() !== "") {
1136
- try {
1137
- body = JSON.parse(raw);
1138
- } catch {
1139
- return send(res, 400, { ok: false, error: "请求体不是合法 JSON" });
1140
- }
1141
- }
1142
- }
1143
- const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() !== ""
1144
- ? body.sessionId.trim()
1145
- : null;
1146
-
1147
- if (req.method === "POST" && path === "/delete") {
1148
- if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1149
- try {
1150
- const result = await deleteSession(sessionId);
1151
- return send(res, 200, { ok: true, result });
1152
- } catch (error) {
1153
- ctx.logger.warn(`session-manager: delete "${sessionId}" failed: ${String(error)}`);
1154
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1155
- }
1156
- }
1157
-
1158
- if (req.method === "POST" && path === "/unarchive") {
1159
- if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1160
- try {
1161
- await unarchiveSession(sessionId);
1162
- return send(res, 200, { ok: true, result: { sessionId } });
1163
- } catch (error) {
1164
- ctx.logger.warn(`session-manager: unarchive "${sessionId}" failed: ${String(error)}`);
1165
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1166
- }
1167
- }
1168
-
1169
- if (req.method === "POST" && path === "/move") {
1170
- if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1171
- const targetWorkspaceId = typeof body.targetWorkspaceId === "string" && body.targetWorkspaceId.trim() !== ""
1172
- ? body.targetWorkspaceId.trim()
1173
- : null;
1174
- if (targetWorkspaceId === null) return send(res, 400, { ok: false, error: "targetWorkspaceId 必填" });
1175
- try {
1176
- const result = await moveSession(sessionId, targetWorkspaceId);
1177
- return send(res, 200, { ok: true, result });
1178
- } catch (error) {
1179
- ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1180
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1181
- }
1182
- }
1183
-
1184
- if (req.method === "GET" && path === "/workspaces") {
1185
- try {
1186
- const workspaces = listWorkspaces();
1187
- return send(res, 200, { ok: true, result: { workspaces } });
1188
- } catch (error) {
1189
- ctx.logger.warn(`session-manager: listWorkspaces failed: ${String(error)}`);
1190
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1191
- }
1192
- }
1193
-
1194
- if (req.method === "GET" && path === "/preset-scan") {
1195
- try {
1196
- const workspaceId = url.searchParams.get("workspaceId") || "";
1197
- const requestedSessionId = url.searchParams.get("sessionId") || "";
1198
- const filter = {
1199
- ...(workspaceId !== "" ? { id: workspaceId } : {}),
1200
- ...(requestedSessionId !== "" ? { sessionId: requestedSessionId } : {})
1201
- };
1202
- const result = await scanPreset(filter);
1203
- return send(res, 200, { ok: true, result });
1204
- } catch (error) {
1205
- ctx.logger.warn("session-manager: preset-scan failed: " + String(error));
1206
- return send(res, 500, { ok: false, error: String(error.message || error) });
1207
- }
1208
- }
1209
- if (req.method === "POST" && path === "/preset-migrate") {
1210
- const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
1211
- const toPreset = typeof body.toPreset === "string" ? body.toPreset.trim() : "";
1212
- if (requestedSessionId === "" || toPreset === "") {
1213
- return send(res, 400, { ok: false, error: "sessionId and toPreset required" });
1214
- }
1215
- try {
1216
- const result = await migratePreset({ sessionId: requestedSessionId, toPreset });
1217
- return send(res, 200, { ok: true, result });
1218
- } catch (error) {
1219
- ctx.logger.warn("session-manager: preset-migrate failed: " + String(error));
1220
- return send(res, 500, { ok: false, error: String(error.message || error) });
1221
- }
1222
- }
1223
-
1224
- return send(res, 404, { ok: false, error: `not found: ${req.method} ${path}` });
1225
- } catch (error) {
1226
- ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1227
- return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1228
- }
1229
- }
1230
- }), "session-manager: http api");
1231
- }
1
+ /**
2
+ * @dsh-session-manager — Host half.
3
+ *
4
+ * DSH 0.1.0-rc.6 ships `workspace.archiveSession` (归档) but has NO session
5
+ * deletion, NO unarchive, and NO cross-workspace move. This plugin fills the
6
+ * gaps:
7
+ *
8
+ * - POST /session-manager/api/delete { sessionId }
9
+ * Physically delete one session: cancel/dispose a live agent, detach the
10
+ * session store entry (emits session/disposed -> host/session-removed so
11
+ * every tab drops the row), flush + remove the JSONL artifact directory,
12
+ * remove workspace accounting, and remove the archive-set membership.
13
+ * - POST /session-manager/api/unarchive { sessionId }
14
+ * Remove one session from the registry-global archive set. The registry's
15
+ * own `domain/changed` -> `host/archived-sessions-changed` frame then
16
+ * refreshes every connected client.
17
+ * - GET /session-manager/api/workspaces
18
+ * Ordered workspace projection: id / title / path / sessionCount /
19
+ * sessionIds (raw record order). Used by the client move dialog to pick a
20
+ * target and to derive the session's current workspace.
21
+ * - POST /session-manager/api/move { sessionId, targetWorkspaceId }
22
+ * TRUE cross-workspace move. DSH's workspace model keys membership off
23
+ * the session header's immutable `cwd`: `WorkspaceEntity.attachSession`
24
+ * validates `realpath(header.cwd) === workspace.path`, and every record
25
+ * write prunes ids whose indexed cwd no longer matches. So a real move
26
+ * must RE-HOME the session:
27
+ *
28
+ * 1. tear down the live session/agent (same order as delete) so the
29
+ * artifact is quiesced and the write path can no longer append at
30
+ * the old location;
31
+ * 2. read the stored artifact verbatim (`readRaw`), rewrite ONLY the
32
+ * header line's `cwd` to the target workspace's canonical path, and
33
+ * re-encode in the backend's own physical layout (zstd frames with
34
+ * an exact one-header-line first frame, or plain JSONL);
35
+ * 3. publish the new artifact at `locate({cwd: target})` and remove the
36
+ * old one, hiding-then-restoring on failure (never leaves the
37
+ * duplicate-id state that would make list()/findLog() throw);
38
+ * 4. enter a short-lived restored session (prepare + enter, never
39
+ * announced) whose fresh header makes attachSession's validation
40
+ * pass and refreshes the registry's canonical-cwd index, then swap
41
+ * accounting: detach from every other workspace's raw record,
42
+ * attach to the target — through the entity's own serialized
43
+ * durability path, so `host/workspace-changed` frames re-group every
44
+ * client sidebar immediately.
45
+ *
46
+ * History, titles, archive-set membership and lineage are preserved; only
47
+ * the working directory the session belongs to changes.
48
+ *
49
+ * All mutations go through the workspace registry's own serialized
50
+ * `enqueueOperation` + `setState` durability path (the same one the built-in
51
+ * archiveSession uses), so a restart cannot resurrect a removed session.
52
+ */
53
+ import { mkdir, open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
54
+ import { dirname, join } from "node:path";
55
+ import { randomBytes } from "node:crypto";
56
+
57
+ export const name = "dsh-session-manager";
58
+
59
+ export const inject = [
60
+ "webServer",
61
+ "workspaceRegistry",
62
+ "sessions",
63
+ "agents",
64
+ "sessionPersistence",
65
+ "agentPresets"
66
+ ];
67
+
68
+ /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
69
+ * logger never throws while reporting a throw. */
70
+ function safeJson(value) { try { return JSON.stringify(value); } catch { return "[unserializable]"; } }
71
+ /** Like safeJson but used as the user-facing error string in HTTP responses. */
72
+ function safeErrorMessage(error) {
73
+ if (error === null || error === undefined) return `<nullish:${typeof error}>`;
74
+ if (typeof error === "string") return error;
75
+ if (typeof error === "object") {
76
+ if (typeof error.message === "string" && error.message.length > 0) return error.message;
77
+ if (typeof error.code === "string" && error.code.length > 0) return `[code=${error.code}]`;
78
+ }
79
+ try { return JSON.stringify(error); } catch { return String(error); }
80
+ }
81
+ const API_PREFIX = "/session-manager/api";
82
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
83
+
84
+ export function apply(ctx) {
85
+ const readBody = async (req) => {
86
+ const chunks = [];
87
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
88
+ return Buffer.concat(chunks).toString("utf8");
89
+ };
90
+
91
+ const send = (res, code, obj) => {
92
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
93
+ res.end(JSON.stringify(obj));
94
+ };
95
+
96
+ /** Remove one id from the registry-global archive set (durable, serialized). */
97
+ const unarchiveSession = async (sessionId) => {
98
+ const registry = ctx.workspaceRegistry;
99
+ await registry.enqueueOperation(async () => {
100
+ const state = registry.requireState();
101
+ if (!state.archivedSessionIds.includes(sessionId)) return;
102
+ await registry.setState({
103
+ ...state,
104
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
105
+ });
106
+ });
107
+ };
108
+
109
+ /** Detach one session from every workspace's ordered accounting. */
110
+ const detachFromWorkspaces = async (sessionId) => {
111
+ for (const entity of ctx.workspaceRegistry.list()) {
112
+ if (entity.sessionIds.includes(sessionId)) {
113
+ await entity.detachSession(sessionId);
114
+ }
115
+ }
116
+ };
117
+
118
+ /** Ordered workspace projection for the client. */
119
+ const listWorkspaces = () => {
120
+ return ctx.workspaceRegistry.list().map((entity) => {
121
+ const rawIds = Array.isArray(entity.record?.sessionIds)
122
+ ? [...entity.record.sessionIds]
123
+ : [...entity.sessionIds];
124
+ return {
125
+ id: entity.id,
126
+ name: entity.title || entity.id,
127
+ title: entity.title || entity.id,
128
+ path: entity.path,
129
+ sessionCount: rawIds.length,
130
+ sessionIds: rawIds
131
+ };
132
+
133
+ });
134
+ };
135
+
136
+
137
+ /**
138
+ * Durable temp-file write next to its final target. Returns the temp path;
139
+ * the caller publishes it with a rename once the coast is clear.
140
+ */
141
+ const writeTempFile = async (finalPath, data) => {
142
+ const temp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
143
+ const handle = await open(temp, "wx", 0o600);
144
+ try {
145
+ await handle.writeFile(data);
146
+ await handle.sync();
147
+ } finally {
148
+ await handle.close();
149
+ }
150
+ return temp;
151
+ };
152
+
153
+ /**
154
+ * Encode the moved artifact in the backend's own physical layout:
155
+ * plain JSONL, or zstd frames whose FIRST frame is exactly the header
156
+ * line (the reader's assertZstdHeaderFrame / readFirstZstdLine contract).
157
+ */
158
+ const encodeArtifact = async (headerLine, rest, isZstd) => {
159
+ if (!isZstd) return Buffer.from(`${headerLine}
160
+ ${rest}`, "utf8");
161
+ const { zstdCompress } = await import("node:zlib");
162
+ if (typeof zstdCompress !== "function") {
163
+ throw new Error("当前 Node 运行时没有 zstd 支持,无法迁移 zstd 编码的会话工件");
164
+ }
165
+ const compress = (buffer) => new Promise((resolve, reject) => {
166
+ zstdCompress(buffer, (error, output) => error == null ? resolve(output) : reject(error));
167
+ });
168
+ const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
169
+ if (rest === "") return headerFrame;
170
+ const bodyFrame = await compress(Buffer.from(rest, "utf8"));
171
+ return Buffer.concat([headerFrame, bodyFrame]);
172
+ };
173
+
174
+ /**
175
+ * TRUE cross-workspace move. See the module docstring for the full design:
176
+ * re-home the stored cwd, migrate the artifact, then swap accounting.
177
+ * Throws an Error with a readable message on every failure path; the
178
+ * artifact is restored (or its location reported) whenever a mid-move
179
+ * step fails, so the session never ends up in a half-moved state.
180
+ */
181
+ const moveSession = async (sessionId, targetWorkspaceId) => {
182
+ const registry = ctx.workspaceRegistry;
183
+ const persistence = ctx.get("sessionPersistence");
184
+ if (persistence === void 0) throw new Error("sessionPersistence 服务不可用");
185
+ if (typeof persistence.locate !== "function") {
186
+ throw new Error("当前持久化后端无法定位会话工件,无法跨工作区移动");
187
+ }
188
+
189
+ const target = registry.list().find((entity) => entity.id === targetWorkspaceId);
190
+ if (target === void 0) {
191
+ const error = new Error(`目标工作区不存在: ${targetWorkspaceId}`);
192
+ error.code = "workspace-not-found";
193
+ throw error;
194
+ }
195
+ const targetPath = target.path;
196
+
197
+ const storedHeaders = await listSessionHeaders(persistence);
198
+ const storedHeader = storedHeaders.find((header) => header.id === sessionId);
199
+ if (storedHeader === void 0) {
200
+ const error = new Error(
201
+ `会话 ${sessionId} 没有磁盘记录(不存在,或是一个尚未发送任何消息的空白会话),无法移动`
202
+ );
203
+ error.code = "session-not-found";
204
+ throw error;
205
+ }
206
+ if (storedHeader.origin === "subagent") {
207
+ const error = new Error("子代理(subagent)会话不支持跨工作区移动");
208
+ error.code = "subagent-unsupported";
209
+ throw error;
210
+ }
211
+ if (storedHeader.cwd !== void 0) {
212
+ let currentCanonical;
213
+ try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
214
+ if (currentCanonical === targetPath) {
215
+ return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
216
+ }
217
+ }
218
+
219
+ const liveSession = ctx.sessions.get(sessionId);
220
+ let liveAgent;
221
+ try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
222
+ // The session header is immutable. A live session therefore has to be
223
+ // retired as one agent-loop lifecycle and recreated after the artifact is
224
+ // re-homed. Never delete registry map entries directly: doing so skips
225
+ // the agent-loop/session disposal effects and leaves a live persistence
226
+ // owner behind.
227
+ // NO-TEARDOWN approach: keep the live agent/session intact so the
228
+ // client UI never has to re-init. Just flush pending events to
229
+ // disk (still at OLD cwd), then atomically rename the artifact to
230
+ // NEW cwd. The persistence coordinator's serialize() inside the
231
+ // move block holds the per-id lock, so the write-behind can't append
232
+ // to the hidden file. The agent's UI keeps showing the same in-memory
233
+ // session -- no client state flip, no half-initialized resume, no
234
+ // "session unavailable" symptoms.
235
+ if (liveSession !== void 0) {
236
+ try { await ctx.sessions.flush(liveSession); } catch { /* best-effort */ }
237
+ }
238
+
239
+ // No teardown -- the agent and session are still live and that's
240
+ // intentional. Move straight to file re-home.
241
+ const coordinator = persistence.coordinator;
242
+ const serialize = typeof coordinator?.serialize === "function"
243
+ ? (operation) => coordinator.serialize(sessionId, operation)
244
+ : (operation) => operation();
245
+
246
+ const result = await serialize(async () => {
247
+ const raw = await readSessionArtifact(sessionId);
248
+ if (raw === void 0) throw new Error("读取会话工件失败:未找到会话文件");
249
+ const newlineAt = raw.content.indexOf("\n");
250
+ if (newlineAt === -1) throw new Error("会话工件缺少头行,数据可能损坏");
251
+ const rest = raw.content.slice(newlineAt + 1);
252
+ let oldHeader;
253
+ try { oldHeader = JSON.parse(raw.content.slice(0, newlineAt)); }
254
+ catch (error) { throw new Error(`会话工件头行无法解析: ${String(error)}`); }
255
+ if (oldHeader.id !== sessionId) throw new Error("会话工件头行 id 与请求不符,拒绝移动");
256
+
257
+ // Keep the stored generation in sync with the filename selected by
258
+ // persistence.locate(). Rewriting a v2 header to v0 while locating it
259
+ // through the same v2 backend creates a v2 filename with v0 content,
260
+ // which makes DSH reject the session on the next startup.
261
+ const newHeader = { ...oldHeader, cwd: targetPath };
262
+ const oldPath = raw.path ?? persistence.locate(raw.meta)?.path;
263
+ const newLocation = persistence.locate(newHeader);
264
+ if (typeof oldPath !== "string" || newLocation === void 0) {
265
+ throw new Error("持久化后端无法定位会话工件路径");
266
+ }
267
+ const newPath = newLocation.path;
268
+ if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
269
+ const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
270
+ await mkdir(dirname(newPath), { recursive: true });
271
+ const tempNew = await writeTempFile(newPath, bytes);
272
+ const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
273
+ try {
274
+ await rename(oldPath, oldHidden);
275
+ } catch (error) {
276
+ await rm(tempNew, { force: true });
277
+ throw new Error(`移动失败(无法隐藏旧会话工件,已取消,会话保持原状): ${String(error)}`);
278
+ }
279
+ try {
280
+ await rename(tempNew, newPath);
281
+ } catch (error) {
282
+ try { await rename(oldHidden, oldPath); } catch { /* reported below */ }
283
+ await rm(tempNew, { force: true });
284
+ throw new Error(`移动失败(发布新会话工件失败,已尝试回滚): ${String(error)}`);
285
+ }
286
+
287
+ const state = coordinator?.states?.get?.(sessionId);
288
+ if (state?.owner !== void 0 && liveSession !== void 0 && state.owner !== liveSession) {
289
+ await rm(newPath, { force: true });
290
+ await rename(oldHidden, oldPath);
291
+ throw new Error(`会话 ${sessionId} 的持久化 owner 与当前 live session 不一致,已回滚`);
292
+ }
293
+ const oldStateMeta = state?.meta;
294
+ const oldLiveHeader = liveSession?.header;
295
+ const oldIndexedHeader = registry.headers?.get?.(sessionId);
296
+ const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
297
+ const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
298
+ const fromWorkspaceIds = registry.list()
299
+ .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
300
+ .map((entity) => entity.id);
301
+ let targetAttached = false;
302
+ const detachedFrom = [];
303
+
304
+ try {
305
+ if (state !== void 0) {
306
+ state.meta = { ...state.meta, ...newHeader, cwd: targetPath };
307
+ state.materialized = true;
308
+ }
309
+ coordinator?.preparations?.invalidate?.(sessionId);
310
+ if (liveSession !== void 0) liveSession.header = Object.freeze({ ...newHeader });
311
+ registry.headers?.set?.(sessionId, { ...newHeader });
312
+ registry.sessionPaths?.set?.(sessionId, targetPath);
313
+ registry.invalidSessionPaths?.delete?.(sessionId);
314
+
315
+ await registry.enqueueOperation(async () => {
316
+ for (const id of fromWorkspaceIds) {
317
+ const entity = registry.list().find((candidate) => candidate.id === id);
318
+ if (entity === void 0) continue;
319
+ await entity.detachSession(sessionId);
320
+ detachedFrom.push(id);
321
+ }
322
+ await target.attachSession(sessionId);
323
+ targetAttached = true;
324
+ });
325
+ } catch (error) {
326
+ if (state !== void 0 && oldStateMeta !== void 0) state.meta = oldStateMeta;
327
+ coordinator?.preparations?.invalidate?.(sessionId);
328
+ if (liveSession !== void 0 && oldLiveHeader !== void 0) liveSession.header = oldLiveHeader;
329
+ if (oldIndexedHeader === void 0) registry.headers?.delete?.(sessionId);
330
+ else registry.headers?.set?.(sessionId, oldIndexedHeader);
331
+ if (oldIndexedPath === void 0) registry.sessionPaths?.delete?.(sessionId);
332
+ else registry.sessionPaths?.set?.(sessionId, oldIndexedPath);
333
+ if (oldInvalidPath === void 0) registry.invalidSessionPaths?.delete?.(sessionId);
334
+ else registry.invalidSessionPaths?.set?.(sessionId, oldInvalidPath);
335
+
336
+ const rollbackErrors = [];
337
+ try { if (targetAttached) await target.detachSession(sessionId); } catch (e) { rollbackErrors.push(e); }
338
+ for (const id of detachedFrom) {
339
+ try {
340
+ const entity = registry.list().find((candidate) => candidate.id === id);
341
+ if (entity !== void 0) await entity.attachSession(sessionId);
342
+ } catch (e) { rollbackErrors.push(e); }
343
+ }
344
+ try { await rm(newPath, { force: true }); } catch (e) { rollbackErrors.push(e); }
345
+ try { await rename(oldHidden, oldPath); } catch (e) { rollbackErrors.push(e); }
346
+ if (rollbackErrors.length > 0) {
347
+ throw new AggregateError([error, ...rollbackErrors], "移动失败且回滚不完整");
348
+ }
349
+ throw error;
350
+ }
351
+
352
+ try { await rm(oldHidden, { force: true }); } catch { /* best-effort */ }
353
+ try { await rm(dirname(oldPath), { recursive: true, force: true }); } catch { /* best-effort */ }
354
+ return {
355
+ ok: true,
356
+ sessionId,
357
+ moved: true,
358
+ fromWorkspaceIds,
359
+ toWorkspaceId: target.id,
360
+ toWorkspaceTitle: target.title || target.id,
361
+ artifactFrom: oldPath,
362
+ artifactTo: newPath,
363
+ wasLive: liveSession !== void 0 || liveSession !== void 0
364
+ };
365
+ });
366
+
367
+ const cache = ctx.get("sessionProjectionCache");
368
+ if (cache !== void 0 && typeof cache.coldSnapshot === "function") {
369
+ try { await cache.coldSnapshot(sessionId); }
370
+ catch (error) { ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`); }
371
+ }
372
+ // The agent is still live (no teardown). The session's in-memory
373
+ // header was rewritten above and the workspace registry updated;
374
+ // future writes go through persistence.locate() against the new
375
+ // cwd, so the file at the new location accumulates new events.
376
+ // Re-resuming the agent here re-creates a half-initialized
377
+ // Session/agent pair and leaves the client UI in a "session
378
+ // unavailable" state (agent/status is only emitted on PHASE
379
+ // CHANGES, never on agent construction). Leaving the live
380
+ // agent/session in place avoids the broken UI entirely.
381
+ ctx.logger.info(`session-manager: moved "${sessionId}" to workspace "${target.id}"${liveSession !== void 0 ? " and resumed" : ""}`);
382
+ return result;
383
+ };
384
+ /* === preset-migration (session-manager v0.3.0) === */
385
+ const NL = String.fromCharCode(10);
386
+
387
+ const scanSelectedEvents = (content) => {
388
+ const out = [];
389
+ const newlineAt = content.indexOf(NL);
390
+ const after = newlineAt === -1 ? content : content.slice(newlineAt + 1);
391
+ for (const line of after.split(NL)) {
392
+ if (line.length === 0) continue;
393
+ let obj;
394
+ try { obj = JSON.parse(line); } catch { continue; }
395
+ if (obj !== null && typeof obj === "object" && obj.type === "agent-preset/selected" && obj.data !== void 0 && typeof obj.data.agentPreset === "string") {
396
+ out.push({ agentPreset: obj.data.agentPreset, time: obj.time, seq: obj.seq });
397
+ }
398
+ }
399
+ return out;
400
+ };
401
+
402
+ const scanPreset = async (filter) => {
403
+ // Pull the full preset roster so the UI shows every available preset,
404
+ // not just the ones currently used by some session.
405
+ let allPresetIds = [];
406
+ try {
407
+ const ap = ctx.get("agentPresets");
408
+ if (ap !== void 0 && typeof ap.list === "function") {
409
+ const list = await ap.list();
410
+ for (const p of (list || [])) {
411
+ if (p && typeof p.id === "string" && p.id.length > 0) allPresetIds.push(p.id);
412
+ }
413
+ }
414
+ } catch { /* ignore -- fall back to used presets only */ }
415
+
416
+ const persistence = ctx.get("sessionPersistence");
417
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
418
+ // Don't pre-flight check readRaw/list -- the actual JSONL backend
419
+ // exposes both. listSessionHeaders is wrapped in try/catch below so
420
+ // any missing-method error surfaces as a useful per-session "no
421
+ // artifact" message instead of a hard "backend does not support"
422
+ // failure that hides which session actually failed.
423
+ let headers = [];
424
+ try {
425
+ headers = await listSessionHeaders(persistence);
426
+ } catch (probeError) {
427
+ throw new Error("failed to enumerate sessions: " + String(probeError.message ?? probeError));
428
+ }
429
+ let allowedIds = null;
430
+ const workspaceId = filter !== void 0 && typeof filter === "object" ? filter.id : void 0;
431
+ if (typeof workspaceId === "string" && workspaceId !== "") {
432
+ const registry = ctx.get("workspaceRegistry");
433
+ if (registry !== void 0 && typeof registry.list === "function") {
434
+ const all = registry.list();
435
+ const ws = all.find((entity) => entity.id === workspaceId);
436
+ if (ws === void 0) {
437
+ const error = new Error("workspace not found: " + workspaceId);
438
+ error.code = "workspace-not-found";
439
+ throw error;
440
+ }
441
+ allowedIds = new Set();
442
+ const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
443
+ if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
444
+ }
445
+ }
446
+ const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
447
+ ? filter.sessionId
448
+ : "";
449
+ const out = [];
450
+ for (const header of headers) {
451
+ if (requestedSessionId !== "" && header.id !== requestedSessionId) continue;
452
+ if (allowedIds !== null && !allowedIds.has(header.id)) continue;
453
+ if (header.origin === "subagent") continue;
454
+ const raw = await readSessionArtifact(header.id);
455
+ if (raw === void 0) continue;
456
+ const newlineAt = raw.content.indexOf(NL);
457
+ if (newlineAt === -1) continue;
458
+ let headerObj;
459
+ try { headerObj = JSON.parse(raw.content.slice(0, newlineAt)); } catch { continue; }
460
+ let selected = scanSelectedEvents(raw.content);
461
+ const live = ctx.sessions.get(header.id);
462
+ if (live !== void 0) {
463
+ const liveEvents = Array.isArray(live.events)
464
+ ? live.events
465
+ : Array.isArray(live.log) ? live.log : [];
466
+ const liveSelected = liveEvents
467
+ .filter((event) => event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string")
468
+ .map((event) => ({ agentPreset: event.data.agentPreset, time: event.time, seq: event.seq }));
469
+ if (liveSelected.length > selected.length) selected = liveSelected;
470
+ }
471
+ const finalPreset = selected.length > 0 ? selected[selected.length - 1].agentPreset : headerObj.agentPreset;
472
+ out.push({ sessionId: header.id, cwd: header.cwd, origin: header.origin, headerPreset: headerObj.agentPreset, selectedEvents: selected, finalPreset });
473
+ }
474
+ // Targets come only from the live roster. A deleted preset can still be
475
+ // displayed as this session's current value, but must not be offered as
476
+ // a destination.
477
+ return { rows: out, availablePresets: Array.from(new Set(allPresetIds)) };
478
+ };
479
+
480
+ /**
481
+ * Fully retire a live agent/session before rewriting its durable preset.
482
+ * Waiting through persistence.load() is the important ownership barrier:
483
+ * session/disposed starts an async final drain, and a disk rewrite before
484
+ * that drain finishes leaves the coordinator's old Session as the live
485
+ * persistence owner (the later resume then fails with "already has a live
486
+ * persistence owner").
487
+ */
488
+ /** Cold-path preset migration: read disk, rewrite, publish. Works whether
489
+ * or not the session is currently in the coordinator, and does not depend on
490
+ * `coordinator.states` carrying a fresh `meta.cwd`. */
491
+ const applyMigrationToArtifact = async (args) => {
492
+ const sessionId = typeof args.sessionId === "string" ? args.sessionId : "";
493
+ const toPreset = typeof args.toPreset === "string" ? args.toPreset : "";
494
+ const liveSession = (args !== void 0 && typeof args === "object") ? args.liveSession : void 0;
495
+ const persistence = ctx.get("sessionPersistence");
496
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
497
+ const coordinator = persistence.coordinator;
498
+ const serialize = typeof coordinator?.serialize === "function"
499
+ ? (operation) => coordinator.serialize(sessionId, operation)
500
+ : (operation) => operation();
501
+ return await serialize(async () => {
502
+ const raw = await readSessionArtifact(sessionId);
503
+ if (raw === void 0) throw new Error(`session "${sessionId}" has no artifact`);
504
+ const lines = raw.content.split(NL);
505
+ if (lines.length < 2) throw new Error("session artifact has no header line");
506
+ let header;
507
+ try { header = JSON.parse(lines[0]); } catch { throw new Error("session header parse failed"); }
508
+ if (header.id !== sessionId) throw new Error("session header id mismatch");
509
+
510
+ let selectedIndex = -1;
511
+ let selectedPreset;
512
+ for (let index = 1; index < lines.length; index += 1) {
513
+ if (lines[index] === "") continue;
514
+ let event;
515
+ try { event = JSON.parse(lines[index]); } catch { continue; }
516
+ if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
517
+ selectedIndex = index;
518
+ selectedPreset = event.data.agentPreset;
519
+ }
520
+ }
521
+ const oldPreset = selectedIndex >= 0 ? selectedPreset : header.agentPreset;
522
+ if (oldPreset === toPreset) {
523
+ return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
524
+ }
525
+
526
+ if (selectedIndex >= 0) {
527
+ const event = JSON.parse(lines[selectedIndex]);
528
+ event.data = { ...event.data, agentPreset: toPreset };
529
+ lines[selectedIndex] = JSON.stringify(event);
530
+ } else {
531
+ header.agentPreset = toPreset;
532
+ lines[0] = JSON.stringify(header);
533
+ }
534
+
535
+ const artifactPath = raw.path ?? persistence.locate?.(raw.meta)?.path;
536
+ if (typeof artifactPath !== "string") throw new Error("cannot locate session artifact");
537
+ const replacementContent = lines.join(NL);
538
+ const firstNewline = replacementContent.indexOf(NL);
539
+ const bytes = await encodeArtifact(
540
+ replacementContent.slice(0, firstNewline),
541
+ replacementContent.slice(firstNewline + 1),
542
+ artifactPath.endsWith(".zstd")
543
+ );
544
+ const tempPath = await writeTempFile(artifactPath, bytes);
545
+ const hiddenPath = `${artifactPath}.${randomBytes(6).toString("hex")}.tmp`;
546
+ try {
547
+ await rename(artifactPath, hiddenPath);
548
+ await rename(tempPath, artifactPath);
549
+ } catch (error) {
550
+ try { await rm(artifactPath, { force: true }); } catch { /* best-effort */ }
551
+ try { await rename(hiddenPath, artifactPath); } catch { /* reported below */ }
552
+ try { await rm(tempPath, { force: true }); } catch { /* best-effort */ }
553
+ throw new Error(`preset migration publish failed: ${String(error)}`);
554
+ }
555
+ try { await rm(hiddenPath, { force: true }); } catch { /* best-effort */ }
556
+
557
+ const state = coordinator?.states?.get?.(sessionId);
558
+ if (state !== void 0 && selectedIndex < 0) state.meta = { ...state.meta, agentPreset: toPreset };
559
+ coordinator?.preparations?.invalidate?.(sessionId);
560
+ // Mirror the disk rewrite into the in-memory live session: the cold path
561
+ // bypasses the persistence write controller (which is intentionally
562
+ // skipped when its state is stale), so the session header, projection,
563
+ // and downstream agent composition would otherwise keep reporting the old
564
+ // preset. Update both the header and the in-memory event log so DSH's
565
+ // `resolveSessionPreset` and any projection subscribers observe the new
566
+ // preset from the same source.
567
+ if (liveSession !== void 0) {
568
+ try {
569
+ const nextHeader = { ...(liveSession.header ?? {}), agentPreset: toPreset };
570
+ try { liveSession.header = Object.freeze(nextHeader); }
571
+ catch { liveSession.header = nextHeader; }
572
+ } catch { /* live session may have been retired mid-flight */ }
573
+ // Append the new selection to the live log so the session/store
574
+ // observers (agent-presets, projections, ctx) fire their normal
575
+ // `session/event` + `agent-preset/selected` pipeline. We append
576
+ // unconditionally (not only when the disk had no prior selection)
577
+ // because the in-memory projection reads the latest appended event
578
+ // and the cold path deliberately skipped the persistence write
579
+ // controller; the appended event will be persisted on the next
580
+ // normal flush, keeping disk and memory consistent.
581
+ if (typeof liveSession.append === "function") {
582
+ try { liveSession.append("agent-preset/selected", { agentPreset: toPreset }); } catch { /* best-effort */ }
583
+ }
584
+ }
585
+ return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
586
+ });
587
+ };
588
+ /** Rewrite exactly one conversation's effective Agent preset. */
589
+ const migratePreset = async (opts) => {
590
+ const sessionId = typeof opts.sessionId === "string" ? opts.sessionId.trim() : "";
591
+ const toPreset = typeof opts.toPreset === "string" ? opts.toPreset.trim() : "";
592
+ if (sessionId === "") { const e = new Error("sessionId required"); e.code = "bad-request"; throw e; }
593
+ if (toPreset === "") { const e = new Error("toPreset required"); e.code = "bad-request"; throw e; }
594
+
595
+ const presets = ctx.get("agentPresets");
596
+ if (presets === void 0 || typeof presets.list !== "function") {
597
+ throw new Error("agentPresets service unavailable");
598
+ }
599
+ const roster = await presets.list();
600
+ const target = (roster || []).find((preset) => preset && preset.id === toPreset);
601
+ if (target === void 0) {
602
+ const available = (roster || []).map((preset) => preset?.id).filter(Boolean);
603
+ throw new Error(`Agent 预设 "${toPreset}" 不存在(可用:${available.join(", ")})`);
604
+ }
605
+ if (target.broken !== void 0) throw new Error(`Agent 预设 "${toPreset}" 不可用:${target.broken}`);
606
+
607
+ const liveSession = ctx.sessions.get(sessionId);
608
+ let liveAgent;
609
+ try { liveAgent = ctx.agents.get(sessionId); } catch { liveAgent = void 0; }
610
+ // Resolve persistence up front so the live path can read the coordinator
611
+ // without tripping JavaScript's temporal dead zone on `const persistence`.
612
+ const persistence = ctx.get("sessionPersistence");
613
+ if (persistence === void 0) throw new Error("sessionPersistence service unavailable");
614
+
615
+ if (liveSession !== void 0) {
616
+ const coordinator = persistence.coordinator;
617
+ const states = coordinator?.states;
618
+ const beforeState = states?.get?.(sessionId);
619
+ const liveCwd = liveSession.header?.cwd;
620
+ // A just-completed moveSession mutates `liveSession.header.cwd` in place,
621
+ // but the persistence coordinator's tracked state may not see the new cwd
622
+ // until its serialize chain drains. If we hand the migration to the
623
+ // coordinator now, its `state.meta.cwd` may still be the pre-move cwd and
624
+ // appendBatch will open the old (now deleted) artifact path, producing the
625
+ // "ENOENT ... session.v2.jsonl.zstd" regression. Falling back to the cold
626
+ // path is always correct: the disk copy already reflects the new cwd.
627
+ const stateStale = beforeState === void 0
628
+ || beforeState.meta?.cwd === void 0
629
+ || (liveCwd !== void 0 && beforeState.meta.cwd !== liveCwd);
630
+ if (stateStale) {
631
+ const raw = await readSessionArtifact(sessionId);
632
+ if (raw !== void 0) {
633
+ const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
634
+ if (result.migrated) { try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ } }
635
+ 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)})`);
636
+ return { sessionId, migrated: true, oldPreset: result.oldPreset, newPreset: toPreset };
637
+ }
638
+ // No artifact yet; fall through to the in-memory + flush path below,
639
+ // which materializes the empty file using the live cwd.
640
+ }
641
+ let oldPreset = liveSession.header?.agentPreset;
642
+ // SessionStore implementations from older DSH builds may expose the
643
+ // history as `log` rather than the newer `events` snapshot getter.
644
+ // Do not dereference `.length` on an absent compatibility surface.
645
+ const liveEvents = Array.isArray(liveSession.events)
646
+ ? liveSession.events
647
+ : Array.isArray(liveSession.log) ? liveSession.log : [];
648
+ for (let index = liveEvents.length - 1; index >= 0; index -= 1) {
649
+ const event = liveEvents[index];
650
+ if (event?.type === "agent-preset/selected" && typeof event.data?.agentPreset === "string") {
651
+ oldPreset = event.data.agentPreset;
652
+ break;
653
+ }
654
+ }
655
+ if (oldPreset === toPreset) {
656
+ return { sessionId, migrated: false, oldPreset, newPreset: toPreset };
657
+ }
658
+ if (liveAgent !== void 0 && typeof presets?.recompose === "function" && liveAgent.ctx) {
659
+ try { await presets.recompose(liveAgent.ctx, toPreset); } catch { /* best-effort */ }
660
+ }
661
+ liveSession.append("agent-preset/selected", { agentPreset: toPreset });
662
+ await ctx.sessions.flush(liveSession);
663
+ ctx.logger.info(`session-manager: migrated live preset for "${sessionId}" from "${String(oldPreset)}" to "${toPreset}"`);
664
+ return { sessionId, migrated: true, oldPreset, newPreset: toPreset };
665
+ }
666
+
667
+ // A session absent from SessionStore is cold. If an older plugin left its
668
+ // exact Session object in the coordinator, retire that proven orphan by
669
+ // the coordinator's own drain path instead of deleting owner bookkeeping.
670
+ const coordinator = persistence.coordinator;
671
+ let tracked = coordinator?.states?.get?.(sessionId);
672
+ if (tracked?.owner !== void 0 && ctx.sessions.get(sessionId) === void 0) {
673
+ coordinator.retire?.(tracked.owner);
674
+ const retirement = coordinator.retirements?.get?.(sessionId);
675
+ if (retirement !== void 0) await retirement;
676
+ tracked = coordinator.states?.get?.(sessionId);
677
+ if (tracked?.owner !== void 0) {
678
+ throw new Error(`会话 ${sessionId} 仍有未释放的持久化 owner,已停止迁移以保护数据`);
679
+ }
680
+ }
681
+
682
+ const result = await applyMigrationToArtifact({ sessionId, toPreset, liveSession });
683
+
684
+ if (result.migrated) {
685
+ try { ctx.emit("agent-preset/selected", sessionId, toPreset); } catch { /* best-effort */ }
686
+ ctx.logger.info(`session-manager: migrated cold preset for "${sessionId}" from "${String(result.oldPreset)}" to "${toPreset}"`);
687
+ }
688
+ return result;
689
+ };
690
+
691
+ /* ==== legacy v0.2.0 move implementation — DISABLED in 0.2.1 ============================
692
+ It never actually worked: DSH's WorkspaceEntity.attachSession validates
693
+ realpath(header.cwd) === workspace.path, so a cross-workspace attach always
694
+ threw "its cwd resolves to ..."; the registry-state fallback below also
695
+ mutated a state object that has no `workspaces` field. Replaced by the
696
+ re-home implementation above (cwd rewrite + artifact migration + fresh
697
+ placeholder header for index refresh).
698
+ /** Move one session from its current workspace to a target workspace. (legacy)
699
+ const moveSession = async (sessionId, targetWorkspaceId) => {
700
+ const registry = ctx.workspaceRegistry;
701
+ const allWorkspaces = registry.list();
702
+
703
+ // Find target workspace
704
+ const targetWorkspace = allWorkspaces.find((e) => e.id === targetWorkspaceId);
705
+ if (!targetWorkspace) {
706
+ throw new Error(`目标工作区不存在: ${targetWorkspaceId}`);
707
+ }
708
+
709
+ // Find current workspace(s) containing this session
710
+ const currentWorkspaces = allWorkspaces.filter((e) => e.sessionIds.includes(sessionId));
711
+
712
+ // Check if already in target workspace
713
+ if (currentWorkspaces.length === 1 && currentWorkspaces[0].id === targetWorkspaceId) {
714
+ return { ok: true, sessionId, moved: false, message: "会话已在目标工作区中" };
715
+ }
716
+
717
+ // Detach from all current workspaces
718
+ for (const entity of currentWorkspaces) {
719
+ await entity.detachSession(sessionId);
720
+ }
721
+
722
+ // Attach to target workspace
723
+ if (typeof targetWorkspace.attachSession === "function") {
724
+ await targetWorkspace.attachSession(sessionId);
725
+ } else if (typeof targetWorkspace.prependSession === "function") {
726
+ await targetWorkspace.prependSession(sessionId);
727
+ } else {
728
+ // Fallback: try to use the registry's enqueueOperation
729
+ await registry.enqueueOperation(async () => {
730
+ const state = registry.requireState();
731
+ // Find target workspace in state and add session
732
+ const targetState = state.workspaces?.find((w) => w.id === targetWorkspaceId);
733
+ if (targetState) {
734
+ if (!targetState.sessionIds) targetState.sessionIds = [];
735
+ if (!targetState.sessionIds.includes(sessionId)) {
736
+ targetState.sessionIds.unshift(sessionId);
737
+ }
738
+ await registry.setState(state);
739
+ }
740
+ });
741
+ }
742
+
743
+ return {
744
+ ok: true,
745
+ sessionId,
746
+ moved: true,
747
+ fromWorkspace: currentWorkspaces.map((e) => e.id),
748
+ toWorkspace: targetWorkspaceId
749
+ };
750
+ };
751
+
752
+ ==== end legacy v0.2.0 move implementation ==== */
753
+ /** Resolve the on-disk session directory (parent of its artifact), if any. */
754
+ /**
755
+ * Encode a session id the way DSH stores it on disk: "--<encoded>--".
756
+ * Mirrors @deepseek-ai/dsh-session-persistence-jsonl encodeSegment.
757
+ */
758
+ const encodeSessionSegment = (id) => {
759
+ let readable = "";
760
+ let separatorRun = false;
761
+ for (let i = 0; i < id.length; i++) {
762
+ const ch = id[i];
763
+ const code = id.charCodeAt(i);
764
+ if (ch === "/" || ch === "\\" || ch === ":") {
765
+ if (!separatorRun) readable += "-";
766
+ separatorRun = true;
767
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
768
+ readable += ch;
769
+ separatorRun = false;
770
+ } else {
771
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
772
+ separatorRun = false;
773
+ }
774
+ }
775
+ return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
776
+ };
777
+ /** Read one session artifact verbatim, falling back to direct disk access when the
778
+ * configured persistence backend cannot see the id (e.g. an older DSH build
779
+ * wrote a "session.v2.jsonl.zstd" file under the raw-id directory).
780
+ */
781
+ const readSessionArtifact = async (sessionId, signal) => {
782
+ const persistence = ctx.get("sessionPersistence");
783
+ if (persistence !== void 0 && typeof persistence.readRaw === "function") {
784
+ try {
785
+ const raw = await persistence.readRaw(sessionId, signal);
786
+ if (raw !== void 0) {
787
+ const located = typeof persistence.locate === "function" ? persistence.locate(raw.meta) : void 0;
788
+ // After a cross-workspace move, a backend may return a raw artifact
789
+ // whose metadata cache still points at the pre-move cwd. Do not
790
+ // hand that stale path to a mutator; verify it and fall through to
791
+ // the id-based filesystem scan when it no longer exists.
792
+ if (located?.path !== void 0) {
793
+ try {
794
+ if ((await stat(located.path)).isFile()) {
795
+ return { ...raw, path: located.path };
796
+ }
797
+ } catch { /* stale locate result; scan the durable files below */ }
798
+ } else {
799
+ return { ...raw };
800
+ }
801
+ }
802
+ } catch { /* fall through to direct disk */ }
803
+ }
804
+ const dshHomePath = ctx.get("dshHomePath");
805
+ if (typeof dshHomePath !== "function") return void 0;
806
+ const root = dshHomePath("sessions");
807
+ let projects;
808
+ try { projects = await readdir(root, { withFileTypes: true }); }
809
+ catch { return void 0; }
810
+ const encoded = encodeSessionSegment(sessionId);
811
+ const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); const { zstdDecompressSync } = require_("node:zlib");
812
+ for (const proj of projects) {
813
+ if (!proj.isDirectory()) continue;
814
+ for (const candidate of [encoded, sessionId]) {
815
+ const dir = join(root, proj.name, candidate);
816
+ for (const filename of ["session.jsonl.zstd", "session.v2.jsonl.zstd", "session.jsonl"]) {
817
+ const filePath = join(dir, filename);
818
+ try {
819
+ const buf = await readFile(filePath);
820
+ let content;
821
+ if (filename.endsWith(".zstd")) {
822
+ // Node's zstdDecompressSync only decodes the FIRST frame in a
823
+ // multi-frame file; DSH writes one frame per batch, so a real
824
+ // artifact contains many concatenated frames and the rest is
825
+ // otherwise silently lost. Walk every magic-prefixed frame and
826
+ // concatenate their plaintexts.
827
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
828
+ const plaintexts = [];
829
+ let pos = 0;
830
+ while (pos < buf.length) {
831
+ const next = buf.indexOf(ZSTD_MAGIC, pos);
832
+ if (next < 0) break;
833
+ try {
834
+ plaintexts.push(zstdDecompressSync(buf.subarray(next)));
835
+ } catch {
836
+ break;
837
+ }
838
+ pos = next + 4;
839
+ }
840
+ if (plaintexts.length === 0) continue;
841
+ content = Buffer.concat(plaintexts).toString("utf8");
842
+ } else {
843
+ content = buf.toString("utf8");
844
+ }
845
+ const headerLine = content.split("\n", 1)[0];
846
+ let meta;
847
+ try { meta = JSON.parse(headerLine); } catch { continue; }
848
+ if (typeof meta?.id !== "string" || meta.id !== sessionId) continue;
849
+ return { meta, filename, content, path: filePath };
850
+ } catch { /* keep scanning */ }
851
+ }
852
+ }
853
+ }
854
+ return void 0;
855
+ };
856
+ /** List every persisted session header visible to the configured persistence
857
+ * backend, supplemented by raw-id sessions written by older DSH builds. */
858
+ const listSessionHeaders = async (persistence) => {
859
+ const seen = new Set();
860
+ const out = [];
861
+ if (persistence !== void 0 && typeof persistence.list === "function") {
862
+ try {
863
+ for (const header of await persistence.list()) {
864
+ if (header === void 0 || typeof header.id !== "string") continue;
865
+ seen.add(header.id);
866
+ out.push(header);
867
+ }
868
+ } catch { /* fall through to disk */ }
869
+ }
870
+ const dshHomePath = ctx.get("dshHomePath");
871
+ if (typeof dshHomePath !== "function") return out;
872
+ const root = dshHomePath("sessions");
873
+ let projects;
874
+ try { projects = await readdir(root, { withFileTypes: true }); }
875
+ catch { return out; }
876
+ const { createRequire } = await import("node:module"); const require_ = createRequire(import.meta.url); const { zstdDecompressSync } = require_("node:zlib");
877
+ for (const proj of projects) {
878
+ if (!proj.isDirectory()) continue;
879
+ let dirs;
880
+ try { dirs = await readdir(join(root, proj.name), { withFileTypes: true }); }
881
+ catch { continue; }
882
+ for (const entry of dirs) {
883
+ if (!entry.isDirectory()) continue;
884
+ for (const filename of ["session.v2.jsonl.zstd", "session.jsonl.zstd", "session.jsonl"]) {
885
+ const filePath = join(root, proj.name, entry.name, filename);
886
+ try {
887
+ const buf = await readFile(filePath);
888
+ let content;
889
+ if (filename.endsWith(".zstd")) {
890
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
891
+ const plaintexts = [];
892
+ let pos = 0;
893
+ while (pos < buf.length) {
894
+ const next = buf.indexOf(ZSTD_MAGIC, pos);
895
+ if (next < 0) break;
896
+ try { plaintexts.push(zstdDecompressSync(buf.subarray(next))); }
897
+ catch { break; }
898
+ pos = next + 4;
899
+ }
900
+ if (plaintexts.length === 0) continue;
901
+ content = Buffer.concat(plaintexts).toString("utf8");
902
+ } else {
903
+ content = buf.toString("utf8");
904
+ }
905
+ const headerLine = content.split("\n", 1)[0];
906
+ const meta = JSON.parse(headerLine);
907
+ if (typeof meta?.id !== "string" || seen.has(meta.id)) continue;
908
+ seen.add(meta.id);
909
+ out.push({ id: meta.id, cwd: meta.cwd, createdAt: meta.createdAt, version: meta.version, origin: meta.origin });
910
+ } catch { /* skip */ }
911
+ }
912
+ }
913
+ }
914
+ return out;
915
+ };
916
+ const sessionDirOf = async (sessionId) => {
917
+ const persistence = ctx.get("sessionPersistence");
918
+ if (persistence !== void 0) {
919
+ try {
920
+ const headers = await persistence.list();
921
+ const meta = headers.find((header) => header.id === sessionId);
922
+ if (meta !== void 0) {
923
+ const location = persistence.locate(meta);
924
+ if (location !== void 0) return dirname(location.path);
925
+ }
926
+ } catch { /* fall through to filesystem scan */ }
927
+ }
928
+ const dshHomePath = ctx.get("dshHomePath");
929
+ if (typeof dshHomePath !== "function") return void 0;
930
+ const root = dshHomePath("sessions");
931
+ let projects;
932
+ try { projects = await readdir(root, { withFileTypes: true }); }
933
+ catch { return void 0; }
934
+ const encoded = encodeSessionSegment(sessionId);
935
+ const candidates = [encoded, sessionId];
936
+ for (const proj of projects) {
937
+ if (!proj.isDirectory()) continue;
938
+ for (const candidate of candidates) {
939
+ const dir = join(root, proj.name, candidate);
940
+ try {
941
+ if ((await stat(dir)).isDirectory()) return dir;
942
+ } catch { /* keep scanning */ }
943
+ }
944
+ }
945
+ return void 0;
946
+ };
947
+
948
+ /**
949
+ * Delete one session end to end. Returns a summary of what was torn down.
950
+ * Idempotent-ish: unknown sessions resolve to { ok: true, deleted: false }.
951
+ */
952
+ const deleteSession = async (sessionId) => {
953
+ const session = ctx.sessions.get(sessionId);
954
+ // 0.1.1-rc.2 throws ApiRemoteSessionNotFound for cold sessions here.
955
+ let agent;try{agent=ctx.agents.get(sessionId)}catch{agent=void 0}
956
+
957
+ if (agent !== void 0) {
958
+ // Stop any running turn (disposed-kind suppresses re-wake).
959
+ agent.cancel({ kind: "disposed" });
960
+ // Quiesce the agent's own fiber (idempotent; bounded in case teardown stalls).
961
+ if (typeof agent.scope?.dispose === "function") {
962
+ await Promise.race([agent.scope.dispose(), sleep(3000)]);
963
+ }
964
+ // Drop the zombie from the registry so a later session.create/open with
965
+ // the same id cannot resurrect it.
966
+ try {
967
+ ctx.agents.store?.delete?.(sessionId);
968
+ } catch { /* best-effort */ }
969
+ }
970
+
971
+ let detached = false;
972
+ if (session !== void 0) {
973
+ // Flush buffered events to disk first so the retirement drain is a no-op.
974
+ try {
975
+ await ctx.sessions.flush(session);
976
+ } catch { /* best-effort */ }
977
+ // Detach the session store entry: emits session/disposed, which the
978
+ // persistence write-path answers with a final drain, and the API proxy
979
+ // relays as host/session-removed so every connected client drops the row.
980
+ try {
981
+ const entry = ctx.sessions.store?.get?.(sessionId);
982
+ if (entry !== void 0 && typeof entry.detach === "function") {
983
+ entry.detach();
984
+ await sleep(200); // let the write-behind retirement settle
985
+ detached = true;
986
+ }
987
+ } catch { /* best-effort */ }
988
+ }
989
+ // Some sessions have a stored artifact but no live store row (e.g.
990
+ // already-scoped scope torn down, or never attached). Without a live
991
+ // entry, ctx.sessions.store?.get?.(sessionId) returns undefined and
992
+ // entry.detach() never fires — so the apiproxy never queues a
993
+ // host/session-removed frame, every connected client keeps showing the
994
+ // session row, and the row collapses into the ungrouped section the
995
+ // moment host/workspace-changed drops its workspace membership. Emit
996
+ // session/disposed explicitly so every connected client drops the row.
997
+ if (!detached) {
998
+ try {
999
+ ctx.emit("session/disposed", { id: sessionId });
1000
+ } catch (_) { /* best-effort */ }
1001
+ }
1002
+
1003
+ // Workspace accounting + archive-set membership.
1004
+ await detachFromWorkspaces(sessionId);
1005
+ await unarchiveSession(sessionId);
1006
+
1007
+ // Physical artifact (session.jsonl / session.jsonl.zstd) + any extras.
1008
+ const dir = await sessionDirOf(sessionId);
1009
+ if (dir !== void 0) {
1010
+ await rm(dir, { recursive: true, force: true });
1011
+ }
1012
+
1013
+ return {
1014
+ ok: true,
1015
+ sessionId,
1016
+ wasLive: session !== void 0 || agent !== void 0,
1017
+ filesRemoved: dir !== void 0
1018
+ };
1019
+ };
1020
+
1021
+ /**
1022
+ * On plugin boot, refold every persisted session projection cache
1023
+ * row from disk. Sessions moved before coldSnapshot was wired into
1024
+ * moveSession would otherwise keep their pre-move {createdAt, cwd}
1025
+ * identity and let the cold list path fall back to basename(cwd),
1026
+ * surfacing the workspace title (for example "DSH") where the
1027
+ * session's own title was expected. The sweep is one-shot and
1028
+ * idempotent: a row whose identity already matches is a cheap
1029
+ * pass-through refold, and a stale one triggers the unrelated-identity
1030
+ * full re-read path.
1031
+ */
1032
+ ctx.effect(async () => {
1033
+ const cache = ctx.get("sessionProjectionCache");
1034
+ const persistence = ctx.get("sessionPersistence");
1035
+ const registry = ctx.get("workspaceRegistry");
1036
+ if (cache === void 0 || typeof cache.coldSnapshot !== "function") return;
1037
+ if (persistence === void 0 || typeof persistence.list !== "function") return;
1038
+ if (registry === void 0 || typeof registry.list !== "function") return;
1039
+ try {
1040
+ // ---- step 1: re-fold every active session's projection cache row,
1041
+ // so a session moved before coldSnapshot was wired in stops being
1042
+ // identity-stuck under a stale {createdAt, cwd} key.
1043
+ const headers = await listSessionHeaders(persistence);
1044
+ let refolded = 0;
1045
+ const headerById = new Map();
1046
+ for (const header of headers) {
1047
+ if (header === void 0 || header.cwd === void 0) continue;
1048
+ headerById.set(header.id, header);
1049
+ try {
1050
+ await cache.coldSnapshot(header.id);
1051
+ refolded += 1;
1052
+ } catch (error) {
1053
+ ctx.logger.warn(`session-manager: post-boot cache refold failed for "${header.id}": ${String(error)}`);
1054
+ }
1055
+ }
1056
+ ctx.logger.info(`session-manager: post-boot re-folded ${refolded} session projection cache rows`);
1057
+
1058
+ // ---- step 2: reconcile workspace accounting with disk. After a few
1059
+ // pre-fix moves, a workspace record could keep a session id whose
1060
+ // disk artifact is gone, OR miss a session whose disk artifact is on
1061
+ // a matching cwd but not in any record. Both symptoms show up as
1062
+ // "AI count higher than visible" / "another session in ungrouped".
1063
+ // Walk the registry, drop missing-disk ids, and add missing-in-record
1064
+ // ids back into the workspace whose path matches header.cwd.
1065
+ const allWorkspaces = registry.list();
1066
+ const recordByWs = new Map();
1067
+ for (const ws of allWorkspaces) {
1068
+ recordByWs.set(ws.id, ws);
1069
+ }
1070
+ const validIds = new Set(headerById.keys());
1071
+ const pathToWs = new Map();
1072
+ for (const ws of allWorkspaces) {
1073
+ pathToWs.set(ws.path, ws);
1074
+ }
1075
+ // Pass 1: drop ids whose disk artifact no longer exists.
1076
+ let removed = 0;
1077
+ for (const ws of allWorkspaces) {
1078
+ const filtered = ws.record?.sessionIds?.filter((id) => validIds.has(id));
1079
+ const original = ws.record?.sessionIds ?? [];
1080
+ if (filtered && filtered.length !== original.length) {
1081
+ await registry.enqueueOperation(async () => {
1082
+ const state = registry.requireState();
1083
+ const w = state.workspaces?.find((s) => s.id === ws.id);
1084
+ if (w !== undefined) {
1085
+ w.sessionIds = filtered;
1086
+ w.updatedAt = new Date().toISOString();
1087
+ await registry.setState(state);
1088
+ }
1089
+ });
1090
+ removed += original.length - filtered.length;
1091
+ }
1092
+ }
1093
+ // Pass 2: attach disk-only sessions to the workspace whose path
1094
+ // matches their cwd.
1095
+ let added = 0;
1096
+ for (const header of headers) {
1097
+ if (header.cwd === void 0) continue;
1098
+ const ws = pathToWs.get(header.cwd);
1099
+ if (ws === undefined) continue;
1100
+ const rec = ws.record;
1101
+ if (rec === undefined || rec.sessionIds === undefined) continue;
1102
+ if (rec.sessionIds.includes(header.id)) continue;
1103
+ await registry.enqueueOperation(async () => {
1104
+ const state = registry.requireState();
1105
+ const w = state.workspaces?.find((s) => s.id === ws.id);
1106
+ if (w !== undefined && w.sessionIds !== undefined && !w.sessionIds.includes(header.id)) {
1107
+ w.sessionIds = [header.id, ...w.sessionIds];
1108
+ w.updatedAt = new Date().toISOString();
1109
+ await registry.setState(state);
1110
+ added += 1;
1111
+ }
1112
+ });
1113
+ }
1114
+ if (removed > 0 || added > 0) {
1115
+ ctx.logger.info(`session-manager: post-boot workspace reconcile removed ${removed} orphans, attached ${added} disk-only sessions`);
1116
+ }
1117
+ } catch (error) {
1118
+ ctx.logger.warn(`session-manager: post-boot reconcile failed: ${String(error)}`);
1119
+ }
1120
+ }, "session-manager: post-boot re-fold stale projection cache rows");
1121
+
1122
+ ctx.effect(() => ctx.webServer.register({
1123
+ kind: "prefix",
1124
+ path: API_PREFIX,
1125
+ handler: async (req, res) => {
1126
+ try {
1127
+ const url = new URL(req.url ?? "/", "http://localhost");
1128
+ const path = url.pathname.startsWith(API_PREFIX)
1129
+ ? url.pathname.slice(API_PREFIX.length) || "/"
1130
+ : "/";
1131
+ let body = {};
1132
+ if (req.method === "POST") {
1133
+ const raw = await readBody(req);
1134
+ if (raw.trim() !== "") {
1135
+ try {
1136
+ body = JSON.parse(raw);
1137
+ } catch {
1138
+ return send(res, 400, { ok: false, error: "请求体不是合法 JSON" });
1139
+ }
1140
+ }
1141
+ }
1142
+ const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() !== ""
1143
+ ? body.sessionId.trim()
1144
+ : null;
1145
+
1146
+ if (req.method === "POST" && path === "/delete") {
1147
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1148
+ try {
1149
+ const result = await deleteSession(sessionId);
1150
+ return send(res, 200, { ok: true, result });
1151
+ } catch (error) {
1152
+ ctx.logger.warn(`session-manager: delete "${sessionId}" failed: ${String(error)}`);
1153
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1154
+ }
1155
+ }
1156
+
1157
+ if (req.method === "POST" && path === "/unarchive") {
1158
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1159
+ try {
1160
+ await unarchiveSession(sessionId);
1161
+ return send(res, 200, { ok: true, result: { sessionId } });
1162
+ } catch (error) {
1163
+ ctx.logger.warn(`session-manager: unarchive "${sessionId}" failed: ${String(error)}`);
1164
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1165
+ }
1166
+ }
1167
+
1168
+ if (req.method === "POST" && path === "/move") {
1169
+ if (sessionId === null) return send(res, 400, { ok: false, error: "sessionId 必填" });
1170
+ const targetWorkspaceId = typeof body.targetWorkspaceId === "string" && body.targetWorkspaceId.trim() !== ""
1171
+ ? body.targetWorkspaceId.trim()
1172
+ : null;
1173
+ if (targetWorkspaceId === null) return send(res, 400, { ok: false, error: "targetWorkspaceId 必填" });
1174
+ try {
1175
+ const result = await moveSession(sessionId, targetWorkspaceId);
1176
+ return send(res, 200, { ok: true, result });
1177
+ } catch (error) {
1178
+ ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1179
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1180
+ }
1181
+ }
1182
+
1183
+ if (req.method === "GET" && path === "/workspaces") {
1184
+ try {
1185
+ const workspaces = listWorkspaces();
1186
+ return send(res, 200, { ok: true, result: { workspaces } });
1187
+ } catch (error) {
1188
+ ctx.logger.warn(`session-manager: listWorkspaces failed: ${String(error)}`);
1189
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1190
+ }
1191
+ }
1192
+
1193
+
1194
+
1195
+ if (req.method === "GET" && path === "/preset-scan") {
1196
+ try {
1197
+ const workspaceId = url.searchParams.get("workspaceId") || "";
1198
+ const requestedSessionId = url.searchParams.get("sessionId") || "";
1199
+ const filter = {
1200
+ ...(workspaceId !== "" ? { id: workspaceId } : {}),
1201
+ ...(requestedSessionId !== "" ? { sessionId: requestedSessionId } : {})
1202
+ };
1203
+ const result = await scanPreset(filter);
1204
+ return send(res, 200, { ok: true, result });
1205
+ } catch (error) {
1206
+ ctx.logger.warn("session-manager: preset-scan failed: " + String(error));
1207
+ return send(res, 500, { ok: false, error: String(error.message || error) });
1208
+ }
1209
+ }
1210
+ if (req.method === "POST" && path === "/preset-migrate") {
1211
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
1212
+ const toPreset = typeof body.toPreset === "string" ? body.toPreset.trim() : "";
1213
+ if (requestedSessionId === "" || toPreset === "") {
1214
+ return send(res, 400, { ok: false, error: "sessionId and toPreset required" });
1215
+ }
1216
+ try {
1217
+ const result = await migratePreset({ sessionId: requestedSessionId, toPreset });
1218
+ return send(res, 200, { ok: true, result });
1219
+ } catch (error) {
1220
+ ctx.logger.warn("session-manager: preset-migrate failed: " + String(error));
1221
+ return send(res, 500, { ok: false, error: String(error.message || error) });
1222
+ }
1223
+ }
1224
+
1225
+ return send(res, 404, { ok: false, error: `not found: ${req.method} ${path}` });
1226
+ } catch (error) {
1227
+ ctx.logger.warn(`session-manager: api error: ${String(error)}; type=${error?.constructor?.name}; json=${safeJson(error)}`);
1228
+ return send(res, 500, { ok: false, error: String(error instanceof Error ? error.message : error) });
1229
+ }
1230
+ } }), "session-manager: http api");
1231
+
1232
+
1233
+ }