dsh-remote 0.8.51 → 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.
package/README.en.md CHANGED
@@ -285,6 +285,13 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
285
285
  **`@` lists remote files but the built-in read tool cannot open them** — the harness's own file tools see the **local mirror**, which stays empty until `rw_sync` downloads it. Read remote files with `rw_read_file` or the sidebar remote tab.
286
286
 
287
287
  **A file link in a remote Session does not open** (issue #55) — fixed. When a link is clicked the harness turns it into `dsh-resource://file/session/<session>/<path>` and hands it to the built-in text preview, which reads the **local** filesystem; a remote Session's cwd is a **local mirror directory** (empty until `rw_sync`), so the open simply failed. The plugin now registers a higher-priority handler for that address and opens it over SFTP through the same editor as the sidebar "Remote Files" tab. The safety rule is deliberately conservative: it takes over only when the Session's cwd is **provably inside a mirror** and the mirror registry has loaded, so local Sessions — and the instant right after a page load — keep the built-in preview and no local file is ever mistaken for a remote one. The two cases that still cannot be mapped say so instead of showing a blank page: an address with no mirror mapping, and a Session whose workspace is not a remote mirror directory.
288
+ Two exceptions (0.8.52+): `.md`/`.markdown` links are **not** taken over and stay with the built-in Markdown renderer (issue #64 — the remote editor would only show source, a net loss for Markdown; it reads the local mirror copy, so `rw_sync` first for the latest content). And images never had a renderer on either path anyway: the sidebar remote tree now renders png/jpg/gif/webp/svg/bmp inline (issue #66; files over 2MB fall back to the download button).
289
+
290
+ **One SFTP timeout wedged every SFTP tool on that machine** (issue #65) — fixed (0.8.52+). A single operation hitting the 20s timeout used to poison the whole process for that machine (every later SFTP call paid its own 20s, while `rw_exec` stayed fine) until a disconnect/reconnect. Now a timeout kills the channel so later requests fail fast; the native backend tears the connection down and re-handshakes after two consecutive timeouts, and the openssh backend rebuilds its sftp child — a transient network blip no longer ends with "disconnect and reconnect". As a bonus, `rw_edit` now writes atomically (temp file + rename), so an interrupted write can no longer truncate the remote file to zero bytes, and the original permission bits are preserved.
291
+
292
+ **The machine's IP / username changed — must every workspace be recreated?** (issue #67) — no (0.8.52+). Edit the machine's new address on the settings page and save, or call `rw_connect(machineId=…, host=…, username=…, port=…)` with the new address: every workspace mirror bound to that machine is **rebound in place** (its `.dsh-remote-meta.json` is rewritten) — local content is kept, no re-sync needed, and the save response says how many mirrors were rebound. The mirror directory name does not follow (live Sessions hold cwd paths inside it); the name is a display label only and does not affect connectivity.
293
+
294
+ **Remote Sessions could not see the workspace AGENTS.md** (issue #68) — fixed (0.8.52+). The core instruction loader only knows the local filesystem, and a remote Session's cwd is the local mirror, so the remote repo's AGENTS.md never reached the model's context. Now, when `rw_pick_workspace` succeeds, the remote workspace gets a shallow scan (root + at most two levels down, matching `AGENTS.md`/`CLAUDE.md` only); the found files are synced into the local mirror under a three-way guard (root-level copies thus enter the core's own injection path, exactly like a local Session), and an index of paths + headings is rendered into the system prompt, telling the model to read nested instruction files with `rw_read_file` before editing. Full-tree recursion and plugin-side full-text injection are deliberately **not** done (the former would scan through multi-project roots; the latter would run a second injection pipeline in parallel with the core's).
288
295
 
289
296
  **The terminal in a remote Session is still the local one** (issue #55) — that is **by design, and a plugin cannot change it**, but there is an equivalent: DSH's sidebar terminal is core, its PTY comes from the **context-global** `subprocess` service, and its cwd is the Session's own workspace directory (the local mirror for a remote Session). A plugin can neither replace that service nor retarget the core terminal onto SSH. For a **genuine remote terminal** use the "Remote DSH Web UI" (above): it opens the **remote machine's own** DSH in this browser, and that interface's terminal runs on the remote host, with no extra configuration. To run a single remote command instead, use `rw_exec` (add `pty: true` for an interactive PTY).
290
297
 
package/README.md CHANGED
@@ -274,6 +274,27 @@ scripts/dev-run.sh --status # 是否在运行
274
274
  本地会话与页面刚加载的那一瞬间仍然走内置预览 —— 不会把本地文件误当成远程文件。
275
275
  两个仍然开不了的情况会给出明确提示而不是空白页:链接指向的是**绝对**远端路径之外无法映射的地址,
276
276
  或该会话的工作区不是一个远程镜像目录。
277
+ 两个例外(0.8.52+):`.md`/`.markdown` 链接**不被接管**、交还内置 Markdown 渲染(issue #64——远程编辑器只能看
278
+ 源码,对 Markdown 是净损失;它读本地镜像副本,要最新内容先 `rw_sync`);图片文件则在内置链路与远程链路
279
+ 都谈不上「渲染」,侧栏远程文件树现在直接内联显示 png/jpg/gif/webp/svg/bmp(issue #66,>2MB 退回下载按钮)。
280
+
281
+ **SFTP 操作一旦超时就永远卡死**(issue #65)—— 已修(0.8.52+)。旧版里一次撞满 20s 超时的 SFTP 操作会把
282
+ 该机器在本进程内的全部 SFTP 工具拖到永久超时(`rw_exec` 却正常),只能断连重连。现在超时即置死该通道、
283
+ 后续请求立即失败;native 后端连续两次超时会废弃整条连接重新握手,openssh 后端会重建 sftp 子进程——
284
+ 一次网络抖动不再需要用「断开重连」来收场。顺带,`rw_edit` 改成了「临时文件 + rename」原子写,
285
+ 写一半超时不会再把远端文件截成 0 字节,原文件的权限位也会保留。
286
+
287
+ **机器换了 IP / 用户名,工作区要全部重建吗?**(issue #67)—— 不用(0.8.52+)。在设置页编辑该机器的新
288
+ 地址并保存,或 `rw_connect(machineId=…, host=…, username=…, port=…)` 同传新地址:该机器名下所有工作区
289
+ 镜像会**原地重绑**(改写镜像的 `.dsh-remote-meta.json`),本地内容保留、无需重新同步,保存后会提示重绑了
290
+ 几个镜像。镜像目录名不会跟着改(活 session 的 cwd 在其中),目录名只是显示标签、不影响连接。
291
+
292
+ **远程会话看不到工作区的 AGENTS.md**(issue #68)—— 已修(0.8.52+)。核心的指令加载只认本地文件系统,
293
+ 而远程会话的 cwd 是本地镜像,远端仓库的 AGENTS.md 以前永远进不了模型上下文。现在 `rw_pick_workspace`
294
+ 成功时会对远程工作区做浅扫(根 + 向下 ≤2 层,只匹配 `AGENTS.md`/`CLAUDE.md`),把命中的文件按三向安全
295
+ 规则同步进本地镜像(根级文件由此进入核心自己的注入链路,与本地会话一致),并把路径+标题索引渲染进
296
+ system prompt,提示模型编辑前用 `rw_read_file` 阅读嵌套层的指令。全树递归扫描与插件侧全文注入都**刻意
297
+ 不做**(前者会扫穿多项目根目录,后者会与核心的注入管线并行打架)。
277
298
 
278
299
  **远程会话里的终端还是本机的**(issue #55)—— 这是**设计如此,插件改不了**,但有等效方案。
279
300
  DSH 的侧栏终端是核心功能:它的 PTY 来自**全局唯一**的 `subprocess` 服务,cwd 取会话自己的
package/lib/client.js CHANGED
@@ -179,6 +179,7 @@ window.__ModuleLoader__.load({
179
179
  'settings.feedback': '💬 反馈建议 / 提 issue',
180
180
  'settings.deleted': '已删除',
181
181
  'settings.saved': '已保存更新',
182
+ 'settings.rebound': '已将 {n} 个本地工作区镜像重新绑定到新地址(内容保留,无需重新同步)',
182
183
  'settings.added': '已添加 — 可设为当前',
183
184
  'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已回退为明文保存在 machines.json,连接仍可用。',
184
185
  'settings.switched': '已切换为当前远程机',
@@ -467,6 +468,7 @@ window.__ModuleLoader__.load({
467
468
  'settings.feedback': '💬 Feedback / report issue',
468
469
  'settings.deleted': 'Deleted',
469
470
  'settings.saved': 'Saved',
471
+ 'settings.rebound': 'Rebound {n} local workspace mirror(s) to the new address (content kept — no re-sync needed)',
470
472
  'settings.added': 'Added — you can set it as current',
471
473
  'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); it was saved as plaintext in machines.json and the machine still connects.',
472
474
  'settings.switched': 'Switched to current remote machine',
@@ -1190,6 +1192,9 @@ window.__ModuleLoader__.load({
1190
1192
  // Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
1191
1193
  // 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
1192
1194
  if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
1195
+ // Issue #67: an edited address rebinds the machine's existing mirrors —
1196
+ // say so, or the user cannot tell their workspaces came along.
1197
+ else if (r && r.reboundMirrors) setMsg(tr('settings.saved') + ' — ' + tr('settings.rebound', { n: r.reboundMirrors }))
1193
1198
  else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
1194
1199
  }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
1195
1200
  }
@@ -3237,7 +3242,7 @@ window.__ModuleLoader__.load({
3237
3242
  React.useEffect(load, [path])
3238
3243
 
3239
3244
  const startEdit = () => {
3240
- if (!data || data.binary) return
3245
+ if (!data || data.binary || data.image) return
3241
3246
  setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
3242
3247
  setEdit(true); setErr('')
3243
3248
  }
@@ -3276,7 +3281,7 @@ window.__ModuleLoader__.load({
3276
3281
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving || !dirty, title: tr('file.saveHint') }, saving ? tr('file.saving') : tr('file.saveToRemote')),
3277
3282
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, tr('file.cancel')))
3278
3283
  : React.createElement(React.Fragment, null,
3279
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, tr('file.edit')),
3284
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary || data.image }, tr('file.edit')),
3280
3285
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, tr('file.download'))),
3281
3286
  ),
3282
3287
  React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0, display: 'flex', flexDirection: 'column' } },
@@ -3285,7 +3290,10 @@ window.__ModuleLoader__.load({
3285
3290
  : (data && data.binary
3286
3291
  ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: T.warn } },
3287
3292
  tr('file.binary', { size: (data.size != null ? data.size + ' bytes' : tr('file.unknownSize')) }))
3288
- : (edit
3293
+ : data && data.image
3294
+ ? React.createElement('div', { style: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12, minHeight: 0 } },
3295
+ React.createElement('img', { src: `data:${data.mime};base64,${data.content}`, alt: baseName, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }))
3296
+ : (edit
3289
3297
  ? React.createElement('textarea', {
3290
3298
  value: draft,
3291
3299
  onChange: (e) => setDraft(e.target.value),
@@ -3636,6 +3644,10 @@ window.__ModuleLoader__.load({
3636
3644
  canOpen: (address) => {
3637
3645
  const parsed = parseCoreFileAddress(address)
3638
3646
  if (!parsed) return false
3647
+ // Let the built-in Markdown preview keep .md/.markdown files: it can
3648
+ // render them, whereas the remote-link editor would only show source.
3649
+ const name = String(parsed.path || '').split(/[\\/]/).pop()
3650
+ if (/\.(md|markdown)$/i.test(name)) return false
3639
3651
  const cwd = sessionCwdFor(parsed.sessionId)
3640
3652
  // `MIRRORS.loaded` is checked so a cold start NEVER steals a local
3641
3653
  // link: an empty registry cannot prove a mirror, and guessing would
package/lib/index.js CHANGED
@@ -38,6 +38,7 @@ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshC
38
38
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
39
39
  import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
40
40
  import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
41
+ import { createInstructionIndex, formatInstructionIndex } from './instructions.js'
41
42
  import { searchRemote } from './search.js'
42
43
  import { installTerminalShell } from './remote-shell.js'
43
44
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
@@ -1124,6 +1125,14 @@ export async function apply(ctx, config) {
1124
1125
  autoPushWatchers.clear()
1125
1126
  }, 'dsh-remote.autopush')
1126
1127
 
1128
+ // ── remote instruction-file index (issue #68) ────────────────────────────
1129
+ // The core AGENTS.md loader only sees the LOCAL mirror, so a remote repo's
1130
+ // instruction files never reach the model's context. This index shallow-
1131
+ // scans a bound workspace (root + ≤2 levels, AGENTS.md / CLAUDE.md only),
1132
+ // mirrors the finds into the local mirror (where the core loader looks),
1133
+ // and lists them in the system-prompt section below. See lib/instructions.js.
1134
+ const instructionIndex = createInstructionIndex()
1135
+
1127
1136
  // Helpers shared by session-bound tools and the machine-scoped web routes take
1128
1137
  // the pool explicitly (`p`), defaulting to the active-machine pool so route
1129
1138
  // callers — which are machine-scoped by design — stay unchanged.
@@ -1225,6 +1234,106 @@ export async function apply(ctx, config) {
1225
1234
  }
1226
1235
  return out
1227
1236
  }
1237
+
1238
+ /**
1239
+ * Rebind every mirror recorded for one machine identity to a NEW identity
1240
+ * (issue #67): when a machine's IP/user/port changes, its workspaces must
1241
+ * follow instead of being recreated. The mirror's `.dsh-remote-meta.json` is
1242
+ * the authoritative binding — session routing (bindingFor), pool keying
1243
+ * (poolForMachine) and the sidebar badges all read identity FROM THE META,
1244
+ * not from the directory name — so rewriting the meta re-points every live
1245
+ * session at the new address while the local mirror content stays put.
1246
+ *
1247
+ * The host directory itself is deliberately NOT renamed: live sessions hold
1248
+ * cwd paths inside it, and a rename would dangle them into "session is
1249
+ * LOCAL" refusals (on Windows the rename usually fails outright while a
1250
+ * shell sits inside). The directory name is a display label only; a stale
1251
+ * label after a rebind is the documented cost of keeping sessions alive.
1252
+ *
1253
+ * Matching covers both machine kinds: an ALIAS machine's mirrors are found
1254
+ * through the recorded `alias` (their meta host is the resolved address at
1255
+ * creation time), a literal machine's through host+port+username.
1256
+ *
1257
+ * @param {object} prev - the machine record BEFORE the identity change.
1258
+ * @param {object} next - the machine record AFTER it.
1259
+ * @returns {number} how many mirrors were rebound.
1260
+ */
1261
+ const rebindMirrors = (prev, next) => {
1262
+ if (!prev || !next) return 0
1263
+ const prevAlias = aliasOf(prev)
1264
+ const prevId = identityOf(prev)
1265
+ const prevRaw = { host: String(prev.host || ''), port: Number(prev.port) || 22, username: String(prev.username || '') }
1266
+ const nextAlias = aliasOf(next)
1267
+ const nextEff = withSshConfig(next) || next
1268
+ const nextId = {
1269
+ host: String(nextEff.host || next.host || ''),
1270
+ port: Number(nextEff.port || next.port) || 22,
1271
+ username: String(nextEff.username || next.username || ''),
1272
+ }
1273
+ const nextRaw = { host: String(next.host || ''), port: Number(next.port) || 22, username: String(next.username || '') }
1274
+ const sameIdentity = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username
1275
+ if (!prevAlias && sameIdentity(prevId, nextId) && sameIdentity(prevRaw, nextRaw)) return 0
1276
+ if (prevAlias && prevAlias === nextAlias) return 0
1277
+ const root = remoteWorkspacesRoot()
1278
+ let rebound = 0
1279
+ let hostDirs = []
1280
+ try { hostDirs = readdirSync(root) } catch { return 0 }
1281
+ for (const hostDir of hostDirs) {
1282
+ const hostPath = path.join(root, hostDir)
1283
+ if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
1284
+ let entries = []
1285
+ try { entries = readdirSync(hostPath) } catch { continue }
1286
+ for (const entry of entries) {
1287
+ const metaPath = path.join(hostPath, entry, '.dsh-remote-meta.json')
1288
+ let meta = null
1289
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch { continue }
1290
+ if (!meta || !meta.host) continue
1291
+ const byAlias = prevAlias && String(meta.alias || '') === prevAlias
1292
+ // A meta records the RESOLVED identity at pick time, but that identity
1293
+ // can also have been written raw — match on either form of the old
1294
+ // identity so a rebind still finds mirrors recorded before a later
1295
+ // ssh-config resolution rewrote what "this machine" meant.
1296
+ const metaId = { host: String(meta.host), port: Number(meta.port) || 22, username: String(meta.username || '') }
1297
+ const byIdentity = sameIdentity(metaId, prevId) || sameIdentity(metaId, prevRaw)
1298
+ if (!byAlias && !byIdentity) continue
1299
+ const updated = { ...meta }
1300
+ if (nextAlias) {
1301
+ // Alias machine: the recorded alias is what future resolution keys
1302
+ // on; the resolved address refreshes only when it actually resolved
1303
+ // (an unmatched alias keeps the old literal values rather than
1304
+ // stamping the alias NAME into the address fields).
1305
+ updated.alias = nextAlias
1306
+ if (nextEff.sshConfigMatched || nextEff.sshAlias) {
1307
+ updated.host = nextId.host
1308
+ updated.port = nextId.port
1309
+ updated.username = nextId.username
1310
+ }
1311
+ } else {
1312
+ delete updated.alias
1313
+ updated.host = nextRaw.host
1314
+ updated.port = nextRaw.port
1315
+ updated.username = nextRaw.username
1316
+ }
1317
+ updated.reboundAt = new Date().toISOString()
1318
+ try {
1319
+ writeFileSync(metaPath, JSON.stringify(updated, null, 2))
1320
+ rebound++
1321
+ } catch { /* leave that mirror as-is */ }
1322
+ }
1323
+ }
1324
+ if (rebound) {
1325
+ // The old identity's pooled connection dials an address the user just
1326
+ // abandoned: drop it so nothing keeps a dead SSH session warm. The new
1327
+ // identity gets its pool lazily on the next tool call.
1328
+ const stale = machinePools.get(poolKey(prevId))
1329
+ if (stale) {
1330
+ machinePools.delete(poolKey(prevId))
1331
+ try { stale.close() } catch { /* already gone */ }
1332
+ }
1333
+ }
1334
+ return rebound
1335
+ }
1336
+
1228
1337
  if (config.autoPush && wsPath()) {
1229
1338
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
1230
1339
  }
@@ -1614,10 +1723,10 @@ export async function apply(ctx, config) {
1614
1723
  defineTool({
1615
1724
  name: 'rw_connect',
1616
1725
  description:
1617
- 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down.',
1726
+ 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down. Rebinding (issue #67): pass machineId TOGETHER with a new host/username/port to update that saved machine in place — every workspace mirror bound to its old address is re-pointed (local content kept, no re-sync) instead of forcing new workspaces.',
1618
1727
  parameters: {
1619
1728
  host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
1620
- machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
1729
+ machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed — EXCEPT for rebinding (issue #67): combine machineId with a new host/username/port to update the machine in place and re-point its existing workspace mirrors.' },
1621
1730
  username: { type: 'string', description: 'SSH user (default from config or root)' },
1622
1731
  port: { type: 'integer', description: 'SSH port (default 22)' },
1623
1732
  password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
@@ -1634,6 +1743,9 @@ export async function apply(ctx, config) {
1634
1743
  },
1635
1744
  output: textOut,
1636
1745
  async execute(args) {
1746
+ // Issue #67: set by the machineId+overrides path, prepended to the
1747
+ // success line so the rebind is visible in the tool result.
1748
+ let reboundNote = ''
1637
1749
  const mid = String(args.machineId || '').trim()
1638
1750
  if (mid) {
1639
1751
  // Connect with an ALREADY SAVED machine (issue #48): the registry
@@ -1641,6 +1753,25 @@ export async function apply(ctx, config) {
1641
1753
  // keychain-backed passwords, stored passphrases, proxies, aliases.
1642
1754
  const i = machineIndex(mid)
1643
1755
  if (i < 0) throw new Error(`rw_connect: no saved machine "${mid}" — list saved machines with rw_machines`)
1756
+ // Issue #67: passing host/username/port ALONGSIDE a machineId rebinds
1757
+ // the saved machine in place — the fix for "the IP changed, do not
1758
+ // make me recreate every workspace". The record keeps its id (and
1759
+ // every credential), and each mirror bound to the old identity is
1760
+ // re-pointed so live sessions keep working against the new address.
1761
+ const prev = machines[i]
1762
+ const overrides = {}
1763
+ if (args.host !== undefined && String(args.host).trim()) overrides.host = String(args.host).trim()
1764
+ if (args.username !== undefined && String(args.username).trim()) overrides.username = String(args.username).trim()
1765
+ if (args.port !== undefined && Number(args.port)) overrides.port = Number(args.port)
1766
+ let rebound = 0
1767
+ if (Object.keys(overrides).length) {
1768
+ const next = { ...prev, ...overrides }
1769
+ try { rebound = rebindMirrors(prev, next) } catch { /* best effort */ }
1770
+ machines[i] = next
1771
+ }
1772
+ if (rebound) {
1773
+ reboundNote = `Rebound ${rebound} workspace mirror(s) from ${prev.username}@${prev.host}:${prev.port} to the new address (local content kept — no re-sync needed).\n`
1774
+ }
1644
1775
  store.currentId = machines[i].id
1645
1776
  ephemeral = null
1646
1777
  saveMachines(machines, store.currentId)
@@ -1749,7 +1880,7 @@ export async function apply(ctx, config) {
1749
1880
  const via = active && active.sshAlias
1750
1881
  ? `\n(ssh-config alias "${active.sshAlias}" → ${effUser}@${effHost}:${effPort}` + (active.sshConfigWarnings && active.sshConfigWarnings.length ? `; ${active.sshConfigWarnings.join(' ')}` : '') + ')'
1751
1882
  : ''
1752
- return { text: `Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1883
+ return { text: `${reboundNote}Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
1753
1884
  } catch (err) {
1754
1885
  throw new Error(friendlyMessage(err, { host: effHost, port: effPort }))
1755
1886
  }
@@ -1825,6 +1956,12 @@ export async function apply(ctx, config) {
1825
1956
  if (!b.bound) persistWorkspace(p)
1826
1957
  const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
1827
1958
  startAutoPush(local)
1959
+ // Issue #68: discover the workspace's AGENTS.md / CLAUDE.md NOW — the
1960
+ // first prompt assembly can come before any rw_sync, and the mirror
1961
+ // copy must already be in place for the core instruction loader to
1962
+ // pick it up. Awaited (the scan is a handful of shallow readdirs) but
1963
+ // fully best-effort: a scan failure never fails the pick.
1964
+ try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
1828
1965
  // The closing hint depends on where THIS session stands (issue #63):
1829
1966
  // in a LOCAL session "Run rw_sync" is a dead instruction — rw_sync
1830
1967
  // refuses a local session — so say the mirror must be OPENED first.
@@ -1878,11 +2015,21 @@ export async function apply(ctx, config) {
1878
2015
  }
1879
2016
  return { text }
1880
2017
  }
2018
+ // Issue #68: a real sync can pull in (or obsolete) instruction files —
2019
+ // refresh the prompt index afterwards. Kept inside the task body so a
2020
+ // background sync refreshes when IT finishes, not when it is queued.
2021
+ const runSyncAndIndex = async () => {
2022
+ const r = await runSync()
2023
+ if (!args.dryRun) {
2024
+ try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
2025
+ }
2026
+ return r
2027
+ }
1881
2028
  if (args.async) {
1882
- const t = tasks.start('sync', `sync ${p}`, runSync)
2029
+ const t = tasks.start('sync', `sync ${p}`, runSyncAndIndex)
1883
2030
  return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1884
2031
  }
1885
- return runSync()
2032
+ return runSyncAndIndex()
1886
2033
  },
1887
2034
  }),
1888
2035
 
@@ -2137,7 +2284,26 @@ export async function apply(ctx, config) {
2137
2284
  if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
2138
2285
  throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
2139
2286
  }
2140
- await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
2287
+ // Atomic write: stage to a temp file and rename over the target so a
2288
+ // mid-write failure cannot truncate the original (issue #65). Same
2289
+ // directory keeps the rename on one filesystem.
2290
+ const tmpPath = `${p}.dsh-remote-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
2291
+ try {
2292
+ await sftp.writeFile(tmpPath, encodeText(out, args.encoding || config.encoding))
2293
+ // renameOverwrite, not rename: the target EXISTS by definition here,
2294
+ // and plain SSH_FXP_RENAME refuses to replace it (issue #65).
2295
+ await sftp.renameOverwrite(tmpPath, p)
2296
+ // The rename swaps in a FRESH inode, so the original permission bits
2297
+ // (an executable script's +x, a 0600 secret) would silently reset to
2298
+ // the umask default. Restore them; a chmod failure must not fail the
2299
+ // edit itself — the content is already safely in place.
2300
+ if (st0 && typeof st0.mode === 'number') {
2301
+ try { await sftp.setstat(p, { mode: st0.mode & 0o7777 }) } catch { /* best effort */ }
2302
+ }
2303
+ } catch (err) {
2304
+ try { await sftp.unlink(tmpPath) } catch { /* best effort */ }
2305
+ throw err
2306
+ }
2141
2307
  audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
2142
2308
  return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
2143
2309
  },
@@ -2549,7 +2715,7 @@ export async function apply(ctx, config) {
2549
2715
  const session = agent && agent.session
2550
2716
  const cwd = session && session.header && session.header.cwd
2551
2717
  if (!cwd) return '' // no session cwd → nothing to map → stay quiet
2552
- const { remotePath, machine } = resolveMirrorForLocal(cwd)
2718
+ const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd)
2553
2719
  if (!remotePath) return '' // this session is NOT a remote session (issue #13)
2554
2720
  // Name the machine the MIRROR records, not the active one: pairing this
2555
2721
  // session's remote path with whichever machine happens to be active
@@ -2562,6 +2728,17 @@ export async function apply(ctx, config) {
2562
2728
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
2563
2729
  let extra = ''
2564
2730
  if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
2731
+ // Issue #68: surface the workspace's AGENTS.md / CLAUDE.md. The scan is
2732
+ // lazy (fire-and-forget on first assembly; negative-cached on failure),
2733
+ // so the index appears from the NEXT assembly once known — rw_pick_workspace
2734
+ // scans eagerly, so a picked workspace normally has it from the start.
2735
+ if (machine && mirrorDir) {
2736
+ instructionIndex.note({ pool: poolForMachine(machine), ws: remotePath, mirrorDir })
2737
+ const instr = instructionIndex.get(mirrorDir)
2738
+ if (instr && instr.files && instr.files.length) {
2739
+ extra += `\n${formatInstructionIndex(instr.files, (p) => toDisplayPath(p, platform))}`
2740
+ }
2741
+ }
2565
2742
  // Issue #39: `@` mentions are resolved from the REMOTE tree by
2566
2743
  // lib/file-reference.js, so the model must know they are remote-relative
2567
2744
  // and that the built-in read tool sees only the local mirror.
@@ -3193,6 +3370,14 @@ export async function apply(ctx, config) {
3193
3370
  // alone, so a fresh registry stays "no choice yet" (not an
3194
3371
  // explicit none) and a previously-cleared registry stays cleared.
3195
3372
  saveMachines(machines, store.currentId, false)
3373
+ // Issue #67: an EDITED connection identity (host/user/port) must
3374
+ // carry the machine's existing workspaces over — without this the
3375
+ // mirrors stayed bound to the old address and the user had to
3376
+ // create (and re-sync) a brand-new workspace for the new IP.
3377
+ let reboundMirrors = 0
3378
+ if (i >= 0 && prev) {
3379
+ try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
3380
+ }
3196
3381
  // Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
3197
3382
  // Saving a literal machine upserts a marker-tagged Host block,
3198
3383
  // so the file and the registry can never drift; the record's
@@ -3234,6 +3419,7 @@ export async function apply(ctx, config) {
3234
3419
  machine: sanitizeMachine(rec),
3235
3420
  machines: machinesForClient(),
3236
3421
  currentId: store.currentId,
3422
+ ...(reboundMirrors ? { reboundMirrors } : {}),
3237
3423
  ...(warning ? { warning, warningDetail } : {}),
3238
3424
  ...(sshConfigWarning ? { sshConfigWarning } : {}),
3239
3425
  })
@@ -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([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.51",
3
+ "version": "0.8.52",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",