dsh-over-ssh 0.2.1 → 0.2.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.
@@ -2,7 +2,7 @@
2
2
  import "@deepseek-ai/cordis";
3
3
 
4
4
  // src/host/integration/fs/file-reference-provider.ts
5
- import { readdir } from "node:fs/promises";
5
+ import { readdir, realpath, stat } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import "@deepseek-ai/cordis";
8
8
  import FileReferenceService from "@deepseek-ai/dsh-file-reference";
@@ -47,7 +47,7 @@ var DEFAULTS = {
47
47
  outputCap: 8 * 1024 * 1024
48
48
  };
49
49
  function rankMatches(paths, query, limit) {
50
- const needle = query.trim().toLowerCase();
50
+ const needle = separatorsForMatching(query.trim().toLowerCase());
51
51
  if (needle === "") {
52
52
  const top = [];
53
53
  const better = (left, right) => {
@@ -72,7 +72,8 @@ function rankMatches(paths, query, limit) {
72
72
  }
73
73
  const scored = [];
74
74
  for (const path of paths) {
75
- const lower = path.toLowerCase();
75
+ const normalized = separatorsForMatching(path);
76
+ const lower = normalized.toLowerCase();
76
77
  const base = lower.slice(lower.lastIndexOf("/") + 1);
77
78
  let score;
78
79
  if (base.startsWith(needle)) score = 0;
@@ -87,11 +88,15 @@ function rankMatches(paths, query, limit) {
87
88
  );
88
89
  return scored.slice(0, limit).map((entry) => entry.path);
89
90
  }
91
+ function separatorsForMatching(path) {
92
+ return path.includes("\\") && !path.includes("/") ? path.replaceAll("\\", "/") : path;
93
+ }
90
94
  function depth(path) {
91
95
  let segments = 0;
92
96
  let inSegment = false;
93
97
  for (let index = 0; index < path.length; index += 1) {
94
- if (path.charCodeAt(index) === 47) {
98
+ const code = path.charCodeAt(index);
99
+ if (code === 47 || code === 92) {
95
100
  inSegment = false;
96
101
  continue;
97
102
  }
@@ -153,12 +158,37 @@ function makeLocalRoot(path, options = {}) {
153
158
  function normalizeLocalPath2(path, options = {}) {
154
159
  return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
155
160
  }
161
+ function localPathContains(root, candidate, options = {}) {
162
+ const normalizedRoot = normalizeLocalPath2(root, options);
163
+ const normalizedCandidate = normalizeLocalPath2(candidate, options);
164
+ if (normalizedRoot === "/" || /^[a-z]:\/$/.test(normalizedRoot)) {
165
+ return normalizedCandidate.startsWith(normalizedRoot);
166
+ }
167
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
168
+ }
169
+
170
+ // src/host/integration/session-fork-pending.ts
171
+ var pending = /* @__PURE__ */ new Map();
172
+ function pendingForkSources(sessionId) {
173
+ if (sessionId === void 0 || sessionId === "") return [];
174
+ const source = pending.get(sessionId);
175
+ return source === void 0 ? [] : [source];
176
+ }
156
177
 
157
178
  // src/host/integration/session-scope.ts
158
179
  var MAX_FORK_DEPTH = 32;
159
180
  var ancestorCache = /* @__PURE__ */ new Map();
160
181
  var ANCESTOR_CACHE_MAX = 256;
161
182
  function readSessionAncestors(ctx, sessionId) {
183
+ const pending2 = pendingForkSources(sessionId);
184
+ if (pending2.length > 0) {
185
+ const inherited = /* @__PURE__ */ new Set();
186
+ for (const source of pending2) {
187
+ inherited.add(source);
188
+ for (const ancestor of walkAncestors(ctx, source).ancestors) inherited.add(ancestor);
189
+ }
190
+ return [...inherited];
191
+ }
162
192
  const cached = ancestorCache.get(sessionId);
163
193
  if (cached !== void 0) {
164
194
  ancestorCache.delete(sessionId);
@@ -231,6 +261,17 @@ var RemoteFileReferenceService = class extends FileReferenceService {
231
261
  * (`local-backend.ts:53`), so there is no change signal to hook — only a short TTL.
232
262
  */
233
263
  #localCache = /* @__PURE__ */ new Map();
264
+ /**
265
+ * 正在进行中的本地走查,按根合并。
266
+ *
267
+ * 为什么需要它:缓存只覆盖"上一次走查**已完成**"的情况。用户敲 `@mo` 时的间隔可能短于一次走查
268
+ * (10 000 条目约 68 ms),于是几次击键会各自发起一次全树走查——同一棵树在宿主事件循环上被重复
269
+ * 扫描。这里让并发的同根调用共享一次走查;查询不同不影响,因为**排序仍按每次调用自己的 query 现算**,
270
+ * 共享的只有与 query 无关的树枚举。
271
+ */
272
+ #inflight = /* @__PURE__ */ new Map();
273
+ /** 已经告警过的来源标签;一次故障只提示一次,避免每个按键都刷一行日志。 */
274
+ #warned = /* @__PURE__ */ new Set();
234
275
  constructor(ctx, options = {}) {
235
276
  super(ctx);
236
277
  this.#options = options;
@@ -262,20 +303,53 @@ var RemoteFileReferenceService = class extends FileReferenceService {
262
303
  * without an index, or a cwd that is not an absolute host path.
263
304
  */
264
305
  async list(agent, query, signal) {
306
+ signal.throwIfAborted();
265
307
  const cwd = agent.session.header.cwd ?? process.cwd();
266
308
  const registry = this.ctx.sshDevRoots;
267
309
  if (registry === void 0) return [];
268
310
  const identity = sessionIdentityOf(this.ctx, agent.session.header.id);
269
311
  const gated = identity.sessionId === void 0 ? registry.routed.tryResolve(cwd) : registry.routed.tryResolve(cwd, { session: identity });
270
- const addedTask = this.#addedRemoteCandidates(cwd, identity, query, signal);
312
+ const addedTask = this.#degrade(
313
+ "\u672C\u4F1A\u8BDD\u6DFB\u52A0\u7684\u8FDC\u7AEF\u6839",
314
+ void 0,
315
+ () => this.#addedRemoteCandidates(cwd, identity, query, signal)
316
+ );
271
317
  const ungated = gated.ok ? void 0 : registry.routed.tryResolve(cwd);
272
318
  const implicit = gated.ok || ungated?.ok === true ? void 0 : sessionLocalRoot(cwd);
273
- const namedTask = gated.ok ? this.#namedCandidates(gated.target.root, query, signal) : implicit === void 0 ? void 0 : this.#localCandidates(implicit.path, query, signal);
319
+ const namedTask = gated.ok ? this.#degrade("cwd \u547D\u540D\u7684\u6839", [], () => this.#namedCandidates(gated.target.root, query, signal)) : implicit === void 0 ? void 0 : this.#degrade("cwd \u81EA\u8EAB\u76EE\u5F55", [], () => this.#localCandidates(implicit.path, query, signal));
274
320
  const [added, named] = await Promise.all([addedTask, namedTask]);
321
+ signal.throwIfAborted();
275
322
  if (gated.ok) return this.#merge(added, named ?? []);
276
323
  if (ungated?.ok === true) return added ?? [];
277
324
  return this.#merge(added, named ?? []);
278
325
  }
326
+ /**
327
+ * Run one candidate source, degrading a failure to `fallback`.
328
+ *
329
+ * 为什么不让一次失败冒到上层:`@` 补全有两个独立来源(本会话「添加目录」的远端根、cwd 命名的根)。
330
+ * 旧写法直接 `Promise.all`,其中**任何一个**失败(连接断、远端超时、walk 抛错)都会让整个 `list()`
331
+ * 拒绝——明明本地候选已经拿到,也会一起消失。取消是例外:调用方已经取消时必须继续抛出,不能把
332
+ * "取消"伪装成"空结果"。
333
+ *
334
+ * @param label - 日志里标识来源的短名,同一来源只告警一次。
335
+ * @param fallback - 失败时返回的降级值。
336
+ * @param task - 真正的枚举任务。
337
+ * @returns 任务结果,或降级值。
338
+ */
339
+ async #degrade(label, fallback, task) {
340
+ try {
341
+ return await task();
342
+ } catch (error) {
343
+ if (isAbortError(error)) throw error;
344
+ if (!this.#warned.has(label)) {
345
+ this.#warned.add(label);
346
+ this.ctx.logger?.warn?.(
347
+ `file-references: ${label} \u53D6\u5019\u9009\u5931\u8D25\uFF0C\u672C\u6B21\u53EA\u8DF3\u8FC7\u8BE5\u6765\u6E90\uFF1A${error instanceof Error ? error.message : String(error)}`
348
+ );
349
+ }
350
+ return fallback;
351
+ }
352
+ }
279
353
  /** cwd 命名的那个根自己的候选:远端走索引,本地走有界走查。 */
280
354
  #namedCandidates(root, query, signal) {
281
355
  return root.location === "remote" && root.connectionId !== void 0 ? this.#remoteCandidates(root.connectionId, root.path, query, signal) : this.#localCandidates(root.path, query, signal);
@@ -342,6 +416,7 @@ var RemoteFileReferenceService = class extends FileReferenceService {
342
416
  * @returns the entries, from cache when the previous walk is still fresh.
343
417
  */
344
418
  async #walkCached(root, walk, signal) {
419
+ signal.throwIfAborted();
345
420
  const started = this.#now();
346
421
  const cached = this.#localCache.get(root);
347
422
  if (cached !== void 0) {
@@ -352,10 +427,33 @@ var RemoteFileReferenceService = class extends FileReferenceService {
352
427
  }
353
428
  this.#localCache.delete(root);
354
429
  }
355
- const entries = await walk(root, signal);
356
- signal.throwIfAborted();
357
- this.#rememberLocal(root, entries, started);
358
- return entries;
430
+ let shared = this.#inflight.get(root);
431
+ if (shared === void 0) {
432
+ const controller = new AbortController();
433
+ const entry = { controller, waiters: 0, promise: Promise.resolve([]) };
434
+ const promise = (async () => {
435
+ const entries = await walk(root, controller.signal);
436
+ controller.signal.throwIfAborted();
437
+ this.#rememberLocal(root, entries, started);
438
+ return entries;
439
+ })();
440
+ entry.promise = promise;
441
+ void promise.catch(() => void 0).finally(() => {
442
+ if (this.#inflight.get(root) === entry) this.#inflight.delete(root);
443
+ });
444
+ this.#inflight.set(root, entry);
445
+ shared = entry;
446
+ }
447
+ shared.waiters += 1;
448
+ try {
449
+ return await raceAbort(shared.promise, signal);
450
+ } finally {
451
+ shared.waiters -= 1;
452
+ if (shared.waiters === 0 && this.#inflight.get(root) === shared) {
453
+ this.#inflight.delete(root);
454
+ shared.controller.abort(new DOMException("no active @ completion callers", "AbortError"));
455
+ }
456
+ }
359
457
  }
360
458
  /** Store one local enumeration, evicting the least recently used root past the cap. */
361
459
  #rememberLocal(root, entries, at) {
@@ -371,6 +469,12 @@ var RemoteFileReferenceService = class extends FileReferenceService {
371
469
  const maxEntries = this.#options.maxEntries ?? LOCAL_MAX_ENTRIES;
372
470
  const maxDepth = this.#options.maxDepth ?? LOCAL_MAX_DEPTH;
373
471
  const found = [];
472
+ const visited = /* @__PURE__ */ new Set();
473
+ const rootReal = await realpath(root).catch(() => root);
474
+ const caseInsensitive = process.platform === "win32";
475
+ const withinRoot = (candidate) => localPathContains(rootReal, candidate, { caseInsensitive });
476
+ visited.add(rootReal);
477
+ const excluded = (name) => LOCAL_EXCLUDED_DIRECTORIES.includes(caseInsensitive ? name.toLowerCase() : name);
374
478
  let frontier = [{ path: root, depth: 0 }];
375
479
  while (frontier.length > 0 && found.length < maxEntries) {
376
480
  signal.throwIfAborted();
@@ -386,11 +490,27 @@ var RemoteFileReferenceService = class extends FileReferenceService {
386
490
  }
387
491
  for (const child of children) {
388
492
  if (found.length >= maxEntries) break;
389
- const isDirectory = child.isDirectory();
390
- if (isDirectory && LOCAL_EXCLUDED_DIRECTORIES.includes(child.name)) continue;
493
+ let isDirectory = child.isDirectory();
494
+ let traversable = child.isDirectory();
391
495
  const path = join(entry.path, child.name);
496
+ if (!isDirectory && child.isSymbolicLink()) {
497
+ try {
498
+ const target = await stat(path);
499
+ isDirectory = target.isDirectory();
500
+ if (isDirectory) {
501
+ const real = await realpath(path);
502
+ if (visited.has(real) || !withinRoot(real)) traversable = false;
503
+ else {
504
+ visited.add(real);
505
+ traversable = true;
506
+ }
507
+ }
508
+ } catch {
509
+ }
510
+ }
511
+ if (isDirectory && excluded(child.name)) continue;
392
512
  found.push({ path, kind: isDirectory ? "directory" : "file" });
393
- if (isDirectory) next.push({ path, depth: entry.depth + 1 });
513
+ if (traversable) next.push({ path, depth: entry.depth + 1 });
394
514
  }
395
515
  }
396
516
  frontier = next;
@@ -398,6 +518,26 @@ var RemoteFileReferenceService = class extends FileReferenceService {
398
518
  return found;
399
519
  }
400
520
  };
521
+ function raceAbort(promise, signal) {
522
+ if (signal.aborted) return Promise.reject(signal.reason ?? new DOMException("aborted", "AbortError"));
523
+ return new Promise((resolve, reject) => {
524
+ const onAbort = () => reject(signal.reason ?? new DOMException("aborted", "AbortError"));
525
+ signal.addEventListener("abort", onAbort, { once: true });
526
+ promise.then(
527
+ (value) => {
528
+ signal.removeEventListener("abort", onAbort);
529
+ resolve(value);
530
+ },
531
+ (error) => {
532
+ signal.removeEventListener("abort", onAbort);
533
+ reject(error);
534
+ }
535
+ );
536
+ });
537
+ }
538
+ function isAbortError(error) {
539
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError") || typeof error === "object" && error !== null && "name" in error && (error.name === "AbortError" || error.name === "TimeoutError");
540
+ }
401
541
  function sessionLocalRoot(cwd) {
402
542
  if (cwd === "" || !isAbsoluteLocalPath(cwd)) return void 0;
403
543
  return makeLocalRoot(cwd);
package/lib/fs.js CHANGED
@@ -630,6 +630,20 @@ async function readBody(req, maxBytes) {
630
630
  }
631
631
  return Buffer.concat(chunks).toString("utf8");
632
632
  }
633
+ async function readJsonObject(req, maxBytes) {
634
+ const text = (await readBody(req, maxBytes)).trim();
635
+ if (text === "") return {};
636
+ let parsed;
637
+ try {
638
+ parsed = JSON.parse(text);
639
+ } catch {
640
+ throw new SshDevError("ROOT_INVALID", "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON");
641
+ }
642
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
643
+ throw new SshDevError("ROOT_INVALID", "\u8BF7\u6C42\u4F53\u9876\u5C42\u5FC5\u987B\u662F\u5BF9\u8C61");
644
+ }
645
+ return parsed;
646
+ }
633
647
  function toBytes(chunk) {
634
648
  if (typeof chunk === "string") return Buffer.from(chunk, "utf8");
635
649
  if (chunk instanceof Uint8Array) return chunk;
@@ -650,6 +664,38 @@ function asRecord(value) {
650
664
  function messageOf(error) {
651
665
  return error instanceof Error ? error.message : String(error);
652
666
  }
667
+ function reasonOf(error) {
668
+ const reason = asRecord(asRecord(error)?.["details"])?.["reason"];
669
+ return typeof reason === "string" && reason !== "" ? reason : void 0;
670
+ }
671
+ var STATUS_BY_CODE = {
672
+ // 输入问题:调用方改一下请求就能成功。
673
+ ROOT_INVALID: 400,
674
+ CONNECTION_INVALID: 400,
675
+ CONNECTION_NOT_FOUND: 400,
676
+ // 路径不在任何已注册的根内 / 根没有注册:客户端换一个路径或先加根就能成功,不是服务器故障。
677
+ // 它此前漏在表外,于是**所有**经 HTTP 暴露的根外访问都报 500(审计 2026-11-06 第 4 轮 P1)。
678
+ ROOT_OUTSIDE: 400,
679
+ // 目标不存在 / 状态冲突 / 本组合不支持:客户端可以据此换一条路。
680
+ TRASH_NOT_FOUND: 404,
681
+ TRANSFER_CONFLICT: 409,
682
+ SESSION_ACTIVE: 409,
683
+ REMOTE_UNSUPPORTED: 501,
684
+ // 调用方主动取消(客户端断开/关掉面板)。499 是 nginx 的 "client closed request" 约定:它不是一个错误,
685
+ // 只是说明"没人等这个回答了";映射成 500 会让日志里出现一堆假故障。
686
+ CANCELLED: 499
687
+ };
688
+ function codeOf(error) {
689
+ const code = asRecord(error)?.["code"];
690
+ return typeof code === "string" && code !== "" ? code : void 0;
691
+ }
692
+ function statusForCode(code) {
693
+ if (code === void 0) return 500;
694
+ return STATUS_BY_CODE[code] ?? 500;
695
+ }
696
+ function statusForError(error) {
697
+ return statusForCode(codeOf(error));
698
+ }
653
699
 
654
700
  // src/host/infra/transport/git-status-route.ts
655
701
  var GIT_STATUS_API_PATH2 = GIT_STATUS_API_PATH;
@@ -668,9 +714,9 @@ function toPublicAnswer(snapshot) {
668
714
  function createGitStatusApi(deps) {
669
715
  const handle = async (req, res) => {
670
716
  const method = (req.method ?? "GET").toUpperCase();
671
- if (method !== "POST" && method !== "GET" && method !== "HEAD") {
672
- res.setHeader("allow", "GET, HEAD, POST");
673
- send(req, res, 405, { ok: false, error: `\u4E0D\u652F\u6301\u7684\u65B9\u6CD5\uFF1A${method}` });
717
+ if (method !== "POST") {
718
+ res.setHeader("allow", "POST");
719
+ send(req, res, 405, { ok: false, error: "git-status \u53EA\u63A5\u53D7 POST" });
674
720
  return;
675
721
  }
676
722
  const verdict = trustVerdict(req);
@@ -678,13 +724,8 @@ function createGitStatusApi(deps) {
678
724
  send(req, res, 403, { ok: false, error: trustMessage(verdict) });
679
725
  return;
680
726
  }
681
- if (method !== "POST") {
682
- res.setHeader("allow", "POST");
683
- send(req, res, 405, { ok: false, error: "git-status \u53EA\u63A5\u53D7 POST" });
684
- return;
685
- }
686
727
  try {
687
- const body = asRecord(JSON.parse(await readBody(req, ROOTS_API_MAX_BODY_BYTES)));
728
+ const body = await readJsonObject(req, ROOTS_API_MAX_BODY_BYTES);
688
729
  const connectionId = body?.["connectionId"];
689
730
  const path = body?.["path"];
690
731
  if (typeof connectionId !== "string" || connectionId === "") {
@@ -708,8 +749,14 @@ function createGitStatusApi(deps) {
708
749
  }
709
750
  send(req, res, 200, toPublicAnswer(snapshot));
710
751
  } catch (error) {
711
- const invalidBody = error instanceof SyntaxError;
712
- send(req, res, invalidBody ? 400 : 500, { ok: false, error: messageOf(error) });
752
+ const code = codeOf(error);
753
+ const reason = reasonOf(error);
754
+ send(req, res, statusForError(error), {
755
+ ok: false,
756
+ error: messageOf(error),
757
+ ...code === void 0 ? {} : { code },
758
+ ...reason === void 0 ? {} : { reason }
759
+ });
713
760
  }
714
761
  };
715
762
  return { handle };
@@ -780,6 +827,16 @@ function replaceLiteral(source, needle, replacement, all) {
780
827
 
781
828
  // src/host/integration/session-scope.ts
782
829
  import { AsyncLocalStorage } from "node:async_hooks";
830
+
831
+ // src/host/integration/session-fork-pending.ts
832
+ var pending = /* @__PURE__ */ new Map();
833
+ function pendingForkSources(sessionId) {
834
+ if (sessionId === void 0 || sessionId === "") return [];
835
+ const source = pending.get(sessionId);
836
+ return source === void 0 ? [] : [source];
837
+ }
838
+
839
+ // src/host/integration/session-scope.ts
783
840
  var STORE_KEY = /* @__PURE__ */ Symbol.for("dsh-over-ssh/session-scope");
784
841
  function store() {
785
842
  const holder = globalThis;
@@ -796,6 +853,15 @@ var MAX_FORK_DEPTH = 32;
796
853
  var ancestorCache = /* @__PURE__ */ new Map();
797
854
  var ANCESTOR_CACHE_MAX = 256;
798
855
  function readSessionAncestors(ctx, sessionId) {
856
+ const pending2 = pendingForkSources(sessionId);
857
+ if (pending2.length > 0) {
858
+ const inherited = /* @__PURE__ */ new Set();
859
+ for (const source of pending2) {
860
+ inherited.add(source);
861
+ for (const ancestor of walkAncestors(ctx, source).ancestors) inherited.add(ancestor);
862
+ }
863
+ return [...inherited];
864
+ }
799
865
  const cached = ancestorCache.get(sessionId);
800
866
  if (cached !== void 0) {
801
867
  ancestorCache.delete(sessionId);
@@ -857,7 +923,7 @@ function readSessionCwd(ctx, sessionId) {
857
923
  }
858
924
  return found.cwd;
859
925
  }
860
- function readSessionCwds(ctx) {
926
+ function readSessionWorkspaces(ctx) {
861
927
  let list;
862
928
  try {
863
929
  const sessions = ctx.get("sessions");
@@ -866,12 +932,14 @@ function readSessionCwds(ctx) {
866
932
  return [];
867
933
  }
868
934
  if (!Array.isArray(list)) return [];
869
- const cwds = [];
935
+ const pairs = [];
870
936
  for (const session of list) {
937
+ const id = session?.header?.id;
871
938
  const cwd = session?.header?.cwd;
872
- if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
939
+ if (typeof id !== "string" || id === "" || typeof cwd !== "string" || cwd === "") continue;
940
+ pairs.push({ sessionId: id, cwd });
873
941
  }
874
- return cwds;
942
+ return pairs;
875
943
  }
876
944
  var sessionCwdCache = /* @__PURE__ */ new Map();
877
945
  var SESSION_CWD_CACHE_MAX = 256;
@@ -940,7 +1008,7 @@ var RoutedFsProvider = class extends FileSystem {
940
1008
  // it"). Every method here may run with the Proxy as `this`, so the state it touches cannot be `#private`.
941
1009
  rangeReader;
942
1010
  rangeChunkBytes;
943
- /** Reads the live sessions' working directories; see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
1011
+ /** Reads the live sessions' working directories (with ids when available); see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
944
1012
  sessionWorkspaceCwds;
945
1013
  /** Reads one session's fork lineage; see {@link RoutedFsProviderOptions.sessionAncestors}. */
946
1014
  sessionAncestors;
@@ -955,7 +1023,7 @@ var RoutedFsProvider = class extends FileSystem {
955
1023
  constructor(ctx, options = {}) {
956
1024
  super(ctx);
957
1025
  this.rangeReader = options.readBytesRange;
958
- this.sessionWorkspaceCwds = options.sessionWorkspaceCwds ?? (() => readSessionCwds(ctx));
1026
+ this.sessionWorkspaceCwds = options.sessionWorkspaceCwds ?? (() => readSessionWorkspaces(ctx));
959
1027
  this.sessionAncestors = options.sessionAncestors ?? ((sessionId) => readSessionAncestors(ctx, sessionId));
960
1028
  this.sessionSync = { lastSignature: "" };
961
1029
  startHostErrorDetection();
@@ -1482,7 +1550,8 @@ async function* pagedText(reader, chunkBytes, routed, firstWindow, signal) {
1482
1550
  }
1483
1551
  function sessionSyncSignature(cwds, registry) {
1484
1552
  const roots = registry.sessionWorkspaceRecords.map((record) => record.key).join("\0");
1485
- return `${cwds.join("\0")}${roots}`;
1553
+ const items = cwds.map((item) => typeof item === "string" ? item : `${item.sessionId}${item.cwd}`).join("\0");
1554
+ return `${items}${roots}`;
1486
1555
  }
1487
1556
  function assertText(displayPath, bytes) {
1488
1557
  if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {