dsh-remote 0.8.50 → 0.8.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.en.md +8 -0
  2. package/README.md +22 -0
  3. package/lib/client.js +15 -3
  4. package/lib/index.js +269 -8
  5. package/lib/instructions.js +288 -0
  6. package/lib/openssh.js +10 -1
  7. package/lib/pool.js +29 -1
  8. package/lib/remote-fs.js +26 -1
  9. package/lib/sftp-surface.js +46 -3
  10. package/lib/sshconfig.js +13 -3
  11. package/node_modules/buildcheck/.eslintrc.js +5 -0
  12. package/node_modules/buildcheck/.github/workflows/ci.yml +88 -0
  13. package/node_modules/buildcheck/.github/workflows/lint.yml +27 -0
  14. package/node_modules/buildcheck/.npmignore +3 -0
  15. package/node_modules/buildcheck/LICENSE +19 -0
  16. package/node_modules/buildcheck/README.md +106 -0
  17. package/node_modules/buildcheck/deps/Find-VisualStudio.cs +250 -0
  18. package/node_modules/buildcheck/lib/findvs.js +361 -0
  19. package/node_modules/buildcheck/lib/index.js +738 -0
  20. package/node_modules/buildcheck/package.json +36 -0
  21. package/node_modules/buildcheck/test/test.js +8 -0
  22. package/node_modules/cpu-features/.eslintrc.js +5 -0
  23. package/node_modules/cpu-features/.github/workflows/ci.yml +74 -0
  24. package/node_modules/cpu-features/.github/workflows/lint.yml +27 -0
  25. package/node_modules/cpu-features/LICENSE +19 -0
  26. package/node_modules/cpu-features/README.md +59 -0
  27. package/node_modules/cpu-features/binding.gyp +16 -0
  28. package/node_modules/cpu-features/buildcheck.js +32 -0
  29. package/node_modules/cpu-features/deps/cpu_features/.clang-format +4 -0
  30. package/node_modules/cpu-features/deps/cpu_features/.dockerignore +31 -0
  31. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/Dockerfile +5 -0
  32. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/aarch64_linux_cmake.yml +30 -0
  33. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_freebsd_cmake.yml +22 -0
  34. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_bazel.yml +26 -0
  35. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_linux_cmake.yml +31 -0
  36. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_macos_cmake.yml +43 -0
  37. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/amd64_windows_cmake.yml +25 -0
  38. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/arm_linux_cmake.yml +31 -0
  39. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/clang_format.yml +24 -0
  40. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/mips_linux_cmake.yml +30 -0
  41. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/power_linux_cmake.yml +29 -0
  42. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/riscv_linux_cmake.yml +28 -0
  43. package/node_modules/cpu-features/deps/cpu_features/.github/workflows/s390x_linux_cmake.yml +27 -0
  44. package/node_modules/cpu-features/deps/cpu_features/.grenrc.yml +21 -0
  45. package/node_modules/cpu-features/deps/cpu_features/BUILD.bazel +329 -0
  46. package/node_modules/cpu-features/deps/cpu_features/CMakeLists.txt +261 -0
  47. package/node_modules/cpu-features/deps/cpu_features/CONTRIBUTING.md +23 -0
  48. package/node_modules/cpu-features/deps/cpu_features/LICENSE +230 -0
  49. package/node_modules/cpu-features/deps/cpu_features/README.md +272 -0
  50. package/node_modules/cpu-features/deps/cpu_features/WORKSPACE +19 -0
  51. package/node_modules/cpu-features/deps/cpu_features/bazel/ci/README.md +5 -0
  52. package/node_modules/cpu-features/deps/cpu_features/bazel/platforms.bzl +11 -0
  53. package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesConfig.cmake.in +3 -0
  54. package/node_modules/cpu-features/deps/cpu_features/cmake/CpuFeaturesNdkCompatConfig.cmake.in +3 -0
  55. package/node_modules/cpu-features/deps/cpu_features/cmake/README.md +30 -0
  56. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/Makefile +252 -0
  57. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/README.md +40 -0
  58. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.dot +64 -0
  59. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/docker.svg +312 -0
  60. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/doc/generate_image.sh +7 -0
  61. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/amd64/Dockerfile +48 -0
  62. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/docker/toolchain/Dockerfile +34 -0
  63. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/CMakeLists.txt +22 -0
  64. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/sample/main.cpp +11 -0
  65. package/node_modules/cpu-features/deps/cpu_features/cmake/ci/vagrant/freebsd/Vagrantfile +107 -0
  66. package/node_modules/cpu-features/deps/cpu_features/cmake/googletest.CMakeLists.txt.in +15 -0
  67. package/node_modules/cpu-features/deps/cpu_features/cpu_features.gyp +105 -0
  68. package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_cache_info.h +54 -0
  69. package/node_modules/cpu-features/deps/cpu_features/include/cpu_features_macros.h +384 -0
  70. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_aarch64.h +259 -0
  71. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_arm.h +121 -0
  72. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_mips.h +74 -0
  73. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_ppc.h +149 -0
  74. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_riscv.h +72 -0
  75. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_s390x.h +108 -0
  76. package/node_modules/cpu-features/deps/cpu_features/include/cpuinfo_x86.h +288 -0
  77. package/node_modules/cpu-features/deps/cpu_features/include/internal/bit_utils.h +40 -0
  78. package/node_modules/cpu-features/deps/cpu_features/include/internal/cpuid_x86.h +37 -0
  79. package/node_modules/cpu-features/deps/cpu_features/include/internal/filesystem.h +39 -0
  80. package/node_modules/cpu-features/deps/cpu_features/include/internal/hwcaps.h +240 -0
  81. package/node_modules/cpu-features/deps/cpu_features/include/internal/stack_line_reader.h +49 -0
  82. package/node_modules/cpu-features/deps/cpu_features/include/internal/string_view.h +110 -0
  83. package/node_modules/cpu-features/deps/cpu_features/include/internal/windows_utils.h +70 -0
  84. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/CMakeLists.txt +60 -0
  85. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/README.md +4 -0
  86. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.c +205 -0
  87. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/cpu-features.h +320 -0
  88. package/node_modules/cpu-features/deps/cpu_features/ndk_compat/ndk-compat-test.c +12 -0
  89. package/node_modules/cpu-features/deps/cpu_features/patches/0001-Add-Apple-Silicon-Support.patch +456 -0
  90. package/node_modules/cpu-features/deps/cpu_features/scripts/generate_badges.d +165 -0
  91. package/node_modules/cpu-features/deps/cpu_features/scripts/make_release.sh +75 -0
  92. package/node_modules/cpu-features/deps/cpu_features/scripts/run_integration.sh +490 -0
  93. package/node_modules/cpu-features/deps/cpu_features/scripts/test_integration.sh +79 -0
  94. package/node_modules/cpu-features/deps/cpu_features/src/copy.inl +19 -0
  95. package/node_modules/cpu-features/deps/cpu_features/src/define_introspection.inl +86 -0
  96. package/node_modules/cpu-features/deps/cpu_features/src/define_introspection_and_hwcaps.inl +26 -0
  97. package/node_modules/cpu-features/deps/cpu_features/src/equals.inl +22 -0
  98. package/node_modules/cpu-features/deps/cpu_features/src/filesystem.c +62 -0
  99. package/node_modules/cpu-features/deps/cpu_features/src/hwcaps.c +169 -0
  100. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64__base_implementation.inl +88 -0
  101. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_linux_or_android.c +79 -0
  102. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_macos_or_iphone.c +82 -0
  103. package/node_modules/cpu-features/deps/cpu_features/src/impl_aarch64_windows.c +138 -0
  104. package/node_modules/cpu-features/deps/cpu_features/src/impl_arm_linux_or_android.c +212 -0
  105. package/node_modules/cpu-features/deps/cpu_features/src/impl_mips_linux_or_android.c +93 -0
  106. package/node_modules/cpu-features/deps/cpu_features/src/impl_ppc_linux.c +163 -0
  107. package/node_modules/cpu-features/deps/cpu_features/src/impl_riscv_linux.c +111 -0
  108. package/node_modules/cpu-features/deps/cpu_features/src/impl_s390x_linux.c +120 -0
  109. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86__base_implementation.inl +2055 -0
  110. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_freebsd.c +68 -0
  111. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_linux_or_android.c +58 -0
  112. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_macos.c +57 -0
  113. package/node_modules/cpu-features/deps/cpu_features/src/impl_x86_windows.c +58 -0
  114. package/node_modules/cpu-features/deps/cpu_features/src/stack_line_reader.c +132 -0
  115. package/node_modules/cpu-features/deps/cpu_features/src/string_view.c +192 -0
  116. package/node_modules/cpu-features/deps/cpu_features/src/utils/list_cpu_features.c +461 -0
  117. package/node_modules/cpu-features/deps/cpu_features/test/CMakeLists.txt +114 -0
  118. package/node_modules/cpu-features/deps/cpu_features/test/bit_utils_test.cc +53 -0
  119. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_aarch64_test.cc +396 -0
  120. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_arm_test.cc +384 -0
  121. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_mips_test.cc +176 -0
  122. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_ppc_test.cc +131 -0
  123. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_riscv_test.cc +180 -0
  124. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_s390x_test.cc +82 -0
  125. package/node_modules/cpu-features/deps/cpu_features/test/cpuinfo_x86_test.cc +1729 -0
  126. package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.cc +103 -0
  127. package/node_modules/cpu-features/deps/cpu_features/test/filesystem_for_testing.h +61 -0
  128. package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.cc +52 -0
  129. package/node_modules/cpu-features/deps/cpu_features/test/hwcaps_for_testing.h +31 -0
  130. package/node_modules/cpu-features/deps/cpu_features/test/stack_line_reader_test.cc +132 -0
  131. package/node_modules/cpu-features/deps/cpu_features/test/string_view_test.cc +202 -0
  132. package/node_modules/cpu-features/lib/index.js +5 -0
  133. package/node_modules/cpu-features/package.json +40 -0
  134. package/node_modules/cpu-features/src/binding.cc +151 -0
  135. package/node_modules/cpu-features/test/test.js +12 -0
  136. package/package.json +1 -1
@@ -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).
package/lib/pool.js CHANGED
@@ -197,6 +197,8 @@ export class SshPool {
197
197
  this.onReady = null
198
198
  /** Optional hook called when the pool closes. */
199
199
  this.onCloseHook = null
200
+ /** Consecutive SFTP subchannel-open timeouts (issue #65). */
201
+ this._sftpTimeouts = 0
200
202
  }
201
203
 
202
204
  resolveKeyPath() {
@@ -324,6 +326,7 @@ export class SshPool {
324
326
  return
325
327
  }
326
328
  this.client = client
329
+ this._sftpTimeouts = 0
327
330
  resolve(client)
328
331
  if (this.onReady) { try { this.onReady(client) } catch {} }
329
332
  })
@@ -684,10 +687,25 @@ export class SshPool {
684
687
  }
685
688
  return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
686
689
  }
690
+ // A successful open proves the connection healthy: the strike
691
+ // counter counts CONSECUTIVE timeouts, so it resets here —
692
+ // two unrelated transient blips hours apart must not tear down
693
+ // a fine connection.
694
+ this._sftpTimeouts = 0
687
695
  // The promisified surface (timeouts, dead-channel fail-fast,
688
696
  // range reads) is shared verbatim with the system-OpenSSH
689
697
  // backend — see lib/sftp-surface.js for the rules it encodes.
690
- resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
698
+ resolve(buildSftpSurface(sftp, {
699
+ commandTimeoutMs: this.config.commandTimeoutMs,
700
+ onTimeout: () => {
701
+ // A timed-out SFTP subchannel is dead: close it so the next
702
+ // operation does not queue behind the wedged one, and count
703
+ // the strike so a second consecutive timeout tears down the
704
+ // whole pooled connection (issue #65).
705
+ try { sftp.end() } catch { /* already gone */ }
706
+ this._noteSftpTimeout()
707
+ },
708
+ }))
691
709
  })
692
710
  } catch (e) {
693
711
  clearAttempt()
@@ -699,6 +717,16 @@ export class SshPool {
699
717
  )
700
718
  }
701
719
 
720
+ /**
721
+ * Count a timed-out SFTP subchannel open. A second consecutive timeout means
722
+ * the pooled connection itself is wedged (issue #65): tear it down so the
723
+ * next sftp() call re-handshakes instead of queueing behind the dead one.
724
+ */
725
+ _noteSftpTimeout() {
726
+ this._sftpTimeouts = (this._sftpTimeouts || 0) + 1
727
+ if (this._sftpTimeouts >= 2) this.invalidate()
728
+ }
729
+
702
730
  /**
703
731
  * Drop the cached client and force a fresh connection on the next call.
704
732
  * Called when a channel open fails (e.g. "Channel open failure: open
package/lib/remote-fs.js CHANGED
@@ -98,6 +98,17 @@ export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
98
98
  return removed
99
99
  }
100
100
 
101
+ /** MIME types for image formats the preview panel can render inline. */
102
+ const IMAGE_MIME_TYPES = {
103
+ png: 'image/png',
104
+ jpg: 'image/jpeg',
105
+ jpeg: 'image/jpeg',
106
+ gif: 'image/gif',
107
+ webp: 'image/webp',
108
+ svg: 'image/svg+xml',
109
+ bmp: 'image/bmp',
110
+ }
111
+
101
112
  function looksBinary(buf) {
102
113
  const n = Math.min(buf.length, 8192)
103
114
  for (let i = 0; i < n; i++) if (buf[i] === 0) return true
@@ -107,6 +118,14 @@ function looksBinary(buf) {
107
118
  /**
108
119
  * Read a remote file for the sidebar/editor. Oversized files use a range read
109
120
  * (`readPartial`) so we never `fastGet` the whole blob onto disk.
121
+ *
122
+ * Images are the exception (issue #66): a PREFIX of an image is a broken
123
+ * picture, not a preview, so for a recognised image extension the caller's
124
+ * text-oriented cap is skipped and the file is read whole up to the hard 2MB
125
+ * ceiling; the panel renders it inline as `<img src="data:…">`. An image past
126
+ * the ceiling degrades to the plain binary stub + download button rather than
127
+ * shipping a guaranteed-broken half render. (`<img>` is also the safe sink
128
+ * for SVG: unlike inline markup it cannot run the file's scripts.)
110
129
  */
111
130
  export async function previewRemoteFile(sftp, target, opts = {}) {
112
131
  const {
@@ -114,7 +133,10 @@ export async function previewRemoteFile(sftp, target, opts = {}) {
114
133
  encoding = 'utf-8',
115
134
  decodeBuf = (buf) => buf.toString('utf8'),
116
135
  } = opts
117
- const cap = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
136
+ const hardCap = 2 * 1024 * 1024
137
+ const name = String(target).split(/[\\/]/).pop() || ''
138
+ const imageMime = IMAGE_MIME_TYPES[(name.split('.').pop() || '').toLowerCase()]
139
+ const cap = imageMime ? hardCap : Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), hardCap)
118
140
  const st = await sftp.stat(target)
119
141
  const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
120
142
  const size = st && typeof st.size === 'number' ? st.size : 0
@@ -131,6 +153,9 @@ export async function previewRemoteFile(sftp, target, opts = {}) {
131
153
  } else {
132
154
  buf = await sftp.readFile(target)
133
155
  }
156
+ if (imageMime && !truncated) {
157
+ return { ok: true, binary: false, image: true, mime: imageMime, content: buf.toString('base64'), truncated, size, mtime }
158
+ }
134
159
  if (looksBinary(buf)) {
135
160
  return {
136
161
  ok: true, binary: true, size, mtime,
@@ -26,7 +26,7 @@ import { toSftpPath } from './paths.js'
26
26
  * @param {number} opts.commandTimeoutMs - per-operation timeout.
27
27
  * @returns {object} promisified sftp surface.
28
28
  */
29
- export function buildSftpSurface(sftp, { commandTimeoutMs }) {
29
+ export function buildSftpSurface(sftp, { commandTimeoutMs, onTimeout }) {
30
30
  // A dead channel must fail fast (see the header). ssh2's SFTP instance and the
31
31
  // system-OpenSSH engine both emit these, so one hook covers both backends.
32
32
  let dead = false
@@ -39,12 +39,19 @@ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
39
39
  const withTimeout = (fn) => (...args) =>
40
40
  new Promise((resolve, reject) => {
41
41
  if (dead) return reject(new Error('sftp channel closed'))
42
- const timer = setTimeout(() => reject(new Error('sftp operation timed out')), commandTimeoutMs)
42
+ let timedOut = false
43
+ const timer = setTimeout(() => {
44
+ timedOut = true
45
+ markDead()
46
+ if (typeof onTimeout === 'function') { try { onTimeout() } catch { /* best effort */ } }
47
+ reject(new Error('sftp operation timed out'))
48
+ }, commandTimeoutMs)
43
49
  const finish = (err, value) => {
44
50
  clearTimeout(timer)
51
+ if (timedOut) return
45
52
  err ? reject(err) : resolve(value)
46
53
  }
47
- try { fn(...args, finish) } catch (err) { clearTimeout(timer); reject(err) }
54
+ try { fn(...args, finish) } catch (err) { clearTimeout(timer); if (!timedOut) reject(err) }
48
55
  })
49
56
  const P = (p) => toSftpPath(p)
50
57
  return {
@@ -55,7 +62,43 @@ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
55
62
  rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
56
63
  unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
57
64
  rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
65
+ // POSIX rename(2) semantics: atomically REPLACE an existing destination.
66
+ // Plain SSH_FXP_RENAME fails when the target exists (OpenSSH's sftp-server
67
+ // enforces that even though it advertises posix-rename@openssh.com — ssh2
68
+ // only sends the extension when asked explicitly, verified against
69
+ // OpenSSH_9.6 in issue #65). Servers with the extension get one atomic
70
+ // request; servers without it get the move-aside dance, whose every hop
71
+ // targets a path that does not exist, which plain RENAME always permits.
72
+ // rw_edit's atomic write depends on this — without it every edit of an
73
+ // existing file fails with a bare "Failure".
74
+ renameOverwrite: (p, d) => withTimeout((a, b, cb) => {
75
+ const ext = sftp._extensions && sftp._extensions['posix-rename@openssh.com']
76
+ if (ext === '1' && typeof sftp.ext_openssh_rename === 'function') {
77
+ return sftp.ext_openssh_rename(a, b, cb)
78
+ }
79
+ const bak = `${b}.dsh-remote-bak-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
80
+ sftp.rename(b, bak, (e1) => {
81
+ if (e1) {
82
+ // Destination absent → a plain rename is already overwrite-safe.
83
+ if (/no such/i.test(String((e1 && e1.message) || e1))) return sftp.rename(a, b, cb)
84
+ return cb(e1)
85
+ }
86
+ sftp.rename(a, b, (e2) => {
87
+ if (e2) {
88
+ // Roll the original back into place before reporting the failure.
89
+ return sftp.rename(bak, b, () => cb(e2))
90
+ }
91
+ // Best effort: a leftover backup must not fail the operation.
92
+ sftp.unlink(bak, () => cb(null))
93
+ })
94
+ })
95
+ })(P(p), P(d)),
58
96
  realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
97
+ // Attribute write (chmod semantics live in setstat at the protocol level,
98
+ // so BOTH backends — ssh2's SFTPWrapper and its raw protocol SFTP — answer
99
+ // this one shape). Used by rw_edit to restore the original mode after an
100
+ // atomic rename replace (issue #65).
101
+ setstat: (p, attrs) => withTimeout((d, a, cb) => sftp.setstat(d, a, cb))(P(p), attrs),
59
102
  readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
60
103
  writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
61
104
  fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
package/lib/sshconfig.js CHANGED
@@ -32,11 +32,21 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
32
32
  import { homedir } from 'node:os'
33
33
  import path from 'node:path'
34
34
 
35
- /** Default location of the user's ssh config. */
36
- export const sshConfigPath = () => path.join(homedir(), '.ssh', 'config')
35
+ /** Default location of the user's ssh config. An env override exists so tests
36
+ * (and any deployment that isolates its home directory) can point the plugin
37
+ * at a substitute file instead of the real ~/.ssh/config. */
38
+ export const sshConfigPath = () => {
39
+ const env = process.env.DSH_REMOTE_SSH_CONFIG
40
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
41
+ return path.join(homedir(), '.ssh', 'config')
42
+ }
37
43
 
38
44
  /** Directory `Include` paths are resolved against (ssh_config(5)). */
39
- export const sshConfigDir = () => path.join(homedir(), '.ssh')
45
+ export const sshConfigDir = () => {
46
+ const env = process.env.DSH_REMOTE_SSH_CONFIG
47
+ if (env && String(env).trim()) return path.dirname(path.resolve(String(env).trim()))
48
+ return path.join(homedir(), '.ssh')
49
+ }
40
50
 
41
51
  /** Parameters the resolver cares about, lowercase (as parsed). */
42
52
  const KNOWN_PARAMS = new Set([
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ extends: '@mscdex/eslint-config',
5
+ };
@@ -0,0 +1,88 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ master ]
7
+
8
+ jobs:
9
+ tests-linux:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.x, 24.x]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ persist-credentials: false
19
+ - name: Use Node.js ${{ matrix.node-version }}
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: ${{ matrix.node-version }}
23
+ - name: Check Node.js version
24
+ run: node -pe process.versions
25
+ - name: Install module
26
+ run: npm install
27
+ - name: Run tests
28
+ run: npm test
29
+ tests-macos-intel:
30
+ runs-on: macos-15-intel
31
+ strategy:
32
+ fail-fast: false
33
+ matrix:
34
+ node-version: [10.x, 12.x, 14.x, 16.x, 18.x, 20.x, 22.x, 24.x]
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ with:
38
+ persist-credentials: false
39
+ - name: Use Node.js ${{ matrix.node-version }}
40
+ uses: actions/setup-node@v4
41
+ with:
42
+ node-version: ${{ matrix.node-version }}
43
+ - name: Check Node.js version
44
+ run: node -pe process.versions
45
+ - name: Install module
46
+ run: npm install
47
+ - name: Run tests
48
+ run: npm test
49
+ tests-macos-arm:
50
+ runs-on: macos-latest
51
+ strategy:
52
+ fail-fast: false
53
+ matrix:
54
+ node-version: [16.x, 18.x, 20.x, 22.x, 24.x]
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ with:
58
+ persist-credentials: false
59
+ - name: Use Node.js ${{ matrix.node-version }}
60
+ uses: actions/setup-node@v4
61
+ with:
62
+ node-version: ${{ matrix.node-version }}
63
+ - name: Check Node.js version
64
+ run: node -pe process.versions
65
+ - name: Install module
66
+ run: npm install
67
+ - name: Run tests
68
+ run: npm test
69
+ tests-windows-2022:
70
+ runs-on: windows-2022
71
+ strategy:
72
+ fail-fast: false
73
+ matrix:
74
+ node-version: [24.x]
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+ with:
78
+ persist-credentials: false
79
+ - name: Use Node.js ${{ matrix.node-version }}
80
+ uses: actions/setup-node@v4
81
+ with:
82
+ node-version: ${{ matrix.node-version }}
83
+ - name: Check Node.js version
84
+ run: node -pe process.versions
85
+ - name: Install module
86
+ run: npm install
87
+ - name: Run tests
88
+ run: npm test
@@ -0,0 +1,27 @@
1
+ name: lint
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ master ]
7
+
8
+ env:
9
+ NODE_VERSION: 22.x
10
+
11
+ jobs:
12
+ lint-js:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ persist-credentials: false
18
+ - name: Use Node.js ${{ env.NODE_VERSION }}
19
+ uses: actions/setup-node@v4
20
+ with:
21
+ node-version: ${{ env.NODE_VERSION }}
22
+ - name: Check Node.js version
23
+ run: node -pe process.versions
24
+ - name: Install ESLint + ESLint configs/plugins
25
+ run: npm install --only=dev
26
+ - name: Lint files
27
+ run: npm run lint
@@ -0,0 +1,3 @@
1
+ .eslintcache
2
+ **/node_modules/**
3
+ /package-lock.json
@@ -0,0 +1,19 @@
1
+ Copyright Brian White. All rights reserved.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to
5
+ deal in the Software without restriction, including without limitation the
6
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7
+ sell copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19
+ IN THE SOFTWARE.