dsh-session-manager 0.7.3 → 0.7.5

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
@@ -28,7 +28,7 @@ import { basename, dirname, join } from "node:path";
28
28
  import { randomBytes } from "node:crypto";
29
29
  import { createAnnotationStore } from "./annotation-store.js";
30
30
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
31
- import { createDshAdapter, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
31
+ import { createDshAdapter, getService, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
32
32
  import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
33
33
 
34
34
  export const name = "dsh-session-manager";
@@ -39,7 +39,8 @@ export const inject = [
39
39
  "sessions",
40
40
  "agents",
41
41
  "sessionPersistence",
42
- "agentPresets"
42
+ "agentPresets",
43
+ "connection"
43
44
  ];
44
45
 
45
46
  /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
@@ -101,6 +102,12 @@ function rebindLiveWriter(persistence, sessionId, newHeader) {
101
102
  * disk fallback semantics are preserved -- the orphan is the whole
102
103
  * reason this loop exists.
103
104
  *
105
+ * The result carries two completeness flags: "persisted" reports whether the
106
+ * persistence index itself listed successfully (its rows are authoritative),
107
+ * while "complete" additionally requires the disk sweep to finish without
108
+ * errors. Callers that only add validated memberships may run on "persisted";
109
+ * callers that remove records must wait for "complete".
110
+ *
104
111
  * @param {object|undefined} persistence DSH's sessionPersistence service
105
112
  * @param {((segment: string) => string)|undefined} dshHome Resolver for
106
113
  * DSH home sub-paths; only "sessions" is read.
@@ -110,6 +117,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
110
117
  const headers = [];
111
118
  const errors = [];
112
119
  let listed = false;
120
+ let persisted = false;
113
121
  if (typeof persistence?.list === "function") {
114
122
  try {
115
123
  const rows = await persistence.list();
@@ -122,9 +130,10 @@ const scanSessionHeaders = async (persistence, dshHome) => {
122
130
  if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
123
131
  }
124
132
  listed = true;
133
+ persisted = true;
125
134
  } catch (error) { errors.push(error); }
126
135
  }
127
- const result = () => ({ headers, complete: listed && errors.length === 0, errors });
136
+ const result = () => ({ headers, persisted, complete: listed && errors.length === 0, errors });
128
137
  // Skip known files before fopen; retain the orphan/raw-id compatibility path.
129
138
  // Only add canonical session directories that do not start and end with "--"
130
139
  // to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
@@ -175,6 +184,16 @@ const scanSessionHeaders = async (persistence, dshHome) => {
175
184
 
176
185
  const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
177
186
 
187
+ /** Canonicalize a directory path the way DSH's workspace registry does
188
+ * (fs.realpath on a fully qualified path). Returns undefined when the path
189
+ * cannot be resolved so callers skip instead of guessing; DSH stores
190
+ * workspace paths canonicalized, so canonical equality is the only safe
191
+ * membership comparison on Windows (case, separators, 8.3 names, junctions). */
192
+ const canonicalizeDirectory = async (path) => {
193
+ if (typeof path !== "string" || path === "") return undefined;
194
+ try { return await realpath(path); } catch { return undefined; }
195
+ };
196
+
178
197
  const UPDATE_STATUS_FILE = ".dsh-session-manager-update.json";
179
198
  const STALE_EXTERNAL_TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes fallback for dead external processes
180
199
  const HEARTBEAT_STALE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes without heartbeat for external processes
@@ -367,7 +386,7 @@ function verifyInstalledPackage(profileDir, expectedVersion) {
367
386
 
368
387
  export function apply(ctx) {
369
388
  try {
370
- const buildId = "dsh-session-manager v0.7.3 (workspace-archive)";
389
+ const buildId = "dsh-session-manager v0.7.5 (workspace-archive)";
371
390
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
372
391
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
373
392
  } catch (_) { /* ignore logging failures */ }
@@ -399,10 +418,26 @@ export function apply(ctx) {
399
418
  const safeGetAgent = (sessionId) => {
400
419
  try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
401
420
  };
421
+ /** Durable workspace membership for one entity. The record account is the
422
+ * authoritative persisted list on DSH 0.2.x (registry entities expose it as
423
+ * the private `record` field); the runtime `sessionIds` getter is filtered
424
+ * by the canonical-cwd index and may hide members whose index entry is
425
+ * stale, so it is only a fallback for hosts without a readable record. */
426
+ const workspaceMemberIds = (entity) => {
427
+ if (entity === null || entity === undefined) return [];
428
+ const record = entity.record;
429
+ if (record !== null && record !== undefined && Array.isArray(record.sessionIds)) return record.sessionIds;
430
+ const ids = entity.sessionIds;
431
+ if (Array.isArray(ids)) return ids;
432
+ if (typeof ids === "function") {
433
+ try { const value = ids(); if (Array.isArray(value)) return value; } catch { /* ignore */ }
434
+ }
435
+ return [];
436
+ };
402
437
  const assertAnnotatable = id => {
403
438
  const registry = adapter.workspaceRegistry;
404
439
  if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
405
- || adapter.workspaceList().some(ws => (ws.record?.sessionIds ?? ws.sessionIds ?? []).includes(id))) return;
440
+ || adapter.workspaceList().some(ws => workspaceMemberIds(ws).includes(id))) return;
406
441
  throw Object.assign(new Error("会话不存在或尚未加载,请先打开该会话后重试"), { code: "session-not-found" });
407
442
  };
408
443
  const operations = new Map();
@@ -567,7 +602,9 @@ export function apply(ctx) {
567
602
  const detachFromWorkspaces = async (sessionId) => {
568
603
  const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
569
604
  for (const entity of registry.list()) {
570
- if (entity.sessionIds.includes(sessionId)) {
605
+ // Durable record, not the runtime-filtered getter: deleting a session
606
+ // must purge its account even when the cwd index is stale.
607
+ if (workspaceMemberIds(entity).includes(sessionId)) {
571
608
  await entity.detachSession(sessionId);
572
609
  }
573
610
  }
@@ -576,9 +613,7 @@ export function apply(ctx) {
576
613
  /** Ordered workspace projection for the client. */
577
614
  const listWorkspaces = () => {
578
615
  return ctx.workspaceRegistry.list().map((entity) => {
579
- const rawIds = Array.isArray(entity.record?.sessionIds)
580
- ? [...entity.record.sessionIds]
581
- : [...entity.sessionIds];
616
+ const rawIds = [...workspaceMemberIds(entity)];
582
617
  const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
583
618
  return {
584
619
  id: entity.id,
@@ -834,6 +869,291 @@ ${rest}`, "utf8");
834
869
  return false;
835
870
  };
836
871
 
872
+ /**
873
+ * Attach one session to the registered workspace that owns its cwd.
874
+ * Returns true only when a new durable membership was written.
875
+ *
876
+ * DSH's own create/fork flow attaches through workspaceRegistry, but a
877
+ * session created outside that flow (cwd-only create, an attach the host
878
+ * declined, or a session that predates this plugin load) stays
879
+ * unaccounted even though its cwd names a registered workspace. The
880
+ * official entity API re-validates the stored header cwd against the
881
+ * canonical workspace path and persists through the domain table, so a
882
+ * wrong match can never be written. Subagent sessions never join a
883
+ * workspace account, and a session any workspace already accounts for is
884
+ * left untouched (no session is ever registered twice).
885
+ */
886
+ const attachSessionToOwningWorkspace = async (header) => {
887
+ const registry = adapter.workspaceRegistry;
888
+ if (registry === void 0 || typeof registry.list !== "function") return false;
889
+ if (header === void 0 || typeof header.id !== "string") return false;
890
+ if (header.origin === "subagent") return false;
891
+ if (typeof header.cwd !== "string" || header.cwd === "") return false;
892
+ const run = async () => {
893
+ const entities = registry.list();
894
+ for (const entity of entities) {
895
+ if (workspaceMemberIds(entity).includes(header.id)) return false;
896
+ }
897
+ const byPath = new Map();
898
+ for (const entity of entities) {
899
+ if (typeof entity.path === "string" && !byPath.has(entity.path)) byPath.set(entity.path, entity);
900
+ }
901
+ let target = byPath.get(header.cwd);
902
+ if (target === void 0 && typeof registry.resolveByPath === "function") {
903
+ try { target = await registry.resolveByPath(header.cwd); } catch { target = void 0; }
904
+ }
905
+ if (target === void 0) {
906
+ const canonical = await canonicalizeDirectory(header.cwd);
907
+ if (canonical !== void 0) target = byPath.get(canonical);
908
+ }
909
+ if (target === void 0 || typeof target.attachSession !== "function") return false;
910
+ await target.attachSession(header.id);
911
+ return true;
912
+ };
913
+ if (typeof registry.enqueueOperation === "function") return await registry.enqueueOperation(run);
914
+ return await run();
915
+ };
916
+
917
+ /**
918
+ * Resolve the header the official attachSession() will validate against.
919
+ * WorkspaceEntity.readSessionHeader prefers the live session, then the
920
+ * registry's own header index, and only then a fresh persistence listing.
921
+ * Mirroring that order lets the repair predict a rejection instead of
922
+ * attempting an attach that cannot succeed.
923
+ */
924
+ const effectiveSessionHeader = (sessionId, storedHeader) => {
925
+ try {
926
+ const live = ctx.sessions?.get?.(sessionId);
927
+ if (live !== undefined && live !== null && live.header !== undefined) return live.header;
928
+ } catch { /* fall through to the index */ }
929
+ try {
930
+ const indexed = adapter.workspaceRegistry?.headers?.get?.(sessionId);
931
+ if (indexed !== undefined) return indexed;
932
+ } catch { /* fall through to the stored header */ }
933
+ return storedHeader;
934
+ };
935
+
936
+ /**
937
+ * Complete the durable workspace membership of a session whose artifact
938
+ * already lives under the target workspace path (same-path move). DSH 0.2.x
939
+ * keeps "cwd matches the workspace path" and "the workspace accounts for
940
+ * the session" as separate facts, so a matching cwd alone must not be
941
+ * reported as success.
942
+ *
943
+ * Conservative policy. The official domain offers no cross-record
944
+ * transaction (single-record updates on one serialized storage unit), and
945
+ * attachSession() validates the session cwd against the workspace path --
946
+ * so an account held by a DIFFERENT workspace can never be restored
947
+ * through the official API once it is removed. Automatic migration would
948
+ * therefore risk destroying the only remaining account if the target
949
+ * attach then failed. Hence:
950
+ * - no workspace accounts for the session: complete the target
951
+ * membership through attachSession(), after pre-checking every
952
+ * condition it validates so a predictable rejection fails before any
953
+ * write;
954
+ * - already accounted by the target and by nobody else: idempotent
955
+ * no-op, no write;
956
+ * - accounted by another workspace: refuse with a membership-conflict
957
+ * error; no automatic cross-record migration;
958
+ * - membership state cannot be confirmed: refuse.
959
+ *
960
+ * No artifact is rewritten, no cwd is touched, and no live session is
961
+ * restarted. A true cross-workspace move re-homes the artifact first and
962
+ * is a separate, unchanged path.
963
+ *
964
+ * @returns {status: "already-member"|"attached", attached: boolean}
965
+ * @throws with code workspace-membership-unknown | workspace-membership-conflict |
966
+ * workspace-attach-unavailable | workspace-repair-unsafe | workspace-repair-failed.
967
+ */
968
+ const repairWorkspaceMembership = async (target, sessionId, storedHeader) => {
969
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
970
+ const run = async () => {
971
+ // Confirm the current accounting from a fresh, readable listing. An
972
+ // unreadable registry must never be guessed at.
973
+ let entities;
974
+ let inTarget;
975
+ let stale;
976
+ try {
977
+ if (typeof registry?.list !== "function") throw new Error("workspace registry 不可用");
978
+ const listed = registry.list();
979
+ if (!Array.isArray(listed)) throw new Error("workspace registry 返回了无效数据");
980
+ entities = listed;
981
+ inTarget = workspaceMemberIds(target).includes(sessionId);
982
+ stale = entities.filter((entity) =>
983
+ entity !== target && entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId));
984
+ } catch (cause) {
985
+ const error = new Error("无法确认会话当前的工作区归属状态(" + safeErrorMessage(cause) + "),已拒绝修复操作");
986
+ error.code = "workspace-membership-unknown";
987
+ throw error;
988
+ }
989
+
990
+ if (stale.length > 0) {
991
+ // Membership conflict: the session is accounted by a workspace whose
992
+ // path differs from its cwd. That account cannot be restored through
993
+ // the official API once removed, so an automatic migration could
994
+ // destroy the only remaining account if the target attach failed.
995
+ // Refuse and let the operator resolve the conflict.
996
+ const names = stale.map((entity) => entity.title || entity.id).join("、");
997
+ const error = new Error(
998
+ "会话当前登记在其他工作区(" + names + "),与其 cwd 指向的目标工作区不一致。"
999
+ + "为避免修复失败造成数据损失,已拒绝自动迁移;请先处理该归属冲突,或使用真正的跨工作区移动"
1000
+ );
1001
+ error.code = "workspace-membership-conflict";
1002
+ throw error;
1003
+ }
1004
+ if (inTarget) return { status: "already-member", attached: false };
1005
+ if (typeof target.attachSession !== "function") {
1006
+ const error = new Error("当前 DSH 宿主未提供官方工作区归属接口(attachSession),无法补全会话归属");
1007
+ error.code = "workspace-attach-unavailable";
1008
+ throw error;
1009
+ }
1010
+
1011
+ // Pre-check every condition attachSession() validates so a predictable
1012
+ // rejection fails before any write. The header it reads is the live
1013
+ // session first, then the registry index, then a fresh persistence
1014
+ // listing -- not necessarily the header this request was validated
1015
+ // against.
1016
+ const effective = effectiveSessionHeader(sessionId, storedHeader);
1017
+ if (effective === void 0 || typeof effective.cwd !== "string" || effective.cwd === "") {
1018
+ const error = new Error("拒绝补全归属:会话没有可校验的 cwd,官方接口无法登记");
1019
+ error.code = "workspace-repair-unsafe";
1020
+ throw error;
1021
+ }
1022
+ const canonical = await canonicalizeDirectory(effective.cwd);
1023
+ if (canonical === void 0 || canonical !== target.path) {
1024
+ const error = new Error(
1025
+ "拒绝补全归属:会话当前 cwd(" + effective.cwd + ")解析后与目标工作区(" + target.path
1026
+ + ")不一致,官方接口会拒绝登记;未做任何修改"
1027
+ );
1028
+ error.code = "workspace-repair-unsafe";
1029
+ throw error;
1030
+ }
1031
+ try {
1032
+ await target.attachSession(sessionId);
1033
+ } catch (cause) {
1034
+ // Nothing has been written yet, so the previous state (no account) is
1035
+ // intact; surface the failure with its cause.
1036
+ const error = new Error("补全归属失败:" + safeErrorMessage(cause));
1037
+ error.code = "workspace-repair-failed";
1038
+ throw error;
1039
+ }
1040
+ return { status: "attached", attached: true };
1041
+ };
1042
+ if (registry !== void 0 && typeof registry.enqueueOperation === "function") {
1043
+ return await registry.enqueueOperation(run);
1044
+ }
1045
+ return await run();
1046
+ };
1047
+
1048
+ /**
1049
+ * Issue #42: record the working-directory change a move implies.
1050
+ *
1051
+ * A cross-workspace move rewrites the artifact header's `cwd`, but DSH
1052
+ * folds the `workingDirectory` projection from the session log: the unit
1053
+ * initializes from `header.cwd` only while it has no checkpoint, and both
1054
+ * the projection cache and every `working-directory/change` event
1055
+ * supersede that initial value. A live session therefore keeps reporting
1056
+ * its pre-move directory -- the projection state is never re-initialized --
1057
+ * and the cache checkpoints that stale state under the NEW header identity,
1058
+ * so a rebuild cannot recover the target either. Route the change through
1059
+ * the host's own path (the `workingDirectory` service is what a user `cd`
1060
+ * uses) so the log gains the event the projection folds from; without that
1061
+ * service, append the equivalent event directly, which is the same shape
1062
+ * the host's `commit()` writes.
1063
+ *
1064
+ * @param agent - the live Agent owning the moved session, when one exists.
1065
+ * @param session - the live Session owning the moved log.
1066
+ * @param targetPath - the destination workspace directory.
1067
+ * @returns `{status}`: "synced" (host service committed the change),
1068
+ * "recorded" (event appended directly) or "restart-required" (the host
1069
+ * rejected the change; the move itself has already succeeded).
1070
+ */
1071
+ const rehomeLiveWorkingDirectory = async ({ agent, session, targetPath }) => {
1072
+ const service = getService(ctx, "workingDirectory");
1073
+ if (service !== undefined && typeof service.set === "function" && agent !== undefined) {
1074
+ try {
1075
+ const cwd = await service.set(agent, targetPath);
1076
+ return { status: "synced", cwd: typeof cwd === "string" && cwd !== "" ? cwd : targetPath };
1077
+ } catch (error) {
1078
+ const isFiberError = error?.code === "INACTIVE_EFFECT" ||
1079
+ String(error?.message || error).toLowerCase().includes("inactive") ||
1080
+ String(error?.message || error).toLowerCase().includes("fiber");
1081
+ if (!isFiberError) {
1082
+ ctx.logger.warn(`session-manager: moved "${safeJson(session?.id)}" but the host rejected the working-directory change: ${safeErrorMessage(error)}`);
1083
+ return { status: "restart-required", reason: safeErrorMessage(error) };
1084
+ }
1085
+ }
1086
+ }
1087
+ if (session === undefined || session === null || typeof session.append !== "function") {
1088
+ return { status: "restart-required", reason: "no live session to record the working-directory change" };
1089
+ }
1090
+ try {
1091
+ session.append("working-directory/change", { cwd: targetPath });
1092
+ return { status: "recorded", cwd: targetPath };
1093
+ } catch (error) {
1094
+ ctx.logger.warn(`session-manager: moved "${safeJson(session.id)}" but could not record the working-directory change: ${safeErrorMessage(error)}`);
1095
+ return { status: "restart-required", reason: safeErrorMessage(error) };
1096
+ }
1097
+ };
1098
+
1099
+ /**
1100
+ * Issue #42 (cold sessions): append the `working-directory/change` event a
1101
+ * live move records, but only when the stored log already carries one --
1102
+ * otherwise the rewritten `header.cwd` alone initializes the projection on
1103
+ * the next resume and touching the log would be gratuitous. A stale change
1104
+ * event would otherwise fold the pre-move directory back in. Rows whose
1105
+ * `seq` does not match their position (packed physical rows) cannot be
1106
+ * extended without the persistence codec, so the body is left untouched and
1107
+ * the caller reports that a restart is required.
1108
+ *
1109
+ * @param rest - the artifact body (everything after the header line).
1110
+ * @param targetPath - the destination workspace directory.
1111
+ * @returns `{rest, status}` with the body to publish.
1112
+ */
1113
+ const rehomeColdWorkingDirectory = (rest, targetPath) => {
1114
+ const nl = String.fromCharCode(10);
1115
+ const terminated = rest.endsWith(nl);
1116
+ const body = terminated ? rest.slice(0, -1) : rest;
1117
+ if (body === "") return { rest, status: "header-only" };
1118
+ const lines = body.split(nl);
1119
+ let lastChange;
1120
+ let hasPackedOrUnparseableRows = false;
1121
+ let hasNonSequentialSeq = false;
1122
+ for (let index = 0; index < lines.length; index += 1) {
1123
+ let event;
1124
+ try {
1125
+ event = JSON.parse(lines[index]);
1126
+ } catch {
1127
+ hasPackedOrUnparseableRows = true;
1128
+ continue;
1129
+ }
1130
+ if (event === null || typeof event !== "object") {
1131
+ hasPackedOrUnparseableRows = true;
1132
+ continue;
1133
+ }
1134
+ if (event.seq !== index) {
1135
+ hasNonSequentialSeq = true;
1136
+ }
1137
+ if (event.type === "working-directory/change") {
1138
+ lastChange = event.data?.cwd;
1139
+ }
1140
+ }
1141
+ if (lastChange === undefined) return { rest, status: "header-only" };
1142
+ if (lastChange === targetPath) return { rest, status: "already-target" };
1143
+ if (hasPackedOrUnparseableRows || hasNonSequentialSeq) {
1144
+ const error = new Error("无法安全同步会话工作目录(会话日志包含压缩行且已有工作目录变更历史),为防止工作目录不一致,已取消移动。原始会话保持原状");
1145
+ error.code = "working-directory-sync-unsafe";
1146
+ throw error;
1147
+ }
1148
+ const appended = JSON.stringify({
1149
+ type: "working-directory/change",
1150
+ seq: lines.length,
1151
+ time: Date.now(),
1152
+ data: { cwd: targetPath }
1153
+ });
1154
+ return { rest: `${body}${nl}${appended}${nl}`, status: "recorded" };
1155
+ };
1156
+
837
1157
  /**
838
1158
  * TRUE cross-workspace move. See the module docstring for the full design:
839
1159
  * re-home the stored cwd, migrate the artifact, then swap accounting.
@@ -896,7 +1216,25 @@ ${rest}`, "utf8");
896
1216
  let currentCanonical;
897
1217
  try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
898
1218
  if (currentCanonical === targetPath) {
899
- return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
1219
+ // The artifact already lives under the target workspace path, but on
1220
+ // DSH 0.2.x "cwd matches" and "the workspace accounts for the
1221
+ // session" are separate facts: a matching cwd with a missing
1222
+ // registration used to return a silent no-op success. Repair the
1223
+ // durable membership through the official entity API (idempotent,
1224
+ // no artifact rewrite, no live-session restart) and report what
1225
+ // actually happened. A repair that cannot be completed fails loudly
1226
+ // instead of reporting success.
1227
+ const repair = await repairWorkspaceMembership(target, sessionId, storedHeader);
1228
+ return {
1229
+ ok: true,
1230
+ sessionId,
1231
+ moved: false,
1232
+ attached: repair.attached,
1233
+ membership: repair.status,
1234
+ message: repair.attached
1235
+ ? "会话已归属目标工作区(已补全归属记录)"
1236
+ : "会话已属于目标工作区"
1237
+ };
900
1238
  }
901
1239
  }
902
1240
 
@@ -968,7 +1306,14 @@ ${rest}`, "utf8");
968
1306
  throw new Error("目标位置已有会话工件,拒绝覆盖");
969
1307
  } catch (error) { if (error.code !== "ENOENT") throw error; }
970
1308
  if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
971
- const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
1309
+ // Issue #42: a cold log is the only authority its working-directory
1310
+ // projection folds from on the next resume, so re-home it together
1311
+ // with the artifact header. Live sessions get the equivalent event
1312
+ // through the host path once the artifact has been published.
1313
+ const bodyRehome = liveSession === void 0
1314
+ ? rehomeColdWorkingDirectory(rest, targetPath)
1315
+ : { rest, status: "live" };
1316
+ const bytes = await encodeArtifact(JSON.stringify(newHeader), bodyRehome.rest, newPath.endsWith(".zstd"));
972
1317
  await mkdir(dirname(newPath), { recursive: true });
973
1318
  const tempNew = await writeTempFile(newPath, bytes);
974
1319
  const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
@@ -1003,7 +1348,7 @@ ${rest}`, "utf8");
1003
1348
  const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
1004
1349
  const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
1005
1350
  const fromWorkspaceIds = registry.list()
1006
- .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
1351
+ .filter((entity) => entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId))
1007
1352
  .map((entity) => entity.id);
1008
1353
  let targetAttached = false;
1009
1354
  const detachedFrom = [];
@@ -1069,7 +1414,7 @@ ${rest}`, "utf8");
1069
1414
  await rm(dirname(oldPath), { recursive: true, force: true });
1070
1415
  } catch (error) { ctx.logger.warn(`session-manager: old directory retained: ${String(error)}`); }
1071
1416
 
1072
- movedArtifact = { header: newHeader, rest };
1417
+ movedArtifact = { header: newHeader, rest: bodyRehome.rest, workingDirectory: bodyRehome.status };
1073
1418
 
1074
1419
  return {
1075
1420
  ok: true,
@@ -1101,7 +1446,34 @@ ${rest}`, "utf8");
1101
1446
  ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
1102
1447
  }
1103
1448
  }
1104
- // The agent is still live (no teardown). The session's in-memory
1449
+ // Issue #42: the artifact header was rewritten, but the folded
1450
+ // `workingDirectory` projection was not. Align it now -- after the
1451
+ // artifact has been published (a live session's change event must land in
1452
+ // the moved log) and after the registry swap.
1453
+ let workingDirectory = movedArtifact?.workingDirectory ?? "not-needed";
1454
+ let restartRequired = false;
1455
+ if (liveSession !== void 0) {
1456
+ const rehomed = await rehomeLiveWorkingDirectory({ agent: liveAgent, session: liveSession, targetPath });
1457
+ workingDirectory = rehomed.status;
1458
+ if (rehomed.status === "restart-required") restartRequired = true;
1459
+ if (rehomed.status === "synced" || rehomed.status === "recorded") {
1460
+ // The refold above seeds from the projection cache, so it cannot see
1461
+ // a change committed after the move: checkpoint what the live
1462
+ // session now actually folds (this flushes the new event first).
1463
+ if (cache !== void 0 && typeof cache.write === "function") {
1464
+ try {
1465
+ await cache.write(liveSession);
1466
+ } catch (error) {
1467
+ ctx.logger.warn(`session-manager: post-move working-directory checkpoint failed for "${sessionId}": ${safeErrorMessage(error)}`);
1468
+ }
1469
+ }
1470
+ }
1471
+ } else if (workingDirectory === "restart-required") {
1472
+ restartRequired = true;
1473
+ }
1474
+ result.workingDirectory = workingDirectory;
1475
+ result.restartRequired = restartRequired;
1476
+ // The agent is still live (no teardown). The session's in-memory
1105
1477
  // header was rewritten above and the workspace registry updated;
1106
1478
  // future writes go through persistence.locate() against the new
1107
1479
  // cwd, so the file at the new location accumulates new events.
@@ -1168,8 +1540,7 @@ ${rest}`, "utf8");
1168
1540
  throw error;
1169
1541
  }
1170
1542
  allowedIds = new Set();
1171
- const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
1172
- if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
1543
+ for (const id of workspaceMemberIds(ws)) allowedIds.add(id);
1173
1544
  }
1174
1545
  }
1175
1546
  const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
@@ -1818,43 +2189,103 @@ ${rest}`, "utf8");
1818
2189
  if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1819
2190
 
1820
2191
  try {
1821
- // Remember only ids that existed before the async scan. Concurrently
1822
- // attached ids must never be removed using this older disk snapshot.
1823
- const initialIds = new Map(registry.list().map(ws => [ws.id, new Set(ws.record?.sessionIds ?? [])]));
1824
- const scan = await scanSessionHeaders(persistence, adapter.homePath);
1825
- if (!scan.complete) {
1826
- ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2192
+ // Snapshot durable membership BEFORE the async scan. Only ids that
2193
+ // already existed here may ever be pruned later; ids attached
2194
+ // concurrently must never be removed using an older disk snapshot.
2195
+ let entities;
2196
+ try { entities = registry.list(); }
2197
+ catch (error) {
2198
+ ctx.logger.warn("session-manager: workspace registry unavailable; workspace reconciliation skipped: " + safeErrorMessage(error));
1827
2199
  return;
1828
2200
  }
1829
- const headers = scan.headers;
1830
- const validIds = new Set(headers.map(header => header.id));
2201
+ const initialIds = new Map(entities.map(ws => [ws.id, new Set(workspaceMemberIds(ws))]));
2202
+ const scan = await scanSessionHeaders(persistence, adapter.homePath);
2203
+ const headers = Array.isArray(scan.headers) ? scan.headers : [];
2204
+
2205
+ // Step 1a: attach sessions whose cwd names a registered workspace but
2206
+ // that no workspace accounts for (issue #41). Every candidate is
2207
+ // re-validated by the official entity API, which persists through the
2208
+ // domain table and is idempotent for already-accounted ids -- so this
2209
+ // pass can only add well-formed memberships, never corrupt records.
2210
+ // It runs whenever the persistence index listed successfully
2211
+ // (scan.persisted); a partial disk sweep cannot make a listed header
2212
+ // invalid.
2213
+ let added = 0;
2214
+ if (scan.persisted) {
2215
+ const assigned = new Set();
2216
+ for (const ws of entities) for (const id of initialIds.get(ws.id)) assigned.add(id);
2217
+ const candidates = headers.filter(header =>
2218
+ typeof header.cwd === "string" && header.cwd !== ""
2219
+ && header.origin !== "subagent"
2220
+ && !assigned.has(header.id));
2221
+ if (candidates.length > 0) {
2222
+ const byPath = new Map();
2223
+ for (const ws of entities) {
2224
+ if (typeof ws.path === "string" && !byPath.has(ws.path)) byPath.set(ws.path, ws);
2225
+ }
2226
+ const attach = async () => {
2227
+ // Re-read membership inside the registry write queue: a concurrent
2228
+ // move may have accounted an id after the snapshot above.
2229
+ const current = typeof registry.list === "function" ? registry.list() : entities;
2230
+ const assignedNow = new Set();
2231
+ for (const ws of current) for (const id of workspaceMemberIds(ws)) assignedNow.add(id);
2232
+ for (const header of candidates) {
2233
+ if (assignedNow.has(header.id)) continue;
2234
+ let target = byPath.get(header.cwd);
2235
+ if (target === void 0) {
2236
+ const canonical = await canonicalizeDirectory(header.cwd);
2237
+ if (canonical !== void 0) target = byPath.get(canonical);
2238
+ }
2239
+ if (target === void 0 || typeof target.attachSession !== "function") continue;
2240
+ try {
2241
+ await target.attachSession(header.id);
2242
+ assignedNow.add(header.id);
2243
+ added += 1;
2244
+ } catch (error) {
2245
+ ctx.logger.warn("session-manager: boot attach of session '" + header.id + "' to workspace '" + target.id + "' failed: " + safeErrorMessage(error));
2246
+ }
2247
+ }
2248
+ };
2249
+ if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(attach);
2250
+ else await attach();
2251
+ }
2252
+ }
1831
2253
 
1832
- // Step 1: Immediate Workspace Member Reconciliation (isolated from projection rebuild)
2254
+ // Step 1b: prune record entries whose session no longer exists. Only a
2255
+ // fully complete scan (persistence index plus disk sweep, no errors)
2256
+ // may remove anything; live sessions and ids attached after the
2257
+ // snapshot are always kept. Durable records are read through
2258
+ // workspaceMemberIds so a stale runtime index cannot hide a dead entry.
1833
2259
  let removed = 0;
1834
- let added = 0;
1835
- await registry.enqueueOperation(async () => {
1836
- const state = registry.requireState();
1837
- const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
1838
- const assignedIds = new Set((state.workspaces ?? []).flatMap(ws => ws.sessionIds ?? []));
1839
- const workspaces = (state.workspaces ?? []).map(ws => {
1840
- const original = ws.sessionIds ?? [];
1841
- const sessionIds = original.filter(id => validIds.has(id)
1842
- || !initialIds.get(ws.id)?.has(id) || adapter.getSession(id) !== undefined);
1843
- const removedHere = original.length - sessionIds.length;
1844
- removed += removedHere;
1845
- for (const header of headers) {
1846
- if (header.cwd === undefined || header.cwd !== pathById.get(ws.id) || assignedIds.has(header.id)) continue;
1847
- sessionIds.unshift(header.id);
1848
- assignedIds.add(header.id);
1849
- added += 1;
2260
+ if (scan.complete) {
2261
+ const validIds = new Set(headers.map(header => header.id));
2262
+ const prune = async () => {
2263
+ for (const ws of (typeof registry.list === "function" ? registry.list() : entities)) {
2264
+ const initial = initialIds.get(ws.id);
2265
+ // Snapshot: detachSession replaces the record array while we walk it.
2266
+ for (const id of [...workspaceMemberIds(ws)]) {
2267
+ if (validIds.has(id)) continue;
2268
+ if (initial === void 0 || !initial.has(id)) continue;
2269
+ if (adapter.getSession(id) !== undefined) continue;
2270
+ if (typeof ws.detachSession !== "function") continue;
2271
+ await ws.detachSession(id);
2272
+ removed += 1;
2273
+ }
1850
2274
  }
1851
- if (sessionIds.length === original.length && sessionIds.every((id, i) => id === original[i])) return ws;
1852
- return { ...ws, sessionIds, updatedAt: new Date().toISOString() };
1853
- });
1854
- if (removed || added) await registry.setState({ ...state, workspaces });
1855
- });
1856
- if (removed || added) {
1857
- ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
2275
+ };
2276
+ if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(prune);
2277
+ else await prune();
2278
+ }
2279
+ if (added || removed) {
2280
+ ctx.logger.info('session-manager: post-boot workspace reconcile attached ' + added + ' unaccounted sessions, removed ' + removed + ' orphans');
2281
+ }
2282
+ if (!scan.persisted) {
2283
+ ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2284
+ return;
2285
+ }
2286
+ if (!scan.complete) {
2287
+ ctx.logger.warn("session-manager: incomplete disk sweep; workspace membership pruning skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2288
+ return;
1858
2289
  }
1859
2290
 
1860
2291
  // Step 2: Budgeted Background Projection Cache Rebuild
@@ -1918,6 +2349,32 @@ ${rest}`, "utf8");
1918
2349
  }
1919
2350
  }, "session-manager: post-boot re-fold stale projection cache rows");
1920
2351
 
2352
+ /**
2353
+ * Runtime membership repair (issue #41): DSH's own create/fork flow attaches
2354
+ * a session to its workspace, but a session created outside that flow -- or
2355
+ * one whose host-side attach failed -- stays unaccounted even though its
2356
+ * cwd names a registered workspace, and would otherwise only be repaired on
2357
+ * the next DSH restart. Repair it as soon as the session is announced.
2358
+ *
2359
+ * The listener must never throw synchronously: a throwing session/created
2360
+ * listener rolls back session creation in DSH. Everything is contained and
2361
+ * fire-and-forget; failures are logged only.
2362
+ */
2363
+ if (typeof ctx.on === "function") ctx.on("session/created", (session) => {
2364
+ void (async () => {
2365
+ try {
2366
+ const header = session?.header ?? session;
2367
+ if (header === void 0 || typeof header.id !== "string") return;
2368
+ const attached = await attachSessionToOwningWorkspace(header);
2369
+ if (attached) {
2370
+ ctx.logger.info("session-manager: attached new session '" + header.id + "' to its cwd workspace");
2371
+ }
2372
+ } catch (error) {
2373
+ ctx.logger.warn("session-manager: session/created membership repair failed: " + safeErrorMessage(error));
2374
+ }
2375
+ })();
2376
+ });
2377
+
1921
2378
  /** Build a normalized annotation patch from one bulk action + payload.
1922
2379
  * Returns null when the payload is missing or malformed so the caller can
1923
2380
  * surface a 400 instead of letting the store throw on a bad value.
@@ -1982,9 +2439,80 @@ ${rest}`, "utf8");
1982
2439
  if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
1983
2440
  };
1984
2441
 
1985
- ctx.effect(() => ctx.webServer.register({ kind: "prefix",
2442
+ /**
2443
+ * Issue #43: Validate incoming request with DSH connection authentication.
2444
+ * Reuses DSH official Connection/Gateway authentication and authorization mechanisms.
2445
+ * Strict fail-closed: requires connection.admit() to succeed with a valid Owner peer.
2446
+ * Must execute before request body reading, JSON parsing, or business operations.
2447
+ */
2448
+ const authenticateApiRequest = (req) => {
2449
+ const connection = getService(ctx, "connection");
2450
+ if (!connection || typeof connection.admit !== "function") {
2451
+ return {
2452
+ ok: false,
2453
+ status: 503,
2454
+ code: "auth-unavailable",
2455
+ error: "DSH 认证服务未就绪或不可用,为保护数据安全已拒绝请求"
2456
+ };
2457
+ }
2458
+ try {
2459
+ const admission = connection.admit(req);
2460
+ if (!admission || typeof admission !== "object") {
2461
+ return {
2462
+ ok: false,
2463
+ status: 401,
2464
+ code: "unauthorized",
2465
+ error: "未授权:需要有效的 DSH 会话凭证"
2466
+ };
2467
+ }
2468
+ if ("rejection" in admission) {
2469
+ const status = typeof admission.rejection === "number" ? admission.rejection : 401;
2470
+ const error = status === 403
2471
+ ? "禁止访问:请求未通过 DSH 信任检查或跨站防护"
2472
+ : "未授权:需要有效的 DSH 会话凭证";
2473
+ return { ok: false, status, code: status === 403 ? "forbidden" : "unauthorized", error };
2474
+ }
2475
+ const peer = admission.peer;
2476
+ if (!peer || typeof peer !== "object") {
2477
+ return {
2478
+ ok: false,
2479
+ status: 401,
2480
+ code: "unauthorized",
2481
+ error: "未授权:缺少有效 Peer 凭证"
2482
+ };
2483
+ }
2484
+ const principalKind = peer.principal?.kind;
2485
+ if (principalKind !== "owner") {
2486
+ return {
2487
+ ok: false,
2488
+ status: 403,
2489
+ code: "forbidden",
2490
+ error: "禁止访问:会话管理属于特权操作,需要 owner 权限"
2491
+ };
2492
+ }
2493
+ return { ok: true, peer };
2494
+ } catch (err) {
2495
+ return {
2496
+ ok: false,
2497
+ status: 403,
2498
+ code: "auth-error",
2499
+ error: "认证检查失败: " + safeErrorMessage(err)
2500
+ };
2501
+ }
2502
+ };
2503
+
2504
+ ctx.effect(() => ctx.webServer.register({
2505
+ kind: "prefix",
1986
2506
  path: API_PREFIX,
1987
2507
  handler: async (req, res) => {
2508
+ const auth = authenticateApiRequest(req);
2509
+ if (!auth.ok) {
2510
+ return send(res, auth.status, {
2511
+ ok: false,
2512
+ code: auth.code,
2513
+ error: auth.error
2514
+ });
2515
+ }
1988
2516
  try {
1989
2517
  const url = new URL(req.url ?? "/", "http://localhost");
1990
2518
  const path = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) || "/" : "/";
@@ -2215,7 +2743,7 @@ ${rest}`, "utf8");
2215
2743
  return send(res, 200, { ok: true, result });
2216
2744
  }
2217
2745
  if (req.method === "GET" && path === "/update/check") {
2218
- const currentVersion = adapter.pluginVersion() || "0.7.3";
2746
+ const currentVersion = adapter.pluginVersion() || "0.7.5";
2219
2747
  const runningVersion = currentVersion;
2220
2748
  let installedVersion = runningVersion;
2221
2749
  const profileDir = resolveProfileDir(adapter, ctx);
@@ -2399,7 +2927,7 @@ ${rest}`, "utf8");
2399
2927
  };
2400
2928
  setActiveTask(taskRecord);
2401
2929
 
2402
- const currentVersion = adapter.pluginVersion() || "0.7.3";
2930
+ const currentVersion = adapter.pluginVersion() || "0.7.5";
2403
2931
  let installedVersion = currentVersion;
2404
2932
  if (profileDir) {
2405
2933
  const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
@@ -2929,7 +3457,7 @@ ${rest}`, "utf8");
2929
3457
  }
2930
3458
  if (req.method === "GET" && path === "/update/status") {
2931
3459
  const profileDir = resolveProfileDir(adapter, ctx);
2932
- const currentVersion = adapter.pluginVersion() || "0.7.3";
3460
+ const currentVersion = adapter.pluginVersion() || "0.7.5";
2933
3461
  let installedVersion = currentVersion;
2934
3462
  if (profileDir) {
2935
3463
  const diskCheck = verifyInstalledPackage(profileDir);
@@ -3004,7 +3532,10 @@ ${rest}`, "utf8");
3004
3532
  ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
3005
3533
  const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
3006
3534
  : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
3007
- : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
3535
+ : error?.code === "working-directory-sync-unsafe" ? 409
3536
+ : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503
3537
+ : error?.code === "workspace-membership-conflict" ? 409
3538
+ : error?.code === "workspace-attach-unavailable" ? 501 : 500;
3008
3539
  return send(res, status, {
3009
3540
  ok: false,
3010
3541
  error: safeErrorMessage(error),