dsh-workspace-mover 2.4.1 → 2.4.3

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
@@ -36,6 +36,7 @@ import { join, dirname, basename, relative, resolve, isAbsolute } from 'node:pat
36
36
  import { homedir } from 'node:os';
37
37
  import { spawn } from 'node:child_process';
38
38
  import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
39
+ import { installLifecycle, lifecycleFor, withReleasedSession } from './lifecycle.js';
39
40
 
40
41
  const name = 'workspace-mover';
41
42
  // 只声明真正必需的两个服务。cordis 把 inject 里的**每一项**都当必需:任一缺失,
@@ -913,7 +914,9 @@ async function moveSessionEntry(ctx, payload = {}, hooks = {}) {
913
914
  `workspace:${targetWorkspaceId}`
914
915
  );
915
916
  try {
916
- return await moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks);
917
+ if (!ctx.workspaceRegistry?.get(targetWorkspaceId)) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
918
+ return await withReleasedSession(ctx, sessionId, payload.stopRunning === true,
919
+ () => moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks));
917
920
  } finally {
918
921
  release();
919
922
  }
@@ -955,7 +958,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
955
958
  const sourceCwd = meta.cwd;
956
959
  const title = typeof sessionTitle === 'string' && sessionTitle.trim()
957
960
  ? sessionTitle.trim()
958
- : (typeof meta.title === 'string' && meta.title.trim() ? meta.title.trim() : '未命名会话');
961
+ : readSessionTitle(ctx, sessionId, readProjectionTitles().get(String(sessionId)) ?? meta.title ?? '未命名会话');
959
962
  if (sourceCwd === undefined) throw new Error('session header carries no cwd');
960
963
  if (samePath(sourceCwd, targetPath)) {
961
964
  // 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
@@ -1129,7 +1132,11 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
1129
1132
  // 才会读取 header 并经 mutate 发出 domain/changed。不能直接预置
1130
1133
  // target.record.sessionIds,否则 attach 变成 no-op,WorkspaceFeed 不会推送目标
1131
1134
  // 工作区的更新帧,浏览器就会暂时把会话显示在“未分组”。
1135
+ const originalProjection = captureProjection(ctx, sessionId);
1132
1136
  try {
1137
+ // Listings can read the title during attach: align BEFORE publishing the
1138
+ // new header, otherwise the host rejects the old cwd-bound checkpoint.
1139
+ await realignProjectionIdentity(ctx, sessionId, targetPath);
1133
1140
  await target.attachSession(sessionId);
1134
1141
  } catch (err) {
1135
1142
  // Roll back original bytes and reattach the source workspace.
@@ -1142,6 +1149,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
1142
1149
  throw new Error(`physical rollback could not move the target directory: ${relocateErr?.message ?? relocateErr}`);
1143
1150
  }
1144
1151
  await storage.replace(sourceLocation, original);
1152
+ await writeProjection(ctx, String(sessionId), originalProjection, sourceCwd, true);
1145
1153
  for (const e of allOwners) {
1146
1154
  try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ }
1147
1155
  }
@@ -1400,6 +1408,26 @@ function readProjectionTitles() {
1400
1408
  return map;
1401
1409
  }
1402
1410
 
1411
+ /** Current hosts use per-record checkpoints; legacy aggregate files can remain
1412
+ * on disk after an upgrade and must never override a current title. */
1413
+ function readSessionTitle(ctx, sessionId, fallback = null) {
1414
+ const titleOf = (record) => {
1415
+ const title = record?.rows?.title?.val;
1416
+ return typeof title === 'string' && title.trim() ? title.trim() : null;
1417
+ };
1418
+ const live = titleOf(captureProjection(ctx, sessionId));
1419
+ if (live) return live;
1420
+ try {
1421
+ const id = validId(String(sessionId), 'sessionId');
1422
+ if (/[\\/:]/.test(id) || id === '.' || id === '..') return fallback;
1423
+ const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
1424
+ const document = JSON.parse(readFileSync(join(base, 'storages', 'session_projcache', 'sessions', `${id}.json`), 'utf8'));
1425
+ const current = titleOf(document?.record);
1426
+ if (current) return current;
1427
+ } catch { /* Cache absence/corruption must not prevent session rescue. */ }
1428
+ return fallback;
1429
+ }
1430
+
1403
1431
  /**
1404
1432
  * 扫描存储根下的全部会话档案并分类:
1405
1433
  * - orphaned 头部 cwd 指向的目录已不存在(项目文件夹被移动/改名/删除,官方讨论 #3012)
@@ -1504,8 +1532,8 @@ async function scanSessions(ctx) {
1504
1532
  : 'misfiled'; // 记账方存在但不是 cwd 匹配的分组
1505
1533
  items.push({
1506
1534
  sessionId,
1507
- title: projectionTitles.get(sessionId)
1508
- ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话'),
1535
+ title: readSessionTitle(ctx, sessionId, projectionTitles.get(sessionId)
1536
+ ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话')),
1509
1537
  cwd,
1510
1538
  status,
1511
1539
  targetWorkspaceId: matching?.id ?? null,
@@ -1522,6 +1550,13 @@ async function scanSessions(ctx) {
1522
1550
  }
1523
1551
 
1524
1552
  const ghosts = [];
1553
+ // Include all discovered archives, even those outside the parsed scan limit.
1554
+ const archiveLocations = new Map();
1555
+ for (const { idDir, artifact } of metas) {
1556
+ const paths = archiveLocations.get(String(idDir)) ?? [];
1557
+ paths.push(artifact);
1558
+ archiveLocations.set(String(idDir), paths);
1559
+ }
1525
1560
  for (const entity of entities) {
1526
1561
  let ids = [];
1527
1562
  // 幽灵正是「有账无档」的 id,而官方公开的 sessionIds getter 会把它们
@@ -1529,7 +1564,11 @@ async function scanSessions(ctx) {
1529
1564
  try { ids = rawRecordSessionIds(entity); } catch { continue; }
1530
1565
  for (const id of ids) {
1531
1566
  try {
1532
- if (!existsSync(artifactPath(root, entity.path, id, compression))) ghosts.push({ workspaceId: entity.id, sessionId: String(id) });
1567
+ if (!existsSync(artifactPath(root, entity.path, id, compression))) {
1568
+ const locations = archiveLocations.get(String(id)) ?? [];
1569
+ ghosts.push({ workspaceId: entity.id, sessionId: String(id), locations,
1570
+ status: locations.length ? 'found-elsewhere' : 'location-unknown', archived: archived.has(String(id)) });
1571
+ }
1533
1572
  } catch { /* skip */ }
1534
1573
  }
1535
1574
  }
@@ -1615,12 +1654,8 @@ function invalidateFileReferenceSearches(ctx, stalePath) {
1615
1654
  */
1616
1655
  async function realignProjectionIdentity(ctx, sessionId, nextCwd) {
1617
1656
  try {
1618
- const table = peekService(ctx, 'sessionProjectionCache')?.table;
1619
- if (typeof table?.update !== 'function') return;
1620
- await table.update(String(sessionId), (rec) =>
1621
- rec?.identity ? { ...rec, identity: { ...rec.identity, cwd: nextCwd },
1622
- rows: Object.fromEntries(Object.entries(rec.rows ?? {}).filter(([key]) => key !== 'workingDirectory')) } : rec
1623
- );
1657
+ const record = captureProjection(ctx, sessionId);
1658
+ await writeProjection(ctx, String(sessionId), record, nextCwd);
1624
1659
  } catch (err) {
1625
1660
  ctx.logger?.warn?.(`workspace-mover: projection identity alignment failed for ${sessionId}: ${err?.message ?? err}`);
1626
1661
  }
@@ -1999,13 +2034,16 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
1999
2034
  const repairMembership = sameWorkspace && !rawRecordSessionIds(target).includes(id);
2000
2035
  let backupPlan = { reused: false, estimatedBytes: 0 };
2001
2036
  let status = 'safe';
2037
+ const liveMigration = Boolean(lifecycleFor(ctx, id));
2002
2038
  if (running) {
2003
- status = 'queued';
2004
- warnings.push('session is currently running and will wait for idle');
2039
+ status = 'warning';
2040
+ warnings.push('session is running; moving requires explicit stop confirmation');
2005
2041
  }
2006
2042
  try { assertNoResidentWriter(ctx, id); } catch (err) {
2007
- status = 'queued';
2008
- warnings.push(err.message);
2043
+ if (!liveMigration) {
2044
+ status = 'queued';
2045
+ warnings.push(err.message);
2046
+ }
2009
2047
  }
2010
2048
  if (adapter.mode === 'filesystem-fallback') warnings.push('physical move uses the JSONL path fallback; official raw-byte path API is unavailable');
2011
2049
  if (!adapter.capabilities.physicalPath) {
@@ -2054,10 +2092,11 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
2054
2092
  status,
2055
2093
  canMove: status === 'safe' || status === 'warning' || status === 'queued',
2056
2094
  sessionId: id,
2057
- title: readProjectionTitles().get(id) ?? meta.title ?? null,
2095
+ title: readSessionTitle(ctx, id, readProjectionTitles().get(id) ?? meta.title ?? null),
2058
2096
  source,
2059
2097
  target: targetInfo,
2060
2098
  running,
2099
+ liveMigration,
2061
2100
  branches: { available: true, count: branches.relations.length, mode, sessionIds: branches.ids, relations: branches.relations },
2062
2101
  estimatedBackupBytes: sameWorkspace ? 0 : backupPlan.estimatedBytes,
2063
2102
  backup: sameWorkspace ? { reused: false, estimatedBytes: 0 } : backupPlan,
@@ -2067,7 +2106,7 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
2067
2106
  }
2068
2107
 
2069
2108
  /** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
2070
- async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only' } = {}) {
2109
+ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only', stopRunning = false, allowQueue = true } = {}) {
2071
2110
  if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
2072
2111
  if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
2073
2112
  if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
@@ -2113,7 +2152,8 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
2113
2152
  sessionId,
2114
2153
  targetWorkspaceId,
2115
2154
  sessionTitle: typeof item.sessionTitle === 'string' ? item.sessionTitle : undefined,
2116
- recordHistory: false
2155
+ recordHistory: false,
2156
+ stopRunning
2117
2157
  });
2118
2158
  if (result.attached) attachedCount += 1; else movedCount += 1;
2119
2159
  let sourceWorkspaceId = null;
@@ -2123,7 +2163,7 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
2123
2163
  moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId, backup: result.backup ?? null });
2124
2164
  results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
2125
2165
  } catch (err) {
2126
- if (isBusyError(err)) {
2166
+ if (allowQueue && isBusyError(err)) {
2127
2167
  const queued = createOrReuseMigration(ctx, {
2128
2168
  sessionId,
2129
2169
  targetWorkspaceId,
@@ -3196,14 +3236,14 @@ async function dropProjection(ctx, sessionId) {
3196
3236
  }
3197
3237
  }
3198
3238
 
3199
- async function writeProjection(ctx, sessionId, record, cwd) {
3239
+ async function writeProjection(ctx, sessionId, record, cwd, preserveWorkingDirectory = false) {
3200
3240
  try {
3201
3241
  const table = projectionTable(ctx);
3202
3242
  if (!table || typeof table.put !== 'function' || !record?.identity || !record?.rows) return;
3203
3243
  // 注意:table 是底层 KvTable,put(key, value) 两参——value 必须是完整 {identity, rows};
3204
3244
  // 服务层的 put(id, identity, rows) 三参签名在这里不可用(会整条写坏,域重开时崩)。
3205
3245
  await table.put(String(sessionId), { identity: { ...record.identity, cwd },
3206
- rows: Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
3246
+ rows: preserveWorkingDirectory ? record.rows : Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
3207
3247
  } catch (err) {
3208
3248
  ctx.logger?.warn?.(`workspace-mover: projection restore failed for ${sessionId} (title will rebuild lazily): ${err?.message ?? err}`);
3209
3249
  }
@@ -3308,7 +3348,26 @@ async function deleteSessionToTrash(ctx, payload = {}, hooks = {}) {
3308
3348
  const sessionId = validId(payload?.sessionId, 'sessionId');
3309
3349
  const release = acquireLocks(`session:${sessionId}`);
3310
3350
  try {
3311
- return await deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, purgeBackups: payload?.purgeBackups === true });
3351
+ assertNotRunning(ctx, sessionId);
3352
+ if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined && !lifecycleFor(ctx, sessionId)) {
3353
+ throw new Error('session is resident in memory without a tracked lifecycle handle; restart the harness before deleting');
3354
+ }
3355
+ // Disposing the Agent may remove its projection; preserve display metadata first.
3356
+ const projection = captureProjection(ctx, sessionId);
3357
+ const title = readSessionTitle(ctx, sessionId, null);
3358
+ return await withReleasedSession(ctx, sessionId, false,
3359
+ () => deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, projection, title, purgeBackups: payload?.purgeBackups === true }), {
3360
+ reloadOnSuccess: false,
3361
+ // A failed rollback must never resume a missing archive as a blank session.
3362
+ canReload: async () => {
3363
+ try {
3364
+ const location = await locateArtifact(ctx, sessionId);
3365
+ const storage = createPersistenceAdapter(ctx);
3366
+ const header = readHeader(await storage.read(location), storage.compression);
3367
+ return String(header?.id) === String(sessionId);
3368
+ } catch { return false; }
3369
+ }
3370
+ });
3312
3371
  } finally {
3313
3372
  release();
3314
3373
  }
@@ -3328,7 +3387,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
3328
3387
  const { artifact, cwd: artifactCwd } = location;
3329
3388
  const header = readHeader(await storage.read(location), storage.compression);
3330
3389
  const archived = readArchivedIds(registry).includes(sessionId);
3331
- const projection = captureProjection(ctx, sessionId);
3390
+ const projection = hooks.projection ?? captureProjection(ctx, sessionId);
3332
3391
  const owners = listOwners(registry, sessionId);
3333
3392
  const entryDir = join(recycleDir(), `${Date.now()}-${String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_')}`);
3334
3393
  mkdirSync(entryDir, { recursive: true });
@@ -3343,7 +3402,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
3343
3402
  const manifest = {
3344
3403
  version: 1,
3345
3404
  sessionId: String(sessionId),
3346
- title: typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null,
3405
+ title: hooks.title ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null),
3347
3406
  cwd: artifactCwd ?? header?.cwd ?? null,
3348
3407
  ownerWorkspaceId: owners[0]?.id ?? null,
3349
3408
  ownerTitle: owners[0]?.title ?? null,
@@ -3364,6 +3423,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
3364
3423
  lastError: `manifest write failed (${err?.message ?? err}) AND move-back failed (${backErr?.message ?? backErr}) — files remain under ${entryDir}`
3365
3424
  });
3366
3425
  ctx.logger?.warn?.(`workspace-mover: manifest write failed AND move-back failed for ${sessionId}: ${backErr?.message ?? backErr} — files remain under ${entryDir}`);
3426
+ throw new Error(`manifest write failed AND rollback failed; session files preserved under ${entryDir}: ${backErr?.message ?? backErr}`);
3367
3427
  }
3368
3428
  try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
3369
3429
  throw new Error(`manifest write failed, session moved back: ${err?.message ?? err}`);
@@ -3477,7 +3537,7 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
3477
3537
  }
3478
3538
  // 先移回原位(头 cwd 与原位一致,attach 校验天然通过)
3479
3539
  await storage.relocate(trashLocation, dirname(originalLocation.artifact));
3480
- let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, moved: null };
3540
+ let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, archived: Boolean(manifest.archived), moved: null };
3481
3541
  try {
3482
3542
  if (targetEntity && !samePath(originalCwd, destPath)) {
3483
3543
  // 原位只是中转:直接走现有 moveSession 全管线(备份/改写/热修/记账全套)
@@ -3537,7 +3597,7 @@ async function listBackups(ctx) {
3537
3597
  const { id, ts } = parsed;
3538
3598
  let sizeBytes = 0;
3539
3599
  try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
3540
- const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
3600
+ const g = groups.get(id) ?? { sessionId: id, title: readSessionTitle(ctx, id, titles.get(id) ?? null), count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
3541
3601
  g.count++;
3542
3602
  g.totalBytes += sizeBytes;
3543
3603
  g.oldest = Math.min(g.oldest, Number(ts));
@@ -4177,6 +4237,7 @@ async function runDoctor(ctx, agentToolsState) {
4177
4237
 
4178
4238
  export function apply(ctx) {
4179
4239
  const logger = ctx.logger ?? console;
4240
+ ctx.inject?.(['agents'], (owner) => installLifecycle(ctx, owner));
4180
4241
  const dispatchRef = { fn: null }; // agent 工具在调用期才用到 dispatch,挂载时序无关
4181
4242
  const agentToolsState = { registered: false };
4182
4243
  const mount = (owner) => {
@@ -4271,15 +4332,15 @@ export function apply(ctx) {
4271
4332
  return ok(await moveManySessions(ctx, {
4272
4333
  sessions: [{ sessionId, sessionTitle: payload?.sessionTitle }],
4273
4334
  targetWorkspaceId,
4274
- mode: 'include-descendants'
4335
+ mode: 'include-descendants', stopRunning: payload?.stopRunning === true, allowQueue: payload?.allowQueue !== false
4275
4336
  }));
4276
4337
  }
4277
- const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
4338
+ const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle, stopRunning: payload?.stopRunning === true });
4278
4339
  logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
4279
4340
  return ok(result);
4280
4341
  } catch (err) {
4281
4342
  logger.warn?.(`workspace-mover: MOVE FAILED sessionId=${payload?.sessionId} target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
4282
- if (isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
4343
+ if (payload?.allowQueue !== false && isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
4283
4344
  const queued = createOrReuseMigration(ctx, {
4284
4345
  sessionId: payload.sessionId,
4285
4346
  targetWorkspaceId: payload.targetWorkspaceId,
@@ -0,0 +1,104 @@
1
+ // Retain the public create/resume disposer. Never close a borrowed persistence
2
+ // writer or rewrite an immutable live Session header.
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ const migrationContext = new AsyncLocalStorage();
5
+ const original = Symbol.for('cordis.original');
6
+ const runtimes = new WeakMap();
7
+ const raw = (value) => value?.[original] ?? value;
8
+
9
+ function blocked() {
10
+ const error = new Error('This session is being migrated; retry after migration completes.');
11
+ error.code = 'migration-in-progress';
12
+ return error;
13
+ }
14
+
15
+ export function installLifecycle(ctx, owner) {
16
+ const agents = raw(owner.agents);
17
+ if (!agents || runtimes.has(agents)) return;
18
+ const runtime = { handles: new Map(), moving: new Set() };
19
+ runtimes.set(agents, runtime);
20
+ const restore = [];
21
+ for (const method of ['create', 'resume']) {
22
+ const fn = agents[method];
23
+ if (typeof fn !== 'function') continue;
24
+ const wrapped = async function (options, ...args) {
25
+ const id = options?.resumeSessionId ?? options?.sessionId;
26
+ if (runtime.moving.has(id) && migrationContext.getStore() !== runtime) throw blocked();
27
+ const handle = await Reflect.apply(fn, this, [options, ...args]);
28
+ const agent = raw(handle?.agent);
29
+ if (agent && typeof handle.dispose === 'function') {
30
+ const guards = [];
31
+ runtime.handles.set(agent.id, { handle, agent, guards });
32
+ // Requests that already resolved the old Agent must also be refused
33
+ // while its lifecycle is draining or after it has been replaced.
34
+ for (const entry of ['send', 'runMaintenance']) {
35
+ const action = agent[entry];
36
+ if (typeof action !== 'function') continue;
37
+ const guarded = function (...params) {
38
+ if (runtime.moving.has(agent.id) || raw(agents.get(agent.id)) !== agent) throw blocked();
39
+ return Reflect.apply(action, this, params);
40
+ };
41
+ agent[entry] = guarded;
42
+ guards.push(() => { if (agent[entry] === guarded) agent[entry] = action; });
43
+ }
44
+ }
45
+ return handle;
46
+ };
47
+ agents[method] = wrapped;
48
+ restore.push(() => { if (agents[method] === wrapped) agents[method] = fn; });
49
+ }
50
+ owner.on?.('agent/disposed', ({ agent }) => {
51
+ if (runtime.handles.get(agent.id)?.agent === raw(agent)) runtime.handles.delete(agent.id);
52
+ });
53
+ owner.effect(() => () => {
54
+ for (const entry of runtime.handles.values()) for (const undo of entry.guards) undo();
55
+ runtime.handles.clear();
56
+ for (const undo of restore.reverse()) undo();
57
+ runtimes.delete(agents);
58
+ }, 'workspace-mover: restore lifecycle observers');
59
+ }
60
+
61
+ export function lifecycleFor(ctx, id) {
62
+ const agents = raw(typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents);
63
+ const runtime = agents && runtimes.get(agents);
64
+ const entry = runtime?.handles.get(id);
65
+ return entry && raw(agents.get(id)) === entry.agent ? { ...entry, runtime, agents } : null;
66
+ }
67
+
68
+ export async function withReleasedSession(ctx, id, stopRunning, operation, { reloadOnSuccess = true, canReload = () => true } = {}) {
69
+ const owned = lifecycleFor(ctx, id);
70
+ if (!owned) return operation();
71
+ const { handle, agent, runtime } = owned;
72
+ if (agent.status === 'running' && !stopRunning) {
73
+ const error = new Error('Session is running. Confirm stop and move, or cancel migration.');
74
+ error.code = 'stop-required';
75
+ throw error;
76
+ }
77
+ if (agent.session.header?.parentSession || agent.session.header?.origin === 'subagent') {
78
+ throw new Error('Live subagent migration is unsupported; stop its owning task first.');
79
+ }
80
+ runtime.moving.add(id);
81
+ let result;
82
+ let failure;
83
+ try {
84
+ await handle.dispose(); // owns cancel → drain → close → unregister
85
+ result = await operation();
86
+ } catch (error) {
87
+ failure = error;
88
+ } finally {
89
+ runtime.handles.delete(id);
90
+ try {
91
+ const controller = ctx.get?.('sessionController');
92
+ if ((failure || reloadOnSuccess) && await canReload() && typeof controller?.resolveAgent === 'function') {
93
+ const restored = await migrationContext.run(runtime, () => controller.resolveAgent(id));
94
+ if (restored?.error) throw restored.error;
95
+ }
96
+ } catch (error) {
97
+ failure = new Error(`${failure ? failure.message : 'Files migrated successfully'}; session reload failed: ${error.message}`, { cause: error });
98
+ } finally {
99
+ runtime.moving.delete(id);
100
+ }
101
+ }
102
+ if (failure) throw failure;
103
+ return result;
104
+ }
package/package.json CHANGED
@@ -1,103 +1,104 @@
1
- {
2
- "name": "dsh-workspace-mover",
3
- "version": "2.4.1",
4
- "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./client": "./client/client.js",
10
- "./package.json": "./package.json"
11
- },
12
- "files": [
13
- "lib",
14
- "client",
15
- "test",
16
- "cordis.patch.yml",
17
- "README.md",
18
- "README_EN.md",
19
- "CHANGELOG.md",
20
- "docs/ARCHITECTURE.md",
21
- "docs/compatibility",
22
- "docs/media",
23
- "LICENSE"
24
- ], "scripts": {
25
- "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs",
26
- "test:installed-host": "node --test test/installed-host.test.mjs",
27
- "check:syntax": "node --check lib/index.js && node --check client/client.js",
28
- "check:pack": "npm pack --dry-run",
29
- "check": "npm run check:syntax && npm test && npm run check:pack"
30
- },
31
- "keywords": [
32
- "deepseek-harness",
33
- "dsh",
34
- "dsh-plugin",
35
- "workspace",
36
- "session",
37
- "session-migration",
38
- "drag-and-drop",
39
- "agent-tools"
40
- ],
41
- "engines": {
42
- "node": ">=22",
43
- "dsh": ">=0.1.5-rc.1"
44
- },
45
- "//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
46
- "peerDependencies": {
47
- "@deepseek-ai/cordis": "*",
48
- "@deepseek-ai/dsh-session": "*",
49
- "@deepseek-ai/dsh-tools": "*",
50
- "@deepseek-ai/dsh-workspace": "*",
51
- "@deepseek-ai/schemastery": "*",
52
- "@deepseek-ai/dsh-client-connection": "*",
53
- "@deepseek-ai/dsh-client-locale": "*",
54
- "@deepseek-ai/dsh-client-ui-slots": "*"
55
- },
56
- "peerDependenciesMeta": {
57
- "@deepseek-ai/cordis": {
58
- "optional": true
59
- },
60
- "@deepseek-ai/dsh-session": {
61
- "optional": true
62
- },
63
- "@deepseek-ai/dsh-tools": {
64
- "optional": true
65
- },
66
- "@deepseek-ai/dsh-workspace": {
67
- "optional": true
68
- },
69
- "@deepseek-ai/schemastery": {
70
- "optional": true
71
- },
72
- "@deepseek-ai/dsh-client-connection": {
73
- "optional": true
74
- },
75
- "@deepseek-ai/dsh-client-locale": {
76
- "optional": true
77
- },
78
- "@deepseek-ai/dsh-client-ui-slots": {
79
- "optional": true
80
- }
81
- },
82
- "license": "MIT",
83
- "repository": {
84
- "type": "git",
85
- "url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
86
- },
87
- "bugs": {
88
- "url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
89
- },
90
- "homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
91
- "dsh": {
92
- "bundle": {
93
- "patch": "./cordis.patch.yml"
94
- },
95
- "client": {
96
- "inject": [
97
- "@deepseek-ai/dsh-client-connection",
98
- "@deepseek-ai/dsh-client-ui-slots"
99
- ],
100
- "platform": "web"
101
- }
102
- }
103
- }
1
+ {
2
+ "name": "dsh-workspace-mover",
3
+ "version": "2.4.3",
4
+ "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./client/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "client",
15
+ "test",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "README_EN.md",
19
+ "CHANGELOG.md",
20
+ "docs/ARCHITECTURE.md",
21
+ "docs/compatibility",
22
+ "docs/media",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs test/live-migration.test.mjs",
27
+ "test:installed-host": "node --test test/installed-host.test.mjs test/live-migration.test.mjs",
28
+ "check:syntax": "node --check lib/index.js && node --check client/client.js && node --check lib/lifecycle.js",
29
+ "check:pack": "npm pack --dry-run",
30
+ "check": "npm run check:syntax && npm test && npm run check:pack"
31
+ },
32
+ "keywords": [
33
+ "deepseek-harness",
34
+ "dsh",
35
+ "dsh-plugin",
36
+ "workspace",
37
+ "session",
38
+ "session-migration",
39
+ "drag-and-drop",
40
+ "agent-tools"
41
+ ],
42
+ "engines": {
43
+ "node": ">=22",
44
+ "dsh": ">=0.1.5-rc.1"
45
+ },
46
+ "//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
47
+ "peerDependencies": {
48
+ "@deepseek-ai/cordis": "*",
49
+ "@deepseek-ai/dsh-session": "*",
50
+ "@deepseek-ai/dsh-tools": "*",
51
+ "@deepseek-ai/dsh-workspace": "*",
52
+ "@deepseek-ai/schemastery": "*",
53
+ "@deepseek-ai/dsh-client-connection": "*",
54
+ "@deepseek-ai/dsh-client-locale": "*",
55
+ "@deepseek-ai/dsh-client-ui-slots": "*"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@deepseek-ai/cordis": {
59
+ "optional": true
60
+ },
61
+ "@deepseek-ai/dsh-session": {
62
+ "optional": true
63
+ },
64
+ "@deepseek-ai/dsh-tools": {
65
+ "optional": true
66
+ },
67
+ "@deepseek-ai/dsh-workspace": {
68
+ "optional": true
69
+ },
70
+ "@deepseek-ai/schemastery": {
71
+ "optional": true
72
+ },
73
+ "@deepseek-ai/dsh-client-connection": {
74
+ "optional": true
75
+ },
76
+ "@deepseek-ai/dsh-client-locale": {
77
+ "optional": true
78
+ },
79
+ "@deepseek-ai/dsh-client-ui-slots": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "license": "MIT",
84
+ "repository": {
85
+ "type": "git",
86
+ "url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
87
+ },
88
+ "bugs": {
89
+ "url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
90
+ },
91
+ "homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
92
+ "dsh": {
93
+ "bundle": {
94
+ "patch": "./cordis.patch.yml"
95
+ },
96
+ "client": {
97
+ "inject": [
98
+ "@deepseek-ai/dsh-client-connection",
99
+ "@deepseek-ai/dsh-client-ui-slots"
100
+ ],
101
+ "platform": "web"
102
+ }
103
+ }
104
+ }