dsh-over-ssh 0.2.2 → 0.2.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.
Files changed (34) hide show
  1. package/README.md +82 -4
  2. package/agents/agent-manifest.json +7 -7
  3. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  4. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  5. package/cordis.patch.yml +10 -12
  6. package/lib/client.js +161 -60
  7. package/lib/file-references.js +136 -13
  8. package/lib/fs.js +2 -1
  9. package/lib/index.js +1245 -165
  10. package/lib/types/src/config.d.ts +44 -0
  11. package/lib/types/src/host/app/backend.d.ts +31 -0
  12. package/lib/types/src/host/app/routed-fs.d.ts +238 -0
  13. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +17 -0
  14. package/lib/types/src/host/app/transport/transport-service.d.ts +66 -11
  15. package/lib/types/src/host/domain/capability.d.ts +6 -0
  16. package/lib/types/src/host/domain/roots/route-table.d.ts +101 -0
  17. package/lib/types/src/host/domain/session/session-admin.d.ts +8 -0
  18. package/lib/types/src/host/infra/backend/poll-watch.d.ts +119 -0
  19. package/lib/types/src/host/infra/backend/remote-backend.d.ts +297 -0
  20. package/lib/types/src/host/infra/cache/budget.d.ts +68 -0
  21. package/lib/types/src/host/infra/cache/versioned.d.ts +98 -0
  22. package/lib/types/src/host/infra/transport/ssh/askpass.d.ts +132 -0
  23. package/lib/types/src/host/infra/transport/transport.d.ts +48 -0
  24. package/lib/types/src/host/integration/agent/askpass-registry.d.ts +65 -0
  25. package/lib/types/src/host/integration/fs/remote-index.d.ts +183 -0
  26. package/lib/types/src/host/integration/roots/remote-home.d.ts +68 -0
  27. package/lib/types/src/host/integration/roots/root-registry.d.ts +148 -0
  28. package/lib/types/src/index.d.ts +7 -0
  29. package/lib/types/src/local-bridge.d.ts +4 -4
  30. package/lib/types/src/wiring/connections.d.ts +261 -0
  31. package/lib/types/src/wiring/install-memo.d.ts +31 -0
  32. package/package.json +1 -5
  33. package/lib/local-bridge-refs.js +0 -16
  34. package/lib/types/src/local-bridge-refs.d.ts +0 -25
@@ -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,6 +158,14 @@ 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
+ }
156
169
 
157
170
  // src/host/integration/session-fork-pending.ts
158
171
  var pending = /* @__PURE__ */ new Map();
@@ -248,6 +261,17 @@ var RemoteFileReferenceService = class extends FileReferenceService {
248
261
  * (`local-backend.ts:53`), so there is no change signal to hook — only a short TTL.
249
262
  */
250
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();
251
275
  constructor(ctx, options = {}) {
252
276
  super(ctx);
253
277
  this.#options = options;
@@ -279,20 +303,53 @@ var RemoteFileReferenceService = class extends FileReferenceService {
279
303
  * without an index, or a cwd that is not an absolute host path.
280
304
  */
281
305
  async list(agent, query, signal) {
306
+ signal.throwIfAborted();
282
307
  const cwd = agent.session.header.cwd ?? process.cwd();
283
308
  const registry = this.ctx.sshDevRoots;
284
309
  if (registry === void 0) return [];
285
310
  const identity = sessionIdentityOf(this.ctx, agent.session.header.id);
286
311
  const gated = identity.sessionId === void 0 ? registry.routed.tryResolve(cwd) : registry.routed.tryResolve(cwd, { session: identity });
287
- 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
+ );
288
317
  const ungated = gated.ok ? void 0 : registry.routed.tryResolve(cwd);
289
318
  const implicit = gated.ok || ungated?.ok === true ? void 0 : sessionLocalRoot(cwd);
290
- 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));
291
320
  const [added, named] = await Promise.all([addedTask, namedTask]);
321
+ signal.throwIfAborted();
292
322
  if (gated.ok) return this.#merge(added, named ?? []);
293
323
  if (ungated?.ok === true) return added ?? [];
294
324
  return this.#merge(added, named ?? []);
295
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
+ }
296
353
  /** cwd 命名的那个根自己的候选:远端走索引,本地走有界走查。 */
297
354
  #namedCandidates(root, query, signal) {
298
355
  return root.location === "remote" && root.connectionId !== void 0 ? this.#remoteCandidates(root.connectionId, root.path, query, signal) : this.#localCandidates(root.path, query, signal);
@@ -359,6 +416,7 @@ var RemoteFileReferenceService = class extends FileReferenceService {
359
416
  * @returns the entries, from cache when the previous walk is still fresh.
360
417
  */
361
418
  async #walkCached(root, walk, signal) {
419
+ signal.throwIfAborted();
362
420
  const started = this.#now();
363
421
  const cached = this.#localCache.get(root);
364
422
  if (cached !== void 0) {
@@ -369,10 +427,33 @@ var RemoteFileReferenceService = class extends FileReferenceService {
369
427
  }
370
428
  this.#localCache.delete(root);
371
429
  }
372
- const entries = await walk(root, signal);
373
- signal.throwIfAborted();
374
- this.#rememberLocal(root, entries, started);
375
- 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
+ }
376
457
  }
377
458
  /** Store one local enumeration, evicting the least recently used root past the cap. */
378
459
  #rememberLocal(root, entries, at) {
@@ -388,6 +469,12 @@ var RemoteFileReferenceService = class extends FileReferenceService {
388
469
  const maxEntries = this.#options.maxEntries ?? LOCAL_MAX_ENTRIES;
389
470
  const maxDepth = this.#options.maxDepth ?? LOCAL_MAX_DEPTH;
390
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);
391
478
  let frontier = [{ path: root, depth: 0 }];
392
479
  while (frontier.length > 0 && found.length < maxEntries) {
393
480
  signal.throwIfAborted();
@@ -403,11 +490,27 @@ var RemoteFileReferenceService = class extends FileReferenceService {
403
490
  }
404
491
  for (const child of children) {
405
492
  if (found.length >= maxEntries) break;
406
- const isDirectory = child.isDirectory();
407
- if (isDirectory && LOCAL_EXCLUDED_DIRECTORIES.includes(child.name)) continue;
493
+ let isDirectory = child.isDirectory();
494
+ let traversable = child.isDirectory();
408
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;
409
512
  found.push({ path, kind: isDirectory ? "directory" : "file" });
410
- if (isDirectory) next.push({ path, depth: entry.depth + 1 });
513
+ if (traversable) next.push({ path, depth: entry.depth + 1 });
411
514
  }
412
515
  }
413
516
  frontier = next;
@@ -415,6 +518,26 @@ var RemoteFileReferenceService = class extends FileReferenceService {
415
518
  return found;
416
519
  }
417
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
+ }
418
541
  function sessionLocalRoot(cwd) {
419
542
  if (cwd === "" || !isAbsoluteLocalPath(cwd)) return void 0;
420
543
  return makeLocalRoot(cwd);
package/lib/fs.js CHANGED
@@ -787,7 +787,8 @@ function wireGitStatus(ctx) {
787
787
  if (connections === void 0 || transports === void 0) return void 0;
788
788
  const profile = connections.get(connectionId);
789
789
  if (profile === void 0) return void 0;
790
- const acquired = await transports.acquire(profile);
790
+ const lanes = ctx.get("sshDevTransportLanes");
791
+ const acquired = lanes === void 0 ? await transports.acquire(profile) : await lanes.acquire(profile, "compute");
791
792
  return acquired.transport;
792
793
  },
793
794
  resolveRoot: (connectionId, path) => {