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.
@@ -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, { commandTimeoutMs: this.config.commandTimeoutMs })
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).