dsh-over-ssh 0.0.0-stage → 0.1.0

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/agents/agent-manifest.json +20 -0
  4. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  5. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  6. package/cordis.patch.yml +84 -0
  7. package/lib/client.js +6611 -0
  8. package/lib/file-references.js +366 -0
  9. package/lib/fs.js +1368 -0
  10. package/lib/index.js +19034 -0
  11. package/lib/local-bridge-refs.js +16 -0
  12. package/lib/local-bridge.js +16 -0
  13. package/lib/terminal-wrapper.js +294 -0
  14. package/lib/types/src/client/controls.d.ts +131 -0
  15. package/lib/types/src/client/core/card-focus-trap.d.ts +45 -0
  16. package/lib/types/src/client/core/controls.d.ts +164 -0
  17. package/lib/types/src/client/core/latest.d.ts +48 -0
  18. package/lib/types/src/client/core/locale.d.ts +38 -0
  19. package/lib/types/src/client/core/message.d.ts +37 -0
  20. package/lib/types/src/client/core/panel-model.d.ts +134 -0
  21. package/lib/types/src/client/core/portal.d.ts +266 -0
  22. package/lib/types/src/client/core/remote.d.ts +84 -0
  23. package/lib/types/src/client/core/styles.d.ts +458 -0
  24. package/lib/types/src/client/file-tree-io.d.ts +19 -0
  25. package/lib/types/src/client/file-tree.d.ts +271 -0
  26. package/lib/types/src/client/flows/directory-browser.d.ts +587 -0
  27. package/lib/types/src/client/flows/session-directory.d.ts +415 -0
  28. package/lib/types/src/client/flows/workspace-directory-flow.d.ts +480 -0
  29. package/lib/types/src/client/index.d.ts +49 -0
  30. package/lib/types/src/client/locale.d.ts +31 -0
  31. package/lib/types/src/client/panel-model.d.ts +37 -0
  32. package/lib/types/src/client/panel.d.ts +77 -0
  33. package/lib/types/src/client/roots/connection-test.d.ts +69 -0
  34. package/lib/types/src/client/roots/roots-panel-state.d.ts +307 -0
  35. package/lib/types/src/client/roots/roots-panel-view.d.ts +280 -0
  36. package/lib/types/src/client/roots/roots-panel.d.ts +364 -0
  37. package/lib/types/src/client/roots/transfer-panel-view.d.ts +35 -0
  38. package/lib/types/src/client/roots/transfer-panel.d.ts +241 -0
  39. package/lib/types/src/client/roots/transfer-progress.d.ts +67 -0
  40. package/lib/types/src/client/roots-panel-view.d.ts +236 -0
  41. package/lib/types/src/client/roots-panel.d.ts +347 -0
  42. package/lib/types/src/client/session/session-panel-view.d.ts +336 -0
  43. package/lib/types/src/client/session/session-panel.d.ts +322 -0
  44. package/lib/types/src/client/session/session-remote-mark.d.ts +110 -0
  45. package/lib/types/src/client/session/session-row-actions.d.ts +398 -0
  46. package/lib/types/src/client/session-panel.d.ts +152 -0
  47. package/lib/types/src/client/styles.d.ts +252 -0
  48. package/lib/types/src/client/workspace-directory-flow.d.ts +342 -0
  49. package/lib/types/src/config.d.ts +312 -0
  50. package/lib/types/src/file-references.d.ts +25 -0
  51. package/lib/types/src/fs.d.ts +46 -0
  52. package/lib/types/src/host/app/backend.d.ts +170 -0
  53. package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +247 -0
  54. package/lib/types/src/host/app/recycle-bin/recycle-bin-move.d.ts +18 -0
  55. package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +45 -0
  56. package/lib/types/src/host/app/recycle-bin/recycle-bin-world.d.ts +38 -0
  57. package/lib/types/src/host/app/recycle-bin-contract.d.ts +215 -0
  58. package/lib/types/src/host/app/recycle-bin-move.d.ts +18 -0
  59. package/lib/types/src/host/app/recycle-bin-service.d.ts +45 -0
  60. package/lib/types/src/host/app/recycle-bin-world.d.ts +35 -0
  61. package/lib/types/src/host/app/roots/roots-api-connections.d.ts +49 -0
  62. package/lib/types/src/host/app/roots/roots-api-contract.d.ts +369 -0
  63. package/lib/types/src/host/app/roots/roots-api-directories.d.ts +13 -0
  64. package/lib/types/src/host/app/roots/roots-api-http.d.ts +77 -0
  65. package/lib/types/src/host/app/roots/roots-api-long-poll.d.ts +130 -0
  66. package/lib/types/src/host/app/roots/roots-api-projections.d.ts +102 -0
  67. package/lib/types/src/host/app/roots/roots-api-roots.d.ts +13 -0
  68. package/lib/types/src/host/app/roots/roots-api-state.d.ts +22 -0
  69. package/lib/types/src/host/app/roots/roots-api-transfers.d.ts +26 -0
  70. package/lib/types/src/host/app/roots/roots-api.d.ts +55 -0
  71. package/lib/types/src/host/app/roots/user-roots.d.ts +552 -0
  72. package/lib/types/src/host/app/roots-api-connections.d.ts +49 -0
  73. package/lib/types/src/host/app/roots-api-contract.d.ts +339 -0
  74. package/lib/types/src/host/app/roots-api-directories.d.ts +13 -0
  75. package/lib/types/src/host/app/roots-api-http.d.ts +77 -0
  76. package/lib/types/src/host/app/roots-api-projections.d.ts +102 -0
  77. package/lib/types/src/host/app/roots-api-roots.d.ts +13 -0
  78. package/lib/types/src/host/app/roots-api-state.d.ts +22 -0
  79. package/lib/types/src/host/app/roots-api-support.d.ts +182 -0
  80. package/lib/types/src/host/app/roots-api-transfers.d.ts +13 -0
  81. package/lib/types/src/host/app/roots-api.d.ts +55 -0
  82. package/lib/types/src/host/app/routed-fs.d.ts +239 -0
  83. package/lib/types/src/host/app/serialize.d.ts +37 -0
  84. package/lib/types/src/host/app/session-admin/session-admin-context.d.ts +36 -0
  85. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +221 -0
  86. package/lib/types/src/host/app/session-admin/session-admin-delete.d.ts +34 -0
  87. package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +40 -0
  88. package/lib/types/src/host/app/session-admin-context.d.ts +36 -0
  89. package/lib/types/src/host/app/session-admin-contract.d.ts +214 -0
  90. package/lib/types/src/host/app/session-admin-delete.d.ts +34 -0
  91. package/lib/types/src/host/app/session-admin-service.d.ts +38 -0
  92. package/lib/types/src/host/app/takeover/takeover-plan.d.ts +227 -0
  93. package/lib/types/src/host/app/takeover/takeover-rows.d.ts +55 -0
  94. package/lib/types/src/host/app/transport/transport-selector.d.ts +98 -0
  95. package/lib/types/src/host/app/transport/transport-service.d.ts +82 -0
  96. package/lib/types/src/host/app/transport-selector.d.ts +98 -0
  97. package/lib/types/src/host/app/transport-service.d.ts +75 -0
  98. package/lib/types/src/host/app/user-roots.d.ts +461 -0
  99. package/lib/types/src/host/domain/bin-location.d.ts +79 -0
  100. package/lib/types/src/host/domain/capability.d.ts +78 -0
  101. package/lib/types/src/host/domain/connection.d.ts +95 -0
  102. package/lib/types/src/host/domain/errors.d.ts +55 -0
  103. package/lib/types/src/host/domain/glob-pattern.d.ts +62 -0
  104. package/lib/types/src/host/domain/literal-text.d.ts +63 -0
  105. package/lib/types/src/host/domain/path-grammar.d.ts +84 -0
  106. package/lib/types/src/host/domain/recycle-bin.d.ts +337 -0
  107. package/lib/types/src/host/domain/root.d.ts +74 -0
  108. package/lib/types/src/host/domain/roots/bin-location.d.ts +91 -0
  109. package/lib/types/src/host/domain/roots/connection.d.ts +106 -0
  110. package/lib/types/src/host/domain/roots/root.d.ts +108 -0
  111. package/lib/types/src/host/domain/roots/route-table.d.ts +102 -0
  112. package/lib/types/src/host/domain/roots/session-roots.d.ts +151 -0
  113. package/lib/types/src/host/domain/roots/ssh-config.d.ts +73 -0
  114. package/lib/types/src/host/domain/route-table.d.ts +80 -0
  115. package/lib/types/src/host/domain/session/session-admin.d.ts +345 -0
  116. package/lib/types/src/host/domain/session/terminal-target.d.ts +136 -0
  117. package/lib/types/src/host/domain/session-admin.d.ts +330 -0
  118. package/lib/types/src/host/domain/shell.d.ts +47 -0
  119. package/lib/types/src/host/domain/ssh-config.d.ts +73 -0
  120. package/lib/types/src/host/domain/terminal-target.d.ts +132 -0
  121. package/lib/types/src/host/domain/tool-requests.d.ts +369 -0
  122. package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +337 -0
  123. package/lib/types/src/host/domain/transfer/transfer.d.ts +260 -0
  124. package/lib/types/src/host/domain/transfer.d.ts +260 -0
  125. package/lib/types/src/host/infra/backend/local-backend.d.ts +84 -0
  126. package/lib/types/src/host/infra/backend/local-version.d.ts +43 -0
  127. package/lib/types/src/host/infra/backend/platform-local-backend.d.ts +89 -0
  128. package/lib/types/src/host/infra/backend/remote-backend.d.ts +239 -0
  129. package/lib/types/src/host/infra/cache/lru.d.ts +52 -0
  130. package/lib/types/src/host/infra/cache/versioned.d.ts +77 -0
  131. package/lib/types/src/host/infra/temp-name.d.ts +19 -0
  132. package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +121 -0
  133. package/lib/types/src/host/infra/transport/agent/agent-protocol-client.d.ts +132 -0
  134. package/lib/types/src/host/infra/transport/agent/agent-protocol-codec.d.ts +140 -0
  135. package/lib/types/src/host/infra/transport/agent/agent-protocol-errors.d.ts +41 -0
  136. package/lib/types/src/host/infra/transport/agent/agent-protocol-frames.d.ts +197 -0
  137. package/lib/types/src/host/infra/transport/agent/agent-protocol-types.d.ts +564 -0
  138. package/lib/types/src/host/infra/transport/agent/agent-transport.d.ts +418 -0
  139. package/lib/types/src/host/infra/transport/agent-installer.d.ts +121 -0
  140. package/lib/types/src/host/infra/transport/agent-protocol-client.d.ts +123 -0
  141. package/lib/types/src/host/infra/transport/agent-protocol-codec.d.ts +131 -0
  142. package/lib/types/src/host/infra/transport/agent-protocol-errors.d.ts +41 -0
  143. package/lib/types/src/host/infra/transport/agent-protocol-frames.d.ts +197 -0
  144. package/lib/types/src/host/infra/transport/agent-protocol-types.d.ts +509 -0
  145. package/lib/types/src/host/infra/transport/agent-transport.d.ts +353 -0
  146. package/lib/types/src/host/infra/transport/alias-transport.d.ts +206 -0
  147. package/lib/types/src/host/infra/transport/askpass-fs.d.ts +20 -0
  148. package/lib/types/src/host/infra/transport/askpass.d.ts +133 -0
  149. package/lib/types/src/host/infra/transport/command-pool.d.ts +61 -0
  150. package/lib/types/src/host/infra/transport/fs-commands.d.ts +140 -0
  151. package/lib/types/src/host/infra/transport/git-status-route.d.ts +63 -0
  152. package/lib/types/src/host/infra/transport/git-status.d.ts +101 -0
  153. package/lib/types/src/host/infra/transport/node-spawn.d.ts +26 -0
  154. package/lib/types/src/host/infra/transport/session-window.d.ts +51 -0
  155. package/lib/types/src/host/infra/transport/shell-quote.d.ts +59 -0
  156. package/lib/types/src/host/infra/transport/ssh/alias-transport.d.ts +206 -0
  157. package/lib/types/src/host/infra/transport/ssh/askpass-fs.d.ts +20 -0
  158. package/lib/types/src/host/infra/transport/ssh/askpass.d.ts +133 -0
  159. package/lib/types/src/host/infra/transport/ssh/shell-quote.d.ts +62 -0
  160. package/lib/types/src/host/infra/transport/ssh/ssh-argv.d.ts +43 -0
  161. package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +328 -0
  162. package/lib/types/src/host/infra/transport/ssh-argv.d.ts +43 -0
  163. package/lib/types/src/host/infra/transport/ssh2-transport.d.ts +302 -0
  164. package/lib/types/src/host/infra/transport/transport-base.d.ts +34 -0
  165. package/lib/types/src/host/infra/transport/transport.d.ts +527 -0
  166. package/lib/types/src/host/integration/agent/agent-install-io.d.ts +33 -0
  167. package/lib/types/src/host/integration/agent/agent-preflight.d.ts +56 -0
  168. package/lib/types/src/host/integration/agent/askpass-registry.d.ts +66 -0
  169. package/lib/types/src/host/integration/agent-install-io.d.ts +33 -0
  170. package/lib/types/src/host/integration/agent-preflight.d.ts +56 -0
  171. package/lib/types/src/host/integration/anchor-directory.d.ts +56 -0
  172. package/lib/types/src/host/integration/askpass-registry.d.ts +59 -0
  173. package/lib/types/src/host/integration/bin-root-resolver.d.ts +81 -0
  174. package/lib/types/src/host/integration/connection-test.d.ts +73 -0
  175. package/lib/types/src/host/integration/file-reference-provider.d.ts +115 -0
  176. package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +142 -0
  177. package/lib/types/src/host/integration/fs/fs-provider.d.ts +368 -0
  178. package/lib/types/src/host/integration/fs/git-status-wiring.d.ts +21 -0
  179. package/lib/types/src/host/integration/fs/platform-errors.d.ts +94 -0
  180. package/lib/types/src/host/integration/fs/remote-file-tools.d.ts +369 -0
  181. package/lib/types/src/host/integration/fs/remote-index.d.ts +177 -0
  182. package/lib/types/src/host/integration/fs-provider.d.ts +226 -0
  183. package/lib/types/src/host/integration/lsp/lsp-framing.d.ts +38 -0
  184. package/lib/types/src/host/integration/lsp/lsp-registry.d.ts +67 -0
  185. package/lib/types/src/host/integration/lsp/lsp-session.d.ts +130 -0
  186. package/lib/types/src/host/integration/management-tools.d.ts +64 -0
  187. package/lib/types/src/host/integration/recycle-bin-service.d.ts +247 -0
  188. package/lib/types/src/host/integration/remote/connection-test.d.ts +73 -0
  189. package/lib/types/src/host/integration/remote/remote-face.d.ts +127 -0
  190. package/lib/types/src/host/integration/remote/remote-tool-backend.d.ts +108 -0
  191. package/lib/types/src/host/integration/remote-face.d.ts +127 -0
  192. package/lib/types/src/host/integration/remote-file-tools.d.ts +241 -0
  193. package/lib/types/src/host/integration/remote-home.d.ts +69 -0
  194. package/lib/types/src/host/integration/remote-index.d.ts +165 -0
  195. package/lib/types/src/host/integration/remote-job-tools.d.ts +295 -0
  196. package/lib/types/src/host/integration/remote-tool-backend.d.ts +97 -0
  197. package/lib/types/src/host/integration/remote-tools.d.ts +206 -0
  198. package/lib/types/src/host/integration/root-registry.d.ts +115 -0
  199. package/lib/types/src/host/integration/roots/anchor-directory.d.ts +56 -0
  200. package/lib/types/src/host/integration/roots/bin-root-resolver.d.ts +81 -0
  201. package/lib/types/src/host/integration/roots/remote-home.d.ts +69 -0
  202. package/lib/types/src/host/integration/roots/root-registry.d.ts +144 -0
  203. package/lib/types/src/host/integration/roots/roots-wiring.d.ts +219 -0
  204. package/lib/types/src/host/integration/roots/session-root-index.d.ts +52 -0
  205. package/lib/types/src/host/integration/roots-api.d.ts +407 -0
  206. package/lib/types/src/host/integration/roots-wiring.d.ts +211 -0
  207. package/lib/types/src/host/integration/session/session-api.d.ts +88 -0
  208. package/lib/types/src/host/integration/session/session-bin-lock.d.ts +80 -0
  209. package/lib/types/src/host/integration/session/session-header.d.ts +151 -0
  210. package/lib/types/src/host/integration/session/session-labels.d.ts +56 -0
  211. package/lib/types/src/host/integration/session/session-route-wiring.d.ts +50 -0
  212. package/lib/types/src/host/integration/session-admin-service.d.ts +236 -0
  213. package/lib/types/src/host/integration/session-api.d.ts +81 -0
  214. package/lib/types/src/host/integration/session-bin-lock.d.ts +80 -0
  215. package/lib/types/src/host/integration/session-header.d.ts +118 -0
  216. package/lib/types/src/host/integration/session-labels.d.ts +56 -0
  217. package/lib/types/src/host/integration/session-route-wiring.d.ts +50 -0
  218. package/lib/types/src/host/integration/session-scope.d.ts +113 -0
  219. package/lib/types/src/host/integration/session-tools.d.ts +44 -0
  220. package/lib/types/src/host/integration/strict-arguments.d.ts +79 -0
  221. package/lib/types/src/host/integration/takeover/takeover-api.d.ts +62 -0
  222. package/lib/types/src/host/integration/takeover/takeover-route-wiring.d.ts +60 -0
  223. package/lib/types/src/host/integration/takeover/takeover-service.d.ts +167 -0
  224. package/lib/types/src/host/integration/terminal-manifest.d.ts +94 -0
  225. package/lib/types/src/host/integration/tools/management-tools.d.ts +245 -0
  226. package/lib/types/src/host/integration/tools/remote-job-tools.d.ts +316 -0
  227. package/lib/types/src/host/integration/tools/remote-lsp-tools.d.ts +43 -0
  228. package/lib/types/src/host/integration/tools/remote-tools.d.ts +236 -0
  229. package/lib/types/src/host/integration/tools/session-target.d.ts +52 -0
  230. package/lib/types/src/host/integration/tools/session-tools.d.ts +46 -0
  231. package/lib/types/src/host/integration/tools/strict-arguments.d.ts +79 -0
  232. package/lib/types/src/host/integration/tools/terminal-manifest.d.ts +94 -0
  233. package/lib/types/src/host/integration/transfer/remote-copy.d.ts +66 -0
  234. package/lib/types/src/host/integration/transfer/transfer-adapters.d.ts +48 -0
  235. package/lib/types/src/host/integration/transfer/transfer-service.d.ts +212 -0
  236. package/lib/types/src/host/integration/transfer/transfer-tracker.d.ts +106 -0
  237. package/lib/types/src/host/integration/transfer-adapters.d.ts +44 -0
  238. package/lib/types/src/host/integration/transfer-service.d.ts +164 -0
  239. package/lib/types/src/host/integration/transfer-tracker.d.ts +106 -0
  240. package/lib/types/src/host/integration/user-roots.d.ts +461 -0
  241. package/lib/types/src/index.d.ts +66 -0
  242. package/lib/types/src/local-bridge-refs.d.ts +26 -0
  243. package/lib/types/src/local-bridge.d.ts +36 -0
  244. package/lib/types/src/shared/format.d.ts +13 -0
  245. package/lib/types/src/wiring/connections.d.ts +189 -0
  246. package/lib/types/src/wiring/fs.d.ts +13 -0
  247. package/lib/types/src/wiring/helpers.d.ts +144 -0
  248. package/lib/types/src/wiring/install-memo.d.ts +32 -0
  249. package/lib/types/src/wiring/legacy-dirs.d.ts +62 -0
  250. package/lib/types/src/wiring/package-paths.d.ts +35 -0
  251. package/lib/types/src/wiring/roots.d.ts +21 -0
  252. package/lib/types/src/wiring/state-dir.d.ts +29 -0
  253. package/lib/types/src/wiring/tools.d.ts +35 -0
  254. package/package.json +120 -4
package/lib/fs.js ADDED
@@ -0,0 +1,1368 @@
1
+ // src/fs.ts
2
+ import "@deepseek-ai/cordis";
3
+
4
+ // src/host/integration/fs/fs-provider.ts
5
+ import { StringDecoder } from "node:string_decoder";
6
+ import "@deepseek-ai/cordis";
7
+ import { FileSystem, FsTargetKey, FsVersion } from "@deepseek-ai/dsh-fs";
8
+
9
+ // src/host/integration/fs/platform-errors.ts
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { FsError } from "@deepseek-ai/dsh-fs";
14
+ var hostClass;
15
+ var detection;
16
+ function resolveHostFsPath(entryScript) {
17
+ if (entryScript === void 0 || entryScript === "") return void 0;
18
+ try {
19
+ const resolved = createRequire(entryScript).resolve("@deepseek-ai/dsh-fs");
20
+ return typeof resolved === "string" && resolved !== "" ? resolved : void 0;
21
+ } catch {
22
+ return void 0;
23
+ }
24
+ }
25
+ function hostFsCandidates(entryScript) {
26
+ if (entryScript === void 0 || entryScript === "") return [];
27
+ const base = dirname(entryScript);
28
+ const relative = join("node_modules", "@deepseek-ai", "dsh-fs", "lib", "index.js");
29
+ return [join(base, relative), join(base, "..", relative), join(base, "..", "..", relative)].map(
30
+ (path) => pathToFileURL(path).href
31
+ );
32
+ }
33
+ function startHostErrorDetection(entryScript = process.argv[1]) {
34
+ if (detection !== void 0) return;
35
+ const started = detectHostFsError(entryScript);
36
+ detection = started;
37
+ void started.catch(() => void 0);
38
+ }
39
+ async function detectHostFsError(entryScript) {
40
+ const resolved = resolveHostFsPath(entryScript);
41
+ const sources = resolved === void 0 ? hostFsCandidates(entryScript) : [pathToFileURL(resolved).href, ...hostFsCandidates(entryScript)];
42
+ for (const candidate of sources) {
43
+ try {
44
+ const module = await import(candidate);
45
+ const found = module.FsError;
46
+ if (typeof found === "function" && found !== FsError) {
47
+ hostClass = found;
48
+ return hostClass;
49
+ }
50
+ } catch {
51
+ }
52
+ }
53
+ return void 0;
54
+ }
55
+ function isFsError(error) {
56
+ if (error instanceof FsError) return true;
57
+ return hostClass !== void 0 && error instanceof hostClass;
58
+ }
59
+ function activeFsErrorClass() {
60
+ return hostClass ?? FsError;
61
+ }
62
+ function platformFsError(message, code, options) {
63
+ const Constructor = activeFsErrorClass();
64
+ return new Constructor(message, code, options);
65
+ }
66
+
67
+ // src/host/infra/cache/lru.ts
68
+ var LruCache = class {
69
+ #maxEntries;
70
+ #maxBytes;
71
+ #sizeOf;
72
+ #entries = /* @__PURE__ */ new Map();
73
+ #bytes = 0;
74
+ #hits = 0;
75
+ #misses = 0;
76
+ #evictions = 0;
77
+ #rejectedOversize = 0;
78
+ constructor(options, sizeOf) {
79
+ if (!Number.isInteger(options.maxEntries) || options.maxEntries < 1) throw new Error("LruCache.maxEntries must be a positive integer");
80
+ if (!Number.isInteger(options.maxBytes) || options.maxBytes < 1) throw new Error("LruCache.maxBytes must be a positive integer");
81
+ this.#maxEntries = options.maxEntries;
82
+ this.#maxBytes = options.maxBytes;
83
+ this.#sizeOf = sizeOf;
84
+ }
85
+ /** Look up a value and mark it most-recently used. */
86
+ get(key) {
87
+ const entry = this.#entries.get(key);
88
+ if (entry === void 0) {
89
+ this.#misses++;
90
+ return void 0;
91
+ }
92
+ this.#entries.delete(key);
93
+ this.#entries.set(key, entry);
94
+ this.#hits++;
95
+ return entry.value;
96
+ }
97
+ /** Look up a value without changing recency or counters. */
98
+ peek(key) {
99
+ return this.#entries.get(key)?.value;
100
+ }
101
+ /** Whether the key is retained. */
102
+ has(key) {
103
+ return this.#entries.has(key);
104
+ }
105
+ /**
106
+ * Insert or replace a value.
107
+ *
108
+ * @returns `false` when the value alone exceeds `maxBytes` and was therefore not stored.
109
+ */
110
+ set(key, value) {
111
+ const bytes = this.#sizeOf(value);
112
+ if (bytes > this.#maxBytes) {
113
+ this.delete(key);
114
+ this.#rejectedOversize++;
115
+ return false;
116
+ }
117
+ this.delete(key);
118
+ this.#entries.set(key, { value, bytes });
119
+ this.#bytes += bytes;
120
+ this.#evictToBounds();
121
+ return true;
122
+ }
123
+ /** Remove a key, returning whether it was present. */
124
+ delete(key) {
125
+ const entry = this.#entries.get(key);
126
+ if (entry === void 0) return false;
127
+ this.#entries.delete(key);
128
+ this.#bytes -= entry.bytes;
129
+ return true;
130
+ }
131
+ /** Remove everything. */
132
+ clear() {
133
+ this.#entries.clear();
134
+ this.#bytes = 0;
135
+ }
136
+ /** Keys from least to most recently used. */
137
+ keys() {
138
+ return [...this.#entries.keys()];
139
+ }
140
+ /** Current counters. */
141
+ get stats() {
142
+ return {
143
+ entries: this.#entries.size,
144
+ bytes: this.#bytes,
145
+ hits: this.#hits,
146
+ misses: this.#misses,
147
+ evictions: this.#evictions,
148
+ rejectedOversize: this.#rejectedOversize
149
+ };
150
+ }
151
+ #evictToBounds() {
152
+ while (this.#entries.size > this.#maxEntries || this.#bytes > this.#maxBytes) {
153
+ const oldest = this.#entries.keys().next();
154
+ if (oldest.done === true) return;
155
+ const key = oldest.value;
156
+ const entry = this.#entries.get(key);
157
+ if (entry === void 0) return;
158
+ this.#entries.delete(key);
159
+ this.#bytes -= entry.bytes;
160
+ this.#evictions++;
161
+ }
162
+ }
163
+ };
164
+
165
+ // src/host/infra/cache/versioned.ts
166
+ var VersionedCache = class {
167
+ #ttlMs;
168
+ #now;
169
+ #lru;
170
+ #hits = 0;
171
+ #misses = 0;
172
+ #freshHits = 0;
173
+ #staleHits = 0;
174
+ #revalidations = 0;
175
+ constructor(options, sizeOf) {
176
+ if (!Number.isInteger(options.ttlMs) || options.ttlMs < 0) throw new Error("VersionedCache.ttlMs must be a non-negative integer");
177
+ this.#ttlMs = options.ttlMs;
178
+ this.#now = options.now ?? Date.now;
179
+ this.#lru = new LruCache({ maxEntries: options.maxEntries, maxBytes: options.maxBytes }, (entry) => sizeOf(entry.value));
180
+ }
181
+ /**
182
+ * Look up an entry.
183
+ *
184
+ * @param key - route key.
185
+ * @returns the entry with its freshness, or `undefined` when nothing is retained.
186
+ */
187
+ get(key) {
188
+ const stored = this.#lru.get(key);
189
+ if (stored === void 0) {
190
+ this.#misses++;
191
+ return void 0;
192
+ }
193
+ const ageMs = Math.max(0, this.#now() - stored.storedAt);
194
+ const fresh = ageMs <= this.#ttlMs;
195
+ this.#hits++;
196
+ if (fresh) this.#freshHits++;
197
+ else this.#staleHits++;
198
+ return { value: stored.value, version: stored.version, fresh, ageMs };
199
+ }
200
+ /** Insert or replace an entry, stamping it as just-read. */
201
+ set(key, value, version) {
202
+ return this.#lru.set(key, { value, version, storedAt: this.#now() });
203
+ }
204
+ /**
205
+ * Refresh an entry's timestamp after a cheap remote version check matched.
206
+ *
207
+ * @param key - route key.
208
+ * @param version - the version observed by the check; a mismatch refuses the touch.
209
+ * @returns whether the entry was refreshed.
210
+ */
211
+ touch(key, version) {
212
+ const stored = this.#lru.peek(key);
213
+ if (stored === void 0 || stored.version !== version) return false;
214
+ const refreshed = this.#lru.set(key, { value: stored.value, version: stored.version, storedAt: this.#now() });
215
+ if (refreshed) this.#revalidations++;
216
+ return refreshed;
217
+ }
218
+ /** Drop one entry. */
219
+ invalidate(key) {
220
+ return this.#lru.delete(key);
221
+ }
222
+ /**
223
+ * Drop every entry whose key starts with the prefix, used when a directory epoch changes.
224
+ *
225
+ * @param prefix - key prefix.
226
+ * @returns the number of removed entries.
227
+ */
228
+ invalidatePrefix(prefix) {
229
+ let removed = 0;
230
+ for (const key of this.#lru.keys()) {
231
+ if (key.startsWith(prefix) && this.#lru.delete(key)) removed++;
232
+ }
233
+ return removed;
234
+ }
235
+ /** Remove everything. */
236
+ clear() {
237
+ this.#lru.clear();
238
+ }
239
+ /** Current counters. */
240
+ get stats() {
241
+ const lru = this.#lru.stats;
242
+ return {
243
+ entries: lru.entries,
244
+ bytes: lru.bytes,
245
+ hits: this.#hits,
246
+ misses: this.#misses,
247
+ freshHits: this.#freshHits,
248
+ staleHits: this.#staleHits,
249
+ revalidations: this.#revalidations,
250
+ evictions: lru.evictions
251
+ };
252
+ }
253
+ };
254
+
255
+ // src/host/domain/errors.ts
256
+ var SshDevError = class extends Error {
257
+ code;
258
+ details;
259
+ constructor(code, message, details = {}) {
260
+ super(message);
261
+ this.name = "SshDevError";
262
+ this.code = code;
263
+ this.details = details;
264
+ }
265
+ };
266
+ function isSshDevError(value) {
267
+ return value instanceof SshDevError;
268
+ }
269
+
270
+ // src/host/domain/path-grammar.ts
271
+ function normalizeRemotePath(path) {
272
+ if (path === "") throw new SshDevError("ROOT_INVALID", "\u8FDC\u7A0B\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A", {});
273
+ if (!path.startsWith("/")) {
274
+ throw new SshDevError("ROOT_INVALID", `\u8FDC\u7A0B\u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\uFF1A${path}`, { path });
275
+ }
276
+ const segments = [];
277
+ for (const segment of path.split("/")) {
278
+ if (segment === "" || segment === ".") continue;
279
+ if (segment === "..") {
280
+ segments.pop();
281
+ continue;
282
+ }
283
+ segments.push(segment);
284
+ }
285
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
286
+ }
287
+ function remotePathContains(root, candidate) {
288
+ const normalizedRoot = normalizeRemotePath(root);
289
+ const normalizedCandidate = normalizeRemotePath(candidate);
290
+ if (normalizedRoot === "/") return true;
291
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`);
292
+ }
293
+
294
+ // src/host/infra/transport/git-status.ts
295
+ function isGitStatusTransport(value) {
296
+ return typeof value === "object" && value !== null && typeof value.gitStatus === "function";
297
+ }
298
+ var DEFAULT_GIT_STATUS_TTL_MS = 5e3;
299
+ var DEFAULT_GIT_STATUS_MAX_ENTRIES = 64;
300
+ var DEFAULT_GIT_STATUS_MAX_BYTES = 4 * 1024 * 1024;
301
+ var DEFAULT_GIT_STATUS_MAX_CHANGES = 2e3;
302
+ var DEFAULT_GIT_STATUS_MAX_PATH_BYTES = 512 * 1024;
303
+ function cacheKey(connectionId, root) {
304
+ return `${connectionId}\0${root}`;
305
+ }
306
+ function awaitCallerCancellation(shared, signal) {
307
+ if (signal === void 0) return shared;
308
+ if (signal.aborted) {
309
+ return Promise.reject(new SshDevError("CANCELLED", "git \u72B6\u6001\u67E5\u8BE2\u5DF2\u88AB\u8C03\u7528\u65B9\u53D6\u6D88", { reason: "aborted" }));
310
+ }
311
+ return new Promise((resolve, reject) => {
312
+ const onAbort = () => {
313
+ reject(new SshDevError("CANCELLED", "git \u72B6\u6001\u67E5\u8BE2\u5DF2\u88AB\u8C03\u7528\u65B9\u53D6\u6D88", { reason: "aborted" }));
314
+ };
315
+ signal.addEventListener("abort", onAbort, { once: true });
316
+ shared.then(
317
+ (value) => {
318
+ signal.removeEventListener("abort", onAbort);
319
+ resolve(value);
320
+ },
321
+ (error) => {
322
+ signal.removeEventListener("abort", onAbort);
323
+ reject(error);
324
+ }
325
+ );
326
+ });
327
+ }
328
+ function createGitStatusService(deps) {
329
+ const maxChanges = Math.max(1, Math.floor(deps.maxChanges ?? DEFAULT_GIT_STATUS_MAX_CHANGES));
330
+ const maxPathBytes = Math.max(1, Math.floor(deps.maxPathBytes ?? DEFAULT_GIT_STATUS_MAX_PATH_BYTES));
331
+ const cache = new VersionedCache(
332
+ {
333
+ ttlMs: deps.ttlMs ?? DEFAULT_GIT_STATUS_TTL_MS,
334
+ maxEntries: deps.maxEntries ?? DEFAULT_GIT_STATUS_MAX_ENTRIES,
335
+ maxBytes: deps.maxBytes ?? DEFAULT_GIT_STATUS_MAX_BYTES,
336
+ ...deps.now === void 0 ? {} : { now: deps.now }
337
+ },
338
+ (snapshot) => snapshot.changes.reduce((total, change) => total + change.path.length + change.status.length, snapshot.branch?.length ?? 0)
339
+ );
340
+ const bounded = (changes, alreadyTruncated) => {
341
+ const kept = [];
342
+ let bytes = 0;
343
+ for (const change of changes) {
344
+ if (kept.length >= maxChanges || bytes + change.path.length > maxPathBytes) {
345
+ return { changes: kept, truncated: true };
346
+ }
347
+ kept.push(change);
348
+ bytes += change.path.length;
349
+ }
350
+ return { changes: kept, truncated: alreadyTruncated };
351
+ };
352
+ const inFlight = /* @__PURE__ */ new Map();
353
+ return {
354
+ async status(query, signal) {
355
+ const root = deps.resolveRoot(query.connectionId, query.path);
356
+ if (root === void 0) {
357
+ return { available: false, reason: "no-root", changes: [], truncated: false };
358
+ }
359
+ const key = cacheKey(query.connectionId, root);
360
+ const directory = relativeDirectory(root, query.path);
361
+ const forCaller = (snapshot) => snapshot.available ? { ...snapshot, directory } : snapshot;
362
+ const cached = cache.get(key);
363
+ if (cached !== void 0 && cached.fresh) return forCaller(cached.value);
364
+ const running = inFlight.get(key);
365
+ if (running !== void 0) return forCaller(await awaitCallerCancellation(running, signal));
366
+ const attempt = (async () => {
367
+ const snapshot = await fetchSnapshot(deps, query, root, directory, void 0, bounded);
368
+ if (snapshot.available || snapshot.reason === "tier-1" || snapshot.reason === "agent-unsupported") {
369
+ cache.set(key, snapshot, "");
370
+ }
371
+ return snapshot;
372
+ })();
373
+ inFlight.set(key, attempt);
374
+ const settle = () => {
375
+ if (inFlight.get(key) === attempt) inFlight.delete(key);
376
+ };
377
+ attempt.then(settle, settle);
378
+ return forCaller(await awaitCallerCancellation(attempt, signal));
379
+ },
380
+ invalidate(connectionId) {
381
+ if (connectionId === void 0) return;
382
+ cache.invalidatePrefix(`${connectionId}\0`);
383
+ },
384
+ get cacheStats() {
385
+ return cache.stats;
386
+ }
387
+ };
388
+ }
389
+ async function fetchSnapshot(deps, query, root, directory, signal, bounded) {
390
+ let transport;
391
+ try {
392
+ transport = await deps.transportFor(query.connectionId);
393
+ } catch (error) {
394
+ if (signal?.aborted === true || isCancellation(error)) throw error;
395
+ return { available: false, reason: "transport-unavailable", changes: [], truncated: false };
396
+ }
397
+ if (!isGitStatusTransport(transport)) {
398
+ return { available: false, reason: "tier-1", changes: [], truncated: false };
399
+ }
400
+ let answer;
401
+ try {
402
+ answer = await transport.gitStatus(root, signal);
403
+ } catch (error) {
404
+ if (signal?.aborted === true || isCancellation(error)) throw error;
405
+ const code = isSshDevError(error) ? error.code : void 0;
406
+ if (code === "REMOTE_UNSUPPORTED") return { available: false, reason: "agent-unsupported", changes: [], truncated: false };
407
+ if (isSshDevError(error) && error.details["agentCode"] === "E_PARAMS") return { available: false, reason: "not-a-work-tree", changes: [], truncated: false };
408
+ return { available: false, reason: "git-status-failed", changes: [], truncated: false };
409
+ }
410
+ const capped = bounded(answer.changes, answer.truncated);
411
+ return {
412
+ available: true,
413
+ root,
414
+ directory,
415
+ ...answer.branch === void 0 ? {} : { branch: answer.branch },
416
+ changes: capped.changes,
417
+ truncated: capped.truncated
418
+ };
419
+ }
420
+ function isCancellation(error) {
421
+ return isSshDevError(error) && error.code === "CANCELLED";
422
+ }
423
+ function relativeDirectory(root, path) {
424
+ const normalizedRoot = normalizeRemotePath(root);
425
+ const normalizedPath = normalizeRemotePath(path);
426
+ if (!remotePathContains(normalizedRoot, normalizedPath)) return "";
427
+ if (normalizedRoot === normalizedPath) return "";
428
+ return normalizedPath.slice(normalizedRoot === "/" ? 1 : normalizedRoot.length + 1);
429
+ }
430
+
431
+ // src/host/domain/roots/root.ts
432
+ function normalizeLocalPath(path, options = {}) {
433
+ const caseInsensitive = options.caseInsensitive ?? process.platform === "win32";
434
+ const unified = path.replace(/\\/g, "/");
435
+ const unc = unified.startsWith("//");
436
+ const posixAbsolute = unified.startsWith("/");
437
+ const driveMatch = /^([A-Za-z]):/.exec(unified);
438
+ const driveLetter = driveMatch?.[1];
439
+ const drive = driveLetter === void 0 ? "" : `${driveLetter.toUpperCase()}:`;
440
+ const rest = unc ? unified.slice(2) : drive === "" ? unified : unified.slice(2);
441
+ const segments = [];
442
+ for (const segment of rest.split("/")) {
443
+ if (segment === "" || segment === ".") continue;
444
+ if (segment === "..") {
445
+ segments.pop();
446
+ continue;
447
+ }
448
+ segments.push(segment);
449
+ }
450
+ const body = segments.join("/");
451
+ let normalized;
452
+ if (unc) normalized = `//${body}`;
453
+ else if (drive !== "") normalized = body === "" ? `${drive}/` : `${drive}/${body}`;
454
+ else if (posixAbsolute) normalized = body === "" ? "/" : `/${body}`;
455
+ else normalized = body === "" ? "." : body;
456
+ return caseInsensitive ? normalized.toLowerCase() : normalized;
457
+ }
458
+
459
+ // src/host/app/roots/roots-api-contract.ts
460
+ var ROOTS_API_MAX_BODY_BYTES = 64 * 1024;
461
+ function trustVerdict(req) {
462
+ const host = headerOf(req, "host");
463
+ if (host === void 0) return "no-host";
464
+ const hostUrl = parseAuthority(host);
465
+ if (hostUrl === void 0) return "bad-host";
466
+ if (headerOf(req, "sec-fetch-site") === "cross-site") return "cross-site";
467
+ const origin = headerOf(req, "origin");
468
+ if (origin === void 0) return "ok";
469
+ const trimmed = origin.trim();
470
+ if (trimmed === "" || trimmed === "null") return "bad-origin";
471
+ let originUrl;
472
+ try {
473
+ originUrl = new URL(trimmed);
474
+ } catch {
475
+ return "bad-origin";
476
+ }
477
+ if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") return "bad-origin";
478
+ if (originUrl.host === hostUrl.host) return "ok";
479
+ if (originUrl.port === "" && originUrl.hostname === hostUrl.hostname) return "ok";
480
+ return "bad-origin";
481
+ }
482
+ function trustMessage(verdict) {
483
+ switch (verdict) {
484
+ case "no-host":
485
+ return "\u8BF7\u6C42\u7F3A\u5C11 Host \u5934\uFF0C\u62D2\u7EDD";
486
+ case "bad-host":
487
+ return "Host \u5934\u65E0\u6CD5\u89E3\u6790\uFF0C\u62D2\u7EDD";
488
+ case "cross-site":
489
+ return "\u8DE8\u7AD9\u8BF7\u6C42\uFF08Sec-Fetch-Site: cross-site\uFF09\u88AB\u62D2\u7EDD";
490
+ case "bad-origin":
491
+ return "\u8BF7\u6C42\u7684 Origin \u4E0E Host \u4E0D\u540C\u6E90\uFF0C\u62D2\u7EDD";
492
+ default:
493
+ return "ok";
494
+ }
495
+ }
496
+ function headerOf(req, name) {
497
+ const value = req.headers[name] ?? req.headers[name.toLowerCase()];
498
+ if (value === void 0) return void 0;
499
+ return Array.isArray(value) ? value[0] : value;
500
+ }
501
+ function parseAuthority(authority) {
502
+ try {
503
+ return new URL(`http://${authority}`);
504
+ } catch {
505
+ return void 0;
506
+ }
507
+ }
508
+
509
+ // src/host/app/roots/roots-api-http.ts
510
+ async function readBody(req) {
511
+ const chunks = [];
512
+ let total = 0;
513
+ for await (const chunk of req) {
514
+ const buffer = toBytes(chunk);
515
+ total += buffer.length;
516
+ if (total > ROOTS_API_MAX_BODY_BYTES) {
517
+ throw new Error(`\u8BF7\u6C42\u4F53\u8D85\u8FC7\u4E0A\u9650 ${ROOTS_API_MAX_BODY_BYTES} \u5B57\u8282\uFF0C\u62D2\u7EDD`);
518
+ }
519
+ chunks.push(buffer);
520
+ }
521
+ return Buffer.concat(chunks).toString("utf8");
522
+ }
523
+ function toBytes(chunk) {
524
+ if (typeof chunk === "string") return Buffer.from(chunk, "utf8");
525
+ if (chunk instanceof Uint8Array) return chunk;
526
+ throw new Error("\u8BF7\u6C42\u4F53\u5206\u7247\u4E0D\u662F\u5B57\u7B26\u4E32\u4E5F\u4E0D\u662F\u5B57\u8282\u5E8F\u5217");
527
+ }
528
+ function send(req, res, status, value) {
529
+ res.statusCode = status;
530
+ res.setHeader("content-type", "application/json; charset=utf-8");
531
+ res.setHeader("cache-control", "no-store");
532
+ const body = `${JSON.stringify(value)}
533
+ `;
534
+ res.end((req.method ?? "GET").toUpperCase() === "HEAD" ? void 0 : body);
535
+ }
536
+ function asRecord(value) {
537
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
538
+ return value;
539
+ }
540
+ function messageOf(error) {
541
+ return error instanceof Error ? error.message : String(error);
542
+ }
543
+
544
+ // src/host/infra/transport/git-status-route.ts
545
+ var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
546
+ function asRouteHost(value) {
547
+ if (typeof value !== "object" || value === null) return void 0;
548
+ const register = value.register;
549
+ return typeof register === "function" ? value : void 0;
550
+ }
551
+ function toPublicAnswer(snapshot) {
552
+ return {
553
+ ok: true,
554
+ available: snapshot.available,
555
+ ...snapshot.root === void 0 ? {} : { root: snapshot.root },
556
+ ...snapshot.directory === void 0 ? {} : { directory: snapshot.directory },
557
+ ...snapshot.branch === void 0 ? {} : { branch: snapshot.branch },
558
+ changes: snapshot.changes.map((change) => ({ path: change.path, status: change.status })),
559
+ truncated: snapshot.truncated,
560
+ ...snapshot.reason === void 0 ? {} : { reason: snapshot.reason }
561
+ };
562
+ }
563
+ function createGitStatusApi(deps) {
564
+ const handle = async (req, res) => {
565
+ const method = (req.method ?? "GET").toUpperCase();
566
+ if (method !== "POST" && method !== "GET" && method !== "HEAD") {
567
+ res.setHeader("allow", "GET, HEAD, POST");
568
+ send(req, res, 405, { ok: false, error: `\u4E0D\u652F\u6301\u7684\u65B9\u6CD5\uFF1A${method}` });
569
+ return;
570
+ }
571
+ const verdict = trustVerdict(req);
572
+ if (verdict !== "ok") {
573
+ send(req, res, 403, { ok: false, error: trustMessage(verdict) });
574
+ return;
575
+ }
576
+ if (method !== "POST") {
577
+ res.setHeader("allow", "POST");
578
+ send(req, res, 405, { ok: false, error: "git-status \u53EA\u63A5\u53D7 POST" });
579
+ return;
580
+ }
581
+ try {
582
+ const body = asRecord(JSON.parse(await readBody(req)));
583
+ const connectionId = body?.["connectionId"];
584
+ const path = body?.["path"];
585
+ if (typeof connectionId !== "string" || connectionId === "") {
586
+ send(req, res, 400, { ok: false, error: "git-status \u9700\u8981 connectionId", reason: "bad-connection" });
587
+ return;
588
+ }
589
+ if (typeof path !== "string" || path === "") {
590
+ send(req, res, 400, { ok: false, error: "git-status \u9700\u8981 path", reason: "bad-path" });
591
+ return;
592
+ }
593
+ const controller = new AbortController();
594
+ const onClose = () => {
595
+ controller.abort();
596
+ };
597
+ res.on?.("close", onClose);
598
+ let snapshot;
599
+ try {
600
+ snapshot = await deps.service.status({ connectionId, path }, controller.signal);
601
+ } finally {
602
+ res.off?.("close", onClose);
603
+ }
604
+ send(req, res, 200, toPublicAnswer(snapshot));
605
+ } catch (error) {
606
+ const invalidBody = error instanceof SyntaxError;
607
+ send(req, res, invalidBody ? 400 : 500, { ok: false, error: messageOf(error) });
608
+ }
609
+ };
610
+ return { handle };
611
+ }
612
+ function registerGitStatusRoute(provider, deps) {
613
+ const api = createGitStatusApi(deps);
614
+ try {
615
+ provider.inject(["webServer"], (scoped) => {
616
+ const host = asRouteHost(scoped.get("webServer"));
617
+ if (host === void 0) return void 0;
618
+ try {
619
+ const dispose = host.register({ kind: "exact", path: GIT_STATUS_API_PATH, handler: api.handle });
620
+ return () => dispose();
621
+ } catch {
622
+ return void 0;
623
+ }
624
+ });
625
+ } catch {
626
+ }
627
+ }
628
+
629
+ // src/host/integration/fs/git-status-wiring.ts
630
+ function wireGitStatus(ctx) {
631
+ const service = createGitStatusService({
632
+ transportFor: async (connectionId) => {
633
+ const connections = ctx.get("sshDevConnections");
634
+ const transports = ctx.get("sshDevTransports");
635
+ if (connections === void 0 || transports === void 0) return void 0;
636
+ const profile = connections.get(connectionId);
637
+ if (profile === void 0) return void 0;
638
+ const acquired = await transports.acquire(profile);
639
+ return acquired.transport;
640
+ },
641
+ resolveRoot: (connectionId, path) => {
642
+ const registry = ctx.get("sshDevRoots");
643
+ if (registry === void 0) return void 0;
644
+ const normalized = normalizeRemotePath(path);
645
+ let best;
646
+ for (const record of registry.records) {
647
+ const root = record.root;
648
+ if (root.location !== "remote" || root.connectionId !== connectionId) continue;
649
+ if (!remotePathContains(root.path, normalized)) continue;
650
+ if (best === void 0 || root.path.length > best.length) best = root.path;
651
+ }
652
+ return best;
653
+ }
654
+ });
655
+ registerGitStatusRoute(ctx, { service });
656
+ }
657
+
658
+ // src/host/domain/literal-text.ts
659
+ function countOccurrences(haystack, needle) {
660
+ if (needle === "") return 0;
661
+ let count = 0;
662
+ let index = haystack.indexOf(needle);
663
+ while (index !== -1) {
664
+ count += 1;
665
+ index = haystack.indexOf(needle, index + needle.length);
666
+ }
667
+ return count;
668
+ }
669
+ function replaceLiteral(source, needle, replacement, all) {
670
+ if (needle === "") return { text: source, replacements: 0 };
671
+ if (all) {
672
+ const parts = source.split(needle);
673
+ return { text: parts.join(replacement), replacements: parts.length - 1 };
674
+ }
675
+ const at = source.indexOf(needle);
676
+ if (at === -1) return { text: source, replacements: 0 };
677
+ return {
678
+ text: `${source.slice(0, at)}${replacement}${source.slice(at + needle.length)}`,
679
+ replacements: 1
680
+ };
681
+ }
682
+
683
+ // src/host/integration/session-scope.ts
684
+ import { AsyncLocalStorage } from "node:async_hooks";
685
+ var STORE_KEY = /* @__PURE__ */ Symbol.for("dsh-over-ssh/session-scope");
686
+ function store() {
687
+ const holder = globalThis;
688
+ const existing = holder[STORE_KEY];
689
+ if (existing !== void 0) return existing;
690
+ const created = new AsyncLocalStorage();
691
+ holder[STORE_KEY] = created;
692
+ return created;
693
+ }
694
+ function currentSessionId() {
695
+ return store().getStore()?.sessionId;
696
+ }
697
+ var MAX_FORK_DEPTH = 32;
698
+ var ancestorCache = /* @__PURE__ */ new Map();
699
+ var ANCESTOR_CACHE_MAX = 256;
700
+ function readSessionAncestors(ctx, sessionId) {
701
+ const cached = ancestorCache.get(sessionId);
702
+ if (cached !== void 0) return cached;
703
+ const walked = walkAncestors(ctx, sessionId);
704
+ if (walked.settled && ancestorCache.size < ANCESTOR_CACHE_MAX) ancestorCache.set(sessionId, walked.ancestors);
705
+ return walked.ancestors;
706
+ }
707
+ function walkAncestors(ctx, sessionId) {
708
+ let list;
709
+ try {
710
+ const sessions = ctx.get("sessions");
711
+ list = sessions?.list?.();
712
+ } catch {
713
+ return { ancestors: [], settled: false };
714
+ }
715
+ if (!Array.isArray(list)) return { ancestors: [], settled: false };
716
+ const parents = /* @__PURE__ */ new Map();
717
+ let settled = false;
718
+ for (const session of list) {
719
+ const id = session?.header?.id;
720
+ const parent = session?.header?.parentSession;
721
+ if (typeof id !== "string" || id === "") continue;
722
+ if (id === sessionId) settled = true;
723
+ if (typeof parent === "string" && parent !== "") parents.set(id, parent);
724
+ }
725
+ const ancestors = [];
726
+ let current = parents.get(sessionId);
727
+ while (current !== void 0 && ancestors.length < MAX_FORK_DEPTH && !ancestors.includes(current)) {
728
+ ancestors.push(current);
729
+ current = parents.get(current);
730
+ }
731
+ return { ancestors, settled };
732
+ }
733
+
734
+ // src/host/integration/fs/fs-provider.ts
735
+ var MAX_WHOLE_READ_BYTES = 16 * 1024 * 1024;
736
+ var BINARY_SNIFF_BYTES = 8e3;
737
+ var UTF8 = new TextDecoder("utf-8", { ignoreBOM: true });
738
+ var MIN_RANGE_CHUNK_BYTES = 1;
739
+ var MAX_RANGE_WINDOW_BYTES = 8 * 1024 * 1024;
740
+ var MAX_RANGE_CHUNK_BYTES = MAX_RANGE_WINDOW_BYTES;
741
+ var RoutedFsProvider = class extends FileSystem {
742
+ static inject = ["sshDevRoots"];
743
+ // `protected`, not `#private`, on purpose: the platform reaches this service through `ctx.fs`, which
744
+ // Cordis wraps in a Proxy, and a `#private` read through a Proxy throws ("…whose class did not declare
745
+ // it"). Every method here may run with the Proxy as `this`, so the state it touches cannot be `#private`.
746
+ rangeReader;
747
+ rangeChunkBytes;
748
+ /** Reads the live sessions' working directories; see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
749
+ sessionWorkspaceCwds;
750
+ /** Reads one session's fork lineage; see {@link RoutedFsProviderOptions.sessionAncestors}. */
751
+ sessionAncestors;
752
+ /**
753
+ * Change detector for {@link syncSessionRoots}: the state the last sync produced.
754
+ *
755
+ * A small mutable holder rather than a `string` field: every method here may run with the Cordis service Proxy
756
+ * as `this`, and mutating a property through that Proxy is the one thing this class must not rely on — mutating
757
+ * a property of an object it *read* through the Proxy is fine.
758
+ */
759
+ sessionSync;
760
+ constructor(ctx, options = {}) {
761
+ super(ctx);
762
+ this.rangeReader = options.readBytesRange;
763
+ this.sessionWorkspaceCwds = options.sessionWorkspaceCwds ?? (() => readSessionCwds(ctx));
764
+ this.sessionAncestors = options.sessionAncestors ?? ((sessionId) => readSessionAncestors(ctx, sessionId));
765
+ this.sessionSync = { lastSignature: "" };
766
+ startHostErrorDetection();
767
+ const requested = Math.floor(options.rangeChunkBytes ?? MAX_RANGE_CHUNK_BYTES);
768
+ this.rangeChunkBytes = Number.isFinite(requested) && requested > 0 ? Math.min(Math.max(requested, MIN_RANGE_CHUNK_BYTES), MAX_RANGE_CHUNK_BYTES) : MAX_RANGE_CHUNK_BYTES;
769
+ }
770
+ /** This provider confines writes to the registered roots. */
771
+ get sandboxMode() {
772
+ return "workspace-write";
773
+ }
774
+ /** Resolve a model-facing path to the router's target and wrap it for the core. */
775
+ async resolve(path, opts) {
776
+ const resolved = this.resolveWithSessionSync(path, opts?.cwd);
777
+ if (!resolved.ok) throw toFsError(resolved.error);
778
+ return { targetKey: FsTargetKey(resolved.target.targetKey), displayPath: resolved.target.displayPath };
779
+ }
780
+ /** Path a subprocess in the target's world can open (the remote absolute path for remote roots). */
781
+ processPath(target) {
782
+ const routed = this.routedTarget(target);
783
+ return routed.backend.processPath?.(routed.absolute) ?? routed.absolute;
784
+ }
785
+ /**
786
+ * Map a host path into the process world: for a local root the two are the same string.
787
+ *
788
+ * **This is a mapping, not an access check.** It used to answer `undefined` for any path outside the
789
+ * registered local roots, which broke the platform's own machinery: the PTC worker maps its runtime into the
790
+ * subprocess world through this method, so every `run_code` call died with
791
+ * `PTC runtime bootstrap is unavailable in the subprocess execution world:
792
+ * …/@deepseek-ai/dsh-ptc-runtime-node/lib/process.js` — a file that exists and that the harness has every
793
+ * right to read (2026-10-31, reported by the user as "the session cannot read the workspace").
794
+ *
795
+ * The core's own `dsh-fs-local` answers `resolve(hostPath)` unconditionally for exactly this reason: the
796
+ * question is "what is this host path called in the process world", not "may this caller open it". Access
797
+ * control lives in {@link resolve}/{@link readFile}/{@link writeText}, where the roots are enforced.
798
+ */
799
+ processPathFromHostPath(hostPath) {
800
+ if (!isAbsoluteHostPath(hostPath)) return void 0;
801
+ return normalizeLocalPath(hostPath, { caseInsensitive: false });
802
+ }
803
+ /** Canonical `file:` URI in the target's world. */
804
+ fileUrl(target) {
805
+ const routed = this.routedTarget(target);
806
+ if (routed.backend.fileUrl !== void 0) return routed.backend.fileUrl(routed.absolute);
807
+ return `file://${encodeURI(routed.absolute)}`;
808
+ }
809
+ /** Containment; two targets in different worlds never contain one another. */
810
+ contains(parent, child) {
811
+ const left = this.routedTarget(parent);
812
+ const right = this.routedTarget(child);
813
+ if (left.backendKey !== right.backendKey) return false;
814
+ return this.router().contains(left, right);
815
+ }
816
+ /** Metadata, or `undefined` for an absent target. */
817
+ async stat(target, signal) {
818
+ const routed = this.routedTarget(target);
819
+ const info = await this.call(() => routed.backend.stat(routed.absolute, signal));
820
+ if (!info.exists) return void 0;
821
+ return toFsInfo(info);
822
+ }
823
+ /** Path metadata without following a final symlink, where the backend can tell. */
824
+ async lstat(path, opts, signal) {
825
+ const resolved = this.resolveWithSessionSync(path, opts?.cwd);
826
+ if (!resolved.ok) throw toFsError(resolved.error);
827
+ const routed = resolved.target;
828
+ const info = await this.call(() => this.router().statTarget(routed, { followSymlink: false, ...signal === void 0 ? {} : { signal } }));
829
+ if (!info.exists) return void 0;
830
+ const fsInfo = toFsInfo(info);
831
+ return info.isSymlink ? { version: fsInfo.version, type: "symlink", ...fsInfo.size === void 0 ? {} : { size: fsInfo.size } } : fsInfo;
832
+ }
833
+ /**
834
+ * Whole-file UTF-8 text; binary and oversize files fail with typed codes.
835
+ *
836
+ * Reads **bytes** rather than calling `backend.readText`, so the binary sniff happens before decoding: the
837
+ * previous shape asked the backend for the decoded text and then re-encoded the whole string
838
+ * (`Buffer.from(text, 'utf8')`) only to look at its first {@link BINARY_SNIFF_BYTES} bytes. Measured on a
839
+ * 9 MiB file (audit 2026-11-01b follow-up): `open`+`stat`+`read` 2.65 ms, `+` decode 3.95 ms, `+` the
840
+ * redundant re-encode **5.66 ms** — 1.71 ms, ~75% of the plugin's overhead, spent to encode bytes the caller
841
+ * had just decoded. `TextDecoder` then decodes straight off the read buffer without the extra copy.
842
+ */
843
+ async readText(target, signal) {
844
+ const routed = this.routedTarget(target);
845
+ const chunk = await this.call(() => routed.backend.readBytes(routed.absolute, MAX_WHOLE_READ_BYTES, signal));
846
+ if (chunk.truncated) {
847
+ throw platformFsError(`cannot read "${routed.displayPath}": file exceeds ${MAX_WHOLE_READ_BYTES} bytes`, "FS_TOO_LARGE");
848
+ }
849
+ assertText(routed.displayPath, chunk.bytes);
850
+ return UTF8.decode(chunk.bytes);
851
+ }
852
+ /**
853
+ * Stream text in chunks, paging through the offset reader when the composition injected one.
854
+ *
855
+ * The platform calls this for every file at or above **its own** streaming threshold —
856
+ * `@deepseek-ai/dsh-tool-fs/lib/index.js:217` (`STREAM_MIN_SIZE = 10 * 1024 * 1024`) feeding `:338` — and
857
+ * `@deepseek-ai/dsh-fs/lib/types/index.d.ts:164-172` makes the backend own both the chunking and the
858
+ * cross-chunk UTF-8 decoding. Answering by reading the whole file first, which is what this did, made that
859
+ * contract a lie twice over: it buffered the entire file *and* it refused anything past the whole-file cap
860
+ * with `FS_TOO_LARGE`, so the `read` tool could not open a large remote log at all even when the caller had
861
+ * asked for one window of it.
862
+ *
863
+ * Without the offset seam the previous semantics are kept exactly: one bounded whole read, one chunk, and
864
+ * `FS_TOO_LARGE` past the cap. Nothing pretends to be chunked when it is not.
865
+ */
866
+ async streamText(target, signal) {
867
+ const routed = this.routedTarget(target);
868
+ const reader = this.rangeReader;
869
+ const chunkBytes = this.rangeChunkBytes;
870
+ if (reader !== void 0) {
871
+ const probe = await this.call(
872
+ () => reader(routed.backend, { absolute: routed.absolute, offset: 0, length: chunkBytes }, signal)
873
+ );
874
+ if (probe !== null) {
875
+ assertText(routed.displayPath, Buffer.from(probe.bytes.subarray(0, BINARY_SNIFF_BYTES)));
876
+ return pagedText(reader, chunkBytes, routed, probe, signal);
877
+ }
878
+ }
879
+ const text = await this.readText(target, signal);
880
+ return (async function* whole() {
881
+ yield text;
882
+ })();
883
+ }
884
+ /** Whole-file bytes, refusing oversized targets instead of truncating. */
885
+ async readBytes(target, signal, maxBytes) {
886
+ const routed = this.routedTarget(target);
887
+ const chunk = await this.call(() => routed.backend.readBytes(routed.absolute, maxBytes, signal));
888
+ if (chunk.truncated) {
889
+ throw platformFsError(`cannot read "${routed.displayPath}": file exceeds ${maxBytes} bytes`, "FS_TOO_LARGE");
890
+ }
891
+ return chunk.bytes;
892
+ }
893
+ /**
894
+ * One byte window, bounded by the caller's window rather than the file size.
895
+ *
896
+ * Two paths, and they are deliberately distinguishable:
897
+ *
898
+ * - the backend has an offset read (the injected {@link BackendRangeReader} answers with a window):
899
+ * the window is fetched as a sequence of requests of at most the configured chunk size, each
900
+ * starting at the file offset it needs. Nothing before `offset` is ever transferred or held, so
901
+ * the memory bound is the chunk size plus the returned window, not the file size. The window
902
+ * itself is capped at {@link MAX_RANGE_WINDOW_BYTES} — it is returned whole, so it is allocated
903
+ * whole, and an unbounded `length` would be an unbounded allocation;
904
+ * - the backend has none (a local backend, a transport without `readFileRange`, or no injected
905
+ * reader at all): the previous semantics are kept exactly — one bounded prefix read `[0, end)`
906
+ * capped at 8 MiB, then a slice — and a window past that cap still fails with `FS_TOO_LARGE`
907
+ * instead of silently buffering the file.
908
+ */
909
+ async readByteRange(target, range, signal) {
910
+ const routed = this.routedTarget(target);
911
+ const start = Math.max(0, Math.floor(range.offset));
912
+ const wanted = Math.max(0, Math.floor(range.length));
913
+ if (wanted === 0) return new Uint8Array(0);
914
+ if (wanted > MAX_RANGE_WINDOW_BYTES) {
915
+ throw platformFsError(
916
+ `cannot read "${routed.displayPath}": the requested window (${wanted} bytes) exceeds the ${MAX_RANGE_WINDOW_BYTES}-byte limit`,
917
+ "FS_TOO_LARGE"
918
+ );
919
+ }
920
+ assertNotAborted(signal, `${routed.displayPath} [${start}, ${start + wanted})`);
921
+ const reader = this.rangeReader;
922
+ const chunkBytes = this.rangeChunkBytes;
923
+ const chunked = await this.call(() => readChunkedWindow(reader, chunkBytes, routed, start, wanted, signal));
924
+ if (chunked !== void 0) return chunked;
925
+ const end = start + wanted;
926
+ if (end > MAX_RANGE_WINDOW_BYTES) {
927
+ throw platformFsError(
928
+ `cannot read "${routed.displayPath}": this backend cannot read the window [${start}, ${end}) without buffering it (limit ${MAX_RANGE_WINDOW_BYTES} bytes)`,
929
+ "FS_TOO_LARGE"
930
+ );
931
+ }
932
+ const chunk = await this.call(() => routed.backend.readBytes(routed.absolute, end, signal));
933
+ return chunk.bytes.subarray(start, Math.min(end, chunk.bytes.byteLength));
934
+ }
935
+ /**
936
+ * Read a window through the injected offset reader.
937
+ *
938
+ * Kept as a thin method so the instance is the receiver here (a direct call is fine); the work happens in
939
+ * the free {@link readChunkedWindow}, because the public read path invokes it from inside `this.call(...)`,
940
+ * where `this` is the Cordis service Proxy and instance state would not be reachable.
941
+ */
942
+ #readChunkedWindow(routed, offset, length, signal) {
943
+ return readChunkedWindow(this.rangeReader, this.rangeChunkBytes, routed, offset, length, signal);
944
+ }
945
+ /** Direct children, in stable name order, with resolved child targets. */
946
+ async listDir(target, signal) {
947
+ const routed = this.routedTarget(target);
948
+ const entries = await this.call(() => this.router().listTarget(routed, signal));
949
+ return entries.map((entry) => ({
950
+ name: entry.name,
951
+ type: entry.isDirectory ? "directory" : entry.isFile ? "file" : "other",
952
+ target: { targetKey: FsTargetKey(entry.targetKey), displayPath: entry.displayPath },
953
+ // 后端给的令牌带 inode,是唯一与 mtime 精度无关的「换过文件」信号;秒级现算会把同一秒内的
954
+ // 两次写入算成同一个版本(审计 0086)。只有后端没给时才退回现算,且退回时也用毫秒而不是秒。
955
+ version: FsVersion(entry.version ?? `${entry.size}:${entry.mtimeMs}`),
956
+ size: entry.size
957
+ }));
958
+ }
959
+ /** Atomic write, honouring the write intent and the plugin's fence. */
960
+ async writeText(target, content, expected, signal, sandboxPolicy) {
961
+ const routed = this.routedTarget(target);
962
+ this.assertWritable(routed, sandboxPolicy);
963
+ const info = await this.call(() => routed.backend.stat(routed.absolute, signal));
964
+ const existed = info.exists;
965
+ const before = existed ? await this.readPriorText(routed, info, signal) : void 0;
966
+ if (expected?.kind === "createIfAbsent" && existed) {
967
+ throw platformFsError(`cannot create "${routed.displayPath}": target already exists`, "FS_NOT_OBSERVED");
968
+ }
969
+ const guard = expected?.kind === "replaceIfVersion" ? expected.version : void 0;
970
+ const outcome = await this.call(
971
+ () => routed.backend.writeText(routed.absolute, content, guard, signal)
972
+ ).catch((error) => {
973
+ throw mapWriteError(error, routed.displayPath);
974
+ });
975
+ return {
976
+ operation: existed ? "update" : "create",
977
+ version: FsVersion(outcome.version),
978
+ // `before` is the **diff basis**, so it is LF-normalized exactly like `after`
979
+ // (`@deepseek-ai/dsh-fs/lib/types/index.d.ts:118-134`: "LF-normalized storage text (the diff basis)"),
980
+ // and the core's own backend does the same (`dsh-fs-local/lib/index.js:872,879`). Reporting the raw text
981
+ // made every CRLF file render as a whole-file diff: `@deepseek-ai/dsh-tool-fs/lib/index.js:596` diffs
982
+ // `before` against an LF-normalized `after`, so one changed line looked like every line changed.
983
+ before: before === void 0 ? null : normalizeLineEndings(before),
984
+ after: normalizeLineEndings(content)
985
+ };
986
+ }
987
+ /** Literal edit with the core's three-state semantics and an optional version guard. */
988
+ async editText(target, edit, expected, signal, sandboxPolicy) {
989
+ const routed = this.routedTarget(target);
990
+ this.assertWritable(routed, sandboxPolicy);
991
+ const chunk = await this.call(() => routed.backend.readText(routed.absolute, MAX_WHOLE_READ_BYTES, signal)).catch((error) => {
992
+ throw mapWriteError(error, routed.displayPath);
993
+ });
994
+ if (expected !== void 0 && expected.version !== chunk.version) {
995
+ throw platformFsError(`cannot edit "${routed.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
996
+ }
997
+ const lineEndings = chunk.text.includes("\r\n") ? "\r\n" : "\n";
998
+ const before = normalizeLineEndings(chunk.text);
999
+ const occurrences = countOccurrences(before, edit.oldString);
1000
+ if (occurrences === 0) {
1001
+ throw platformFsError(`cannot edit "${routed.displayPath}": the requested text was not found`, "FS_EDIT_NOT_FOUND");
1002
+ }
1003
+ if (occurrences > 1 && !edit.replaceAll) {
1004
+ throw platformFsError(`cannot edit "${routed.displayPath}": the requested text appears ${occurrences} times`, "FS_AMBIGUOUS_EDIT");
1005
+ }
1006
+ const after = replaceLiteral(before, edit.oldString, edit.newString, edit.replaceAll === true).text;
1007
+ if (routed.backend.editText !== void 0 && lineEndings === "\n") {
1008
+ try {
1009
+ const native = await this.call(
1010
+ () => routed.backend.editText(
1011
+ routed.absolute,
1012
+ edit.oldString,
1013
+ edit.newString,
1014
+ edit.replaceAll === true,
1015
+ chunk.version,
1016
+ signal
1017
+ )
1018
+ );
1019
+ return { version: FsVersion(native.version), before, after };
1020
+ } catch (error) {
1021
+ if (!isSshDevError(error) || error.code !== "REMOTE_UNSUPPORTED") {
1022
+ throw mapWriteError(error, routed.displayPath);
1023
+ }
1024
+ }
1025
+ }
1026
+ const outcome = await this.call(
1027
+ () => routed.backend.writeText(routed.absolute, restoreLineEndings(after, lineEndings), chunk.version, signal)
1028
+ ).catch((error) => {
1029
+ throw mapWriteError(error, routed.displayPath);
1030
+ });
1031
+ return { version: FsVersion(outcome.version), before, after };
1032
+ }
1033
+ /**
1034
+ * Watch one target through its backend.
1035
+ *
1036
+ * The route decides the world, then the backend owns the mechanism:
1037
+ *
1038
+ * - **local roots** delegate to the platform's own `FileSystem.watch` (chokidar) via `PlatformLocalBackend`.
1039
+ * Before this method was wired, taking over `ctx.fs` made **local** files stop reporting changes: the
1040
+ * platform's `dsh-api-workspace-files` got `workspace-file/watch-unsupported` for every target, because
1041
+ * `RoutedFsProvider` replaced the sandboxed provider (a `LocalFileSystem` subclass) and this method only
1042
+ * ever threw. Delegating restores the pre-takeover behaviour without a second chokidar implementation.
1043
+ * - **remote roots** with a native agent watcher use `watch.start/poll/stop`; Tier 1 remains unsupported
1044
+ * and keeps failing honestly (the caller falls back to revalidation on read).
1045
+ *
1046
+ * A backend that has **no** `watch` method keeps the historical `FS_IO_ERROR` refusal — never a silent
1047
+ * no-op, which would leave a consumer believing it is subscribed.
1048
+ *
1049
+ * @param target - the target whose changes were requested.
1050
+ * @param changed - platform callback: re-stat on no argument, subscription-failure on an error.
1051
+ * @param signal - cancels watcher initialization and subscription.
1052
+ * @returns the disposer (`() => Promise<void>`), idempotent.
1053
+ * @throws FsError `FS_IO_ERROR` when the backend cannot watch, plus the mapped backend failures
1054
+ * (`FS_NOT_FOUND`, `AbortError` for cancellation, …).
1055
+ */
1056
+ async watch(target, changed, signal) {
1057
+ const routed = this.routedTarget(target);
1058
+ const backendWatch = routed.backend.watch;
1059
+ if (backendWatch === void 0) {
1060
+ throw platformFsError(`filesystem watching is not supported for "${routed.displayPath}"`, "FS_IO_ERROR");
1061
+ }
1062
+ return await this.call(() => backendWatch.call(routed.backend, routed.absolute, changed, signal));
1063
+ }
1064
+ rootsService() {
1065
+ const registry = this.ctx.sshDevRoots;
1066
+ if (registry === void 0) {
1067
+ throw platformFsError("the over-ssh root registry is not registered", "FS_IO_ERROR");
1068
+ }
1069
+ return registry;
1070
+ }
1071
+ router() {
1072
+ return this.rootsService().routed;
1073
+ }
1074
+ /**
1075
+ * Resolve a path, syncing the live sessions' working directories once if it fails.
1076
+ *
1077
+ * Why on failure rather than before every call: the sync costs a walk of the live-session list plus a
1078
+ * containment scan over the roots, and the overwhelming majority of resolutions succeed against a registered
1079
+ * root. Why it must exist at all: the platform reads the workspace through `ctx.fs` by **absolute path without
1080
+ * a cwd** while a turn starts, so a session whose own directory no root covers lost the entire turn before the
1081
+ * model was called — there was no cwd to key a fallback on.
1082
+ *
1083
+ * One sync, then one retry: a session that appeared a moment ago is therefore picked up on its **first** read
1084
+ * rather than failing once and working on the next call, and a second failure means the sync genuinely had
1085
+ * nothing to add (the path is outside every root), so the caller gets the original error.
1086
+ *
1087
+ * @param path - model-facing path.
1088
+ * @param cwd - the caller's session cwd, when it has one.
1089
+ * @returns the resolution; when nothing changed the caller keeps the **original** failure.
1090
+ */
1091
+ resolveWithSessionSync(path, cwd) {
1092
+ const options = this.sessionRouteOptions(cwd);
1093
+ const first = this.router().tryResolve(path, options);
1094
+ if (first.ok) return first;
1095
+ this.syncSessionRoots();
1096
+ return this.router().tryResolve(path, options);
1097
+ }
1098
+ /**
1099
+ * The route options for one call: the cwd the platform supplied, plus the session identity this call runs
1100
+ * under when the tool dispatcher carried one down (`integration/session-scope.ts`, ADR-0013).
1101
+ *
1102
+ * Both are kept because they answer different questions. The cwd is what the platform's contract provides
1103
+ * (relative spellings, the workspace-level rule, the anchor rule); the session is the only thing that can
1104
+ * separate two conversations opened on the same directory. A call with neither is an agentless one and keeps
1105
+ * the plugin-wide view, exactly as before.
1106
+ *
1107
+ * @param cwd - the caller's session cwd, when it has one.
1108
+ * @returns the options to hand the route table.
1109
+ */
1110
+ sessionRouteOptions(cwd) {
1111
+ const sessionId = currentSessionId();
1112
+ const identity = sessionId === void 0 || sessionId === "" ? void 0 : { sessionId, ancestors: this.sessionAncestors(sessionId) };
1113
+ const sessionCwd = cwd !== void 0 || sessionId === void 0 || sessionId === "" ? void 0 : this.sessionWorkspaceCwdOf(sessionId);
1114
+ return {
1115
+ ...cwd === void 0 ? {} : { cwd },
1116
+ ...identity === void 0 ? {} : { session: identity },
1117
+ ...sessionCwd === void 0 ? {} : { sessionCwd }
1118
+ };
1119
+ }
1120
+ /**
1121
+ * The working directory of one live session, or `undefined` when it is not listed.
1122
+ *
1123
+ * @param sessionId - the asking session.
1124
+ * @returns its cwd, when the platform reports one.
1125
+ */
1126
+ sessionWorkspaceCwdOf(sessionId) {
1127
+ return readSessionCwd(this.ctx, sessionId);
1128
+ }
1129
+ /**
1130
+ * Re-read the live sessions and register their working directories as session roots.
1131
+ *
1132
+ * Guarded by **change detection**, not by a time window: reading the session list and comparing it with the
1133
+ * state the last sync produced costs microseconds, while a time throttle would have a window in which a session
1134
+ * created a moment ago is not registered yet — and that session's very first read is exactly the one that used
1135
+ * to kill the turn. The signature includes the session roots that currently exist, so a registry reset (which
1136
+ * drops them) is noticed and repaired too.
1137
+ */
1138
+ syncSessionRoots() {
1139
+ try {
1140
+ const cwds = this.sessionWorkspaceCwds();
1141
+ if (cwds.length === 0) return;
1142
+ const registry = this.rootsService();
1143
+ if (sessionSyncSignature(cwds, registry) === this.sessionSync.lastSignature) return;
1144
+ registry.syncSessionWorkspaces(cwds);
1145
+ this.sessionSync.lastSignature = sessionSyncSignature(cwds, registry);
1146
+ } catch {
1147
+ }
1148
+ }
1149
+ routedTarget(target) {
1150
+ try {
1151
+ return this.router().targetFromKey(target.targetKey);
1152
+ } catch (error) {
1153
+ throw isSshDevError(error) ? toFsError(error) : error;
1154
+ }
1155
+ }
1156
+ /** Enforce the per-call policy and the root's own writability. */
1157
+ assertWritable(routed, policy) {
1158
+ const mode = policy?.mode ?? this.sandboxMode;
1159
+ if (mode === "read-only") {
1160
+ throw platformFsError(`sandbox denies writing "${routed.displayPath}" (read-only)`, "FS_SANDBOX_DENIED");
1161
+ }
1162
+ if (routed.root.writable === false) {
1163
+ throw platformFsError(`the root of "${routed.displayPath}" is registered read-only`, "FS_SANDBOX_DENIED");
1164
+ }
1165
+ }
1166
+ /**
1167
+ * Previous text content, when it is readable text inside the cap; `undefined` otherwise.
1168
+ *
1169
+ * `undefined` here means **"no previous text to report"**, never "the target is absent" — the caller
1170
+ * already decided existence from {@link BackendStat.exists}. Keeping those two apart is what stops a
1171
+ * directory / oversized / binary file from slipping past a `createIfAbsent` guard.
1172
+ */
1173
+ async readPriorText(routed, info, signal) {
1174
+ if (info.isDirectory || info.size > MAX_WHOLE_READ_BYTES) return void 0;
1175
+ const chunk = await routed.backend.readText(routed.absolute, MAX_WHOLE_READ_BYTES, signal).catch((error) => {
1176
+ if (signal?.aborted === true) throw error;
1177
+ return void 0;
1178
+ });
1179
+ if (chunk === void 0 || chunk.truncated) return void 0;
1180
+ const bytes = Buffer.from(chunk.text, "utf8");
1181
+ const sniff = bytes.subarray(0, BINARY_SNIFF_BYTES);
1182
+ if (sniff.includes(0)) return void 0;
1183
+ return chunk.text;
1184
+ }
1185
+ /** Run a backend call, translating domain errors into core codes. */
1186
+ async call(operation) {
1187
+ try {
1188
+ return await operation();
1189
+ } catch (error) {
1190
+ if (isFsError(error)) throw error;
1191
+ throw isSshDevError(error) ? toFsError(error) : error;
1192
+ }
1193
+ }
1194
+ };
1195
+ function createRoutedFsProvider(options) {
1196
+ const plugin = ((ctx) => {
1197
+ new RoutedFsProvider(ctx, options);
1198
+ try {
1199
+ wireGitStatus(ctx);
1200
+ } catch (error) {
1201
+ ctx.logger?.warn?.(`git status route: ${error instanceof Error ? error.message : String(error)}`);
1202
+ }
1203
+ });
1204
+ plugin.inject = RoutedFsProvider.inject;
1205
+ return plugin;
1206
+ }
1207
+ function assertNotAborted(signal, what) {
1208
+ if (signal?.aborted !== true) return;
1209
+ const error = new Error(`the read of ${what} was cancelled`);
1210
+ error.name = "AbortError";
1211
+ throw error;
1212
+ }
1213
+ function toFsInfo(info) {
1214
+ return {
1215
+ version: FsVersion(info.version),
1216
+ type: info.isDirectory ? "directory" : info.isFile ? "file" : "other",
1217
+ ...info.isDirectory ? {} : { size: info.size }
1218
+ };
1219
+ }
1220
+ async function readChunkedWindow(reader, chunkBytes, routed, offset, length, signal) {
1221
+ if (reader === void 0) return void 0;
1222
+ const buffer = Buffer.allocUnsafe(length);
1223
+ let filled = 0;
1224
+ while (filled < length) {
1225
+ signal?.throwIfAborted();
1226
+ const requestLength = Math.min(length - filled, chunkBytes);
1227
+ const window = await reader(
1228
+ routed.backend,
1229
+ { absolute: routed.absolute, offset: offset + filled, length: requestLength },
1230
+ signal
1231
+ );
1232
+ if (window === null) return void 0;
1233
+ assertNotAborted(signal, `${routed.displayPath} [${offset + filled}, ${offset + filled + requestLength})`);
1234
+ const bytes = window.bytes.subarray(0, requestLength);
1235
+ Buffer.from(bytes).copy(buffer, filled);
1236
+ filled += bytes.byteLength;
1237
+ if (window.truncated === false || bytes.byteLength === 0) break;
1238
+ }
1239
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, filled);
1240
+ }
1241
+ async function* pagedText(reader, chunkBytes, routed, firstWindow, signal) {
1242
+ const decoder = new StringDecoder("utf8");
1243
+ let offset = 0;
1244
+ let window = firstWindow;
1245
+ for (; ; ) {
1246
+ signal?.throwIfAborted();
1247
+ const bytes = Buffer.from(window.bytes.subarray(0, chunkBytes));
1248
+ offset += bytes.byteLength;
1249
+ if (bytes.byteLength > 0) {
1250
+ const text = decoder.write(bytes);
1251
+ if (text.length > 0) yield text;
1252
+ }
1253
+ if (window.truncated === false || bytes.byteLength === 0) break;
1254
+ window = await reader(routed.backend, { absolute: routed.absolute, offset, length: chunkBytes }, signal).catch((error) => {
1255
+ throw isSshDevError(error) ? toFsError(error) : error;
1256
+ });
1257
+ assertNotAborted(signal, `${routed.displayPath} [${offset}, ${offset + chunkBytes})`);
1258
+ if (window === null) {
1259
+ throw platformFsError(
1260
+ `cannot stream "${routed.displayPath}": the backend stopped offering offset reads mid-stream`,
1261
+ "FS_IO_ERROR"
1262
+ );
1263
+ }
1264
+ }
1265
+ const tail = decoder.end();
1266
+ if (tail.length > 0) yield tail;
1267
+ }
1268
+ function sessionSyncSignature(cwds, registry) {
1269
+ const roots = registry.sessionWorkspaceRecords.map((record) => record.key).join("\0");
1270
+ return `${cwds.join("\0")}${roots}`;
1271
+ }
1272
+ function readSessionCwd(ctx, sessionId) {
1273
+ let list;
1274
+ try {
1275
+ const sessions = ctx.get("sessions");
1276
+ list = sessions?.list?.();
1277
+ } catch {
1278
+ return void 0;
1279
+ }
1280
+ if (!Array.isArray(list)) return void 0;
1281
+ for (const session of list) {
1282
+ if (session?.header?.id !== sessionId) continue;
1283
+ const cwd = session.header.cwd;
1284
+ return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
1285
+ }
1286
+ return void 0;
1287
+ }
1288
+ function readSessionCwds(ctx) {
1289
+ let list;
1290
+ try {
1291
+ const sessions = ctx.get("sessions");
1292
+ list = sessions?.list?.();
1293
+ } catch {
1294
+ return [];
1295
+ }
1296
+ if (!Array.isArray(list)) return [];
1297
+ const cwds = [];
1298
+ for (const session of list) {
1299
+ const cwd = session?.header?.cwd;
1300
+ if (typeof cwd === "string" && cwd !== "") cwds.push(cwd);
1301
+ }
1302
+ return cwds;
1303
+ }
1304
+ function assertText(displayPath, bytes) {
1305
+ if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
1306
+ throw platformFsError(`cannot read "${displayPath}": not a UTF-8 text file`, "FS_NOT_TEXT");
1307
+ }
1308
+ }
1309
+ function normalizeLineEndings(text) {
1310
+ return text.replace(/\r\n/g, "\n");
1311
+ }
1312
+ function restoreLineEndings(text, lineEndings) {
1313
+ return lineEndings === "\n" ? text : text.replace(/\n/g, lineEndings);
1314
+ }
1315
+ function isAbsoluteHostPath(path) {
1316
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("/") || path.startsWith("//");
1317
+ }
1318
+ function toFsError(error) {
1319
+ if (error.code === "CANCELLED") {
1320
+ const cancelled = new Error(`the filesystem operation was cancelled: ${error.message}`);
1321
+ cancelled.name = "AbortError";
1322
+ throw cancelled;
1323
+ }
1324
+ const code = error.code === "REMOTE_NOT_FOUND" || error.code === "ROOT_OUTSIDE" ? "FS_NOT_FOUND" : error.code === "SANDBOX_DENIED" ? "FS_SANDBOX_DENIED" : error.code === "TRANSFER_CONFLICT" ? "FS_STALE_VERSION" : "FS_IO_ERROR";
1325
+ return platformFsError(`${error.code}: ${error.message}`, code, { cause: error });
1326
+ }
1327
+ function mapWriteError(error, displayPath) {
1328
+ if (isFsError(error)) return error;
1329
+ if (isSshDevError(error)) {
1330
+ if (error.code === "TRANSFER_CONFLICT") {
1331
+ return platformFsError(`cannot write "${displayPath}": file changed since it was read`, "FS_STALE_VERSION", { cause: error });
1332
+ }
1333
+ return toFsError(error);
1334
+ }
1335
+ return error;
1336
+ }
1337
+ var MAPPED_DOMAIN_CODES = [
1338
+ "REMOTE_NOT_FOUND",
1339
+ "ROOT_OUTSIDE",
1340
+ "SANDBOX_DENIED",
1341
+ "TRANSFER_CONFLICT",
1342
+ "REMOTE_UNSUPPORTED",
1343
+ "ROOT_INVALID",
1344
+ "REMOTE_COMMAND_FAILED",
1345
+ "CANCELLED"
1346
+ ];
1347
+
1348
+ // src/fs.ts
1349
+ function apply(ctx) {
1350
+ if (ctx.get("fs") !== void 0) {
1351
+ ctx.logger?.warn?.("fs: ctx.fs \u5DF2\u88AB\u5360\u7528\uFF08\u6838\u5FC3 fs-sandbox \u4ECD\u5728\uFF09\uFF0C\u672C\u6B21\u8DF3\u8FC7\u63A5\u7BA1\uFF1B\u8981\u63A5\u7BA1\u8BF7\u540C\u65F6\u7981\u7528\u6838\u5FC3\u884C");
1352
+ return void 0;
1353
+ }
1354
+ const range = ctx.get("sshDevRangeReader");
1355
+ return ctx.plugin(
1356
+ createRoutedFsProvider({
1357
+ ...range === void 0 ? {} : { readBytesRange: range.readBytesRange },
1358
+ ...range?.rangeChunkBytes === void 0 ? {} : { rangeChunkBytes: range.rangeChunkBytes }
1359
+ })
1360
+ );
1361
+ }
1362
+ export {
1363
+ MAPPED_DOMAIN_CODES,
1364
+ RoutedFsProvider,
1365
+ createRoutedFsProvider,
1366
+ apply as default,
1367
+ toFsError
1368
+ };