dsh-over-ssh 0.0.0-stage → 0.1.1

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/agents/agent-manifest.json +20 -0
  4. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  5. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  6. package/cordis.patch.yml +84 -0
  7. package/lib/client.js +6660 -0
  8. package/lib/file-references.js +411 -0
  9. package/lib/fs.js +1451 -0
  10. package/lib/index.js +19740 -0
  11. package/lib/local-bridge-refs.js +16 -0
  12. package/lib/local-bridge.js +16 -0
  13. package/lib/terminal-wrapper.js +299 -0
  14. package/lib/types/src/client/index.d.ts +49 -0
  15. package/lib/types/src/config.d.ts +383 -0
  16. package/lib/types/src/file-references.d.ts +24 -0
  17. package/lib/types/src/host/app/backend.d.ts +178 -0
  18. package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
  19. package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
  20. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
  21. package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
  22. package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
  23. package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
  24. package/lib/types/src/host/domain/capability.d.ts +77 -0
  25. package/lib/types/src/host/domain/errors.d.ts +54 -0
  26. package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
  27. package/lib/types/src/host/domain/roots/root.d.ts +123 -0
  28. package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
  29. package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
  30. package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
  31. package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
  32. package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
  33. package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
  34. package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
  35. package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
  36. package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
  37. package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
  38. package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
  39. package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
  40. package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
  41. package/lib/types/src/index.d.ts +88 -0
  42. package/lib/types/src/local-bridge-refs.d.ts +25 -0
  43. package/lib/types/src/local-bridge.d.ts +35 -0
  44. package/lib/types/src/shared/format.d.ts +13 -0
  45. package/lib/types/src/shared/root-keys.d.ts +120 -0
  46. package/lib/types/src/wiring/helpers.d.ts +143 -0
  47. package/package.json +122 -4
@@ -0,0 +1,411 @@
1
+ // src/file-references.ts
2
+ import "@deepseek-ai/cordis";
3
+
4
+ // src/host/integration/fs/file-reference-provider.ts
5
+ import { readdir } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import "@deepseek-ai/cordis";
8
+ import FileReferenceService from "@deepseek-ai/dsh-file-reference";
9
+
10
+ // src/host/domain/errors.ts
11
+ var SshDevError = class extends Error {
12
+ code;
13
+ details;
14
+ constructor(code, message, details = {}) {
15
+ super(message);
16
+ this.name = "SshDevError";
17
+ this.code = code;
18
+ this.details = details;
19
+ }
20
+ };
21
+
22
+ // src/host/integration/fs/remote-index.ts
23
+ var DEFAULTS = {
24
+ ttlMs: 3e4,
25
+ maxRoots: 8,
26
+ /**
27
+ * Cap for {@link RemoteIndex} `#scannedOnce`.
28
+ *
29
+ * Larger than `maxRoots` on purpose: a miss there costs one extra full scan (correct, only slower), while a
30
+ * hit saves one, so it can afford to remember more roots. What matters is that it is **finite** — the key is
31
+ * a model-supplied path (`remote_glob`), so an unbounded Set grew for the life of the connection.
32
+ */
33
+ maxScannedRoots: 256,
34
+ budgetMs: 900,
35
+ maxEntries: 2e4,
36
+ /**
37
+ * Byte cap for the path cache, alongside {@link DEFAULTS.maxRoots}.
38
+ *
39
+ * The repository's own rule (`doc/05-benchmarks/baseline.md`) is that every cache names a **count** cap, a
40
+ * **byte** cap and an eviction policy. This one had only a count cap and a root cap, so its real footprint
41
+ * depended on path length and entry count — both of which the *user's* repository decides, not us. Eight
42
+ * roots at the 20 000-entry cap with ~60-character paths is roughly 22 MB of JS strings; 16 MiB keeps that
43
+ * bounded while still holding several full-size roots.
44
+ */
45
+ maxBytes: 16 * 1024 * 1024,
46
+ limit: 50,
47
+ outputCap: 8 * 1024 * 1024
48
+ };
49
+ function rankMatches(paths, query, limit) {
50
+ const needle = query.trim().toLowerCase();
51
+ if (needle === "") {
52
+ const order = [];
53
+ for (let index = 0; index < paths.length; index += 1) order.push(index);
54
+ const depths = new Int32Array(paths.length);
55
+ for (let index = 0; index < paths.length; index += 1) depths[index] = depth(paths[index]);
56
+ order.sort(
57
+ (left, right) => depths[left] - depths[right] || (paths[left] < paths[right] ? -1 : 1)
58
+ );
59
+ return order.slice(0, limit).map((index) => paths[index]);
60
+ }
61
+ const scored = [];
62
+ for (const path of paths) {
63
+ const lower = path.toLowerCase();
64
+ const base = lower.slice(lower.lastIndexOf("/") + 1);
65
+ let score;
66
+ if (base.startsWith(needle)) score = 0;
67
+ else if (base.includes(needle)) score = 1;
68
+ else if (lower.includes(`/${needle}`)) score = 2;
69
+ else if (lower.includes(needle)) score = 3;
70
+ else continue;
71
+ scored.push({ path, score, depth: depth(path) });
72
+ }
73
+ scored.sort(
74
+ (left, right) => left.score - right.score || left.depth - right.depth || (left.path < right.path ? -1 : 1)
75
+ );
76
+ return scored.slice(0, limit).map((entry) => entry.path);
77
+ }
78
+ function depth(path) {
79
+ let segments = 0;
80
+ let inSegment = false;
81
+ for (let index = 0; index < path.length; index += 1) {
82
+ if (path.charCodeAt(index) === 47) {
83
+ inSegment = false;
84
+ continue;
85
+ }
86
+ if (!inSegment) {
87
+ segments += 1;
88
+ inSegment = true;
89
+ }
90
+ }
91
+ return segments;
92
+ }
93
+
94
+ // src/shared/root-keys.ts
95
+ function isAbsoluteLocalPath(path) {
96
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("/") || path.startsWith("\\\\");
97
+ }
98
+ function normalizeLocalPath(path, options = {}) {
99
+ const caseInsensitive = options.caseInsensitive ?? false;
100
+ const unified = path.replace(/\\/g, "/");
101
+ const unc = unified.startsWith("//");
102
+ const posixAbsolute = unified.startsWith("/");
103
+ const driveMatch = /^([A-Za-z]):/.exec(unified);
104
+ const driveLetter = driveMatch?.[1];
105
+ const drive = driveLetter === void 0 ? "" : `${driveLetter.toUpperCase()}:`;
106
+ const rest = unc ? unified.slice(2) : drive === "" ? unified : unified.slice(2);
107
+ const segments = [];
108
+ for (const segment of rest.split("/")) {
109
+ if (segment === "" || segment === ".") continue;
110
+ if (segment === "..") {
111
+ segments.pop();
112
+ continue;
113
+ }
114
+ segments.push(segment);
115
+ }
116
+ const body = segments.join("/");
117
+ let normalized;
118
+ if (unc) normalized = `//${body}`;
119
+ else if (drive !== "") normalized = body === "" ? `${drive}/` : `${drive}/${body}`;
120
+ else if (posixAbsolute) normalized = body === "" ? "/" : `/${body}`;
121
+ else normalized = body === "" ? "." : body;
122
+ return caseInsensitive ? normalized.toLowerCase() : normalized;
123
+ }
124
+
125
+ // src/host/domain/roots/root.ts
126
+ function makeLocalRoot(path, options = {}) {
127
+ const normalized = normalizeLocalPath2(path, { caseInsensitive: false });
128
+ if (!isAbsoluteLocalPath(normalized)) {
129
+ throw new SshDevError("ROOT_INVALID", `\u672C\u5730\u6839\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${path}`, { path });
130
+ }
131
+ return {
132
+ location: "local",
133
+ path: normalized,
134
+ writable: options.writable ?? true,
135
+ ...options.label === void 0 ? {} : { label: options.label },
136
+ ...options.workspaces === void 0 ? {} : { workspaces: options.workspaces },
137
+ ...options.sessions === void 0 ? {} : { sessions: options.sessions },
138
+ ...options.shared === void 0 ? {} : { shared: options.shared }
139
+ };
140
+ }
141
+ function normalizeLocalPath2(path, options = {}) {
142
+ return normalizeLocalPath(path, { caseInsensitive: options.caseInsensitive ?? process.platform === "win32" });
143
+ }
144
+
145
+ // src/host/integration/session-scope.ts
146
+ var MAX_FORK_DEPTH = 32;
147
+ var ancestorCache = /* @__PURE__ */ new Map();
148
+ var ANCESTOR_CACHE_MAX = 256;
149
+ function readSessionAncestors(ctx, sessionId) {
150
+ const cached = ancestorCache.get(sessionId);
151
+ if (cached !== void 0) {
152
+ ancestorCache.delete(sessionId);
153
+ ancestorCache.set(sessionId, cached);
154
+ return cached;
155
+ }
156
+ const walked = walkAncestors(ctx, sessionId);
157
+ if (walked.settled) {
158
+ ancestorCache.set(sessionId, walked.ancestors);
159
+ while (ancestorCache.size > ANCESTOR_CACHE_MAX) {
160
+ const oldest = ancestorCache.keys().next();
161
+ if (oldest.done === true) break;
162
+ ancestorCache.delete(oldest.value);
163
+ }
164
+ }
165
+ return walked.ancestors;
166
+ }
167
+ function walkAncestors(ctx, sessionId) {
168
+ let list;
169
+ try {
170
+ const sessions = ctx.get("sessions");
171
+ list = sessions?.list?.();
172
+ } catch {
173
+ return { ancestors: [], settled: false };
174
+ }
175
+ if (!Array.isArray(list)) return { ancestors: [], settled: false };
176
+ const parents = /* @__PURE__ */ new Map();
177
+ let settled = false;
178
+ for (const session of list) {
179
+ const id = session?.header?.id;
180
+ const parent = session?.header?.parentSession;
181
+ if (typeof id !== "string" || id === "") continue;
182
+ if (id === sessionId) settled = true;
183
+ if (typeof parent === "string" && parent !== "") parents.set(id, parent);
184
+ }
185
+ const ancestors = [];
186
+ let current = parents.get(sessionId);
187
+ while (current !== void 0 && ancestors.length < MAX_FORK_DEPTH && !ancestors.includes(current)) {
188
+ ancestors.push(current);
189
+ current = parents.get(current);
190
+ }
191
+ return { ancestors, settled };
192
+ }
193
+ function sessionIdentityOf(ctx, sessionId) {
194
+ if (sessionId === void 0 || sessionId === "") return {};
195
+ return { sessionId, ancestors: readSessionAncestors(ctx, sessionId) };
196
+ }
197
+
198
+ // src/host/integration/fs/file-reference-provider.ts
199
+ var LOCAL_EXCLUDED_DIRECTORIES = [".git", "node_modules", "dist", "lib", "build", ".next", ".venv", "__pycache__"];
200
+ var LOCAL_MAX_ENTRIES = 2e4;
201
+ var LOCAL_MAX_DEPTH = 12;
202
+ var LOCAL_CACHE_TTL_MS = 250;
203
+ var LOCAL_MAX_CACHED_ROOTS = 8;
204
+ var DEFAULT_MAX_RESULTS = 50;
205
+ var RemoteFileReferenceService = class extends FileReferenceService {
206
+ static inject = ["sshDevRoots", "sshDevIndex"];
207
+ #options;
208
+ #localTtlMs;
209
+ #maxCachedRoots;
210
+ #now;
211
+ /**
212
+ * Recently walked local roots, newest last (insertion order doubles as the LRU order).
213
+ *
214
+ * The local branch used to re-walk the whole tree on **every keystroke** while the remote branch was served
215
+ * from `RemoteIndex`'s 30 s cache — the same seam, two very different costs. Measured at 10 000 entries:
216
+ * typing `@module` = 6 walks = 413 ms, all on the host's event loop (audit 0057 §11 #1).
217
+ *
218
+ * Deliberately **not** an `invalidate()`-driven cache: local backends report `watches: false`
219
+ * (`local-backend.ts:53`), so there is no change signal to hook — only a short TTL.
220
+ */
221
+ #localCache = /* @__PURE__ */ new Map();
222
+ constructor(ctx, options = {}) {
223
+ super(ctx);
224
+ this.#options = options;
225
+ this.#localTtlMs = options.localTtlMs ?? LOCAL_CACHE_TTL_MS;
226
+ this.#maxCachedRoots = options.maxCachedRoots ?? LOCAL_MAX_CACHED_ROOTS;
227
+ this.#now = options.now ?? Date.now;
228
+ }
229
+ /**
230
+ * List candidates for one agent's working directory.
231
+ *
232
+ * **The session cwd always answers, even when no registered root covers it** (FR-1.9 / ADR-0012).
233
+ * Stage 2 mounts this provider as `ctx.fs`, and the workspace a session was opened on need not be a
234
+ * registered root — the shape that broke it is a root registered *underneath* the cwd (session
235
+ * `D:\project\x`, the plugin's own root `D:\project\x\dsh-over-ssh`), where no root contains
236
+ * the cwd and the router answers `ROOT_OUTSIDE`. `@` completion must not go dark in that workspace,
237
+ * so the cwd itself is used as an **implicit local root** — the same rule `routed-fs` applies to the
238
+ * filesystem: local only, only the cwd subtree, remote roots unchanged.
239
+ *
240
+ * The implicit root is **this session's own directory**, so session isolation (ADR-0012) is not
241
+ * weakened: the walk starts at the cwd and cannot reach a sibling root, and a path outside the cwd is
242
+ * still not offered. Containment comes from the walk's root argument rather than a per-entry filter —
243
+ * a filter would re-normalize up to {@link LOCAL_MAX_ENTRIES} paths on **every keystroke**, which is
244
+ * exactly the cost audit 0057 removed.
245
+ *
246
+ * @param agent - target agent; its session cwd decides which root answers.
247
+ * @param query - text after `@` (may be empty: "show the shallowest entries").
248
+ * @param signal - caller cancellation.
249
+ * @returns ranked candidates, best first; empty when nothing can answer — no registry, a remote cwd
250
+ * without an index, or a cwd that is not an absolute host path.
251
+ */
252
+ async list(agent, query, signal) {
253
+ const cwd = agent.session.header.cwd ?? process.cwd();
254
+ const registry = this.ctx.sshDevRoots;
255
+ if (registry === void 0) return [];
256
+ const identity = sessionIdentityOf(this.ctx, agent.session.header.id);
257
+ const gated = identity.sessionId === void 0 ? registry.routed.tryResolve(cwd) : registry.routed.tryResolve(cwd, { session: identity });
258
+ const addedTask = this.#addedRemoteCandidates(cwd, identity, query, signal);
259
+ const ungated = gated.ok ? void 0 : registry.routed.tryResolve(cwd);
260
+ const implicit = gated.ok || ungated?.ok === true ? void 0 : sessionLocalRoot(cwd);
261
+ const namedTask = gated.ok ? this.#namedCandidates(gated.target.root, query, signal) : implicit === void 0 ? void 0 : this.#localCandidates(implicit.path, query, signal);
262
+ const [added, named] = await Promise.all([addedTask, namedTask]);
263
+ if (gated.ok) return this.#merge(added, named ?? []);
264
+ if (ungated?.ok === true) return added ?? [];
265
+ return this.#merge(added, named ?? []);
266
+ }
267
+ /** cwd 命名的那个根自己的候选:远端走索引,本地走有界走查。 */
268
+ #namedCandidates(root, query, signal) {
269
+ return root.location === "remote" && root.connectionId !== void 0 ? this.#remoteCandidates(root.connectionId, root.path, query, signal) : this.#localCandidates(root.path, query, signal);
270
+ }
271
+ /** Remote candidates for one root, or `[]` when the composition has no index. */
272
+ async #remoteCandidates(connectionId, root, query, signal) {
273
+ const index = this.ctx.sshDevIndex;
274
+ if (index === void 0) return [];
275
+ return [...await index.complete(connectionId, root, query, signal)];
276
+ }
277
+ /** Local candidates for one root path (bounded walk, short TTL). */
278
+ async #localCandidates(rootPath, query, signal) {
279
+ const walk = this.#options.walk ?? ((root, walkSignal) => this.#walkLocal(root, walkSignal));
280
+ const entries = await this.#walkCached(rootPath, walk, signal);
281
+ const ranked = rankMatches(
282
+ entries.map((entry) => entry.path),
283
+ query,
284
+ this.#options.maxResults ?? DEFAULT_MAX_RESULTS
285
+ );
286
+ if (ranked.length === 0) return [];
287
+ const wanted = new Set(ranked);
288
+ const kinds = /* @__PURE__ */ new Map();
289
+ for (const entry of entries) {
290
+ if (!wanted.has(entry.path)) continue;
291
+ kinds.set(entry.path, entry.kind);
292
+ if (kinds.size === wanted.size) break;
293
+ }
294
+ return ranked.map((path) => ({ path, kind: kinds.get(path) ?? "file" }));
295
+ }
296
+ /**
297
+ * Candidates from the remote directory this conversation added with 「添加目录」, when there is one.
298
+ *
299
+ * `undefined` (not `[]`) means "this session added nothing remote" — the caller then answers with the
300
+ * cwd's own candidates alone, exactly as before ADR-0013.
301
+ */
302
+ async #addedRemoteCandidates(cwd, identity, query, signal) {
303
+ const sessionRoots = this.ctx.sshDevSessionRoots;
304
+ if (sessionRoots === void 0) return void 0;
305
+ const outcome = sessionRoots.outcomeFor(cwd, identity);
306
+ if (!outcome.ok) return void 0;
307
+ return await this.#remoteCandidates(outcome.target.connectionId, outcome.target.root, query, signal);
308
+ }
309
+ /** Remote first, then the cwd's own candidates; de-duplicated by path and bounded by the result cap. */
310
+ #merge(added, named) {
311
+ const seen = /* @__PURE__ */ new Set();
312
+ const merged = [];
313
+ for (const candidate of [...added ?? [], ...named]) {
314
+ if (seen.has(candidate.path)) continue;
315
+ seen.add(candidate.path);
316
+ merged.push(candidate);
317
+ }
318
+ return merged.slice(0, this.#options.maxResults ?? DEFAULT_MAX_RESULTS);
319
+ }
320
+ /**
321
+ * Enumerate a local root, reusing a very recent walk.
322
+ *
323
+ * The **ranking** still runs on every call — only the walk is reused. That matters: the query changes on
324
+ * every keystroke, so caching the ranked result would answer the wrong question; the expensive, query-
325
+ * independent part is the tree walk, and that is the only thing cached.
326
+ *
327
+ * @param root - the local root to enumerate.
328
+ * @param walk - the enumerator (injected in tests).
329
+ * @param signal - caller cancellation; **not** cached, because an aborted walk must stay aborted.
330
+ * @returns the entries, from cache when the previous walk is still fresh.
331
+ */
332
+ async #walkCached(root, walk, signal) {
333
+ const started = this.#now();
334
+ const cached = this.#localCache.get(root);
335
+ if (cached !== void 0) {
336
+ if (started - cached.at <= this.#localTtlMs) {
337
+ this.#localCache.delete(root);
338
+ this.#localCache.set(root, cached);
339
+ return cached.entries;
340
+ }
341
+ this.#localCache.delete(root);
342
+ }
343
+ const entries = await walk(root, signal);
344
+ signal.throwIfAborted();
345
+ this.#rememberLocal(root, entries, started);
346
+ return entries;
347
+ }
348
+ /** Store one local enumeration, evicting the least recently used root past the cap. */
349
+ #rememberLocal(root, entries, at) {
350
+ this.#localCache.set(root, { entries, at });
351
+ while (this.#localCache.size > this.#maxCachedRoots) {
352
+ const oldest = this.#localCache.keys().next();
353
+ if (oldest.done === true) break;
354
+ this.#localCache.delete(oldest.value);
355
+ }
356
+ }
357
+ /** Bounded breadth-first walk over a local root, excluding the usual noise directories. */
358
+ async #walkLocal(root, signal) {
359
+ const maxEntries = this.#options.maxEntries ?? LOCAL_MAX_ENTRIES;
360
+ const maxDepth = this.#options.maxDepth ?? LOCAL_MAX_DEPTH;
361
+ const found = [];
362
+ let frontier = [{ path: root, depth: 0 }];
363
+ while (frontier.length > 0 && found.length < maxEntries) {
364
+ signal.throwIfAborted();
365
+ const next = [];
366
+ for (const entry of frontier) {
367
+ if (found.length >= maxEntries) break;
368
+ if (entry.depth >= maxDepth) continue;
369
+ let children;
370
+ try {
371
+ children = await readdir(entry.path, { withFileTypes: true });
372
+ } catch {
373
+ continue;
374
+ }
375
+ for (const child of children) {
376
+ if (found.length >= maxEntries) break;
377
+ const isDirectory = child.isDirectory();
378
+ if (isDirectory && LOCAL_EXCLUDED_DIRECTORIES.includes(child.name)) continue;
379
+ const path = join(entry.path, child.name);
380
+ found.push({ path, kind: isDirectory ? "directory" : "file" });
381
+ if (isDirectory) next.push({ path, depth: entry.depth + 1 });
382
+ }
383
+ }
384
+ frontier = next;
385
+ }
386
+ return found;
387
+ }
388
+ };
389
+ function sessionLocalRoot(cwd) {
390
+ if (cwd === "" || !isAbsoluteLocalPath(cwd)) return void 0;
391
+ return makeLocalRoot(cwd);
392
+ }
393
+
394
+ // src/file-references.ts
395
+ function apply(ctx) {
396
+ if (ctx.get("fileReferences") !== void 0) {
397
+ ctx.logger?.warn?.(
398
+ "file-references: ctx.fileReferences \u5DF2\u88AB\u5360\u7528\uFF08\u6838\u5FC3 file-reference-local \u4ECD\u5728\uFF09\uFF0C\u672C\u6B21\u8DF3\u8FC7\u63A5\u7BA1\uFF1B\u8981\u63A5\u7BA1\u8BF7\u540C\u65F6\u7981\u7528\u6838\u5FC3\u884C\uFF08cordis.patch.yml \u9636\u6BB5 2\uFF09"
399
+ );
400
+ return void 0;
401
+ }
402
+ return ctx.plugin(RemoteFileReferenceService);
403
+ }
404
+ export {
405
+ DEFAULT_MAX_RESULTS,
406
+ LOCAL_EXCLUDED_DIRECTORIES,
407
+ LOCAL_MAX_DEPTH,
408
+ LOCAL_MAX_ENTRIES,
409
+ RemoteFileReferenceService,
410
+ apply as default
411
+ };