dsh-session-manager 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.7.4 — 2026-10-20
6
+
7
+ - **fix(workspace)**: Repair workspace session membership on DSH 0.2.x. New and forked sessions are now attributed to the workspace owning their `cwd` as soon as the session is announced (runtime repair, no restart needed), and historical sessions whose `cwd` matches a registered workspace are re-registered on startup — all through the official DSH workspace entity API (#41).
8
+ - **fix(move)**: "Move to workspace" on a session whose `cwd` already equals the target workspace path now repairs the missing membership instead of returning a silent no-op. The response reports whether a membership was written (`attached`); the artifact's content, size and mtime are untouched and running sessions are never restarted (#41).
9
+ - **fix(compat)**: Resolve the DSH home through the official `$DSH_HOME` → `~/.dsh` order (tilde-expanded, absolute) when the `dshHomePath` service is not visible to the plugin scope, so session scans and stores work with a custom DSH home (#41).
10
+ - **fix(workspace)**: Read durable workspace membership from the registry record instead of the runtime-filtered projection when deleting sessions, so stale runtime indexes can no longer leave dead entries behind.
11
+ - **test**: Add regression coverage for runtime new/forked session attribution, boot repair, same-path move repair (including artifact byte/size/mtime invariants), idempotence, cross-workspace moves, cwd mismatch safety, incomplete-scan safety, restart stability, archived-session preservation, and custom DSH home resolution (#41).
12
+ - **compat**: Compatible with DSH Desktop v0.2.1-rc.2 (and v0.2.0-rc.2); workspace membership repair uses the official workspace registry entity API.
13
+ - **fix(safety)**: Workspace membership repair never migrates a session that is already accounted by another workspace: such a conflict is refused with `409 workspace-membership-conflict` and both records stay untouched, because an account removed from a different workspace cannot be restored through the official API. Completing a missing membership still goes through the official `attachSession()` after its validation conditions (header readability, cwd resolution, canonical path equality) are pre-checked, so a predictable rejection fails before any write (#41).
14
+ - **fix(api)**: "Move to workspace" no longer reports success when a repair cannot be completed: hosts without the official `attachSession` API answer `501 workspace-attach-unavailable`, an unconfirmable membership state is refused, a membership conflict answers `409 workspace-membership-conflict`, and unsafe or failed repairs answer `500` with `workspace-repair-unsafe` / `workspace-repair-failed`. Successful repairs report `membership` (`already-member` / `attached`) next to the existing `moved` / `attached` fields (#41).
15
+
5
16
  ## 0.7.3 — 2026-10-18
6
17
 
7
18
  - **fix(update)**: Fix built-in one-click update and resolve `cannot get property "remote" without inject` by routing update requests through the plugin's own Host API instead of client-side `remote.pluginManager`.
package/README.md CHANGED
@@ -20,7 +20,7 @@ Verified with the official DSH Desktop client; see [5 Compatibility](#5-compatib
20
20
 
21
21
  - **Delete** is irreversible and always asks for confirmation. Subagent sessions and transient blank placeholders (no persisted artifact) cannot be deleted.
22
22
  - **Archive / Unarchive** moves a session in and out of the active list without touching its disk content.
23
- - **Move to workspace** keeps history, title, archive state and derived-session relationships intact, rewrites the session's `cwd` to the target workspace, and updates the live writer's header in place so any pending tool calls keep landing on the new path.
23
+ - **Move to workspace** keeps history, title, archive state and derived-session relationships intact, rewrites the session's `cwd` to the target workspace, and updates the live writer's header in place so any pending tool calls keep landing on the new path. When the session's `cwd` already equals the target workspace path but its membership was never recorded, only the missing membership is repaired: no `cwd` rewrite, no artifact rewrite or move, and no session restart.
24
24
  - **Migrate Agent preset** rewrites the latest `agent-preset/selected` event (or the session header if no such event exists) so a session whose preset was renamed or removed can resume. Message history is never altered.
25
25
 
26
26
  ### 1.2 Session shortcuts
@@ -136,7 +136,7 @@ After changing plugin dependencies, `package.json`, bundle configuration, or oth
136
136
  - Cold rewrites preserve the artifact's stored format (V1 / V2 / V3 / V4 are all readable; the plugin never forces an upgrade). Moves and rewrites refuse corrupt / truncated Zstd logs or JSONL logs with incomplete final lines instead of publishing partial history.
137
137
  - Preset migration separates backup, publication and rollback. If rollback fails, recovery files are retained and their paths are included in the error; do not remove them.
138
138
  - **Workspace archiving rollback protection**: archiving is refused if active sessions are present, and batch session archiving automatically rolls back on partial failures to preserve state consistency.
139
- - Incomplete startup scans skip workspace reconciliation. Complete scans preserve live sessions and any membership added during the scan.
139
+ - On startup, sessions whose `cwd` names a registered workspace but whose membership record is missing are re-registered through the official DSH API (idempotent). Incomplete scans skip pruning only: existing membership is never dropped, and live sessions or memberships added during the scan are always preserved.
140
140
  - Plugin mutations are serialized per session and request bodies are limited to 64 KiB. This queue supplements, rather than replaces, DSH's own persistence coordination.
141
141
 
142
142
  ## 5 Compatibility
@@ -147,7 +147,7 @@ The table below lists verified version combinations between DeepSeek Harness (DS
147
147
 
148
148
  | DeepSeek Harness (DSH) | dsh-session-manager | Verified Environment |
149
149
  | :--- | :--- | :--- |
150
- | **v0.2.1-rc.2** | `0.7.3`, `0.7.2` | Desktop |
150
+ | **v0.2.1-rc.2** | `0.7.4`, `0.7.3`, `0.7.2` | Desktop |
151
151
  | **v0.2.0-rc.2** | `0.7.1`, `0.7.0`, `0.6.4`, `0.6.3`, `0.6.2`, `0.5.4` | Desktop |
152
152
  | **v0.1.7-rc.2** | `0.5.3` | Desktop / Web UI |
153
153
  | **v0.1.7-rc.1** | `0.5.2` | Desktop / Web UI |
package/README.zh.md CHANGED
@@ -20,7 +20,7 @@ DSH 会话与工作区管理——工作区:归档与显示。会话:跨工
20
20
 
21
21
  - **删除**:不可逆操作,UI 始终要求二次确认。subagent 会话和尚未落盘的空白会话占位不能删除。
22
22
  - **归档 / 移出归档**:把会话移出或移回主列表,不删除磁盘内容。
23
- - **移动至工作区**:跨工作区移动时保留历史、标题、归档状态和派生会话关系,同时把 `cwd` 重写为目标工作区,并就地更新 live writer 的 header,使挂起的工具调用继续落到新路径。
23
+ - **移动至工作区**:跨工作区移动时保留历史、标题、归档状态和派生会话关系,同时把 `cwd` 重写为目标工作区,并就地更新 live writer 的 header,使挂起的工具调用继续落到新路径。若会话的 `cwd` 本来就等于目标工作区、只是归属未登记,则只补全归属记录,不改写 `cwd`、不重写或移动工件、不重启会话。
24
24
  - **迁移 Agent 预设**:原预设被改名或删除导致会话无法恢复时,可修复该会话。迁移会就地重写最后一条 `agent-preset/selected` 事件(若从未记录则修改会话 header),不改动历史消息。
25
25
 
26
26
  ### 1.2 会话快捷管理
@@ -134,7 +134,7 @@ dsh plugin --profile web add <本仓库路径>
134
134
  - 冷会话重写保留原工件格式(V1 / V2 / V3 / V4 都可读,绝不强制升级)。损坏或截断的 Zstd 日志、缺少完整尾行的 JSONL 会拒绝移动 / 重写,不会把部分历史当作完整日志保存。
135
135
  - 迁移预设按"备份 → 发布 → 回滚"分阶段处理。如果回滚失败,会保留恢复文件并在错误中报告路径;不要删除这些文件。
136
136
  - **工作区归档事务保护**:若工作区内存在运行中会话则拒绝归档;批量归档会话若中途遇到异常会自动回滚已变更状态,避免部分成功导致状态不一致。
137
- - 启动扫描不完整时跳过工作区归属修复;完整扫描也不会清除仍在内存中或扫描期间新加入的会话。
137
+ - 启动时若发现 `cwd` 对应已注册工作区但归属记录遗漏的会话,会通过 DSH 官方接口补全登记(幂等);扫描不完整时只跳过清理,不会误删现有归属,也不会清除仍在内存中或扫描期间新加入的会话。
138
138
  - 同一会话的插件写操作按顺序执行;请求体限制为 64 KiB。该队列不替代 DSH 自身的持久化写入协调。
139
139
 
140
140
  ## 5 兼容性
@@ -145,7 +145,7 @@ dsh plugin --profile web add <本仓库路径>
145
145
 
146
146
  | DeepSeek Harness (DSH) | dsh-session-manager | 验证环境 |
147
147
  | :--- | :--- | :--- |
148
- | **v0.2.1-rc.2** | `0.7.3`, `0.7.2` | Desktop |
148
+ | **v0.2.1-rc.2** | `0.7.4`, `0.7.3`, `0.7.2` | Desktop |
149
149
  | **v0.2.0-rc.2** | `0.7.1`, `0.7.0`, `0.6.4`, `0.6.3`, `0.6.2`, `0.5.4` | Desktop |
150
150
  | **v0.1.7-rc.2** | `0.5.3` | Desktop / Web UI |
151
151
  | **v0.1.7-rc.1** | `0.5.2` | Desktop / Web UI |
package/lib/client.js CHANGED
@@ -3773,7 +3773,7 @@ function isTopmostDialog(z) {
3773
3773
  ));
3774
3774
  }
3775
3775
 
3776
- const CURRENT_VERSION = "0.7.3";
3776
+ const CURRENT_VERSION = "0.7.4";
3777
3777
 
3778
3778
  function compareSemver(v1, v2) {
3779
3779
  if (typeof v1 !== "string" || typeof v2 !== "string") return 0;
@@ -9,10 +9,36 @@
9
9
  */
10
10
  import { createRequire } from "node:module";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
- import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { join, resolve } from "node:path";
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
 
17
+ /** Expand a leading `~` / `~/` / `~\` against the OS home, matching
18
+ * `@deepseek-ai/dsh-home-paths` `expandHomePath`. */
19
+ function expandHomePath(path) {
20
+ if (path === "~") return homedir();
21
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
22
+ return path;
23
+ }
24
+
25
+ /**
26
+ * Resolve the DSH home without the `dshHomePath` service. DSH 0.2.x resolves
27
+ * the harness home exclusively through `resolveDshHome()`: `$DSH_HOME` when
28
+ * set and non-blank, otherwise `~/.dsh`; the value is tilde-expanded and
29
+ * resolved to an absolute path. Mirror that order so a plugin scope that
30
+ * cannot see the root-context service still lands on the same directory.
31
+ */
32
+ function fallbackDshHome() {
33
+ const raw = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
34
+ const selected = raw.length > 0 ? expandHomePath(raw) : join(homedir(), ".dsh");
35
+ return resolve(selected);
36
+ }
37
+
38
+ /** Same calling convention as the official `dshHomePath(...segments)`. */
39
+ function fallbackHomePath(...segments) {
40
+ return join(fallbackDshHome(), ...segments);
41
+ }
16
42
  /** Pull one service out of the Cordis ctx, tolerating direct property fallbacks. */
17
43
  export function getService(ctx, name) {
18
44
  try {
@@ -157,7 +183,15 @@ export function createDshAdapter(ctx) {
157
183
  get persistence() { return getService(ctx, "sessionPersistence"); },
158
184
  get agentPresets() { return getService(ctx, "agentPresets"); },
159
185
  get projectionCache() { return getService(ctx, "sessionProjectionCache"); },
160
- get homePath() { return getService(ctx, "dshHomePath"); },
186
+ get homePath() {
187
+ // DSH 0.2.x provides `dshHomePath` on the root context (app-boot), but a
188
+ // plugin scope may not resolve it; without a fallback every disk-backed
189
+ // path (sessions scan, annotation store, workspace archive store) is
190
+ // lost. Fall back to the official home resolution instead.
191
+ const served = getService(ctx, "dshHomePath");
192
+ if (typeof served === "function") return served;
193
+ return fallbackHomePath;
194
+ },
161
195
  get pluginManager() { return getService(ctx, "pluginManager"); },
162
196
 
163
197
  getSession(id) {
package/lib/index.js CHANGED
@@ -101,6 +101,12 @@ function rebindLiveWriter(persistence, sessionId, newHeader) {
101
101
  * disk fallback semantics are preserved -- the orphan is the whole
102
102
  * reason this loop exists.
103
103
  *
104
+ * The result carries two completeness flags: "persisted" reports whether the
105
+ * persistence index itself listed successfully (its rows are authoritative),
106
+ * while "complete" additionally requires the disk sweep to finish without
107
+ * errors. Callers that only add validated memberships may run on "persisted";
108
+ * callers that remove records must wait for "complete".
109
+ *
104
110
  * @param {object|undefined} persistence DSH's sessionPersistence service
105
111
  * @param {((segment: string) => string)|undefined} dshHome Resolver for
106
112
  * DSH home sub-paths; only "sessions" is read.
@@ -110,6 +116,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
110
116
  const headers = [];
111
117
  const errors = [];
112
118
  let listed = false;
119
+ let persisted = false;
113
120
  if (typeof persistence?.list === "function") {
114
121
  try {
115
122
  const rows = await persistence.list();
@@ -122,9 +129,10 @@ const scanSessionHeaders = async (persistence, dshHome) => {
122
129
  if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
123
130
  }
124
131
  listed = true;
132
+ persisted = true;
125
133
  } catch (error) { errors.push(error); }
126
134
  }
127
- const result = () => ({ headers, complete: listed && errors.length === 0, errors });
135
+ const result = () => ({ headers, persisted, complete: listed && errors.length === 0, errors });
128
136
  // Skip known files before fopen; retain the orphan/raw-id compatibility path.
129
137
  // Only add canonical session directories that do not start and end with "--"
130
138
  // to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
@@ -175,6 +183,16 @@ const scanSessionHeaders = async (persistence, dshHome) => {
175
183
 
176
184
  const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
177
185
 
186
+ /** Canonicalize a directory path the way DSH's workspace registry does
187
+ * (fs.realpath on a fully qualified path). Returns undefined when the path
188
+ * cannot be resolved so callers skip instead of guessing; DSH stores
189
+ * workspace paths canonicalized, so canonical equality is the only safe
190
+ * membership comparison on Windows (case, separators, 8.3 names, junctions). */
191
+ const canonicalizeDirectory = async (path) => {
192
+ if (typeof path !== "string" || path === "") return undefined;
193
+ try { return await realpath(path); } catch { return undefined; }
194
+ };
195
+
178
196
  const UPDATE_STATUS_FILE = ".dsh-session-manager-update.json";
179
197
  const STALE_EXTERNAL_TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes fallback for dead external processes
180
198
  const HEARTBEAT_STALE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes without heartbeat for external processes
@@ -367,7 +385,7 @@ function verifyInstalledPackage(profileDir, expectedVersion) {
367
385
 
368
386
  export function apply(ctx) {
369
387
  try {
370
- const buildId = "dsh-session-manager v0.7.3 (workspace-archive)";
388
+ const buildId = "dsh-session-manager v0.7.4 (workspace-archive)";
371
389
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
372
390
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
373
391
  } catch (_) { /* ignore logging failures */ }
@@ -399,10 +417,26 @@ export function apply(ctx) {
399
417
  const safeGetAgent = (sessionId) => {
400
418
  try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
401
419
  };
420
+ /** Durable workspace membership for one entity. The record account is the
421
+ * authoritative persisted list on DSH 0.2.x (registry entities expose it as
422
+ * the private `record` field); the runtime `sessionIds` getter is filtered
423
+ * by the canonical-cwd index and may hide members whose index entry is
424
+ * stale, so it is only a fallback for hosts without a readable record. */
425
+ const workspaceMemberIds = (entity) => {
426
+ if (entity === null || entity === undefined) return [];
427
+ const record = entity.record;
428
+ if (record !== null && record !== undefined && Array.isArray(record.sessionIds)) return record.sessionIds;
429
+ const ids = entity.sessionIds;
430
+ if (Array.isArray(ids)) return ids;
431
+ if (typeof ids === "function") {
432
+ try { const value = ids(); if (Array.isArray(value)) return value; } catch { /* ignore */ }
433
+ }
434
+ return [];
435
+ };
402
436
  const assertAnnotatable = id => {
403
437
  const registry = adapter.workspaceRegistry;
404
438
  if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
405
- || adapter.workspaceList().some(ws => (ws.record?.sessionIds ?? ws.sessionIds ?? []).includes(id))) return;
439
+ || adapter.workspaceList().some(ws => workspaceMemberIds(ws).includes(id))) return;
406
440
  throw Object.assign(new Error("会话不存在或尚未加载,请先打开该会话后重试"), { code: "session-not-found" });
407
441
  };
408
442
  const operations = new Map();
@@ -567,7 +601,9 @@ export function apply(ctx) {
567
601
  const detachFromWorkspaces = async (sessionId) => {
568
602
  const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
569
603
  for (const entity of registry.list()) {
570
- if (entity.sessionIds.includes(sessionId)) {
604
+ // Durable record, not the runtime-filtered getter: deleting a session
605
+ // must purge its account even when the cwd index is stale.
606
+ if (workspaceMemberIds(entity).includes(sessionId)) {
571
607
  await entity.detachSession(sessionId);
572
608
  }
573
609
  }
@@ -576,9 +612,7 @@ export function apply(ctx) {
576
612
  /** Ordered workspace projection for the client. */
577
613
  const listWorkspaces = () => {
578
614
  return ctx.workspaceRegistry.list().map((entity) => {
579
- const rawIds = Array.isArray(entity.record?.sessionIds)
580
- ? [...entity.record.sessionIds]
581
- : [...entity.sessionIds];
615
+ const rawIds = [...workspaceMemberIds(entity)];
582
616
  const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
583
617
  return {
584
618
  id: entity.id,
@@ -834,6 +868,182 @@ ${rest}`, "utf8");
834
868
  return false;
835
869
  };
836
870
 
871
+ /**
872
+ * Attach one session to the registered workspace that owns its cwd.
873
+ * Returns true only when a new durable membership was written.
874
+ *
875
+ * DSH's own create/fork flow attaches through workspaceRegistry, but a
876
+ * session created outside that flow (cwd-only create, an attach the host
877
+ * declined, or a session that predates this plugin load) stays
878
+ * unaccounted even though its cwd names a registered workspace. The
879
+ * official entity API re-validates the stored header cwd against the
880
+ * canonical workspace path and persists through the domain table, so a
881
+ * wrong match can never be written. Subagent sessions never join a
882
+ * workspace account, and a session any workspace already accounts for is
883
+ * left untouched (no session is ever registered twice).
884
+ */
885
+ const attachSessionToOwningWorkspace = async (header) => {
886
+ const registry = adapter.workspaceRegistry;
887
+ if (registry === void 0 || typeof registry.list !== "function") return false;
888
+ if (header === void 0 || typeof header.id !== "string") return false;
889
+ if (header.origin === "subagent") return false;
890
+ if (typeof header.cwd !== "string" || header.cwd === "") return false;
891
+ const run = async () => {
892
+ const entities = registry.list();
893
+ for (const entity of entities) {
894
+ if (workspaceMemberIds(entity).includes(header.id)) return false;
895
+ }
896
+ const byPath = new Map();
897
+ for (const entity of entities) {
898
+ if (typeof entity.path === "string" && !byPath.has(entity.path)) byPath.set(entity.path, entity);
899
+ }
900
+ let target = byPath.get(header.cwd);
901
+ if (target === void 0 && typeof registry.resolveByPath === "function") {
902
+ try { target = await registry.resolveByPath(header.cwd); } catch { target = void 0; }
903
+ }
904
+ if (target === void 0) {
905
+ const canonical = await canonicalizeDirectory(header.cwd);
906
+ if (canonical !== void 0) target = byPath.get(canonical);
907
+ }
908
+ if (target === void 0 || typeof target.attachSession !== "function") return false;
909
+ await target.attachSession(header.id);
910
+ return true;
911
+ };
912
+ if (typeof registry.enqueueOperation === "function") return await registry.enqueueOperation(run);
913
+ return await run();
914
+ };
915
+
916
+ /**
917
+ * Resolve the header the official attachSession() will validate against.
918
+ * WorkspaceEntity.readSessionHeader prefers the live session, then the
919
+ * registry's own header index, and only then a fresh persistence listing.
920
+ * Mirroring that order lets the repair predict a rejection instead of
921
+ * attempting an attach that cannot succeed.
922
+ */
923
+ const effectiveSessionHeader = (sessionId, storedHeader) => {
924
+ try {
925
+ const live = ctx.sessions?.get?.(sessionId);
926
+ if (live !== undefined && live !== null && live.header !== undefined) return live.header;
927
+ } catch { /* fall through to the index */ }
928
+ try {
929
+ const indexed = adapter.workspaceRegistry?.headers?.get?.(sessionId);
930
+ if (indexed !== undefined) return indexed;
931
+ } catch { /* fall through to the stored header */ }
932
+ return storedHeader;
933
+ };
934
+
935
+ /**
936
+ * Complete the durable workspace membership of a session whose artifact
937
+ * already lives under the target workspace path (same-path move). DSH 0.2.x
938
+ * keeps "cwd matches the workspace path" and "the workspace accounts for
939
+ * the session" as separate facts, so a matching cwd alone must not be
940
+ * reported as success.
941
+ *
942
+ * Conservative policy. The official domain offers no cross-record
943
+ * transaction (single-record updates on one serialized storage unit), and
944
+ * attachSession() validates the session cwd against the workspace path --
945
+ * so an account held by a DIFFERENT workspace can never be restored
946
+ * through the official API once it is removed. Automatic migration would
947
+ * therefore risk destroying the only remaining account if the target
948
+ * attach then failed. Hence:
949
+ * - no workspace accounts for the session: complete the target
950
+ * membership through attachSession(), after pre-checking every
951
+ * condition it validates so a predictable rejection fails before any
952
+ * write;
953
+ * - already accounted by the target and by nobody else: idempotent
954
+ * no-op, no write;
955
+ * - accounted by another workspace: refuse with a membership-conflict
956
+ * error; no automatic cross-record migration;
957
+ * - membership state cannot be confirmed: refuse.
958
+ *
959
+ * No artifact is rewritten, no cwd is touched, and no live session is
960
+ * restarted. A true cross-workspace move re-homes the artifact first and
961
+ * is a separate, unchanged path.
962
+ *
963
+ * @returns {status: "already-member"|"attached", attached: boolean}
964
+ * @throws with code workspace-membership-unknown | workspace-membership-conflict |
965
+ * workspace-attach-unavailable | workspace-repair-unsafe | workspace-repair-failed.
966
+ */
967
+ const repairWorkspaceMembership = async (target, sessionId, storedHeader) => {
968
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
969
+ const run = async () => {
970
+ // Confirm the current accounting from a fresh, readable listing. An
971
+ // unreadable registry must never be guessed at.
972
+ let entities;
973
+ let inTarget;
974
+ let stale;
975
+ try {
976
+ if (typeof registry?.list !== "function") throw new Error("workspace registry 不可用");
977
+ const listed = registry.list();
978
+ if (!Array.isArray(listed)) throw new Error("workspace registry 返回了无效数据");
979
+ entities = listed;
980
+ inTarget = workspaceMemberIds(target).includes(sessionId);
981
+ stale = entities.filter((entity) =>
982
+ entity !== target && entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId));
983
+ } catch (cause) {
984
+ const error = new Error("无法确认会话当前的工作区归属状态(" + safeErrorMessage(cause) + "),已拒绝修复操作");
985
+ error.code = "workspace-membership-unknown";
986
+ throw error;
987
+ }
988
+
989
+ if (stale.length > 0) {
990
+ // Membership conflict: the session is accounted by a workspace whose
991
+ // path differs from its cwd. That account cannot be restored through
992
+ // the official API once removed, so an automatic migration could
993
+ // destroy the only remaining account if the target attach failed.
994
+ // Refuse and let the operator resolve the conflict.
995
+ const names = stale.map((entity) => entity.title || entity.id).join("、");
996
+ const error = new Error(
997
+ "会话当前登记在其他工作区(" + names + "),与其 cwd 指向的目标工作区不一致。"
998
+ + "为避免修复失败造成数据损失,已拒绝自动迁移;请先处理该归属冲突,或使用真正的跨工作区移动"
999
+ );
1000
+ error.code = "workspace-membership-conflict";
1001
+ throw error;
1002
+ }
1003
+ if (inTarget) return { status: "already-member", attached: false };
1004
+ if (typeof target.attachSession !== "function") {
1005
+ const error = new Error("当前 DSH 宿主未提供官方工作区归属接口(attachSession),无法补全会话归属");
1006
+ error.code = "workspace-attach-unavailable";
1007
+ throw error;
1008
+ }
1009
+
1010
+ // Pre-check every condition attachSession() validates so a predictable
1011
+ // rejection fails before any write. The header it reads is the live
1012
+ // session first, then the registry index, then a fresh persistence
1013
+ // listing -- not necessarily the header this request was validated
1014
+ // against.
1015
+ const effective = effectiveSessionHeader(sessionId, storedHeader);
1016
+ if (effective === void 0 || typeof effective.cwd !== "string" || effective.cwd === "") {
1017
+ const error = new Error("拒绝补全归属:会话没有可校验的 cwd,官方接口无法登记");
1018
+ error.code = "workspace-repair-unsafe";
1019
+ throw error;
1020
+ }
1021
+ const canonical = await canonicalizeDirectory(effective.cwd);
1022
+ if (canonical === void 0 || canonical !== target.path) {
1023
+ const error = new Error(
1024
+ "拒绝补全归属:会话当前 cwd(" + effective.cwd + ")解析后与目标工作区(" + target.path
1025
+ + ")不一致,官方接口会拒绝登记;未做任何修改"
1026
+ );
1027
+ error.code = "workspace-repair-unsafe";
1028
+ throw error;
1029
+ }
1030
+ try {
1031
+ await target.attachSession(sessionId);
1032
+ } catch (cause) {
1033
+ // Nothing has been written yet, so the previous state (no account) is
1034
+ // intact; surface the failure with its cause.
1035
+ const error = new Error("补全归属失败:" + safeErrorMessage(cause));
1036
+ error.code = "workspace-repair-failed";
1037
+ throw error;
1038
+ }
1039
+ return { status: "attached", attached: true };
1040
+ };
1041
+ if (registry !== void 0 && typeof registry.enqueueOperation === "function") {
1042
+ return await registry.enqueueOperation(run);
1043
+ }
1044
+ return await run();
1045
+ };
1046
+
837
1047
  /**
838
1048
  * TRUE cross-workspace move. See the module docstring for the full design:
839
1049
  * re-home the stored cwd, migrate the artifact, then swap accounting.
@@ -896,7 +1106,25 @@ ${rest}`, "utf8");
896
1106
  let currentCanonical;
897
1107
  try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
898
1108
  if (currentCanonical === targetPath) {
899
- return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
1109
+ // The artifact already lives under the target workspace path, but on
1110
+ // DSH 0.2.x "cwd matches" and "the workspace accounts for the
1111
+ // session" are separate facts: a matching cwd with a missing
1112
+ // registration used to return a silent no-op success. Repair the
1113
+ // durable membership through the official entity API (idempotent,
1114
+ // no artifact rewrite, no live-session restart) and report what
1115
+ // actually happened. A repair that cannot be completed fails loudly
1116
+ // instead of reporting success.
1117
+ const repair = await repairWorkspaceMembership(target, sessionId, storedHeader);
1118
+ return {
1119
+ ok: true,
1120
+ sessionId,
1121
+ moved: false,
1122
+ attached: repair.attached,
1123
+ membership: repair.status,
1124
+ message: repair.attached
1125
+ ? "会话已归属目标工作区(已补全归属记录)"
1126
+ : "会话已属于目标工作区"
1127
+ };
900
1128
  }
901
1129
  }
902
1130
 
@@ -1003,7 +1231,7 @@ ${rest}`, "utf8");
1003
1231
  const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
1004
1232
  const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
1005
1233
  const fromWorkspaceIds = registry.list()
1006
- .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
1234
+ .filter((entity) => entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId))
1007
1235
  .map((entity) => entity.id);
1008
1236
  let targetAttached = false;
1009
1237
  const detachedFrom = [];
@@ -1168,8 +1396,7 @@ ${rest}`, "utf8");
1168
1396
  throw error;
1169
1397
  }
1170
1398
  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);
1399
+ for (const id of workspaceMemberIds(ws)) allowedIds.add(id);
1173
1400
  }
1174
1401
  }
1175
1402
  const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
@@ -1818,43 +2045,103 @@ ${rest}`, "utf8");
1818
2045
  if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1819
2046
 
1820
2047
  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("; "));
2048
+ // Snapshot durable membership BEFORE the async scan. Only ids that
2049
+ // already existed here may ever be pruned later; ids attached
2050
+ // concurrently must never be removed using an older disk snapshot.
2051
+ let entities;
2052
+ try { entities = registry.list(); }
2053
+ catch (error) {
2054
+ ctx.logger.warn("session-manager: workspace registry unavailable; workspace reconciliation skipped: " + safeErrorMessage(error));
1827
2055
  return;
1828
2056
  }
1829
- const headers = scan.headers;
1830
- const validIds = new Set(headers.map(header => header.id));
2057
+ const initialIds = new Map(entities.map(ws => [ws.id, new Set(workspaceMemberIds(ws))]));
2058
+ const scan = await scanSessionHeaders(persistence, adapter.homePath);
2059
+ const headers = Array.isArray(scan.headers) ? scan.headers : [];
2060
+
2061
+ // Step 1a: attach sessions whose cwd names a registered workspace but
2062
+ // that no workspace accounts for (issue #41). Every candidate is
2063
+ // re-validated by the official entity API, which persists through the
2064
+ // domain table and is idempotent for already-accounted ids -- so this
2065
+ // pass can only add well-formed memberships, never corrupt records.
2066
+ // It runs whenever the persistence index listed successfully
2067
+ // (scan.persisted); a partial disk sweep cannot make a listed header
2068
+ // invalid.
2069
+ let added = 0;
2070
+ if (scan.persisted) {
2071
+ const assigned = new Set();
2072
+ for (const ws of entities) for (const id of initialIds.get(ws.id)) assigned.add(id);
2073
+ const candidates = headers.filter(header =>
2074
+ typeof header.cwd === "string" && header.cwd !== ""
2075
+ && header.origin !== "subagent"
2076
+ && !assigned.has(header.id));
2077
+ if (candidates.length > 0) {
2078
+ const byPath = new Map();
2079
+ for (const ws of entities) {
2080
+ if (typeof ws.path === "string" && !byPath.has(ws.path)) byPath.set(ws.path, ws);
2081
+ }
2082
+ const attach = async () => {
2083
+ // Re-read membership inside the registry write queue: a concurrent
2084
+ // move may have accounted an id after the snapshot above.
2085
+ const current = typeof registry.list === "function" ? registry.list() : entities;
2086
+ const assignedNow = new Set();
2087
+ for (const ws of current) for (const id of workspaceMemberIds(ws)) assignedNow.add(id);
2088
+ for (const header of candidates) {
2089
+ if (assignedNow.has(header.id)) continue;
2090
+ let target = byPath.get(header.cwd);
2091
+ if (target === void 0) {
2092
+ const canonical = await canonicalizeDirectory(header.cwd);
2093
+ if (canonical !== void 0) target = byPath.get(canonical);
2094
+ }
2095
+ if (target === void 0 || typeof target.attachSession !== "function") continue;
2096
+ try {
2097
+ await target.attachSession(header.id);
2098
+ assignedNow.add(header.id);
2099
+ added += 1;
2100
+ } catch (error) {
2101
+ ctx.logger.warn("session-manager: boot attach of session '" + header.id + "' to workspace '" + target.id + "' failed: " + safeErrorMessage(error));
2102
+ }
2103
+ }
2104
+ };
2105
+ if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(attach);
2106
+ else await attach();
2107
+ }
2108
+ }
1831
2109
 
1832
- // Step 1: Immediate Workspace Member Reconciliation (isolated from projection rebuild)
2110
+ // Step 1b: prune record entries whose session no longer exists. Only a
2111
+ // fully complete scan (persistence index plus disk sweep, no errors)
2112
+ // may remove anything; live sessions and ids attached after the
2113
+ // snapshot are always kept. Durable records are read through
2114
+ // workspaceMemberIds so a stale runtime index cannot hide a dead entry.
1833
2115
  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;
2116
+ if (scan.complete) {
2117
+ const validIds = new Set(headers.map(header => header.id));
2118
+ const prune = async () => {
2119
+ for (const ws of (typeof registry.list === "function" ? registry.list() : entities)) {
2120
+ const initial = initialIds.get(ws.id);
2121
+ // Snapshot: detachSession replaces the record array while we walk it.
2122
+ for (const id of [...workspaceMemberIds(ws)]) {
2123
+ if (validIds.has(id)) continue;
2124
+ if (initial === void 0 || !initial.has(id)) continue;
2125
+ if (adapter.getSession(id) !== undefined) continue;
2126
+ if (typeof ws.detachSession !== "function") continue;
2127
+ await ws.detachSession(id);
2128
+ removed += 1;
2129
+ }
1850
2130
  }
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');
2131
+ };
2132
+ if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(prune);
2133
+ else await prune();
2134
+ }
2135
+ if (added || removed) {
2136
+ ctx.logger.info('session-manager: post-boot workspace reconcile attached ' + added + ' unaccounted sessions, removed ' + removed + ' orphans');
2137
+ }
2138
+ if (!scan.persisted) {
2139
+ ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2140
+ return;
2141
+ }
2142
+ if (!scan.complete) {
2143
+ ctx.logger.warn("session-manager: incomplete disk sweep; workspace membership pruning skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2144
+ return;
1858
2145
  }
1859
2146
 
1860
2147
  // Step 2: Budgeted Background Projection Cache Rebuild
@@ -1918,6 +2205,32 @@ ${rest}`, "utf8");
1918
2205
  }
1919
2206
  }, "session-manager: post-boot re-fold stale projection cache rows");
1920
2207
 
2208
+ /**
2209
+ * Runtime membership repair (issue #41): DSH's own create/fork flow attaches
2210
+ * a session to its workspace, but a session created outside that flow -- or
2211
+ * one whose host-side attach failed -- stays unaccounted even though its
2212
+ * cwd names a registered workspace, and would otherwise only be repaired on
2213
+ * the next DSH restart. Repair it as soon as the session is announced.
2214
+ *
2215
+ * The listener must never throw synchronously: a throwing session/created
2216
+ * listener rolls back session creation in DSH. Everything is contained and
2217
+ * fire-and-forget; failures are logged only.
2218
+ */
2219
+ if (typeof ctx.on === "function") ctx.on("session/created", (session) => {
2220
+ void (async () => {
2221
+ try {
2222
+ const header = session?.header ?? session;
2223
+ if (header === void 0 || typeof header.id !== "string") return;
2224
+ const attached = await attachSessionToOwningWorkspace(header);
2225
+ if (attached) {
2226
+ ctx.logger.info("session-manager: attached new session '" + header.id + "' to its cwd workspace");
2227
+ }
2228
+ } catch (error) {
2229
+ ctx.logger.warn("session-manager: session/created membership repair failed: " + safeErrorMessage(error));
2230
+ }
2231
+ })();
2232
+ });
2233
+
1921
2234
  /** Build a normalized annotation patch from one bulk action + payload.
1922
2235
  * Returns null when the payload is missing or malformed so the caller can
1923
2236
  * surface a 400 instead of letting the store throw on a bad value.
@@ -2215,7 +2528,7 @@ ${rest}`, "utf8");
2215
2528
  return send(res, 200, { ok: true, result });
2216
2529
  }
2217
2530
  if (req.method === "GET" && path === "/update/check") {
2218
- const currentVersion = adapter.pluginVersion() || "0.7.3";
2531
+ const currentVersion = adapter.pluginVersion() || "0.7.4";
2219
2532
  const runningVersion = currentVersion;
2220
2533
  let installedVersion = runningVersion;
2221
2534
  const profileDir = resolveProfileDir(adapter, ctx);
@@ -2399,7 +2712,7 @@ ${rest}`, "utf8");
2399
2712
  };
2400
2713
  setActiveTask(taskRecord);
2401
2714
 
2402
- const currentVersion = adapter.pluginVersion() || "0.7.3";
2715
+ const currentVersion = adapter.pluginVersion() || "0.7.4";
2403
2716
  let installedVersion = currentVersion;
2404
2717
  if (profileDir) {
2405
2718
  const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
@@ -2929,7 +3242,7 @@ ${rest}`, "utf8");
2929
3242
  }
2930
3243
  if (req.method === "GET" && path === "/update/status") {
2931
3244
  const profileDir = resolveProfileDir(adapter, ctx);
2932
- const currentVersion = adapter.pluginVersion() || "0.7.3";
3245
+ const currentVersion = adapter.pluginVersion() || "0.7.4";
2933
3246
  let installedVersion = currentVersion;
2934
3247
  if (profileDir) {
2935
3248
  const diskCheck = verifyInstalledPackage(profileDir);
@@ -3004,7 +3317,9 @@ ${rest}`, "utf8");
3004
3317
  ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
3005
3318
  const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
3006
3319
  : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
3007
- : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
3320
+ : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503
3321
+ : error?.code === "workspace-membership-conflict" ? 409
3322
+ : error?.code === "workspace-attach-unavailable" ? 501 : 500;
3008
3323
  return send(res, status, {
3009
3324
  ok: false,
3010
3325
  error: safeErrorMessage(error),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-manager",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "DeepSeek Harness session and workspace manager —— Workspaces: archive and visibility. Sessions: move, migrate presets, delete, favorite, review later, prioritize, tag, and annotate. Batch operations. | DSH 会话与工作区管理——工作区:归档与显示。会话:跨工作区移动、预设迁移、删除;收藏、待回看、优先级、标签、备注。批量操作。",
5
5
  "license": "MIT",
6
6
  "keywords": [