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
package/README.en.md CHANGED
@@ -276,6 +276,7 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
276
276
  | `webInstallPrefix` | string | `''` | where an automatic deployment installs dsh; empty means `$HOME/.dsh-remote/dsh` (no system directory, no PATH change) |
277
277
  | `webInstallVersion` | string | `0.1.5-rc.2` | dsh version an automatic deployment installs; the default is the first that can start the web surface on Linux |
278
278
  | `webInstallRegistry` | string | `''` | npm registry for the install; **empty keeps the remote's own config**, so an internal mirror is never overridden |
279
+ | `localTools` | string | `bootstrap` | which `rw_*` tools a LOCAL session (cwd outside every remote mirror) sees (issue #63): `bootstrap` keeps only the configuration loop `rw_machines`/`rw_connect`/`rw_pick_workspace` (the other 19 refuse a local session with "this session is LOCAL", so exposing them just wastes schema budget); `none` hides all `rw_*` (remote setup happens via the settings page only); `all` restores the legacy full surface. REMOTE sessions (cwd inside a mirror) always see everything; an invalid value degrades to `bootstrap` |
279
280
 
280
281
  > The authoritative list is the `Config` schema in `lib/index.js`; this table mirrors it.
281
282
 
@@ -284,6 +285,13 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
284
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.
285
286
 
286
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).
287
295
 
288
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).
289
297
 
package/README.md CHANGED
@@ -258,6 +258,7 @@ scripts/dev-run.sh --status # 是否在运行
258
258
  | `webInstallPrefix` | string | `''` | 自动部署装到远端的哪个目录;留空用 `$HOME/.dsh-remote/dsh`(不写系统目录、不改 PATH) |
259
259
  | `webInstallVersion` | string | `0.1.5-rc.2` | 自动部署安装的 dsh 版本;默认值是第一个在 Linux 上能启动 web 的版本 |
260
260
  | `webInstallRegistry` | string | `''` | 安装用的 npm 源;**留空沿用远端自己的配置**,不会覆盖内网镜像 |
261
+ | `localTools` | string | `bootstrap` | 本地 session(cwd 不在远程镜像里)暴露哪些 `rw_*` 工具(issue #63):`bootstrap`=只暴露配置闭环 `rw_machines`/`rw_connect`/`rw_pick_workspace`(其余 19 个在本地 session 必然报 "this session is LOCAL",暴露只是白占 schema);`none`=完全不暴露 `rw_*`(远程配置只能走设置页);`all`=旧的 full 暴露。远程 session(cwd 在镜像里)不受影响,始终全量;非法值按 `bootstrap` 处理 |
261
262
 
262
263
  > 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
263
264
 
@@ -273,6 +274,27 @@ scripts/dev-run.sh --status # 是否在运行
273
274
  本地会话与页面刚加载的那一瞬间仍然走内置预览 —— 不会把本地文件误当成远程文件。
274
275
  两个仍然开不了的情况会给出明确提示而不是空白页:链接指向的是**绝对**远端路径之外无法映射的地址,
275
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
+ 不做**(前者会扫穿多项目根目录,后者会与核心的注入管线并行打架)。
276
298
 
277
299
  **远程会话里的终端还是本机的**(issue #55)—— 这是**设计如此,插件改不了**,但有等效方案。
278
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'
@@ -99,6 +100,35 @@ export function win32FolderPickScript() {
99
100
  )
100
101
  }
101
102
 
103
+ /** The rw_* tools a LOCAL session keeps under `localTools: 'bootstrap'`
104
+ * (issue #63): exactly the configuration loop — list machines, connect, pick
105
+ * a workspace. Every other rw_* tool needs a mirror-bound session and refuses
106
+ * a local one with "this session is LOCAL", so keeping it visible only spends
107
+ * schema budget. Exported so the test suite pins the set against the real
108
+ * registrations. */
109
+ export const LOCAL_BOOTSTRAP_TOOLS = ['rw_machines', 'rw_connect', 'rw_pick_workspace']
110
+
111
+ /** Normalize the `localTools` config value (issue #63). Anything unrecognized
112
+ * degrades to the safe default 'bootstrap' rather than disabling the filter
113
+ * (a typo must not silently re-expose the dead surface the user asked to
114
+ * hide) — mirroring how an unknown value would otherwise be mistaken for
115
+ * 'all'. */
116
+ export function normalizeLocalTools(value) {
117
+ const v = String(value || '').trim().toLowerCase()
118
+ return v === 'none' || v === 'all' ? v : 'bootstrap'
119
+ }
120
+
121
+ /** rw_pick_workspace's closing "what next" sentence (issue #63). In a LOCAL
122
+ * session `rw_sync` cannot run — the mirror path must be opened as a NEW
123
+ * session first, because tool routing keys on the session's cwd and picking
124
+ * a workspace does not change it. Only a mirror-bound (or caller-context-
125
+ * less) session can sync right away. */
126
+ export function pickWorkspaceNextStep(localSession) {
127
+ return localSession
128
+ ? 'NOTE: this session is LOCAL — rw_sync and the other remote tools refuse to run here. Open a session whose workspace IS the local mirror path above, then run rw_sync there to download the remote files.'
129
+ : 'Run rw_sync to download its files into the local mirror.'
130
+ }
131
+
102
132
  // Web and Desktop share the SSH tools; each optional UI transport is attached
103
133
  // reactively below, so service startup order does not drop the JSON routes.
104
134
  export const inject = ['tools', 'systemPrompt']
@@ -235,6 +265,16 @@ export const Config = z.object({
235
265
  /** npm registry to install from. Empty uses the remote's own npm config, which
236
266
  * is what an internal mirror needs to stay in effect. */
237
267
  webInstallRegistry: z.string().default(''),
268
+ /** Which rw_* tools a LOCAL session (session cwd is NOT a remote-workspaces
269
+ * mirror) sees (issue #63): `bootstrap` (default) exposes only the
270
+ * configuration loop — rw_machines / rw_connect / rw_pick_workspace — since
271
+ * the other tools can never succeed there (they refuse with "this session
272
+ * is LOCAL") and exposing them only spends ~4k tokens of schema per request;
273
+ * `none` hides every rw_* tool (remote setup then happens via the settings
274
+ * page only); `all` keeps the legacy full surface. A REMOTE session (cwd
275
+ * inside a mirror) always sees all tools, and an invalid value falls back
276
+ * to `bootstrap`. (schemastery 3.18 has no .enum — validated in code.) */
277
+ localTools: z.string().default('bootstrap'),
238
278
  })
239
279
 
240
280
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -1085,6 +1125,14 @@ export async function apply(ctx, config) {
1085
1125
  autoPushWatchers.clear()
1086
1126
  }, 'dsh-remote.autopush')
1087
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
+
1088
1136
  // Helpers shared by session-bound tools and the machine-scoped web routes take
1089
1137
  // the pool explicitly (`p`), defaulting to the active-machine pool so route
1090
1138
  // callers — which are machine-scoped by design — stay unchanged.
@@ -1186,6 +1234,106 @@ export async function apply(ctx, config) {
1186
1234
  }
1187
1235
  return out
1188
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
+
1189
1337
  if (config.autoPush && wsPath()) {
1190
1338
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
1191
1339
  }
@@ -1575,10 +1723,10 @@ export async function apply(ctx, config) {
1575
1723
  defineTool({
1576
1724
  name: 'rw_connect',
1577
1725
  description:
1578
- '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.',
1579
1727
  parameters: {
1580
1728
  host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
1581
- 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.' },
1582
1730
  username: { type: 'string', description: 'SSH user (default from config or root)' },
1583
1731
  port: { type: 'integer', description: 'SSH port (default 22)' },
1584
1732
  password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
@@ -1595,6 +1743,9 @@ export async function apply(ctx, config) {
1595
1743
  },
1596
1744
  output: textOut,
1597
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 = ''
1598
1749
  const mid = String(args.machineId || '').trim()
1599
1750
  if (mid) {
1600
1751
  // Connect with an ALREADY SAVED machine (issue #48): the registry
@@ -1602,6 +1753,25 @@ export async function apply(ctx, config) {
1602
1753
  // keychain-backed passwords, stored passphrases, proxies, aliases.
1603
1754
  const i = machineIndex(mid)
1604
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
+ }
1605
1775
  store.currentId = machines[i].id
1606
1776
  ephemeral = null
1607
1777
  saveMachines(machines, store.currentId)
@@ -1710,7 +1880,7 @@ export async function apply(ctx, config) {
1710
1880
  const via = active && active.sshAlias
1711
1881
  ? `\n(ssh-config alias "${active.sshAlias}" → ${effUser}@${effHost}:${effPort}` + (active.sshConfigWarnings && active.sshConfigWarnings.length ? `; ${active.sshConfigWarnings.join(' ')}` : '') + ')'
1712
1882
  : ''
1713
- 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>).` }
1714
1884
  } catch (err) {
1715
1885
  throw new Error(friendlyMessage(err, { host: effHost, port: effPort }))
1716
1886
  }
@@ -1786,8 +1956,17 @@ export async function apply(ctx, config) {
1786
1956
  if (!b.bound) persistWorkspace(p)
1787
1957
  const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
1788
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 */ }
1965
+ // The closing hint depends on where THIS session stands (issue #63):
1966
+ // in a LOCAL session "Run rw_sync" is a dead instruction — rw_sync
1967
+ // refuses a local session — so say the mirror must be OPENED first.
1789
1968
  return {
1790
- text: `Remote workspace set to ${shown} on ${b.username}@${b.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
1969
+ text: `Remote workspace set to ${shown} on ${b.username}@${b.host}.\nLocal mirror (native workspace path): ${local}\n\n${pickWorkspaceNextStep(b.local)}`,
1791
1970
  }
1792
1971
  },
1793
1972
  }),
@@ -1836,11 +2015,21 @@ export async function apply(ctx, config) {
1836
2015
  }
1837
2016
  return { text }
1838
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
+ }
1839
2028
  if (args.async) {
1840
- const t = tasks.start('sync', `sync ${p}`, runSync)
2029
+ const t = tasks.start('sync', `sync ${p}`, runSyncAndIndex)
1841
2030
  return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
1842
2031
  }
1843
- return runSync()
2032
+ return runSyncAndIndex()
1844
2033
  },
1845
2034
  }),
1846
2035
 
@@ -2095,7 +2284,26 @@ export async function apply(ctx, config) {
2095
2284
  if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
2096
2285
  throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
2097
2286
  }
2098
- 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
+ }
2099
2307
  audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
2100
2308
  return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
2101
2309
  },
@@ -2507,7 +2715,7 @@ export async function apply(ctx, config) {
2507
2715
  const session = agent && agent.session
2508
2716
  const cwd = session && session.header && session.header.cwd
2509
2717
  if (!cwd) return '' // no session cwd → nothing to map → stay quiet
2510
- const { remotePath, machine } = resolveMirrorForLocal(cwd)
2718
+ const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd)
2511
2719
  if (!remotePath) return '' // this session is NOT a remote session (issue #13)
2512
2720
  // Name the machine the MIRROR records, not the active one: pairing this
2513
2721
  // session's remote path with whichever machine happens to be active
@@ -2520,6 +2728,17 @@ export async function apply(ctx, config) {
2520
2728
  const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
2521
2729
  let extra = ''
2522
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
+ }
2523
2742
  // Issue #39: `@` mentions are resolved from the REMOTE tree by
2524
2743
  // lib/file-reference.js, so the model must know they are remote-relative
2525
2744
  // and that the built-in read tool sees only the local mirror.
@@ -2535,6 +2754,39 @@ export async function apply(ctx, config) {
2535
2754
  },
2536
2755
  })
2537
2756
 
2757
+ // ── local-session tool visibility (issue #63) ────────────────────────────
2758
+ // All rw_* tools register globally (at registration time there is no session
2759
+ // to consult), but in a LOCAL session the operation tools can never succeed:
2760
+ // bindingFor() refuses them with "this session is LOCAL". Exposing them
2761
+ // anyway costs every local request a few thousand tokens of dead schema.
2762
+ // The `system-prompt/assemble` waterfall is where the final per-request tool
2763
+ // list passes with the calling agent attached, so the SAME cwd test the
2764
+ // prompt section above already performs can trim the list there. A session
2765
+ // whose cwd maps to a mirror keeps the full surface; anything else — a
2766
+ // plain local directory, or a mirror whose meta is missing/unparsable
2767
+ // (resolveMirror reports no binding; bindingFor refuses it as local too) —
2768
+ // is reduced per config.localTools. An assembly without agent/cwd is left
2769
+ // alone, matching bindingFor's "no caller context" semantics. `ctx.on` is
2770
+ // cordis-core, but guard like the other
2771
+ // optional surfaces: a minimal embedding (or a small test double) without
2772
+ // event support simply keeps the legacy full surface.
2773
+ if (typeof ctx.on === 'function') {
2774
+ ctx.on('system-prompt/assemble', async (_assembly, assembleContext, next) => {
2775
+ const assembled = await next()
2776
+ const mode = normalizeLocalTools(config.localTools)
2777
+ if (mode === 'all') return assembled
2778
+ const agent = assembleContext && assembleContext.agent
2779
+ const cwd = agent && agent.session && agent.session.header && agent.session.header.cwd
2780
+ if (!cwd) return assembled // no caller context → not definitively local
2781
+ const { remotePath, machine } = resolveMirrorForLocal(String(cwd))
2782
+ if (remotePath && machine) return assembled // remote session → full surface
2783
+ const tools = (assembled.tools || []).filter(
2784
+ (t) => !t.name || !t.name.startsWith('rw_') || (mode === 'bootstrap' && LOCAL_BOOTSTRAP_TOOLS.includes(t.name)),
2785
+ )
2786
+ return { ...assembled, tools }
2787
+ })
2788
+ }
2789
+
2538
2790
  // ── slash commands ─────────────────────────────────────────────────────────
2539
2791
  const commands = ctx.get('commands')
2540
2792
  if (commands !== undefined) {
@@ -3118,6 +3370,14 @@ export async function apply(ctx, config) {
3118
3370
  // alone, so a fresh registry stays "no choice yet" (not an
3119
3371
  // explicit none) and a previously-cleared registry stays cleared.
3120
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
+ }
3121
3381
  // Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
3122
3382
  // Saving a literal machine upserts a marker-tagged Host block,
3123
3383
  // so the file and the registry can never drift; the record's
@@ -3159,6 +3419,7 @@ export async function apply(ctx, config) {
3159
3419
  machine: sanitizeMachine(rec),
3160
3420
  machines: machinesForClient(),
3161
3421
  currentId: store.currentId,
3422
+ ...(reboundMirrors ? { reboundMirrors } : {}),
3162
3423
  ...(warning ? { warning, warningDetail } : {}),
3163
3424
  ...(sshConfigWarning ? { sshConfigWarning } : {}),
3164
3425
  })