dsh-remote 0.8.21 → 0.8.23

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.
@@ -0,0 +1,457 @@
1
+ // dsh-remote — remote-backed `@file` completion (issue #39).
2
+ //
3
+ // ## Why this module exists
4
+ //
5
+ // The harness resolves `@` candidates through `ctx.fileReferences`. Its only
6
+ // shipped provider (`@deepseek-ai/dsh-file-reference-local`) indexes the
7
+ // AGENT SESSION's cwd on the LOCAL filesystem. A dsh-remote session bound to a
8
+ // remote workspace has a cwd that is the local MIRROR directory — which
9
+ // `ensureMirror()` creates EMPTY (files only appear once the user syncs) — so
10
+ // `@` listed nothing at all in a remote session.
11
+ //
12
+ // The seam's own README names this gap: "other namespaces (remote or virtual
13
+ // filesystems) need a provider whose discovery matches the effective tools".
14
+ // `ctx.fileReferences` is a single-owner service, so a second provider cannot
15
+ // be mounted next to the local one. This module therefore WRAPS
16
+ // `fileReferences.list`: a call whose agent is bound to a remote workspace is
17
+ // answered from the remote host over SFTP; every other call is delegated to the
18
+ // original implementation, untouched.
19
+ //
20
+ // ## Contract
21
+ //
22
+ // Candidates are workspace-RELATIVE, POSIX-separated paths (`src/main.c`) —
23
+ // the seam's documented shape and the same shape the local provider returns.
24
+ // Only paths are ever read: file contents stay behind `rw_read_file` / the
25
+ // mirror, exactly like the local provider.
26
+ //
27
+ // ## Cost control
28
+ //
29
+ // A bare (slash-free) query searches the whole remote tree, so the traversal is
30
+ // bounded four ways — entry budget, directory budget, wall-clock deadline and a
31
+ // per-root index cache with stale-while-revalidate. A remote listing failure
32
+ // opens a short circuit breaker for that root so a broken/unauthenticated host
33
+ // cannot make every keystroke wait for an SSH connect timeout.
34
+
35
+ /** Directory basenames skipped by the remote traversal (mirrors the local
36
+ * provider's default list, so both namespaces hide the same noise). */
37
+ export const DEFAULT_EXCLUDED_DIRECTORIES = [
38
+ '.git',
39
+ 'node_modules',
40
+ 'dist',
41
+ 'build',
42
+ 'out',
43
+ 'coverage',
44
+ 'target',
45
+ '.next',
46
+ '.nuxt',
47
+ '.turbo',
48
+ '.venv',
49
+ '__pycache__',
50
+ '.pytest_cache',
51
+ '.mypy_cache',
52
+ '.gradle',
53
+ ]
54
+
55
+ /** Maximum candidates rendered for one query (matches the local default). */
56
+ export const DEFAULT_MAX_RESULTS = 20
57
+ /** Maximum entries retained in the remote index of one workspace. */
58
+ export const DEFAULT_MAX_ENTRIES = 3000
59
+ /** Maximum remote directories visited while building one index. */
60
+ export const DEFAULT_MAX_DIRS = 300
61
+ /** Wall-clock budget for one indexing pass; on expiry the partial index is used. */
62
+ export const DEFAULT_TIMEOUT_MS = 4000
63
+ /** How long a built index is considered fresh. */
64
+ export const DEFAULT_CACHE_TTL_MS = 10000
65
+ /** Cooldown after a failed remote listing, during which local results are used. */
66
+ export const DEFAULT_FAILURE_COOLDOWN_MS = 30000
67
+
68
+ /**
69
+ * Rank candidates for a query. Ported from `@deepseek-ai/dsh-file-reference-local`
70
+ * (same author, MIT) so a remote listing orders exactly like a local one.
71
+ * @param {Array<{path: string, kind: string}>} candidates
72
+ * @param {string} query
73
+ * @param {number} limit
74
+ * @returns {Array<{path: string, kind: string}>}
75
+ */
76
+ export function rankCandidates(candidates, query, limit) {
77
+ const ranked = []
78
+ for (const candidate of candidates) {
79
+ const score = scoreCandidate(candidate, query)
80
+ if (score !== undefined) ranked.push({ candidate, score })
81
+ }
82
+ ranked.sort((left, right) =>
83
+ right.score - left.score
84
+ || kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
85
+ || (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
86
+ || compareText(left.candidate.path, right.candidate.path))
87
+ return ranked.slice(0, limit).map((entry) => entry.candidate)
88
+ }
89
+
90
+ function scoreCandidate(candidate, query) {
91
+ if (query === '') return 0
92
+ const path = candidate.path.toLowerCase()
93
+ const name = path.slice(path.lastIndexOf('/') + 1)
94
+ const needle = query.toLowerCase()
95
+ const directoryBonus = candidate.kind === 'directory' ? 25 : 0
96
+ if (name === needle) return 1000 + directoryBonus
97
+ if (name.startsWith(needle)) return 900 + directoryBonus
98
+ if (name.includes(needle)) return 700 + directoryBonus
99
+ if (path.includes(needle)) return 500 + directoryBonus
100
+ const subsequence = subsequenceScore(path, needle)
101
+ return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
102
+ }
103
+
104
+ function subsequenceScore(target, query) {
105
+ let targetIndex = 0
106
+ let gap = 0
107
+ for (const character of query) {
108
+ const found = target.indexOf(character, targetIndex)
109
+ if (found < 0) return undefined
110
+ gap += found - targetIndex
111
+ targetIndex = found + 1
112
+ }
113
+ return Math.max(0, 100 - gap)
114
+ }
115
+
116
+ function kindRank(kind) {
117
+ return kind === 'directory' ? 0 : 1
118
+ }
119
+
120
+ function compareText(left, right) {
121
+ return left < right ? -1 : left > right ? 1 : 0
122
+ }
123
+
124
+ /** A dot-prefixed path segment stays out of a bare (no-slash) fuzzy query
125
+ * unless the user asked for hidden entries explicitly. */
126
+ function visibleForGlobalQuery(path, query) {
127
+ if (query.startsWith('.') || query.includes('/.')) return true
128
+ return !path.split('/').some((segment) => segment.startsWith('.'))
129
+ }
130
+
131
+ /** Whether a workspace-relative directory is safe to resolve: no `..` segment
132
+ * (which would escape the remote workspace root) and no absolute prefix. */
133
+ export function isSafeRelativeDir(rel) {
134
+ const s = String(rel || '')
135
+ if (s.startsWith('/') || /^[a-zA-Z]:/.test(s)) return false
136
+ return !s.split('/').some((segment) => segment === '..')
137
+ }
138
+
139
+ /**
140
+ * Cancellable, cached remote-tree index rooted at one remote workspace.
141
+ *
142
+ * `listDir(relDir)` is injected: it returns the direct children of a
143
+ * workspace-relative directory ('' = the workspace root) as
144
+ * `{ name, kind: 'file'|'directory' }` and throws when the host/directory is
145
+ * unreachable. Everything else here is pure logic, which is what the unit tests
146
+ * exercise without an SSH server.
147
+ */
148
+ export class RemoteWorkspaceIndex {
149
+ /**
150
+ * @param {object} opts
151
+ * @param {(relDir: string) => Promise<Array<{name: string, kind: string}>>} opts.listDir
152
+ * @param {number} [opts.maxResults]
153
+ * @param {number} [opts.maxEntries]
154
+ * @param {number} [opts.maxDirs]
155
+ * @param {number} [opts.timeoutMs]
156
+ * @param {number} [opts.cacheTtlMs]
157
+ * @param {string[]} [opts.excludedDirectories]
158
+ * @param {(err: unknown) => void} [opts.onError]
159
+ * @param {() => number} [opts.now]
160
+ */
161
+ constructor({
162
+ listDir,
163
+ maxResults = DEFAULT_MAX_RESULTS,
164
+ maxEntries = DEFAULT_MAX_ENTRIES,
165
+ maxDirs = DEFAULT_MAX_DIRS,
166
+ timeoutMs = DEFAULT_TIMEOUT_MS,
167
+ cacheTtlMs = DEFAULT_CACHE_TTL_MS,
168
+ excludedDirectories = DEFAULT_EXCLUDED_DIRECTORIES,
169
+ onError,
170
+ now = () => Date.now(),
171
+ }) {
172
+ this.listDir = listDir
173
+ this.maxResults = maxResults
174
+ this.maxEntries = maxEntries
175
+ this.maxDirs = maxDirs
176
+ this.timeoutMs = timeoutMs
177
+ this.cacheTtlMs = cacheTtlMs
178
+ this.excluded = new Set(excludedDirectories)
179
+ this.onError = onError
180
+ this.now = now
181
+ /** Settled index plus the time its traversal started. */
182
+ this.settled = undefined
183
+ /** Single-flight traversal. */
184
+ this.generation = undefined
185
+ /** Monotonic invalidation counter; a settled index below it is stale. */
186
+ this.invalidations = 0
187
+ this.disposed = false
188
+ /** True once a traversal stopped early on a budget instead of finishing. */
189
+ this.truncated = false
190
+ }
191
+
192
+ /**
193
+ * Candidates for the active `@` token.
194
+ * @param {string} rawQuery - path text after `@` (or `@"`).
195
+ * @param {AbortSignal} [signal] - cancels this caller's wait, not a shared traversal.
196
+ */
197
+ async list(rawQuery, signal) {
198
+ throwIfAborted(signal)
199
+ if (this.disposed) return []
200
+ const query = String(rawQuery ?? '').replaceAll('\\', '/')
201
+ const slash = query.lastIndexOf('/')
202
+ if (query === '' || slash >= 0) {
203
+ const directory = slash < 0 ? '' : query.slice(0, slash + 1)
204
+ const fragment = slash < 0 ? '' : query.slice(slash + 1)
205
+ return this.listDirectory(directory, fragment, signal)
206
+ }
207
+ const entries = await this.indexFor(signal)
208
+ throwIfAborted(signal)
209
+ return rankCandidates(entries.filter((candidate) => visibleForGlobalQuery(candidate.path, query)), query, this.maxResults)
210
+ }
211
+
212
+ /** Mark the settled index stale: the next bare query rebuilds it. */
213
+ invalidate() {
214
+ if (this.disposed) return
215
+ this.invalidations += 1
216
+ }
217
+
218
+ /** Abort the traversal and make later queries return nothing. */
219
+ dispose() {
220
+ if (this.disposed) return
221
+ this.disposed = true
222
+ if (this.generation) this.generation.controller.abort(new Error('remote file-reference index disposed'))
223
+ this.generation = undefined
224
+ this.settled = undefined
225
+ }
226
+
227
+ /** Direct children of one workspace-relative directory. */
228
+ async listDirectory(displayDirectory, fragment, signal) {
229
+ throwIfAborted(signal)
230
+ const rel = displayDirectory.replace(/\/+$/, '')
231
+ if (!isSafeRelativeDir(rel)) return []
232
+ if (rel.split('/').some((segment) => this.excluded.has(segment))) return []
233
+ const items = await this.readDir(rel)
234
+ throwIfAborted(signal)
235
+ const candidates = []
236
+ for (const item of items) {
237
+ const name = String(item.name || '')
238
+ if (!name || name === '.' || name === '..') continue
239
+ if (name.startsWith('.') && !String(fragment).startsWith('.')) continue
240
+ const kind = item.kind === 'directory' ? 'directory' : 'file'
241
+ if (kind === 'directory' && this.excluded.has(name)) continue
242
+ candidates.push({ path: `${displayDirectory}${name}`, kind })
243
+ }
244
+ return rankCandidates(candidates, fragment, this.maxResults)
245
+ }
246
+
247
+ /** Entries a bare fuzzy query ranks. Only the first query of a workspace
248
+ * waits for the traversal; afterwards a stale index answers immediately and
249
+ * its replacement builds behind the caret. Staleness is either an explicit
250
+ * invalidation (a tool result changed the tree) or the cache TTL. */
251
+ async indexFor(signal) {
252
+ const settled = this.settled
253
+ if (settled === undefined) return waitFor(this.ensureIndex(), signal)
254
+ if (settled.version < this.invalidations || this.now() - settled.builtAt > this.cacheTtlMs) {
255
+ this.ensureIndex().catch(() => {})
256
+ }
257
+ return settled.entries
258
+ }
259
+
260
+ ensureIndex() {
261
+ if (this.disposed) return Promise.resolve([])
262
+ if (this.generation !== undefined) return this.generation.promise
263
+ const controller = new AbortController()
264
+ const version = this.invalidations
265
+ const generation = { controller, promise: Promise.resolve([]) }
266
+ generation.promise = this.scan(controller.signal).then((entries) => {
267
+ if (this.disposed) return entries
268
+ this.generation = undefined
269
+ this.settled = { entries, version, builtAt: this.now() }
270
+ return entries
271
+ }, (error) => {
272
+ if (this.generation === generation) this.generation = undefined
273
+ throw error
274
+ })
275
+ this.generation = generation
276
+ return generation.promise
277
+ }
278
+
279
+ /** Breadth-first bounded traversal of the remote tree. */
280
+ async scan(signal) {
281
+ const entries = []
282
+ const deadline = this.now() + this.timeoutMs
283
+ const directories = ['']
284
+ let visited = 0
285
+ for (let cursor = 0; cursor < directories.length; cursor += 1) {
286
+ throwIfAborted(signal)
287
+ if (entries.length >= this.maxEntries || visited >= this.maxDirs) { this.truncated = true; break }
288
+ if (this.now() > deadline) { this.truncated = true; break }
289
+ const rel = directories[cursor]
290
+ visited += 1
291
+ let items
292
+ try {
293
+ items = await this.readDir(rel)
294
+ } catch (err) {
295
+ // An unreachable root is a connection problem, not a missing subtree:
296
+ // surface it so the caller can open the circuit breaker.
297
+ if (rel === '') throw err
298
+ this.onError?.(err)
299
+ continue
300
+ }
301
+ for (const item of items) {
302
+ throwIfAborted(signal)
303
+ const name = String(item.name || '')
304
+ if (!name || name === '.' || name === '..') continue
305
+ const path = rel === '' ? name : `${rel}/${name}`
306
+ if (item.kind === 'directory') {
307
+ if (this.excluded.has(name)) continue
308
+ entries.push({ path, kind: 'directory' })
309
+ directories.push(path)
310
+ } else {
311
+ entries.push({ path, kind: 'file' })
312
+ }
313
+ if (entries.length >= this.maxEntries) { this.truncated = true; break }
314
+ }
315
+ }
316
+ return entries
317
+ }
318
+
319
+ async readDir(rel) {
320
+ const items = await this.listDir(rel)
321
+ return Array.isArray(items) ? items : []
322
+ }
323
+ }
324
+
325
+ function throwIfAborted(signal) {
326
+ if (signal && signal.aborted) throw abortError(signal.reason)
327
+ }
328
+
329
+ function abortError(reason) {
330
+ if (reason instanceof Error) return reason
331
+ const err = new Error('file-reference query aborted')
332
+ err.name = 'AbortError'
333
+ return err
334
+ }
335
+
336
+ function waitFor(promise, signal) {
337
+ if (!signal) return promise
338
+ if (signal.aborted) return Promise.reject(abortError(signal.reason))
339
+ return new Promise((resolvePromise, rejectPromise) => {
340
+ const onAbort = () => rejectPromise(abortError(signal.reason))
341
+ signal.addEventListener('abort', onAbort, { once: true })
342
+ promise.then(
343
+ (value) => { signal.removeEventListener('abort', onAbort); resolvePromise(value) },
344
+ (error) => { signal.removeEventListener('abort', onAbort); rejectPromise(error) },
345
+ )
346
+ })
347
+ }
348
+
349
+ /**
350
+ * One remote workspace's discovery provider, with the failure circuit breaker
351
+ * that keeps a broken host from stalling every keystroke.
352
+ *
353
+ * `list()` never rejects: it returns `null` when the breaker is open or the
354
+ * remote listing failed, which tells the overlay to fall back to the original
355
+ * (local) provider instead of showing an empty list.
356
+ */
357
+ export function createRemoteFileReference({ listDir, config = {}, onError, now }) {
358
+ const index = new RemoteWorkspaceIndex({ listDir, ...config, onError, now })
359
+ const clock = now || (() => Date.now())
360
+ const cooldownMs = config.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS
361
+ let downUntil = 0
362
+ return {
363
+ root: config.root || '',
364
+ /** False while the last failure is still cooling down. */
365
+ ready: () => clock() >= downUntil,
366
+ /** Called when the remote listing fails; opens the cooldown window. */
367
+ markDown: () => { downUntil = clock() + cooldownMs },
368
+ async list(query, signal) {
369
+ try {
370
+ return await index.list(query, signal)
371
+ } catch (err) {
372
+ if (signal && signal.aborted) throw err
373
+ onError?.(err)
374
+ return null
375
+ }
376
+ },
377
+ invalidate: () => index.invalidate(),
378
+ dispose: () => index.dispose(),
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Wrap `ctx.fileReferences.list` so remote-bound sessions are served from the
384
+ * remote host.
385
+ *
386
+ * The seam is a single-owner service, so this is an overlay rather than a
387
+ * second provider: `resolve(agent)` returns a remote provider for an agent whose
388
+ * workspace is a dsh-remote mirror, and `null` for every other agent (including
389
+ * a remote agent whose host is currently unavailable) — in which case the
390
+ * original implementation answers with the local view.
391
+ *
392
+ * @param {object} ctx - plugin context.
393
+ * @param {object} opts
394
+ * @param {(agent: object) => ({list: Function, ready: Function, markDown: Function}|null)} opts.resolve
395
+ * @param {(err: unknown) => void} [opts.onError]
396
+ * @returns {{dispose: () => void}|undefined} teardown handle, when the seam exists.
397
+ */
398
+ export function installFileReferenceOverlay(ctx, { resolve, onError } = {}) {
399
+ if (!ctx || typeof ctx.inject !== 'function' || typeof resolve !== 'function') return undefined
400
+ const report = (err) => { try { onError?.(err) } catch { /* a logging hook must never break completion */ } }
401
+ let restore = null
402
+ const fiber = ctx.inject(['fileReferences'], (inner) => {
403
+ const service = inner.fileReferences
404
+ if (!service || typeof service.list !== 'function') return
405
+ const original = service.list
406
+ const patched = function (agent, query, signal) {
407
+ let remote = null
408
+ try {
409
+ remote = resolve(agent)
410
+ } catch (err) {
411
+ report(err)
412
+ remote = null
413
+ }
414
+ // Fall back to the local provider whenever this agent is not a remote
415
+ // session, the feature is off, or the host is cooling down after a
416
+ // failure: showing the synced mirror beats showing nothing.
417
+ if (!remote || (typeof remote.ready === 'function' && !remote.ready())) {
418
+ return original.call(service, agent, query, signal)
419
+ }
420
+ const fallback = () => original.call(service, agent, query, signal)
421
+ try {
422
+ return Promise.resolve(remote.list(String(query ?? ''), signal)).then(
423
+ (candidates) => {
424
+ if (candidates === null) {
425
+ remote.markDown?.()
426
+ return fallback()
427
+ }
428
+ return candidates
429
+ },
430
+ (err) => {
431
+ // An aborted keystroke is not a host failure: don't open the breaker.
432
+ if (signal && signal.aborted) throw err
433
+ report(err)
434
+ remote.markDown?.()
435
+ return fallback()
436
+ },
437
+ )
438
+ } catch (err) {
439
+ report(err)
440
+ remote.markDown?.()
441
+ return fallback()
442
+ }
443
+ }
444
+ service.list = patched
445
+ restore = () => { if (service.list === patched) service.list = original }
446
+ try {
447
+ inner.effect(() => () => { if (restore) restore() }, 'dsh-remote: file-reference overlay')
448
+ } catch { /* no effect registry: the returned handle still unpatches */ }
449
+ })
450
+ return {
451
+ dispose() {
452
+ try { if (restore) restore() } catch { /* already restored */ }
453
+ restore = null
454
+ try { fiber?.dispose?.() } catch { /* fiber already torn down */ }
455
+ },
456
+ }
457
+ }