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.
- package/README.md +28 -1
- package/README.zh.md +26 -1
- package/lib/binding.js +9 -3
- package/lib/client.js +275 -56
- package/lib/file-reference.js +457 -0
- package/lib/index.js +456 -84
- package/lib/pool.js +49 -18
- package/lib/routes-fs.js +14 -2
- package/lib/sshconfig.js +415 -18
- package/package.json +1 -1
|
@@ -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
|
+
}
|