dsh-remote 0.8.51 → 0.8.53
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.en.md +11 -0
- package/README.md +43 -0
- package/lib/client.js +15 -3
- package/lib/exec-job.js +221 -0
- package/lib/index.js +349 -18
- package/lib/instructions-dynamic.js +336 -0
- package/lib/instructions.js +288 -0
- package/lib/openssh.js +10 -1
- package/lib/pool.js +37 -1
- package/lib/remote-fs.js +26 -1
- package/lib/sftp-surface.js +46 -3
- package/lib/sshconfig.js +13 -3
- package/package.json +1 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// dsh-remote — dynamic remote instruction injection (issue #71).
|
|
2
|
+
//
|
|
3
|
+
// Issue #68 gave a remote workspace a STATIC instruction index: the workspace
|
|
4
|
+
// is shallow-scanned at pick/sync time, the files are mirrored locally, and the
|
|
5
|
+
// prompt lists them. What remained missing is the DYNAMIC half the core does
|
|
6
|
+
// for local sessions — three gaps, all plugin-side fixable:
|
|
7
|
+
//
|
|
8
|
+
// 1. The mirrored copies never refreshed, so a mid-session edit to a remote
|
|
9
|
+
// AGENTS.md was invisible until a manual rw_sync.
|
|
10
|
+
// 2. The core's incremental injection only recognizes read/write/edit with a
|
|
11
|
+
// `file_path` argument. `rw_read_file` is named differently AND takes
|
|
12
|
+
// `path`, so a remote file touch never registered.
|
|
13
|
+
// 3. The core expands scopes from cwd to the TOUCHED path — both local. A
|
|
14
|
+
// remote absolute path (`/root/proj/x.md`) can never map onto a local
|
|
15
|
+
// mirror cwd, so even a "touch" would expand nothing.
|
|
16
|
+
//
|
|
17
|
+
// This module closes all three without a single line of core change:
|
|
18
|
+
// • `noteTouchedPath` turns an rw_* file touch into the FULL ancestor chain
|
|
19
|
+
// below the workspace root (the same shape core's descendantDirsBetween
|
|
20
|
+
// produces — every level, not just the deepest);
|
|
21
|
+
// • `refreshTouched` stats each level's AGENTS.md/CLAUDE.md, pulls the ones
|
|
22
|
+
// that changed, and returns the chain's current contents;
|
|
23
|
+
// • `buildInstructionMessage` renders that into ONE user message whose source
|
|
24
|
+
// is our OWN kind, so the core's `agent-instructions` state machine never
|
|
25
|
+
// confuses it for its own (that is what keeps the two paths from fighting).
|
|
26
|
+
//
|
|
27
|
+
// Everything here is best-effort and synchronous-safe: an offline machine must
|
|
28
|
+
// never hang a tool call or a prompt assembly, so every remote step is guarded
|
|
29
|
+
// and every caller is expected to treat a rejection as "no injection".
|
|
30
|
+
|
|
31
|
+
import { statSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
import { joinRemotePath, relPathUnder } from './paths.js'
|
|
35
|
+
import { INSTRUCTION_MAX_BYTES } from './instructions.js'
|
|
36
|
+
|
|
37
|
+
/** The message source kind this plugin owns. Deliberately NOT the core's
|
|
38
|
+
* `agent-instructions`: consumers fall through unknown kinds, and the core's
|
|
39
|
+
* reconcile only claims messages carrying its own kind. */
|
|
40
|
+
export const REMOTE_INSTRUCTIONS_KIND = 'dsh-remote-instructions'
|
|
41
|
+
|
|
42
|
+
/** Total byte budget for one injected batch (the core's own budget is similar). */
|
|
43
|
+
export const INSTRUCTION_MESSAGE_MAX_BYTES = 16 * 1024
|
|
44
|
+
|
|
45
|
+
/** Per-session throttle for the stat-and-refresh sweep, in milliseconds. */
|
|
46
|
+
export const REFRESH_THROTTLE_MS = 30 * 1000
|
|
47
|
+
|
|
48
|
+
const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split a remote path into the ancestor directories strictly BELOW `root`,
|
|
52
|
+
* root-excluded — the core's `ancestorChain(cwd, dir).slice(1)` shape.
|
|
53
|
+
*
|
|
54
|
+
* Reading `<root>/a/b/c.ts` yields `['a', 'a/b']`, so `a/AGENTS.md` and
|
|
55
|
+
* `a/b/AGENTS.md` are both in scope while `<root>/AGENTS.md` (the baseline
|
|
56
|
+
* layer) is not re-scanned. A path at or above the root yields `[]`.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} root - the workspace root (remote absolute path).
|
|
59
|
+
* @param {string} touched - the touched remote path.
|
|
60
|
+
* @returns {string[]} workspace-relative directory prefixes, shallow → deep.
|
|
61
|
+
*/
|
|
62
|
+
export function descendantDirs(root, touched) {
|
|
63
|
+
const rootRel = relPathUnder(String(root || ''), String(touched || ''))
|
|
64
|
+
if (!rootRel) return []
|
|
65
|
+
const parts = rootRel.split('/').filter(Boolean)
|
|
66
|
+
// The last part is the file itself; the chain stops at its directory.
|
|
67
|
+
parts.pop()
|
|
68
|
+
const out = []
|
|
69
|
+
let cur = ''
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
cur = cur ? `${cur}/${part}` : part
|
|
72
|
+
out.push(cur)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which instruction files a touch puts in scope, as workspace-relative paths:
|
|
79
|
+
* each directory in the chain contributes `AGENTS.md` and `CLAUDE.md`.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} root - workspace root.
|
|
82
|
+
* @param {string} touched - touched path.
|
|
83
|
+
* @param {number} depth - how many trailing chain levels to include (0 = none,
|
|
84
|
+
* which is what "touched a file directly in the root" means).
|
|
85
|
+
* @returns {string[]} candidate relative paths.
|
|
86
|
+
*/
|
|
87
|
+
export function instructionCandidates(root, touched, depth = Infinity) {
|
|
88
|
+
const dirs = descendantDirs(root, touched)
|
|
89
|
+
// `depth: 0` means "no chain level" (the touched file sits directly in the
|
|
90
|
+
// root): `slice(-0)` would return the WHOLE array, so this needs its own branch.
|
|
91
|
+
const chain = depth === 0 ? [] : (Number.isFinite(depth) ? dirs.slice(-depth) : dirs)
|
|
92
|
+
const out = []
|
|
93
|
+
for (const dir of chain) {
|
|
94
|
+
out.push(`${dir}/AGENTS.md`, `${dir}/CLAUDE.md`)
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A digest of an instruction's content, used to inject a given version once.
|
|
101
|
+
* Size + mtime is not enough: a same-size edit inside one second would slip
|
|
102
|
+
* through, and a spurious re-injection costs prompt budget.
|
|
103
|
+
*
|
|
104
|
+
* @param {Buffer|string} content
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function contentDigest(content) {
|
|
108
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8')
|
|
109
|
+
// A 16-char prefix is plenty for "did this text change" within one session,
|
|
110
|
+
// and keeps the tracker's maps small.
|
|
111
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 16)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-session injection state: which (remote path → digest) pairs have already
|
|
116
|
+
* been delivered, plus the last sweep time for throttling.
|
|
117
|
+
*
|
|
118
|
+
* @returns {object} a tracker with `shouldInject`, `markInjected`, `removed`, and `throttled`.
|
|
119
|
+
*/
|
|
120
|
+
export function createInjectionTracker({ throttleMs = REFRESH_THROTTLE_MS } = {}) {
|
|
121
|
+
/** sessionId → { injected: Map<relPath, digest>, lastSweep: number } */
|
|
122
|
+
const sessions = new Map()
|
|
123
|
+
const stateFor = (sessionId) => {
|
|
124
|
+
const key = String(sessionId || 'unscoped')
|
|
125
|
+
let s = sessions.get(key)
|
|
126
|
+
if (!s) { s = { injected: new Map(), lastSweep: 0 }; sessions.set(key, s) }
|
|
127
|
+
return s
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
/** Whether a sweep may run now (the throttle gate). */
|
|
131
|
+
throttled(sessionId, now = Date.now()) {
|
|
132
|
+
const s = stateFor(sessionId)
|
|
133
|
+
return now - s.lastSweep < throttleMs
|
|
134
|
+
},
|
|
135
|
+
/** Record that a sweep happened now. */
|
|
136
|
+
swept(sessionId, now = Date.now()) {
|
|
137
|
+
stateFor(sessionId).lastSweep = now
|
|
138
|
+
},
|
|
139
|
+
/** Whether this exact content has NOT been delivered to this session yet. */
|
|
140
|
+
shouldInject(sessionId, rel, digest) {
|
|
141
|
+
return stateFor(sessionId).injected.get(rel) !== digest
|
|
142
|
+
},
|
|
143
|
+
/** Mark one file's content as delivered. */
|
|
144
|
+
markInjected(sessionId, rel, digest) {
|
|
145
|
+
stateFor(sessionId).injected.set(rel, digest)
|
|
146
|
+
},
|
|
147
|
+
/** Every rel path this session has been given, for scoped reconciliation. */
|
|
148
|
+
injectedRels(sessionId) {
|
|
149
|
+
return stateFor(sessionId).injected.keys()
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Files injected before that are gone from a sweep LIMITED to `covers`.
|
|
153
|
+
*
|
|
154
|
+
* Scoping matters in both directions: a file outside the swept chain was
|
|
155
|
+
* never examined, so it must keep its record (dropping it would re-inject
|
|
156
|
+
* live rules later and, worse, could report a live rule as removed).
|
|
157
|
+
*
|
|
158
|
+
* @param {string} sessionId
|
|
159
|
+
* @param {Set<string>} present - rel paths the sweep actually found.
|
|
160
|
+
* @param {(rel: string) => boolean} covers - whether the sweep covered a rel.
|
|
161
|
+
* @returns {string[]} rel paths to report as removed.
|
|
162
|
+
*/
|
|
163
|
+
removedInScope(sessionId, present, covers) {
|
|
164
|
+
const s = stateFor(sessionId)
|
|
165
|
+
const out = []
|
|
166
|
+
for (const rel of [...s.injected.keys()]) {
|
|
167
|
+
if (typeof covers === 'function' && !covers(rel)) continue
|
|
168
|
+
if (present.has(rel)) continue
|
|
169
|
+
out.push(rel)
|
|
170
|
+
s.injected.delete(rel)
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Files that were injected before but no longer exist remotely — the model
|
|
176
|
+
* must be told, or it keeps following rules that were deleted.
|
|
177
|
+
* @returns {string[]} rel paths to report as removed.
|
|
178
|
+
*/
|
|
179
|
+
removed(sessionId, stillPresent) {
|
|
180
|
+
return this.removedInScope(sessionId, stillPresent, null)
|
|
181
|
+
},
|
|
182
|
+
/** Forget a session entirely (its cwd changed, or it went away). */
|
|
183
|
+
reset(sessionId) {
|
|
184
|
+
sessions.delete(String(sessionId || 'unscoped'))
|
|
185
|
+
},
|
|
186
|
+
/** Every session currently tracked (diagnostics/tests). */
|
|
187
|
+
sessions() {
|
|
188
|
+
return [...sessions.keys()]
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fetch the CURRENT content of one instruction candidate from the remote and
|
|
195
|
+
* mirror it locally, three-way safe (a local edit is never clobbered).
|
|
196
|
+
*
|
|
197
|
+
* Returns null when the file does not exist remotely — that is a normal answer,
|
|
198
|
+
* not an error, since most directories carry no instruction file.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} sftp - promisified sftp surface.
|
|
201
|
+
* @param {string} remoteRoot - workspace root.
|
|
202
|
+
* @param {string} rel - workspace-relative candidate path.
|
|
203
|
+
* @param {string} mirrorDir - local mirror root.
|
|
204
|
+
* @returns {Promise<{rel: string, remote: string, content: string, digest: string, mirrored: boolean}|null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function fetchInstruction(sftp, remoteRoot, rel, mirrorDir) {
|
|
207
|
+
const remote = joinRemotePath(remoteRoot, rel)
|
|
208
|
+
let st
|
|
209
|
+
try { st = await sftp.stat(remote) } catch { return null }
|
|
210
|
+
if (!st || (typeof st.isDirectory === 'function' && st.isDirectory())) return null
|
|
211
|
+
const size = typeof st.size === 'number' ? st.size : 0
|
|
212
|
+
if (size > INSTRUCTION_MAX_BYTES) {
|
|
213
|
+
// Too large to inject whole: report the file, not its text.
|
|
214
|
+
return { rel, remote, content: '', digest: `oversize:${size}`, mirrored: false, oversizeBytes: size }
|
|
215
|
+
}
|
|
216
|
+
let buf
|
|
217
|
+
try { buf = await sftp.readFile(remote) } catch { return null }
|
|
218
|
+
const digest = contentDigest(buf)
|
|
219
|
+
let mirrored = false
|
|
220
|
+
if (mirrorDir) {
|
|
221
|
+
try {
|
|
222
|
+
const lp = path.join(mirrorDir, ...String(rel).split('/'))
|
|
223
|
+
let same = false
|
|
224
|
+
try {
|
|
225
|
+
const lst = statSync(lp)
|
|
226
|
+
same = lst.size === size && keyOf(lst.size, lst.mtimeMs / 1000) === keyOf(size, st.mtime)
|
|
227
|
+
} catch { same = false }
|
|
228
|
+
if (!same) {
|
|
229
|
+
mkdirSync(path.dirname(lp), { recursive: true })
|
|
230
|
+
writeFileSync(lp, buf)
|
|
231
|
+
try { utimesSync(lp, new Date((st.mtime || 0) * 1000), new Date((st.mtime || 0) * 1000)) } catch { /* cosmetic */ }
|
|
232
|
+
}
|
|
233
|
+
mirrored = true
|
|
234
|
+
} catch { /* a mirror failure must not lose the instruction itself */ }
|
|
235
|
+
}
|
|
236
|
+
return { rel, remote, content: buf.toString('utf8'), digest, mirrored }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sweep the ancestor chain of a touched path and return the instructions that
|
|
241
|
+
* should enter the context now.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} arg
|
|
244
|
+
* @param {object} arg.sftp
|
|
245
|
+
* @param {string} arg.remoteRoot - workspace root.
|
|
246
|
+
* @param {string} arg.touched - the touched remote path.
|
|
247
|
+
* @param {string} arg.mirrorDir - local mirror.
|
|
248
|
+
* @param {object} arg.tracker - from {@link createInjectionTracker}.
|
|
249
|
+
* @param {string} arg.sessionId
|
|
250
|
+
* @param {number} [arg.depth] - chain depth to consider (default: whole chain).
|
|
251
|
+
* @returns {Promise<{items: Array, removed: string[]}>} new/changed files and removals.
|
|
252
|
+
*/
|
|
253
|
+
export async function refreshTouched({ sftp, remoteRoot, touched, mirrorDir, tracker, sessionId, depth }) {
|
|
254
|
+
const candidates = instructionCandidates(remoteRoot, touched, depth)
|
|
255
|
+
if (!candidates.length) return { items: [], removed: [] }
|
|
256
|
+
const found = []
|
|
257
|
+
const present = new Set()
|
|
258
|
+
for (const rel of candidates) {
|
|
259
|
+
const got = await fetchInstruction(sftp, remoteRoot, rel, mirrorDir)
|
|
260
|
+
if (!got) continue
|
|
261
|
+
found.push(got)
|
|
262
|
+
present.add(got.rel)
|
|
263
|
+
}
|
|
264
|
+
const items = found.filter((f) => tracker.shouldInject(sessionId, f.rel, f.digest))
|
|
265
|
+
for (const f of items) tracker.markInjected(sessionId, f.rel, f.digest)
|
|
266
|
+
// Removal notices must be scoped to the directories THIS touch covers — both
|
|
267
|
+
// directions matter:
|
|
268
|
+
// • a file that no longer exists anywhere in the covered chain is gone;
|
|
269
|
+
// • a file outside the covered chain was simply never looked at, so its
|
|
270
|
+
// absence from `present` says nothing (and must not delete its record,
|
|
271
|
+
// or the model would be told a live rule vanished).
|
|
272
|
+
const dirs = descendantDirs(remoteRoot, touched)
|
|
273
|
+
if (!dirs.length) return { items, removed: [] }
|
|
274
|
+
const prefixes = dirs.map((d) => d + '/')
|
|
275
|
+
const covers = (rel) => prefixes.includes(rel.slice(0, rel.lastIndexOf('/') + 1))
|
|
276
|
+
// Only files INSIDE the covered chain can be judged by this sweep: a record
|
|
277
|
+
// for some other subtree was simply not examined, so its absence from
|
|
278
|
+
// `present` means nothing there.
|
|
279
|
+
const removed = tracker.removedInScope(sessionId, present, covers)
|
|
280
|
+
return { items, removed }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Render the injection batch into ONE user message.
|
|
285
|
+
*
|
|
286
|
+
* The shape mirrors what the core produces for its own incremental batches —
|
|
287
|
+
* a system-reminder-framed body with per-file sections — so the model reads the
|
|
288
|
+
* two identically while the SOURCE kind keeps them distinguishable.
|
|
289
|
+
*
|
|
290
|
+
* @param {Array} items - from {@link refreshTouched}.
|
|
291
|
+
* @param {string[]} removed - rel paths that no longer exist.
|
|
292
|
+
* @param {object} [opts]
|
|
293
|
+
* @param {number} [opts.maxBytes]
|
|
294
|
+
* @param {(p: string) => string} [opts.display] - path formatter for sections.
|
|
295
|
+
* @param {(text: string, source: object) => object} [opts.createUserMessage] -
|
|
296
|
+
* the core message factory when available.
|
|
297
|
+
* @returns {object|null} a user message, or null when there is nothing to say.
|
|
298
|
+
*/
|
|
299
|
+
export function buildInstructionMessage(items, removed, opts = {}) {
|
|
300
|
+
const maxBytes = Number(opts.maxBytes) || INSTRUCTION_MESSAGE_MAX_BYTES
|
|
301
|
+
const display = typeof opts.display === 'function' ? opts.display : (p) => p
|
|
302
|
+
const factory = typeof opts.createUserMessage === 'function' ? opts.createUserMessage : null
|
|
303
|
+
if ((!items || !items.length) && (!removed || !removed.length)) return null
|
|
304
|
+
const blocks = []
|
|
305
|
+
let used = 0
|
|
306
|
+
const sent = []
|
|
307
|
+
const truncated = []
|
|
308
|
+
for (const item of items || []) {
|
|
309
|
+
const head = item.oversizeBytes
|
|
310
|
+
? `### ${display(item.remote)}\n(too large to inject: ${item.oversizeBytes} bytes — read it with rw_read_file if you need its rules)`
|
|
311
|
+
: `### ${display(item.remote)}\n${item.content}`
|
|
312
|
+
const bytes = Buffer.byteLength(head, 'utf8')
|
|
313
|
+
if (used + bytes > maxBytes) { truncated.push(display(item.remote)); continue }
|
|
314
|
+
used += bytes
|
|
315
|
+
blocks.push(head)
|
|
316
|
+
sent.push(item.rel)
|
|
317
|
+
}
|
|
318
|
+
if (removed && removed.length) {
|
|
319
|
+
const line = `Removed on the remote host (no longer in effect): ${removed.map((r) => display(r)).join(', ')}`
|
|
320
|
+
if (used + Buffer.byteLength(line, 'utf8') <= maxBytes) blocks.push(line)
|
|
321
|
+
}
|
|
322
|
+
if (truncated.length) {
|
|
323
|
+
blocks.push(`Workspace instruction budget ${maxBytes} bytes: truncated ${truncated.join(', ')}`)
|
|
324
|
+
}
|
|
325
|
+
if (!blocks.length) return null
|
|
326
|
+
const text = [
|
|
327
|
+
'<system-reminder>',
|
|
328
|
+
'Remote workspace instructions for the directories you just touched. These are the CURRENT contents from the remote host, delivered because you accessed files under them.',
|
|
329
|
+
'',
|
|
330
|
+
blocks.join('\n\n'),
|
|
331
|
+
'</system-reminder>',
|
|
332
|
+
].join('\n')
|
|
333
|
+
const source = { kind: REMOTE_INSTRUCTIONS_KIND, form: 'instructions', changes: sent.map((rel) => ({ path: rel, change: 'changed' })) }
|
|
334
|
+
const content = [{ type: 'text', text }]
|
|
335
|
+
return factory ? factory({ content, source }) : { role: 'user', content, source }
|
|
336
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// dsh-remote — remote instruction-file index (issue #68).
|
|
2
|
+
//
|
|
3
|
+
// The core instruction loader (@deepseek-ai/dsh-agent-instructions) discovers
|
|
4
|
+
// AGENTS.md through the LOCAL filesystem only: from two user-global roots plus
|
|
5
|
+
// the projectRoot→cwd ancestor chain. A remote session's cwd is the local
|
|
6
|
+
// MIRROR of the remote workspace, and that mirror stays empty until rw_sync —
|
|
7
|
+
// so a 20 KB AGENTS.md that governs the remote repo never enters the model's
|
|
8
|
+
// context, while the same file is auto-injected in a local session. File
|
|
9
|
+
// ACCESS was never the problem (rw_read_file reads it fine); what is missing
|
|
10
|
+
// is the model knowing it should.
|
|
11
|
+
//
|
|
12
|
+
// This module closes the gap on the plugin side, along the two cheapest axes
|
|
13
|
+
// the issue lists (the third — a remote ctx.fs provider — belongs to core):
|
|
14
|
+
//
|
|
15
|
+
// 1. INDEX in the system prompt: a shallow scan of the remote workspace
|
|
16
|
+
// (root + at most two directory levels down, AGENTS.md / CLAUDE.md only)
|
|
17
|
+
// is rendered into the dsh-remote prompt section as a path list with
|
|
18
|
+
// headings, telling every new session the files exist and to read them
|
|
19
|
+
// with rw_read_file before editing that tree. This is an INDEX, not a
|
|
20
|
+
// second injection pipeline — the full text stays out of the prompt and
|
|
21
|
+
// out of any parallel baseline, exactly as the issue demands.
|
|
22
|
+
// 2. MIRROR the found files into the local mirror at their relative paths
|
|
23
|
+
// (three-way safe — a local edit is never clobbered, see below). A
|
|
24
|
+
// root-level AGENTS.md then sits exactly where the CORE loader looks in
|
|
25
|
+
// a local session, so the normal baseline/incremental machinery picks it
|
|
26
|
+
// up unchanged; no plugin-side injection logic runs in parallel with it.
|
|
27
|
+
//
|
|
28
|
+
// Deliberate non-goals (the issue's "请不要做"):
|
|
29
|
+
// - NO unbounded recursion of the remote root (multi-project roots like
|
|
30
|
+
// /root would scan forever): the walk is depth- and count-capped.
|
|
31
|
+
// - NO full-text injection from the plugin.
|
|
32
|
+
|
|
33
|
+
import { existsSync, mkdirSync, statSync, writeFileSync, utimesSync } from 'node:fs'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { joinRemotePath, relPathUnder } from './paths.js'
|
|
36
|
+
import { loadSyncState, saveSyncState } from './sync.js'
|
|
37
|
+
|
|
38
|
+
/** File names treated as agent instruction files (compared case-insensitively;
|
|
39
|
+
* the mirrored copy keeps the remote's exact casing). */
|
|
40
|
+
export const INSTRUCTION_NAMES = ['agents.md', 'claude.md']
|
|
41
|
+
|
|
42
|
+
/** An instruction file bigger than this is indexed (path + note) but not read. */
|
|
43
|
+
export const INSTRUCTION_MAX_BYTES = 256 * 1024
|
|
44
|
+
|
|
45
|
+
/** Caps that keep the shallow scan shallow. */
|
|
46
|
+
export const INSTRUCTION_MAX_FILES = 20
|
|
47
|
+
export const INSTRUCTION_MAX_DIRS = 60
|
|
48
|
+
export const INSTRUCTION_MAX_DEPTH = 2 // 0 = workspace root, 1 = sub/, 2 = sub/sub/
|
|
49
|
+
|
|
50
|
+
/** Directories that never contain a project's governing instructions. */
|
|
51
|
+
const SKIP_DIRS = new Set([
|
|
52
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
|
|
53
|
+
'.next', '.nuxt', '.cache', '.turbo', 'coverage', 'target', 'vendor',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
export const isInstructionName = (name) => INSTRUCTION_NAMES.includes(String(name || '').toLowerCase())
|
|
57
|
+
|
|
58
|
+
/** Pull the first Markdown headings out of an instruction file, for the index. */
|
|
59
|
+
export function extractHeadings(text, max = 8) {
|
|
60
|
+
const out = []
|
|
61
|
+
for (const line of String(text || '').split('\n')) {
|
|
62
|
+
const m = /^#{1,3}\s+(.+?)\s*#*\s*$/.exec(line)
|
|
63
|
+
if (!m) continue
|
|
64
|
+
out.push(m[1].slice(0, 60))
|
|
65
|
+
if (out.length >= max) break
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shallow-scan a remote workspace for instruction files: the root directory
|
|
72
|
+
* plus up to INSTRUCTION_MAX_DEPTH levels of subdirectories, matching
|
|
73
|
+
* AGENTS.md / CLAUDE.md basenames. Bounded by INSTRUCTION_MAX_DIRS readdirs
|
|
74
|
+
* and INSTRUCTION_MAX_FILES files; anything past the caps is silently
|
|
75
|
+
* skipped (the index is a hint, not an inventory).
|
|
76
|
+
*
|
|
77
|
+
* @param {object} sftp - the promisified sftp surface (readdir/stat/readFile).
|
|
78
|
+
* @param {string} remoteRoot - absolute remote workspace path (normalized).
|
|
79
|
+
* @returns {Promise<Array<{rel: string, remote: string, size: number, mtime: number, content: Buffer|null, headings: string[]}>>}
|
|
80
|
+
* `content`/`headings` are null/empty for files over INSTRUCTION_MAX_BYTES.
|
|
81
|
+
*/
|
|
82
|
+
export async function scanInstructionFiles(sftp, remoteRoot, opts = {}) {
|
|
83
|
+
const maxDepth = Math.max(0, Math.min(Number(opts.maxDepth ?? INSTRUCTION_MAX_DEPTH) || 0, 4))
|
|
84
|
+
const maxDirs = Math.max(1, Number(opts.maxDirs ?? INSTRUCTION_MAX_DIRS) || INSTRUCTION_MAX_DIRS)
|
|
85
|
+
const maxFiles = Math.max(1, Number(opts.maxFiles ?? INSTRUCTION_MAX_FILES) || INSTRUCTION_MAX_FILES)
|
|
86
|
+
const files = []
|
|
87
|
+
// Breadth-first so a wide root cannot push the walk deep before the shallow
|
|
88
|
+
// (more commonly governing) files are seen.
|
|
89
|
+
const queue = [{ dir: remoteRoot, depth: 0 }]
|
|
90
|
+
let dirsScanned = 0
|
|
91
|
+
while (queue.length && files.length < maxFiles && dirsScanned < maxDirs) {
|
|
92
|
+
const { dir, depth } = queue.shift()
|
|
93
|
+
dirsScanned++
|
|
94
|
+
let entries = []
|
|
95
|
+
try { entries = (await sftp.readdir(dir)) || [] } catch { continue }
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const name = String(e.filename || '')
|
|
98
|
+
if (!name || name === '.' || name === '..' || name.startsWith('.')) {
|
|
99
|
+
// Hidden entries are skipped wholesale EXCEPT the names themselves
|
|
100
|
+
// never start with a dot, so nothing is lost.
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const isDir = !!(e.attrs && typeof e.attrs.isDirectory === 'function' && e.attrs.isDirectory())
|
|
104
|
+
const isLink = !!(e.attrs && typeof e.attrs.isSymbolicLink === 'function' && e.attrs.isSymbolicLink())
|
|
105
|
+
if (isDir) {
|
|
106
|
+
// Symlinked dirs are not followed: the walk stays inside the workspace
|
|
107
|
+
// tree and cannot loop through a link back to an ancestor.
|
|
108
|
+
if (isLink) continue
|
|
109
|
+
if (depth < maxDepth && !SKIP_DIRS.has(name.toLowerCase()) && queue.length + dirsScanned < maxDirs) {
|
|
110
|
+
queue.push({ dir: joinRemotePath(dir, name), depth: depth + 1 })
|
|
111
|
+
}
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (!isInstructionName(name)) continue
|
|
115
|
+
const remote = joinRemotePath(dir, name)
|
|
116
|
+
const rel = relPathUnder(remoteRoot, remote) || name
|
|
117
|
+
let st = null
|
|
118
|
+
try { st = await sftp.stat(remote) } catch { continue }
|
|
119
|
+
const size = st && typeof st.size === 'number' ? st.size : 0
|
|
120
|
+
const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
|
|
121
|
+
let content = null
|
|
122
|
+
let headings = []
|
|
123
|
+
if (size <= INSTRUCTION_MAX_BYTES) {
|
|
124
|
+
try {
|
|
125
|
+
content = await sftp.readFile(remote)
|
|
126
|
+
headings = extractHeadings(content.toString('utf8'))
|
|
127
|
+
} catch { content = null }
|
|
128
|
+
}
|
|
129
|
+
files.push({ rel, remote, size, mtime, content, headings })
|
|
130
|
+
if (files.length >= maxFiles) break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return files
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The three-way keys mirror lib/sync.js exactly (size + whole-second mtime):
|
|
137
|
+
// a file this module places into the mirror must be indistinguishable from
|
|
138
|
+
// one rw_sync pulled, or the next sync would report a phantom conflict.
|
|
139
|
+
const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
|
|
140
|
+
const localKeyOf = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Write scanned instruction files into the local mirror at their workspace-
|
|
144
|
+
* relative paths, three-way safe against `.dsh-remote-sync-state.json`:
|
|
145
|
+
*
|
|
146
|
+
* - local missing → write (+ align mtime, record state)
|
|
147
|
+
* - local identical to remote → keep (record state if absent)
|
|
148
|
+
* - remote changed, local untouched (L==S) → write (a plain pull)
|
|
149
|
+
* - local edited (L!=S), remote unchanged → SKIP — a local edit is never
|
|
150
|
+
* clobbered; rw_sync surfaces it as a conflict like any other file
|
|
151
|
+
* - both changed / unknown local content → SKIP (rw_sync arbitrates)
|
|
152
|
+
*
|
|
153
|
+
* @returns {{written: string[], present: Set<string>}} rel paths written now,
|
|
154
|
+
* and every rel that exists locally afterwards (for the index's "mirrored"
|
|
155
|
+
* badge).
|
|
156
|
+
*/
|
|
157
|
+
export function syncInstructionsToMirror(files, mirrorDir) {
|
|
158
|
+
const written = []
|
|
159
|
+
const present = new Set()
|
|
160
|
+
if (!mirrorDir || !Array.isArray(files) || !files.length) return { written, present }
|
|
161
|
+
const state = loadSyncState(mirrorDir)
|
|
162
|
+
let stateDirty = false
|
|
163
|
+
for (const f of files) {
|
|
164
|
+
if (!f || !f.rel || !f.content) continue
|
|
165
|
+
const lp = path.join(mirrorDir, ...String(f.rel).split('/'))
|
|
166
|
+
const rk = keyOf(f.size, f.mtime)
|
|
167
|
+
const sk = state[f.rel] ? keyOf(state[f.rel].size, state[f.rel].mtime) : null
|
|
168
|
+
let lk = null
|
|
169
|
+
try { lk = existsSync(lp) ? localKeyOf(statSync(lp)) : null } catch { lk = null }
|
|
170
|
+
if (lk === rk) {
|
|
171
|
+
// Identical bytes in place already (same size AND mtime second) — only
|
|
172
|
+
// make sure the snapshot knows about it.
|
|
173
|
+
present.add(f.rel)
|
|
174
|
+
if (!sk) { state[f.rel] = { size: f.size, mtime: f.mtime }; stateDirty = true }
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const canWrite = lk === null || (sk !== null && rk !== sk && lk === sk)
|
|
178
|
+
if (!canWrite) continue
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(path.dirname(lp), { recursive: true })
|
|
181
|
+
writeFileSync(lp, f.content)
|
|
182
|
+
// Align the local mtime to the remote's so the next rw_sync sees
|
|
183
|
+
// L==R==S and skips the file instead of flagging a local modification.
|
|
184
|
+
try { utimesSync(lp, new Date(f.mtime * 1000), new Date(f.mtime * 1000)) } catch { /* cosmetic */ }
|
|
185
|
+
state[f.rel] = { size: f.size, mtime: f.mtime }
|
|
186
|
+
stateDirty = true
|
|
187
|
+
written.push(f.rel)
|
|
188
|
+
present.add(f.rel)
|
|
189
|
+
} catch { /* a mirror write failure must not break the index */ }
|
|
190
|
+
}
|
|
191
|
+
if (stateDirty) {
|
|
192
|
+
try { saveSyncState(mirrorDir, state) } catch { /* best effort */ }
|
|
193
|
+
}
|
|
194
|
+
return { written, present }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render the prompt-section index of remote instruction files. Paths are
|
|
199
|
+
* shown through `display` (the platform-aware display formatter), headings
|
|
200
|
+
* give the model a content hint without injecting the full text.
|
|
201
|
+
*/
|
|
202
|
+
export function formatInstructionIndex(files, display = (p) => p) {
|
|
203
|
+
const lines = files.slice(0, 10).map((f) => {
|
|
204
|
+
const h = f.headings && f.headings.length ? ` (headings: ${f.headings.slice(0, 6).join(' / ')})` : ''
|
|
205
|
+
const over = f.size > INSTRUCTION_MAX_BYTES ? ` (${Math.round(f.size / 1024)} KB)` : ''
|
|
206
|
+
const mirrored = f.mirrored ? '; synced into the local mirror' : ''
|
|
207
|
+
return `- ${display(f.remote)}${h}${over}${mirrored}`
|
|
208
|
+
})
|
|
209
|
+
return (
|
|
210
|
+
'Remote instruction files found in this workspace (they govern how to work in their directories; '
|
|
211
|
+
+ 'root-level copies were synced into the local mirror, but nested ones are NOT auto-injected — '
|
|
212
|
+
+ 'read them with rw_read_file before editing that tree):\n'
|
|
213
|
+
+ lines.join('\n')
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Per-process index of remote instruction scans, keyed by mirror directory.
|
|
219
|
+
* Deduplicates in-flight scans, negative-caches failures for a minute (an
|
|
220
|
+
* offline machine must not turn every prompt assembly into a connect
|
|
221
|
+
* attempt), and never throws — the index is strictly best-effort.
|
|
222
|
+
*/
|
|
223
|
+
export function createInstructionIndex() {
|
|
224
|
+
const cache = new Map() // resolvedMirrorDir → { scannedAt, files } | { failedAt }
|
|
225
|
+
const inflight = new Map() // resolvedMirrorDir → Promise
|
|
226
|
+
const RETRY_AFTER_MS = 60 * 1000
|
|
227
|
+
|
|
228
|
+
const keyOfDir = (mirrorDir) => {
|
|
229
|
+
try { return path.resolve(String(mirrorDir)) } catch { return String(mirrorDir || '') }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Scan (or return the cached scan of) one mirror's remote workspace.
|
|
234
|
+
* @param {{pool: {sftp: Function}, ws: string, mirrorDir: string, force?: boolean}} arg
|
|
235
|
+
* @returns {Promise<{scannedAt: number, files: Array}|null>}
|
|
236
|
+
*/
|
|
237
|
+
const refresh = ({ pool, ws, mirrorDir, force = false }) => {
|
|
238
|
+
if (!pool || typeof pool.sftp !== 'function' || !ws || !mirrorDir) return Promise.resolve(null)
|
|
239
|
+
const key = keyOfDir(mirrorDir)
|
|
240
|
+
const cur = cache.get(key)
|
|
241
|
+
if (!force && cur && cur.files) return Promise.resolve(cur)
|
|
242
|
+
if (!force && cur && cur.failedAt && Date.now() - cur.failedAt < RETRY_AFTER_MS) return Promise.resolve(null)
|
|
243
|
+
if (inflight.has(key)) return inflight.get(key)
|
|
244
|
+
const job = (async () => {
|
|
245
|
+
try {
|
|
246
|
+
const sftp = await pool.sftp()
|
|
247
|
+
const files = await scanInstructionFiles(sftp, ws)
|
|
248
|
+
const synced = syncInstructionsToMirror(files, mirrorDir)
|
|
249
|
+
const entry = {
|
|
250
|
+
scannedAt: Date.now(),
|
|
251
|
+
files: files.map((f) => ({
|
|
252
|
+
rel: f.rel,
|
|
253
|
+
remote: f.remote,
|
|
254
|
+
size: f.size,
|
|
255
|
+
headings: f.headings,
|
|
256
|
+
mirrored: synced.present.has(f.rel),
|
|
257
|
+
})),
|
|
258
|
+
}
|
|
259
|
+
cache.set(key, entry)
|
|
260
|
+
return entry
|
|
261
|
+
} catch {
|
|
262
|
+
// Negative-cache: the machine may simply be offline; retry at most
|
|
263
|
+
// once a minute, not once per prompt assembly.
|
|
264
|
+
cache.set(key, { failedAt: Date.now() })
|
|
265
|
+
return null
|
|
266
|
+
} finally {
|
|
267
|
+
inflight.delete(key)
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
inflight.set(key, job)
|
|
271
|
+
return job
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The cached index for a mirror, or null when unknown/failed. */
|
|
275
|
+
const get = (mirrorDir) => {
|
|
276
|
+
const e = cache.get(keyOfDir(mirrorDir))
|
|
277
|
+
return e && e.files ? e : null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Fire-and-forget refresh for prompt-assembly time: starts a scan only
|
|
281
|
+
* when nothing is known about the mirror yet (recent failures included). */
|
|
282
|
+
const note = ({ pool, ws, mirrorDir }) => {
|
|
283
|
+
if (cache.has(keyOfDir(mirrorDir))) return
|
|
284
|
+
refresh({ pool, ws, mirrorDir }).catch(() => { /* guarded inside */ })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { refresh, get, note, _cache: cache }
|
|
288
|
+
}
|
package/lib/openssh.js
CHANGED
|
@@ -785,7 +785,16 @@ export class SystemOpenSshPool {
|
|
|
785
785
|
throw new Error('ssh target changed while opening sftp')
|
|
786
786
|
}
|
|
787
787
|
this._sftpChild = child
|
|
788
|
-
return buildSftpSurface(sftp, {
|
|
788
|
+
return buildSftpSurface(sftp, {
|
|
789
|
+
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
790
|
+
onTimeout: () => {
|
|
791
|
+
// A timed-out SFTP session is dead: kill the child and drop the cached
|
|
792
|
+
// session so the next call re-handshakes instead of queueing behind the
|
|
793
|
+
// wedged one (issue #65).
|
|
794
|
+
try { child.kill() } catch { /* already gone */ }
|
|
795
|
+
drop()
|
|
796
|
+
},
|
|
797
|
+
})
|
|
789
798
|
})
|
|
790
799
|
// Never cache a rejected open: the next call must retry rather than get the
|
|
791
800
|
// same error back forever (a failed handshake is usually transient).
|