@animalabs/agent-framework 0.22.2 → 0.23.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 (997) hide show
  1. package/README.md +108 -4
  2. package/dist/src/agent.d.ts +108 -17
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +207 -67
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +1 -1
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +5 -1
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/api/types.d.ts +1 -1
  12. package/dist/src/api/types.d.ts.map +1 -1
  13. package/dist/src/branch-names.d.ts +14 -0
  14. package/dist/src/branch-names.d.ts.map +1 -0
  15. package/dist/src/branch-names.js +22 -0
  16. package/dist/src/branch-names.js.map +1 -0
  17. package/dist/src/closest-names.d.ts +28 -0
  18. package/dist/src/closest-names.d.ts.map +1 -0
  19. package/dist/src/closest-names.js +98 -0
  20. package/dist/src/closest-names.js.map +1 -0
  21. package/dist/src/code-execution/index.d.ts +1 -1
  22. package/dist/src/code-execution/index.d.ts.map +1 -1
  23. package/dist/src/code-execution/index.js.map +1 -1
  24. package/dist/src/code-execution/py-runner.d.ts +50 -7
  25. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  26. package/dist/src/code-execution/py-runner.js +258 -90
  27. package/dist/src/code-execution/py-runner.js.map +1 -1
  28. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  29. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  30. package/dist/src/code-execution/runtime-py.js +94 -8
  31. package/dist/src/code-execution/runtime-py.js.map +1 -1
  32. package/dist/src/code-execution/tool-definition.d.ts +17 -1
  33. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  34. package/dist/src/code-execution/tool-definition.js +72 -6
  35. package/dist/src/code-execution/tool-definition.js.map +1 -1
  36. package/dist/src/config-checks.d.ts +28 -0
  37. package/dist/src/config-checks.d.ts.map +1 -0
  38. package/dist/src/config-checks.js +56 -0
  39. package/dist/src/config-checks.js.map +1 -0
  40. package/dist/src/deadline-timer.d.ts +21 -0
  41. package/dist/src/deadline-timer.d.ts.map +1 -0
  42. package/dist/src/deadline-timer.js +43 -0
  43. package/dist/src/deadline-timer.js.map +1 -0
  44. package/dist/src/error-message.d.ts +30 -0
  45. package/dist/src/error-message.d.ts.map +1 -0
  46. package/dist/src/error-message.js +50 -0
  47. package/dist/src/error-message.js.map +1 -0
  48. package/dist/src/framework.d.ts +1529 -165
  49. package/dist/src/framework.d.ts.map +1 -1
  50. package/dist/src/framework.js +8264 -1939
  51. package/dist/src/framework.js.map +1 -1
  52. package/dist/src/gate/event-gate.d.ts +8 -0
  53. package/dist/src/gate/event-gate.d.ts.map +1 -1
  54. package/dist/src/gate/event-gate.js +36 -5
  55. package/dist/src/gate/event-gate.js.map +1 -1
  56. package/dist/src/held-writes.d.ts +16 -0
  57. package/dist/src/held-writes.d.ts.map +1 -0
  58. package/dist/src/held-writes.js +56 -0
  59. package/dist/src/held-writes.js.map +1 -0
  60. package/dist/src/index.d.ts +10 -4
  61. package/dist/src/index.d.ts.map +1 -1
  62. package/dist/src/index.js +11 -5
  63. package/dist/src/index.js.map +1 -1
  64. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  65. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  66. package/dist/src/mcpl/capability-grant.js +1 -1
  67. package/dist/src/mcpl/capability-grant.js.map +1 -1
  68. package/dist/src/mcpl/channel-registry.d.ts +233 -18
  69. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  70. package/dist/src/mcpl/channel-registry.js +975 -386
  71. package/dist/src/mcpl/channel-registry.js.map +1 -1
  72. package/dist/src/mcpl/checkpoint-manager.d.ts +21 -1
  73. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  74. package/dist/src/mcpl/checkpoint-manager.js +91 -30
  75. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  76. package/dist/src/mcpl/conversation-router.d.ts +19 -3
  77. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/conversation-router.js +23 -8
  79. package/dist/src/mcpl/conversation-router.js.map +1 -1
  80. package/dist/src/mcpl/convert-block.d.ts +20 -0
  81. package/dist/src/mcpl/convert-block.d.ts.map +1 -0
  82. package/dist/src/mcpl/convert-block.js +88 -0
  83. package/dist/src/mcpl/convert-block.js.map +1 -0
  84. package/dist/src/mcpl/errors.d.ts +13 -0
  85. package/dist/src/mcpl/errors.d.ts.map +1 -1
  86. package/dist/src/mcpl/errors.js +28 -0
  87. package/dist/src/mcpl/errors.js.map +1 -1
  88. package/dist/src/mcpl/hook-orchestrator.d.ts +23 -0
  89. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  90. package/dist/src/mcpl/hook-orchestrator.js +83 -72
  91. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  92. package/dist/src/mcpl/inbound-source.d.ts +114 -0
  93. package/dist/src/mcpl/inbound-source.d.ts.map +1 -0
  94. package/dist/src/mcpl/inbound-source.js +83 -0
  95. package/dist/src/mcpl/inbound-source.js.map +1 -0
  96. package/dist/src/mcpl/index.d.ts +7 -1
  97. package/dist/src/mcpl/index.d.ts.map +1 -1
  98. package/dist/src/mcpl/index.js +5 -1
  99. package/dist/src/mcpl/index.js.map +1 -1
  100. package/dist/src/mcpl/inference-router.d.ts +19 -13
  101. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  102. package/dist/src/mcpl/inference-router.js +155 -87
  103. package/dist/src/mcpl/inference-router.js.map +1 -1
  104. package/dist/src/mcpl/log-value.d.ts +7 -0
  105. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  106. package/dist/src/mcpl/log-value.js +9 -0
  107. package/dist/src/mcpl/log-value.js.map +1 -1
  108. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  109. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  110. package/dist/src/mcpl/modern-connection.js +1088 -0
  111. package/dist/src/mcpl/modern-connection.js.map +1 -0
  112. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  113. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  114. package/dist/src/mcpl/prefix-rule.js +25 -0
  115. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  116. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  117. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  118. package/dist/src/mcpl/protocol-family.js +697 -0
  119. package/dist/src/mcpl/protocol-family.js.map +1 -0
  120. package/dist/src/mcpl/push-coalescer.d.ts +58 -17
  121. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  122. package/dist/src/mcpl/push-coalescer.js +93 -32
  123. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  124. package/dist/src/mcpl/push-handler.d.ts +16 -9
  125. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  126. package/dist/src/mcpl/push-handler.js +18 -83
  127. package/dist/src/mcpl/push-handler.js.map +1 -1
  128. package/dist/src/mcpl/reference-fetcher.d.ts +76 -13
  129. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  130. package/dist/src/mcpl/reference-fetcher.js +275 -66
  131. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  132. package/dist/src/mcpl/references.d.ts +98 -18
  133. package/dist/src/mcpl/references.d.ts.map +1 -1
  134. package/dist/src/mcpl/references.js +170 -32
  135. package/dist/src/mcpl/references.js.map +1 -1
  136. package/dist/src/mcpl/server-connection.d.ts +406 -31
  137. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  138. package/dist/src/mcpl/server-connection.js +1331 -272
  139. package/dist/src/mcpl/server-connection.js.map +1 -1
  140. package/dist/src/mcpl/server-registry.d.ts +35 -6
  141. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  142. package/dist/src/mcpl/server-registry.js +63 -13
  143. package/dist/src/mcpl/server-registry.js.map +1 -1
  144. package/dist/src/mcpl/skip-reply-wake.d.ts +36 -0
  145. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -0
  146. package/dist/src/mcpl/skip-reply-wake.js +55 -0
  147. package/dist/src/mcpl/skip-reply-wake.js.map +1 -0
  148. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  149. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  150. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  151. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  152. package/dist/src/mcpl/transport.d.ts +104 -12
  153. package/dist/src/mcpl/transport.d.ts.map +1 -1
  154. package/dist/src/mcpl/transport.js +315 -85
  155. package/dist/src/mcpl/transport.js.map +1 -1
  156. package/dist/src/mcpl/types.d.ts +76 -19
  157. package/dist/src/mcpl/types.d.ts.map +1 -1
  158. package/dist/src/mcpl/types.js.map +1 -1
  159. package/dist/src/mcpl/visible-content.d.ts +16 -0
  160. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  161. package/dist/src/mcpl/visible-content.js +26 -0
  162. package/dist/src/mcpl/visible-content.js.map +1 -1
  163. package/dist/src/module-registry.d.ts +34 -6
  164. package/dist/src/module-registry.d.ts.map +1 -1
  165. package/dist/src/module-registry.js +119 -31
  166. package/dist/src/module-registry.js.map +1 -1
  167. package/dist/src/modules/api/index.d.ts +1 -1
  168. package/dist/src/modules/api/index.d.ts.map +1 -1
  169. package/dist/src/modules/api/index.js +3 -2
  170. package/dist/src/modules/api/index.js.map +1 -1
  171. package/dist/src/modules/discord/discord-js-client.d.ts +43 -2
  172. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  173. package/dist/src/modules/discord/discord-js-client.js +286 -67
  174. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  175. package/dist/src/modules/discord/index.d.ts +53 -5
  176. package/dist/src/modules/discord/index.d.ts.map +1 -1
  177. package/dist/src/modules/discord/index.js +712 -186
  178. package/dist/src/modules/discord/index.js.map +1 -1
  179. package/dist/src/modules/discord/refusal.d.ts +14 -0
  180. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  181. package/dist/src/modules/discord/refusal.js +14 -0
  182. package/dist/src/modules/discord/refusal.js.map +1 -0
  183. package/dist/src/modules/discord/types.d.ts +27 -4
  184. package/dist/src/modules/discord/types.d.ts.map +1 -1
  185. package/dist/src/modules/health/index.d.ts.map +1 -1
  186. package/dist/src/modules/health/index.js +8 -3
  187. package/dist/src/modules/health/index.js.map +1 -1
  188. package/dist/src/modules/history/index.d.ts +3 -1
  189. package/dist/src/modules/history/index.d.ts.map +1 -1
  190. package/dist/src/modules/history/index.js +359 -127
  191. package/dist/src/modules/history/index.js.map +1 -1
  192. package/dist/src/modules/history/semantic.d.ts +28 -1
  193. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  194. package/dist/src/modules/history/semantic.js +177 -32
  195. package/dist/src/modules/history/semantic.js.map +1 -1
  196. package/dist/src/modules/index.d.ts +1 -1
  197. package/dist/src/modules/index.d.ts.map +1 -1
  198. package/dist/src/modules/index.js +1 -1
  199. package/dist/src/modules/index.js.map +1 -1
  200. package/dist/src/modules/unknown-tool.d.ts +10 -0
  201. package/dist/src/modules/unknown-tool.d.ts.map +1 -0
  202. package/dist/src/modules/unknown-tool.js +17 -0
  203. package/dist/src/modules/unknown-tool.js.map +1 -0
  204. package/dist/src/modules/workspace/disk-agreement.d.ts +57 -4
  205. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  206. package/dist/src/modules/workspace/disk-agreement.js +75 -7
  207. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  208. package/dist/src/modules/workspace/effects.d.ts +66 -7
  209. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  210. package/dist/src/modules/workspace/effects.js +303 -46
  211. package/dist/src/modules/workspace/effects.js.map +1 -1
  212. package/dist/src/modules/workspace/index.d.ts +313 -45
  213. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  214. package/dist/src/modules/workspace/index.js +1404 -304
  215. package/dist/src/modules/workspace/index.js.map +1 -1
  216. package/dist/src/modules/workspace/observe.d.ts +315 -16
  217. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  218. package/dist/src/modules/workspace/observe.js +727 -37
  219. package/dist/src/modules/workspace/observe.js.map +1 -1
  220. package/dist/src/modules/workspace/reconcile.d.ts +58 -7
  221. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  222. package/dist/src/modules/workspace/reconcile.js +521 -56
  223. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  224. package/dist/src/modules/workspace/types.d.ts +28 -5
  225. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  226. package/dist/src/modules/workspace/types.js.map +1 -1
  227. package/dist/src/modules/workspace/watcher.d.ts +49 -2
  228. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  229. package/dist/src/modules/workspace/watcher.js +158 -29
  230. package/dist/src/modules/workspace/watcher.js.map +1 -1
  231. package/dist/src/prose-drafts.d.ts +240 -0
  232. package/dist/src/prose-drafts.d.ts.map +1 -0
  233. package/dist/src/prose-drafts.js +362 -0
  234. package/dist/src/prose-drafts.js.map +1 -0
  235. package/dist/src/prose-segments.d.ts +21 -2
  236. package/dist/src/prose-segments.d.ts.map +1 -1
  237. package/dist/src/prose-segments.js +46 -9
  238. package/dist/src/prose-segments.js.map +1 -1
  239. package/dist/src/recovery/offline-branch.d.ts +1 -0
  240. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  241. package/dist/src/recovery/offline-branch.js +3 -1
  242. package/dist/src/recovery/offline-branch.js.map +1 -1
  243. package/dist/src/safe-slice.d.ts +5 -1
  244. package/dist/src/safe-slice.d.ts.map +1 -1
  245. package/dist/src/safe-slice.js +9 -1
  246. package/dist/src/safe-slice.js.map +1 -1
  247. package/dist/src/silent-heartbeat.d.ts +112 -39
  248. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  249. package/dist/src/silent-heartbeat.js +100 -29
  250. package/dist/src/silent-heartbeat.js.map +1 -1
  251. package/dist/src/tool-args.d.ts +40 -0
  252. package/dist/src/tool-args.d.ts.map +1 -0
  253. package/dist/src/tool-args.js +207 -0
  254. package/dist/src/tool-args.js.map +1 -0
  255. package/dist/src/tool-image-ledger.d.ts +87 -19
  256. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  257. package/dist/src/tool-image-ledger.js +135 -32
  258. package/dist/src/tool-image-ledger.js.map +1 -1
  259. package/dist/src/tool-presentation.d.ts +9 -0
  260. package/dist/src/tool-presentation.d.ts.map +1 -1
  261. package/dist/src/tool-presentation.js +266 -62
  262. package/dist/src/tool-presentation.js.map +1 -1
  263. package/dist/src/tool-result-guard.d.ts.map +1 -1
  264. package/dist/src/tool-result-guard.js +7 -10
  265. package/dist/src/tool-result-guard.js.map +1 -1
  266. package/dist/src/tool-result-history.d.ts +32 -3
  267. package/dist/src/tool-result-history.d.ts.map +1 -1
  268. package/dist/src/tool-result-history.js +106 -19
  269. package/dist/src/tool-result-history.js.map +1 -1
  270. package/dist/src/tune-out/coordinator.d.ts +25 -1
  271. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  272. package/dist/src/tune-out/coordinator.js +260 -65
  273. package/dist/src/tune-out/coordinator.js.map +1 -1
  274. package/dist/src/types/agent.d.ts +44 -22
  275. package/dist/src/types/agent.d.ts.map +1 -1
  276. package/dist/src/types/events.d.ts +25 -0
  277. package/dist/src/types/events.d.ts.map +1 -1
  278. package/dist/src/types/framework.d.ts +38 -7
  279. package/dist/src/types/framework.d.ts.map +1 -1
  280. package/dist/src/types/inference-log.d.ts +1 -1
  281. package/dist/src/types/inference-log.d.ts.map +1 -1
  282. package/dist/src/types/module.d.ts +77 -14
  283. package/dist/src/types/module.d.ts.map +1 -1
  284. package/dist/src/types/trace.d.ts +97 -8
  285. package/dist/src/types/trace.d.ts.map +1 -1
  286. package/dist/src/usage/usage-tracker.d.ts +5 -0
  287. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  288. package/dist/src/usage/usage-tracker.js +35 -8
  289. package/dist/src/usage/usage-tracker.js.map +1 -1
  290. package/package.json +5 -4
  291. package/src/agent.ts +257 -72
  292. package/src/api/mcp-server-core.ts +1 -1
  293. package/src/api/server.ts +4 -1
  294. package/src/api/types.ts +1 -1
  295. package/src/branch-names.ts +19 -0
  296. package/src/closest-names.ts +94 -0
  297. package/src/code-execution/index.ts +1 -0
  298. package/src/code-execution/py-runner.ts +304 -93
  299. package/src/code-execution/runtime-py.ts +94 -8
  300. package/src/code-execution/tool-definition.ts +85 -7
  301. package/src/config-checks.ts +50 -0
  302. package/src/deadline-timer.ts +44 -0
  303. package/src/error-message.ts +48 -0
  304. package/src/framework.ts +8625 -1984
  305. package/src/gate/event-gate.ts +38 -5
  306. package/src/held-writes.ts +59 -0
  307. package/src/index.ts +29 -5
  308. package/src/mcpl/capability-grant.ts +1 -1
  309. package/src/mcpl/channel-registry.ts +1095 -411
  310. package/src/mcpl/checkpoint-manager.ts +104 -29
  311. package/src/mcpl/conversation-router.ts +28 -8
  312. package/src/mcpl/convert-block.ts +105 -0
  313. package/src/mcpl/errors.ts +28 -0
  314. package/src/mcpl/hook-orchestrator.ts +84 -77
  315. package/src/mcpl/inbound-source.ts +156 -0
  316. package/src/mcpl/index.ts +23 -1
  317. package/src/mcpl/inference-router.ts +179 -103
  318. package/src/mcpl/log-value.ts +10 -0
  319. package/src/mcpl/modern-connection.ts +1182 -0
  320. package/src/mcpl/prefix-rule.ts +32 -0
  321. package/src/mcpl/protocol-family.ts +691 -0
  322. package/src/mcpl/push-coalescer.ts +110 -36
  323. package/src/mcpl/push-handler.ts +20 -90
  324. package/src/mcpl/reference-fetcher.ts +318 -67
  325. package/src/mcpl/references.ts +183 -34
  326. package/src/mcpl/server-connection.ts +1404 -280
  327. package/src/mcpl/server-registry.ts +63 -14
  328. package/src/mcpl/skip-reply-wake.ts +73 -0
  329. package/src/mcpl/tool-result-normalize.ts +294 -0
  330. package/src/mcpl/transport.ts +320 -76
  331. package/src/mcpl/types.ts +77 -19
  332. package/src/mcpl/visible-content.ts +28 -0
  333. package/src/module-registry.ts +126 -36
  334. package/src/modules/api/index.ts +3 -2
  335. package/src/modules/discord/discord-js-client.ts +306 -74
  336. package/src/modules/discord/index.ts +737 -201
  337. package/src/modules/discord/refusal.ts +13 -0
  338. package/src/modules/discord/types.ts +27 -6
  339. package/src/modules/health/index.ts +8 -3
  340. package/src/modules/history/index.ts +410 -120
  341. package/src/modules/history/semantic.ts +189 -32
  342. package/src/modules/index.ts +1 -1
  343. package/src/modules/unknown-tool.ts +16 -0
  344. package/src/modules/workspace/disk-agreement.ts +97 -8
  345. package/src/modules/workspace/effects.ts +291 -36
  346. package/src/modules/workspace/index.ts +1469 -321
  347. package/src/modules/workspace/observe.ts +784 -41
  348. package/src/modules/workspace/reconcile.ts +619 -64
  349. package/src/modules/workspace/types.ts +28 -5
  350. package/src/modules/workspace/watcher.ts +154 -30
  351. package/src/prose-drafts.ts +481 -0
  352. package/src/prose-segments.ts +51 -8
  353. package/src/recovery/offline-branch.ts +4 -1
  354. package/src/safe-slice.ts +9 -1
  355. package/src/silent-heartbeat.ts +184 -59
  356. package/src/tool-args.ts +213 -0
  357. package/src/tool-image-ledger.ts +153 -34
  358. package/src/tool-presentation.ts +233 -42
  359. package/src/tool-result-guard.ts +7 -8
  360. package/src/tool-result-history.ts +124 -20
  361. package/src/tune-out/coordinator.ts +259 -65
  362. package/src/types/agent.ts +44 -24
  363. package/src/types/events.ts +25 -0
  364. package/src/types/framework.ts +38 -7
  365. package/src/types/inference-log.ts +1 -1
  366. package/src/types/module.ts +79 -14
  367. package/src/types/trace.ts +103 -8
  368. package/src/usage/usage-tracker.ts +32 -8
  369. package/dist/test/activation-request-sanitize.test.d.ts +0 -2
  370. package/dist/test/activation-request-sanitize.test.d.ts.map +0 -1
  371. package/dist/test/activation-request-sanitize.test.js +0 -89
  372. package/dist/test/activation-request-sanitize.test.js.map +0 -1
  373. package/dist/test/agent-cache-ttl.test.d.ts +0 -2
  374. package/dist/test/agent-cache-ttl.test.d.ts.map +0 -1
  375. package/dist/test/agent-cache-ttl.test.js +0 -41
  376. package/dist/test/agent-cache-ttl.test.js.map +0 -1
  377. package/dist/test/agent-runtime-settings.test.d.ts +0 -2
  378. package/dist/test/agent-runtime-settings.test.d.ts.map +0 -1
  379. package/dist/test/agent-runtime-settings.test.js +0 -1598
  380. package/dist/test/agent-runtime-settings.test.js.map +0 -1
  381. package/dist/test/api-admin-gate.test.d.ts +0 -2
  382. package/dist/test/api-admin-gate.test.d.ts.map +0 -1
  383. package/dist/test/api-admin-gate.test.js +0 -116
  384. package/dist/test/api-admin-gate.test.js.map +0 -1
  385. package/dist/test/assistant-persist-refusal.test.d.ts +0 -2
  386. package/dist/test/assistant-persist-refusal.test.d.ts.map +0 -1
  387. package/dist/test/assistant-persist-refusal.test.js +0 -63
  388. package/dist/test/assistant-persist-refusal.test.js.map +0 -1
  389. package/dist/test/batched-wake-locus.test.d.ts +0 -2
  390. package/dist/test/batched-wake-locus.test.d.ts.map +0 -1
  391. package/dist/test/batched-wake-locus.test.js +0 -166
  392. package/dist/test/batched-wake-locus.test.js.map +0 -1
  393. package/dist/test/busy-agent-scheduler.test.d.ts +0 -2
  394. package/dist/test/busy-agent-scheduler.test.d.ts.map +0 -1
  395. package/dist/test/busy-agent-scheduler.test.js +0 -92
  396. package/dist/test/busy-agent-scheduler.test.js.map +0 -1
  397. package/dist/test/channel-incoming-targeting.test.d.ts +0 -2
  398. package/dist/test/channel-incoming-targeting.test.d.ts.map +0 -1
  399. package/dist/test/channel-incoming-targeting.test.js +0 -81
  400. package/dist/test/channel-incoming-targeting.test.js.map +0 -1
  401. package/dist/test/channel-label-history.test.d.ts +0 -2
  402. package/dist/test/channel-label-history.test.d.ts.map +0 -1
  403. package/dist/test/channel-label-history.test.js +0 -424
  404. package/dist/test/channel-label-history.test.js.map +0 -1
  405. package/dist/test/channel-lifecycle-provenance.test.d.ts +0 -2
  406. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +0 -1
  407. package/dist/test/channel-lifecycle-provenance.test.js +0 -47
  408. package/dist/test/channel-lifecycle-provenance.test.js.map +0 -1
  409. package/dist/test/channel-registry-routing.test.d.ts +0 -2
  410. package/dist/test/channel-registry-routing.test.d.ts.map +0 -1
  411. package/dist/test/channel-registry-routing.test.js +0 -446
  412. package/dist/test/channel-registry-routing.test.js.map +0 -1
  413. package/dist/test/channel-registry-typing.test.d.ts +0 -2
  414. package/dist/test/channel-registry-typing.test.d.ts.map +0 -1
  415. package/dist/test/channel-registry-typing.test.js +0 -91
  416. package/dist/test/channel-registry-typing.test.js.map +0 -1
  417. package/dist/test/channels-changed-respond-first.test.d.ts +0 -2
  418. package/dist/test/channels-changed-respond-first.test.d.ts.map +0 -1
  419. package/dist/test/channels-changed-respond-first.test.js +0 -74
  420. package/dist/test/channels-changed-respond-first.test.js.map +0 -1
  421. package/dist/test/checkpoint-attribution.test.d.ts +0 -2
  422. package/dist/test/checkpoint-attribution.test.d.ts.map +0 -1
  423. package/dist/test/checkpoint-attribution.test.js +0 -200
  424. package/dist/test/checkpoint-attribution.test.js.map +0 -1
  425. package/dist/test/code-execution.test.d.ts +0 -11
  426. package/dist/test/code-execution.test.d.ts.map +0 -1
  427. package/dist/test/code-execution.test.js +0 -1272
  428. package/dist/test/code-execution.test.js.map +0 -1
  429. package/dist/test/component-defaults.test.d.ts +0 -2
  430. package/dist/test/component-defaults.test.d.ts.map +0 -1
  431. package/dist/test/component-defaults.test.js +0 -54
  432. package/dist/test/component-defaults.test.js.map +0 -1
  433. package/dist/test/conversation-fork-injection-scoping.test.d.ts +0 -2
  434. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +0 -1
  435. package/dist/test/conversation-fork-injection-scoping.test.js +0 -200
  436. package/dist/test/conversation-fork-injection-scoping.test.js.map +0 -1
  437. package/dist/test/conversation-router.test.d.ts +0 -2
  438. package/dist/test/conversation-router.test.d.ts.map +0 -1
  439. package/dist/test/conversation-router.test.js +0 -174
  440. package/dist/test/conversation-router.test.js.map +0 -1
  441. package/dist/test/conversation-routing.test.d.ts +0 -7
  442. package/dist/test/conversation-routing.test.d.ts.map +0 -1
  443. package/dist/test/conversation-routing.test.js +0 -621
  444. package/dist/test/conversation-routing.test.js.map +0 -1
  445. package/dist/test/deferred-wake-integrity.test.d.ts +0 -29
  446. package/dist/test/deferred-wake-integrity.test.d.ts.map +0 -1
  447. package/dist/test/deferred-wake-integrity.test.js +0 -320
  448. package/dist/test/deferred-wake-integrity.test.js.map +0 -1
  449. package/dist/test/discord-awareness-outbox.test.d.ts +0 -2
  450. package/dist/test/discord-awareness-outbox.test.d.ts.map +0 -1
  451. package/dist/test/discord-awareness-outbox.test.js +0 -371
  452. package/dist/test/discord-awareness-outbox.test.js.map +0 -1
  453. package/dist/test/discord-history-delete-window.test.d.ts +0 -2
  454. package/dist/test/discord-history-delete-window.test.d.ts.map +0 -1
  455. package/dist/test/discord-history-delete-window.test.js +0 -81
  456. package/dist/test/discord-history-delete-window.test.js.map +0 -1
  457. package/dist/test/discord-history-sync.test.d.ts +0 -2
  458. package/dist/test/discord-history-sync.test.d.ts.map +0 -1
  459. package/dist/test/discord-history-sync.test.js +0 -56
  460. package/dist/test/discord-history-sync.test.js.map +0 -1
  461. package/dist/test/discord-typing-keepalive.test.d.ts +0 -2
  462. package/dist/test/discord-typing-keepalive.test.d.ts.map +0 -1
  463. package/dist/test/discord-typing-keepalive.test.js +0 -77
  464. package/dist/test/discord-typing-keepalive.test.js.map +0 -1
  465. package/dist/test/ephemeral-settle.test.d.ts +0 -2
  466. package/dist/test/ephemeral-settle.test.d.ts.map +0 -1
  467. package/dist/test/ephemeral-settle.test.js +0 -354
  468. package/dist/test/ephemeral-settle.test.js.map +0 -1
  469. package/dist/test/event-gate-passthrough.test.d.ts +0 -15
  470. package/dist/test/event-gate-passthrough.test.d.ts.map +0 -1
  471. package/dist/test/event-gate-passthrough.test.js +0 -289
  472. package/dist/test/event-gate-passthrough.test.js.map +0 -1
  473. package/dist/test/event-gate-policy-mutation.test.d.ts +0 -2
  474. package/dist/test/event-gate-policy-mutation.test.d.ts.map +0 -1
  475. package/dist/test/event-gate-policy-mutation.test.js +0 -137
  476. package/dist/test/event-gate-policy-mutation.test.js.map +0 -1
  477. package/dist/test/event-gate-rate-limit.test.d.ts +0 -2
  478. package/dist/test/event-gate-rate-limit.test.d.ts.map +0 -1
  479. package/dist/test/event-gate-rate-limit.test.js +0 -627
  480. package/dist/test/event-gate-rate-limit.test.js.map +0 -1
  481. package/dist/test/event-gate-script.test.d.ts +0 -2
  482. package/dist/test/event-gate-script.test.d.ts.map +0 -1
  483. package/dist/test/event-gate-script.test.js +0 -95
  484. package/dist/test/event-gate-script.test.js.map +0 -1
  485. package/dist/test/event-gate-selfwake.test.d.ts +0 -15
  486. package/dist/test/event-gate-selfwake.test.d.ts.map +0 -1
  487. package/dist/test/event-gate-selfwake.test.js +0 -125
  488. package/dist/test/event-gate-selfwake.test.js.map +0 -1
  489. package/dist/test/event-gate-tags.test.d.ts +0 -2
  490. package/dist/test/event-gate-tags.test.d.ts.map +0 -1
  491. package/dist/test/event-gate-tags.test.js +0 -76
  492. package/dist/test/event-gate-tags.test.js.map +0 -1
  493. package/dist/test/event-gate.test.d.ts +0 -2
  494. package/dist/test/event-gate.test.d.ts.map +0 -1
  495. package/dist/test/event-gate.test.js +0 -919
  496. package/dist/test/event-gate.test.js.map +0 -1
  497. package/dist/test/explicit-prose-routing.test.d.ts +0 -11
  498. package/dist/test/explicit-prose-routing.test.d.ts.map +0 -1
  499. package/dist/test/explicit-prose-routing.test.js +0 -1715
  500. package/dist/test/explicit-prose-routing.test.js.map +0 -1
  501. package/dist/test/framework.test.d.ts +0 -5
  502. package/dist/test/framework.test.d.ts.map +0 -1
  503. package/dist/test/framework.test.js +0 -1048
  504. package/dist/test/framework.test.js.map +0 -1
  505. package/dist/test/gather-context-timeout.test.d.ts +0 -2
  506. package/dist/test/gather-context-timeout.test.d.ts.map +0 -1
  507. package/dist/test/gather-context-timeout.test.js +0 -120
  508. package/dist/test/gather-context-timeout.test.js.map +0 -1
  509. package/dist/test/health-snapshot.test.d.ts +0 -2
  510. package/dist/test/health-snapshot.test.d.ts.map +0 -1
  511. package/dist/test/health-snapshot.test.js +0 -74
  512. package/dist/test/health-snapshot.test.js.map +0 -1
  513. package/dist/test/helpers/coalescing-fixture.d.ts +0 -74
  514. package/dist/test/helpers/coalescing-fixture.d.ts.map +0 -1
  515. package/dist/test/helpers/coalescing-fixture.js +0 -152
  516. package/dist/test/helpers/coalescing-fixture.js.map +0 -1
  517. package/dist/test/helpers/coalescing-kill-child.d.ts +0 -2
  518. package/dist/test/helpers/coalescing-kill-child.d.ts.map +0 -1
  519. package/dist/test/helpers/coalescing-kill-child.js +0 -24
  520. package/dist/test/helpers/coalescing-kill-child.js.map +0 -1
  521. package/dist/test/helpers/mock-membrane.d.ts +0 -53
  522. package/dist/test/helpers/mock-membrane.d.ts.map +0 -1
  523. package/dist/test/helpers/mock-membrane.js +0 -168
  524. package/dist/test/helpers/mock-membrane.js.map +0 -1
  525. package/dist/test/helpers/pytc-stop-rows.d.ts +0 -11
  526. package/dist/test/helpers/pytc-stop-rows.d.ts.map +0 -1
  527. package/dist/test/helpers/pytc-stop-rows.js +0 -143
  528. package/dist/test/helpers/pytc-stop-rows.js.map +0 -1
  529. package/dist/test/helpers/quiesce-crash-child.d.ts +0 -2
  530. package/dist/test/helpers/quiesce-crash-child.d.ts.map +0 -1
  531. package/dist/test/helpers/quiesce-crash-child.js +0 -156
  532. package/dist/test/helpers/quiesce-crash-child.js.map +0 -1
  533. package/dist/test/hide-then-turn.test.d.ts +0 -2
  534. package/dist/test/hide-then-turn.test.d.ts.map +0 -1
  535. package/dist/test/hide-then-turn.test.js +0 -101
  536. package/dist/test/hide-then-turn.test.js.map +0 -1
  537. package/dist/test/history-module-overview.test.d.ts +0 -2
  538. package/dist/test/history-module-overview.test.d.ts.map +0 -1
  539. package/dist/test/history-module-overview.test.js +0 -711
  540. package/dist/test/history-module-overview.test.js.map +0 -1
  541. package/dist/test/history-module-realstore.test.d.ts +0 -2
  542. package/dist/test/history-module-realstore.test.d.ts.map +0 -1
  543. package/dist/test/history-module-realstore.test.js +0 -363
  544. package/dist/test/history-module-realstore.test.js.map +0 -1
  545. package/dist/test/history-module-semantic.test.d.ts +0 -2
  546. package/dist/test/history-module-semantic.test.d.ts.map +0 -1
  547. package/dist/test/history-module-semantic.test.js +0 -213
  548. package/dist/test/history-module-semantic.test.js.map +0 -1
  549. package/dist/test/history-module-ux.test.d.ts +0 -2
  550. package/dist/test/history-module-ux.test.d.ts.map +0 -1
  551. package/dist/test/history-module-ux.test.js +0 -304
  552. package/dist/test/history-module-ux.test.js.map +0 -1
  553. package/dist/test/history-module.test.d.ts +0 -2
  554. package/dist/test/history-module.test.d.ts.map +0 -1
  555. package/dist/test/history-module.test.js +0 -619
  556. package/dist/test/history-module.test.js.map +0 -1
  557. package/dist/test/history-semantic-review-findings.test.d.ts +0 -2
  558. package/dist/test/history-semantic-review-findings.test.d.ts.map +0 -1
  559. package/dist/test/history-semantic-review-findings.test.js +0 -338
  560. package/dist/test/history-semantic-review-findings.test.js.map +0 -1
  561. package/dist/test/history-semantic-sync-converges.test.d.ts +0 -2
  562. package/dist/test/history-semantic-sync-converges.test.d.ts.map +0 -1
  563. package/dist/test/history-semantic-sync-converges.test.js +0 -109
  564. package/dist/test/history-semantic-sync-converges.test.js.map +0 -1
  565. package/dist/test/host-quiesce.test.d.ts +0 -2
  566. package/dist/test/host-quiesce.test.d.ts.map +0 -1
  567. package/dist/test/host-quiesce.test.js +0 -988
  568. package/dist/test/host-quiesce.test.js.map +0 -1
  569. package/dist/test/inference-exhaustion-rewind.test.d.ts +0 -2
  570. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +0 -1
  571. package/dist/test/inference-exhaustion-rewind.test.js +0 -387
  572. package/dist/test/inference-exhaustion-rewind.test.js.map +0 -1
  573. package/dist/test/inference-failure-bounds.test.d.ts +0 -2
  574. package/dist/test/inference-failure-bounds.test.d.ts.map +0 -1
  575. package/dist/test/inference-failure-bounds.test.js +0 -1202
  576. package/dist/test/inference-failure-bounds.test.js.map +0 -1
  577. package/dist/test/inference-failure-observability.test.d.ts +0 -2
  578. package/dist/test/inference-failure-observability.test.d.ts.map +0 -1
  579. package/dist/test/inference-failure-observability.test.js +0 -84
  580. package/dist/test/inference-failure-observability.test.js.map +0 -1
  581. package/dist/test/journal-tool.test.d.ts +0 -2
  582. package/dist/test/journal-tool.test.d.ts.map +0 -1
  583. package/dist/test/journal-tool.test.js +0 -33
  584. package/dist/test/journal-tool.test.js.map +0 -1
  585. package/dist/test/kv-unified-open-flight-retry.test.d.ts +0 -2
  586. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +0 -1
  587. package/dist/test/kv-unified-open-flight-retry.test.js +0 -189
  588. package/dist/test/kv-unified-open-flight-retry.test.js.map +0 -1
  589. package/dist/test/kv-unified-wiring.test.d.ts +0 -2
  590. package/dist/test/kv-unified-wiring.test.d.ts.map +0 -1
  591. package/dist/test/kv-unified-wiring.test.js +0 -95
  592. package/dist/test/kv-unified-wiring.test.js.map +0 -1
  593. package/dist/test/late-events-after-stop.test.d.ts +0 -2
  594. package/dist/test/late-events-after-stop.test.d.ts.map +0 -1
  595. package/dist/test/late-events-after-stop.test.js +0 -122
  596. package/dist/test/late-events-after-stop.test.js.map +0 -1
  597. package/dist/test/liveness-watchdog.test.d.ts +0 -2
  598. package/dist/test/liveness-watchdog.test.d.ts.map +0 -1
  599. package/dist/test/liveness-watchdog.test.js +0 -94
  600. package/dist/test/liveness-watchdog.test.js.map +0 -1
  601. package/dist/test/machine-close-provenance.test.d.ts +0 -2
  602. package/dist/test/machine-close-provenance.test.d.ts.map +0 -1
  603. package/dist/test/machine-close-provenance.test.js +0 -116
  604. package/dist/test/machine-close-provenance.test.js.map +0 -1
  605. package/dist/test/maintenance-timer.test.d.ts +0 -2
  606. package/dist/test/maintenance-timer.test.d.ts.map +0 -1
  607. package/dist/test/maintenance-timer.test.js +0 -1827
  608. package/dist/test/maintenance-timer.test.js.map +0 -1
  609. package/dist/test/mcp-server-bin.test.d.ts +0 -2
  610. package/dist/test/mcp-server-bin.test.d.ts.map +0 -1
  611. package/dist/test/mcp-server-bin.test.js +0 -142
  612. package/dist/test/mcp-server-bin.test.js.map +0 -1
  613. package/dist/test/mcpl-access-provider.test.d.ts +0 -2
  614. package/dist/test/mcpl-access-provider.test.d.ts.map +0 -1
  615. package/dist/test/mcpl-access-provider.test.js +0 -20
  616. package/dist/test/mcpl-access-provider.test.js.map +0 -1
  617. package/dist/test/mcpl-awareness-barrier.test.d.ts +0 -2
  618. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +0 -1
  619. package/dist/test/mcpl-awareness-barrier.test.js +0 -769
  620. package/dist/test/mcpl-awareness-barrier.test.js.map +0 -1
  621. package/dist/test/mcpl-capability-grant.test.d.ts +0 -10
  622. package/dist/test/mcpl-capability-grant.test.d.ts.map +0 -1
  623. package/dist/test/mcpl-capability-grant.test.js +0 -111
  624. package/dist/test/mcpl-capability-grant.test.js.map +0 -1
  625. package/dist/test/mcpl-capability-scoping.test.d.ts +0 -2
  626. package/dist/test/mcpl-capability-scoping.test.d.ts.map +0 -1
  627. package/dist/test/mcpl-capability-scoping.test.js +0 -266
  628. package/dist/test/mcpl-capability-scoping.test.js.map +0 -1
  629. package/dist/test/mcpl-child-env.test.d.ts +0 -2
  630. package/dist/test/mcpl-child-env.test.d.ts.map +0 -1
  631. package/dist/test/mcpl-child-env.test.js +0 -158
  632. package/dist/test/mcpl-child-env.test.js.map +0 -1
  633. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +0 -2
  634. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +0 -1
  635. package/dist/test/mcpl-coalesced-content-parity.test.js +0 -532
  636. package/dist/test/mcpl-coalesced-content-parity.test.js.map +0 -1
  637. package/dist/test/mcpl-coalescing.test.d.ts +0 -2
  638. package/dist/test/mcpl-coalescing.test.d.ts.map +0 -1
  639. package/dist/test/mcpl-coalescing.test.js +0 -1125
  640. package/dist/test/mcpl-coalescing.test.js.map +0 -1
  641. package/dist/test/mcpl-config-diagnostics.test.d.ts +0 -2
  642. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +0 -1
  643. package/dist/test/mcpl-config-diagnostics.test.js +0 -452
  644. package/dist/test/mcpl-config-diagnostics.test.js.map +0 -1
  645. package/dist/test/mcpl-empty-content.test.d.ts +0 -2
  646. package/dist/test/mcpl-empty-content.test.d.ts.map +0 -1
  647. package/dist/test/mcpl-empty-content.test.js +0 -368
  648. package/dist/test/mcpl-empty-content.test.js.map +0 -1
  649. package/dist/test/mcpl-gate-roundtrip.test.d.ts +0 -2
  650. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +0 -1
  651. package/dist/test/mcpl-gate-roundtrip.test.js +0 -285
  652. package/dist/test/mcpl-gate-roundtrip.test.js.map +0 -1
  653. package/dist/test/mcpl-list-status.test.d.ts +0 -2
  654. package/dist/test/mcpl-list-status.test.d.ts.map +0 -1
  655. package/dist/test/mcpl-list-status.test.js +0 -82
  656. package/dist/test/mcpl-list-status.test.js.map +0 -1
  657. package/dist/test/mcpl-manifest-changed.test.d.ts +0 -2
  658. package/dist/test/mcpl-manifest-changed.test.d.ts.map +0 -1
  659. package/dist/test/mcpl-manifest-changed.test.js +0 -178
  660. package/dist/test/mcpl-manifest-changed.test.js.map +0 -1
  661. package/dist/test/mcpl-push-convert.test.d.ts +0 -2
  662. package/dist/test/mcpl-push-convert.test.d.ts.map +0 -1
  663. package/dist/test/mcpl-push-convert.test.js +0 -42
  664. package/dist/test/mcpl-push-convert.test.js.map +0 -1
  665. package/dist/test/mcpl-reconnect-events.test.d.ts +0 -2
  666. package/dist/test/mcpl-reconnect-events.test.d.ts.map +0 -1
  667. package/dist/test/mcpl-reconnect-events.test.js +0 -144
  668. package/dist/test/mcpl-reconnect-events.test.js.map +0 -1
  669. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +0 -2
  670. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +0 -1
  671. package/dist/test/mcpl-reconnect-reregistration.test.js +0 -238
  672. package/dist/test/mcpl-reconnect-reregistration.test.js.map +0 -1
  673. package/dist/test/mcpl-reference-fetcher.test.d.ts +0 -2
  674. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +0 -1
  675. package/dist/test/mcpl-reference-fetcher.test.js +0 -427
  676. package/dist/test/mcpl-reference-fetcher.test.js.map +0 -1
  677. package/dist/test/mcpl-reference-fields.test.d.ts +0 -2
  678. package/dist/test/mcpl-reference-fields.test.d.ts.map +0 -1
  679. package/dist/test/mcpl-reference-fields.test.js +0 -149
  680. package/dist/test/mcpl-reference-fields.test.js.map +0 -1
  681. package/dist/test/mcpl-rejection-log-lines.test.d.ts +0 -2
  682. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +0 -1
  683. package/dist/test/mcpl-rejection-log-lines.test.js +0 -349
  684. package/dist/test/mcpl-rejection-log-lines.test.js.map +0 -1
  685. package/dist/test/mcpl-request-timeout.test.d.ts +0 -2
  686. package/dist/test/mcpl-request-timeout.test.d.ts.map +0 -1
  687. package/dist/test/mcpl-request-timeout.test.js +0 -138
  688. package/dist/test/mcpl-request-timeout.test.js.map +0 -1
  689. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +0 -7
  690. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +0 -1
  691. package/dist/test/mcpl-runtime-lifecycle.test.js +0 -142
  692. package/dist/test/mcpl-runtime-lifecycle.test.js.map +0 -1
  693. package/dist/test/mcpl-stderr-tail.test.d.ts +0 -2
  694. package/dist/test/mcpl-stderr-tail.test.d.ts.map +0 -1
  695. package/dist/test/mcpl-stderr-tail.test.js +0 -210
  696. package/dist/test/mcpl-stderr-tail.test.js.map +0 -1
  697. package/dist/test/mcpl-tool-class-listing.test.d.ts +0 -2
  698. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +0 -1
  699. package/dist/test/mcpl-tool-class-listing.test.js +0 -147
  700. package/dist/test/mcpl-tool-class-listing.test.js.map +0 -1
  701. package/dist/test/mcpl-tool-classes.test.d.ts +0 -7
  702. package/dist/test/mcpl-tool-classes.test.d.ts.map +0 -1
  703. package/dist/test/mcpl-tool-classes.test.js +0 -80
  704. package/dist/test/mcpl-tool-classes.test.js.map +0 -1
  705. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +0 -2
  706. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +0 -1
  707. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +0 -237
  708. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +0 -1
  709. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +0 -11
  710. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +0 -1
  711. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +0 -412
  712. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +0 -1
  713. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +0 -2
  714. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +0 -1
  715. package/dist/test/mcpl-tool-lifecycle-script.test.js +0 -288
  716. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +0 -1
  717. package/dist/test/mcpl-tool-lifecycle.test.d.ts +0 -11
  718. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +0 -1
  719. package/dist/test/mcpl-tool-lifecycle.test.js +0 -270
  720. package/dist/test/mcpl-tool-lifecycle.test.js.map +0 -1
  721. package/dist/test/mcpl-transport-close.test.d.ts +0 -2
  722. package/dist/test/mcpl-transport-close.test.d.ts.map +0 -1
  723. package/dist/test/mcpl-transport-close.test.js +0 -373
  724. package/dist/test/mcpl-transport-close.test.js.map +0 -1
  725. package/dist/test/mcpl-transport-spawn.test.d.ts +0 -2
  726. package/dist/test/mcpl-transport-spawn.test.d.ts.map +0 -1
  727. package/dist/test/mcpl-transport-spawn.test.js +0 -58
  728. package/dist/test/mcpl-transport-spawn.test.js.map +0 -1
  729. package/dist/test/mcpl-ws-transport.test.d.ts +0 -2
  730. package/dist/test/mcpl-ws-transport.test.d.ts.map +0 -1
  731. package/dist/test/mcpl-ws-transport.test.js +0 -259
  732. package/dist/test/mcpl-ws-transport.test.js.map +0 -1
  733. package/dist/test/module-context-integration.test.d.ts +0 -18
  734. package/dist/test/module-context-integration.test.d.ts.map +0 -1
  735. package/dist/test/module-context-integration.test.js +0 -181
  736. package/dist/test/module-context-integration.test.js.map +0 -1
  737. package/dist/test/offline-recovery-branch.test.d.ts +0 -2
  738. package/dist/test/offline-recovery-branch.test.d.ts.map +0 -1
  739. package/dist/test/offline-recovery-branch.test.js +0 -570
  740. package/dist/test/offline-recovery-branch.test.js.map +0 -1
  741. package/dist/test/operator-surgery.test.d.ts +0 -2
  742. package/dist/test/operator-surgery.test.d.ts.map +0 -1
  743. package/dist/test/operator-surgery.test.js +0 -595
  744. package/dist/test/operator-surgery.test.js.map +0 -1
  745. package/dist/test/ops-alert.test.d.ts +0 -2
  746. package/dist/test/ops-alert.test.d.ts.map +0 -1
  747. package/dist/test/ops-alert.test.js +0 -153
  748. package/dist/test/ops-alert.test.js.map +0 -1
  749. package/dist/test/overbudget-drain-breaker.test.d.ts +0 -2
  750. package/dist/test/overbudget-drain-breaker.test.d.ts.map +0 -1
  751. package/dist/test/overbudget-drain-breaker.test.js +0 -198
  752. package/dist/test/overbudget-drain-breaker.test.js.map +0 -1
  753. package/dist/test/pack-installs-alone.test.d.ts +0 -2
  754. package/dist/test/pack-installs-alone.test.d.ts.map +0 -1
  755. package/dist/test/pack-installs-alone.test.js +0 -284
  756. package/dist/test/pack-installs-alone.test.js.map +0 -1
  757. package/dist/test/per-agent-delivery.test.d.ts +0 -2
  758. package/dist/test/per-agent-delivery.test.d.ts.map +0 -1
  759. package/dist/test/per-agent-delivery.test.js +0 -108
  760. package/dist/test/per-agent-delivery.test.js.map +0 -1
  761. package/dist/test/physical-window-restart.test.d.ts +0 -16
  762. package/dist/test/physical-window-restart.test.d.ts.map +0 -1
  763. package/dist/test/physical-window-restart.test.js +0 -487
  764. package/dist/test/physical-window-restart.test.js.map +0 -1
  765. package/dist/test/placed-reaction-baseline.test.d.ts +0 -2
  766. package/dist/test/placed-reaction-baseline.test.d.ts.map +0 -1
  767. package/dist/test/placed-reaction-baseline.test.js +0 -312
  768. package/dist/test/placed-reaction-baseline.test.js.map +0 -1
  769. package/dist/test/present-while-acting.test.d.ts +0 -19
  770. package/dist/test/present-while-acting.test.d.ts.map +0 -1
  771. package/dist/test/present-while-acting.test.js +0 -1649
  772. package/dist/test/present-while-acting.test.js.map +0 -1
  773. package/dist/test/prose-segments.test.d.ts +0 -2
  774. package/dist/test/prose-segments.test.d.ts.map +0 -1
  775. package/dist/test/prose-segments.test.js +0 -52
  776. package/dist/test/prose-segments.test.js.map +0 -1
  777. package/dist/test/prose-stream-router.test.d.ts +0 -2
  778. package/dist/test/prose-stream-router.test.d.ts.map +0 -1
  779. package/dist/test/prose-stream-router.test.js +0 -171
  780. package/dist/test/prose-stream-router.test.js.map +0 -1
  781. package/dist/test/provider-acceleration-cooldown.test.d.ts +0 -2
  782. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +0 -1
  783. package/dist/test/provider-acceleration-cooldown.test.js +0 -435
  784. package/dist/test/provider-acceleration-cooldown.test.js.map +0 -1
  785. package/dist/test/provider-waits.test.d.ts +0 -2
  786. package/dist/test/provider-waits.test.d.ts.map +0 -1
  787. package/dist/test/provider-waits.test.js +0 -2259
  788. package/dist/test/provider-waits.test.js.map +0 -1
  789. package/dist/test/puppet-tool-call.test.d.ts +0 -2
  790. package/dist/test/puppet-tool-call.test.d.ts.map +0 -1
  791. package/dist/test/puppet-tool-call.test.js +0 -226
  792. package/dist/test/puppet-tool-call.test.js.map +0 -1
  793. package/dist/test/puppet-turn-reservation.test.d.ts +0 -2
  794. package/dist/test/puppet-turn-reservation.test.d.ts.map +0 -1
  795. package/dist/test/puppet-turn-reservation.test.js +0 -139
  796. package/dist/test/puppet-turn-reservation.test.js.map +0 -1
  797. package/dist/test/reaction-baseline-platform.test.d.ts +0 -2
  798. package/dist/test/reaction-baseline-platform.test.d.ts.map +0 -1
  799. package/dist/test/reaction-baseline-platform.test.js +0 -124
  800. package/dist/test/reaction-baseline-platform.test.js.map +0 -1
  801. package/dist/test/record-journal.test.d.ts +0 -6
  802. package/dist/test/record-journal.test.d.ts.map +0 -1
  803. package/dist/test/record-journal.test.js +0 -269
  804. package/dist/test/record-journal.test.js.map +0 -1
  805. package/dist/test/refusal-reaction-baseline.test.d.ts +0 -2
  806. package/dist/test/refusal-reaction-baseline.test.d.ts.map +0 -1
  807. package/dist/test/refusal-reaction-baseline.test.js +0 -81
  808. package/dist/test/refusal-reaction-baseline.test.js.map +0 -1
  809. package/dist/test/refusal-rewind.test.d.ts +0 -2
  810. package/dist/test/refusal-rewind.test.d.ts.map +0 -1
  811. package/dist/test/refusal-rewind.test.js +0 -117
  812. package/dist/test/refusal-rewind.test.js.map +0 -1
  813. package/dist/test/refusal-single-call.test.d.ts +0 -2
  814. package/dist/test/refusal-single-call.test.d.ts.map +0 -1
  815. package/dist/test/refusal-single-call.test.js +0 -177
  816. package/dist/test/refusal-single-call.test.js.map +0 -1
  817. package/dist/test/release-bump.test.d.ts +0 -2
  818. package/dist/test/release-bump.test.d.ts.map +0 -1
  819. package/dist/test/release-bump.test.js +0 -248
  820. package/dist/test/release-bump.test.js.map +0 -1
  821. package/dist/test/release-changelog.test.d.ts +0 -2
  822. package/dist/test/release-changelog.test.d.ts.map +0 -1
  823. package/dist/test/release-changelog.test.js +0 -159
  824. package/dist/test/release-changelog.test.js.map +0 -1
  825. package/dist/test/release-holds.test.d.ts +0 -2
  826. package/dist/test/release-holds.test.d.ts.map +0 -1
  827. package/dist/test/release-holds.test.js +0 -456
  828. package/dist/test/release-holds.test.js.map +0 -1
  829. package/dist/test/same-round-think-policy.test.d.ts +0 -2
  830. package/dist/test/same-round-think-policy.test.d.ts.map +0 -1
  831. package/dist/test/same-round-think-policy.test.js +0 -853
  832. package/dist/test/same-round-think-policy.test.js.map +0 -1
  833. package/dist/test/save-recent-image.test.d.ts +0 -26
  834. package/dist/test/save-recent-image.test.d.ts.map +0 -1
  835. package/dist/test/save-recent-image.test.js +0 -974
  836. package/dist/test/save-recent-image.test.js.map +0 -1
  837. package/dist/test/shutdown-errors.test.d.ts +0 -2
  838. package/dist/test/shutdown-errors.test.d.ts.map +0 -1
  839. package/dist/test/shutdown-errors.test.js +0 -662
  840. package/dist/test/shutdown-errors.test.js.map +0 -1
  841. package/dist/test/silent-heartbeat-rows.test.d.ts +0 -2
  842. package/dist/test/silent-heartbeat-rows.test.d.ts.map +0 -1
  843. package/dist/test/silent-heartbeat-rows.test.js +0 -451
  844. package/dist/test/silent-heartbeat-rows.test.js.map +0 -1
  845. package/dist/test/silent-heartbeat.test.d.ts +0 -2
  846. package/dist/test/silent-heartbeat.test.d.ts.map +0 -1
  847. package/dist/test/silent-heartbeat.test.js +0 -165
  848. package/dist/test/silent-heartbeat.test.js.map +0 -1
  849. package/dist/test/state-scaling-e2e.test.d.ts +0 -22
  850. package/dist/test/state-scaling-e2e.test.d.ts.map +0 -1
  851. package/dist/test/state-scaling-e2e.test.js +0 -206
  852. package/dist/test/state-scaling-e2e.test.js.map +0 -1
  853. package/dist/test/state-scaling.test.d.ts +0 -12
  854. package/dist/test/state-scaling.test.d.ts.map +0 -1
  855. package/dist/test/state-scaling.test.js +0 -251
  856. package/dist/test/state-scaling.test.js.map +0 -1
  857. package/dist/test/stop-with-host-operations.test.d.ts +0 -20
  858. package/dist/test/stop-with-host-operations.test.d.ts.map +0 -1
  859. package/dist/test/stop-with-host-operations.test.js +0 -1174
  860. package/dist/test/stop-with-host-operations.test.js.map +0 -1
  861. package/dist/test/stop-with-work-in-flight.test.d.ts +0 -23
  862. package/dist/test/stop-with-work-in-flight.test.d.ts.map +0 -1
  863. package/dist/test/stop-with-work-in-flight.test.js +0 -784
  864. package/dist/test/stop-with-work-in-flight.test.js.map +0 -1
  865. package/dist/test/surgery-marks-receipt.test.d.ts +0 -2
  866. package/dist/test/surgery-marks-receipt.test.d.ts.map +0 -1
  867. package/dist/test/surgery-marks-receipt.test.js +0 -540
  868. package/dist/test/surgery-marks-receipt.test.js.map +0 -1
  869. package/dist/test/thinking-token-stamp.test.d.ts +0 -12
  870. package/dist/test/thinking-token-stamp.test.d.ts.map +0 -1
  871. package/dist/test/thinking-token-stamp.test.js +0 -129
  872. package/dist/test/thinking-token-stamp.test.js.map +0 -1
  873. package/dist/test/timezone.test.d.ts +0 -2
  874. package/dist/test/timezone.test.d.ts.map +0 -1
  875. package/dist/test/timezone.test.js +0 -18
  876. package/dist/test/timezone.test.js.map +0 -1
  877. package/dist/test/tool-batch-hook.test.d.ts +0 -8
  878. package/dist/test/tool-batch-hook.test.d.ts.map +0 -1
  879. package/dist/test/tool-batch-hook.test.js +0 -275
  880. package/dist/test/tool-batch-hook.test.js.map +0 -1
  881. package/dist/test/tool-policy.test.d.ts +0 -2
  882. package/dist/test/tool-policy.test.d.ts.map +0 -1
  883. package/dist/test/tool-policy.test.js +0 -51
  884. package/dist/test/tool-policy.test.js.map +0 -1
  885. package/dist/test/tool-presentation.test.d.ts +0 -2
  886. package/dist/test/tool-presentation.test.d.ts.map +0 -1
  887. package/dist/test/tool-presentation.test.js +0 -268
  888. package/dist/test/tool-presentation.test.js.map +0 -1
  889. package/dist/test/tool-result-content.test.d.ts +0 -2
  890. package/dist/test/tool-result-content.test.d.ts.map +0 -1
  891. package/dist/test/tool-result-content.test.js +0 -37
  892. package/dist/test/tool-result-content.test.js.map +0 -1
  893. package/dist/test/tool-result-guard.test.d.ts +0 -2
  894. package/dist/test/tool-result-guard.test.d.ts.map +0 -1
  895. package/dist/test/tool-result-guard.test.js +0 -3729
  896. package/dist/test/tool-result-guard.test.js.map +0 -1
  897. package/dist/test/tool-result-history.test.d.ts +0 -7
  898. package/dist/test/tool-result-history.test.d.ts.map +0 -1
  899. package/dist/test/tool-result-history.test.js +0 -156
  900. package/dist/test/tool-result-history.test.js.map +0 -1
  901. package/dist/test/tool-result-spill.test.d.ts +0 -13
  902. package/dist/test/tool-result-spill.test.d.ts.map +0 -1
  903. package/dist/test/tool-result-spill.test.js +0 -586
  904. package/dist/test/tool-result-spill.test.js.map +0 -1
  905. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +0 -2
  906. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +0 -1
  907. package/dist/test/tool-wrapper-prose-guard-integration.test.js +0 -485
  908. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +0 -1
  909. package/dist/test/tool-wrapper-prose-guard.test.d.ts +0 -2
  910. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +0 -1
  911. package/dist/test/tool-wrapper-prose-guard.test.js +0 -34
  912. package/dist/test/tool-wrapper-prose-guard.test.js.map +0 -1
  913. package/dist/test/tools-observe-parser-conformance.test.d.ts +0 -2
  914. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +0 -1
  915. package/dist/test/tools-observe-parser-conformance.test.js +0 -129
  916. package/dist/test/tools-observe-parser-conformance.test.js.map +0 -1
  917. package/dist/test/trunk-channel-routing.test.d.ts +0 -19
  918. package/dist/test/trunk-channel-routing.test.d.ts.map +0 -1
  919. package/dist/test/trunk-channel-routing.test.js +0 -652
  920. package/dist/test/trunk-channel-routing.test.js.map +0 -1
  921. package/dist/test/tune-out-e2e.test.d.ts +0 -2
  922. package/dist/test/tune-out-e2e.test.d.ts.map +0 -1
  923. package/dist/test/tune-out-e2e.test.js +0 -142
  924. package/dist/test/tune-out-e2e.test.js.map +0 -1
  925. package/dist/test/tune-out-gate-composition.test.d.ts +0 -2
  926. package/dist/test/tune-out-gate-composition.test.d.ts.map +0 -1
  927. package/dist/test/tune-out-gate-composition.test.js +0 -221
  928. package/dist/test/tune-out-gate-composition.test.js.map +0 -1
  929. package/dist/test/tune-out-lifecycle.test.d.ts +0 -2
  930. package/dist/test/tune-out-lifecycle.test.d.ts.map +0 -1
  931. package/dist/test/tune-out-lifecycle.test.js +0 -121
  932. package/dist/test/tune-out-lifecycle.test.js.map +0 -1
  933. package/dist/test/turn-trigger-provenance.test.d.ts +0 -2
  934. package/dist/test/turn-trigger-provenance.test.d.ts.map +0 -1
  935. package/dist/test/turn-trigger-provenance.test.js +0 -137
  936. package/dist/test/turn-trigger-provenance.test.js.map +0 -1
  937. package/dist/test/unconvertible-tool-result.test.d.ts +0 -2
  938. package/dist/test/unconvertible-tool-result.test.d.ts.map +0 -1
  939. package/dist/test/unconvertible-tool-result.test.js +0 -358
  940. package/dist/test/unconvertible-tool-result.test.js.map +0 -1
  941. package/dist/test/unsent-compiles.test.d.ts +0 -2
  942. package/dist/test/unsent-compiles.test.d.ts.map +0 -1
  943. package/dist/test/unsent-compiles.test.js +0 -139
  944. package/dist/test/unsent-compiles.test.js.map +0 -1
  945. package/dist/test/usage-accounting.test.d.ts +0 -2
  946. package/dist/test/usage-accounting.test.d.ts.map +0 -1
  947. package/dist/test/usage-accounting.test.js +0 -13
  948. package/dist/test/usage-accounting.test.js.map +0 -1
  949. package/dist/test/usage-tracker.test.d.ts +0 -2
  950. package/dist/test/usage-tracker.test.d.ts.map +0 -1
  951. package/dist/test/usage-tracker.test.js +0 -129
  952. package/dist/test/usage-tracker.test.js.map +0 -1
  953. package/dist/test/utils-surface.test.d.ts +0 -2
  954. package/dist/test/utils-surface.test.d.ts.map +0 -1
  955. package/dist/test/utils-surface.test.js +0 -145
  956. package/dist/test/utils-surface.test.js.map +0 -1
  957. package/dist/test/workspace-branch-continuation.test.d.ts +0 -15
  958. package/dist/test/workspace-branch-continuation.test.d.ts.map +0 -1
  959. package/dist/test/workspace-branch-continuation.test.js +0 -183
  960. package/dist/test/workspace-branch-continuation.test.js.map +0 -1
  961. package/dist/test/workspace-materialize-freshness.test.d.ts +0 -16
  962. package/dist/test/workspace-materialize-freshness.test.d.ts.map +0 -1
  963. package/dist/test/workspace-materialize-freshness.test.js +0 -252
  964. package/dist/test/workspace-materialize-freshness.test.js.map +0 -1
  965. package/dist/test/workspace-path-containment.test.d.ts +0 -19
  966. package/dist/test/workspace-path-containment.test.d.ts.map +0 -1
  967. package/dist/test/workspace-path-containment.test.js +0 -91
  968. package/dist/test/workspace-path-containment.test.js.map +0 -1
  969. package/dist/test/workspace-read-file-from-disk.test.d.ts +0 -17
  970. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +0 -1
  971. package/dist/test/workspace-read-file-from-disk.test.js +0 -174
  972. package/dist/test/workspace-read-file-from-disk.test.js.map +0 -1
  973. package/dist/test/workspace-read-image.test.d.ts +0 -2
  974. package/dist/test/workspace-read-image.test.d.ts.map +0 -1
  975. package/dist/test/workspace-read-image.test.js +0 -597
  976. package/dist/test/workspace-read-image.test.js.map +0 -1
  977. package/dist/test/workspace-read-text.test.d.ts +0 -2
  978. package/dist/test/workspace-read-text.test.d.ts.map +0 -1
  979. package/dist/test/workspace-read-text.test.js +0 -101
  980. package/dist/test/workspace-read-text.test.js.map +0 -1
  981. package/dist/test/workspace-restore-order.test.d.ts +0 -16
  982. package/dist/test/workspace-restore-order.test.d.ts.map +0 -1
  983. package/dist/test/workspace-restore-order.test.js +0 -140
  984. package/dist/test/workspace-restore-order.test.js.map +0 -1
  985. package/dist/test/workspace-three-way.test.d.ts +0 -12
  986. package/dist/test/workspace-three-way.test.d.ts.map +0 -1
  987. package/dist/test/workspace-three-way.test.js +0 -4094
  988. package/dist/test/workspace-three-way.test.js.map +0 -1
  989. package/dist/test/workspace-watcher.test.d.ts +0 -2
  990. package/dist/test/workspace-watcher.test.d.ts.map +0 -1
  991. package/dist/test/workspace-watcher.test.js +0 -281
  992. package/dist/test/workspace-watcher.test.js.map +0 -1
  993. package/dist/test/xml-round-persist.test.d.ts +0 -19
  994. package/dist/test/xml-round-persist.test.d.ts.map +0 -1
  995. package/dist/test/xml-round-persist.test.js +0 -235
  996. package/dist/test/xml-round-persist.test.js.map +0 -1
  997. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -5,10 +5,11 @@
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
7
 
8
+ import { isUtf8 } from 'node:buffer';
8
9
  import { constants as fsConstants } from 'node:fs';
9
10
  import type { Stats } from 'node:fs';
10
- import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
11
- import { join, resolve, relative, sep } from 'node:path';
11
+ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
12
+ import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
12
13
  import type { JsStore } from '@animalabs/chronicle';
13
14
  import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
14
15
  import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
@@ -39,8 +40,8 @@ import { MountWatcher, type FsChange } from './watcher.js';
39
40
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
40
41
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
41
42
  import { BranchIntents } from './branch-intent.js';
42
- import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js';
43
- import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
43
+ import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
44
+ import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
44
45
  import {
45
46
  type MountRuntime,
46
47
  type PassOptions,
@@ -50,7 +51,9 @@ import {
50
51
  type PushResult,
51
52
  type Scope,
52
53
  type TreeOp,
54
+ NOT_PUSHED_IN_OTHER_MOUNT,
53
55
  WALK_CAP,
56
+ entryMode,
54
57
  pushPaths,
55
58
  reconcilePass,
56
59
  settleAdoptionBeforeMutation,
@@ -58,6 +61,14 @@ import {
58
61
 
59
62
  /** Default for how long the after-batch scan may hold the agent's next inference. */
60
63
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
64
+ /**
65
+ * How long a mount's catch-up pass waits for its watcher to become ready
66
+ * before it starts anyway (ensureRunning). In a probe on Linux at a load near
67
+ * 40, chokidar's first scan of 10,000 entries took 1.3 s, about a twentieth of
68
+ * this. A scan slower than the bound has the pass started beside it, and the
69
+ * watcher's ready runs the pass again once the scan ends.
70
+ */
71
+ const CATCH_UP_BOUND_MS = 30_000;
61
72
  /** Why a call is refused once the module has stopped. */
62
73
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
63
74
  /** The workspace's own deadline must end before the framework's hook bound. */
@@ -90,6 +101,8 @@ class WorkspaceImageReadError extends Error {
90
101
  | 'not_found'
91
102
  | 'directory'
92
103
  | 'symlink'
104
+ | 'parent_symlink'
105
+ | 'other_mount'
93
106
  | 'escape'
94
107
  | 'changed'
95
108
  | 'empty'
@@ -108,25 +121,42 @@ class WorkspaceImageReadError extends Error {
108
121
  /**
109
122
  * Why a workspace-owned filesystem read was refused. Distinguishes the cases a
110
123
  * peer needs to react to differently: a path that never resolved inside the
111
- * mount (`unknown_mount`, `traversal`), a mount whose root is gone
112
- * (`mount_unavailable`), an ordinary missing file (`not_found`), a directory
113
- * (`directory`), a symlink refused by the mount's `followSymlinks` policy
114
- * (`symlink`), a symlink whose canonical target lands outside the canonical
115
- * mount root (`escape`), and a file swapped underneath the read (`changed`).
124
+ * mount (`unknown_mount`, `traversal`), a mount whose root is gone, isn't a
125
+ * directory, or isn't the directory the workspace was using
126
+ * (`mount_unavailable`: with errno ENOENT or ENOTDIR when no directory is
127
+ * there at all, with none for another directory), an ordinary missing file
128
+ * or one that isn't a regular file, such as a FIFO (`not_found`), a directory
129
+ * (`directory`), a file that is itself a symlink, refused by the mount's
130
+ * `followSymlinks` policy (`symlink`), a symlink among the path's directories
131
+ * on a mount that doesn't follow symlinks, leading to a directory or a file
132
+ * inside the mount, or nowhere (`parent_symlink`), whose message names the
133
+ * link and, where it leads somewhere, its target, a symlink whose canonical
134
+ * target lands outside the canonical mount root, or one among the path's
135
+ * directories leading out of it (`escape`), a file swapped underneath the read
136
+ * (`changed`), and a path in another mount's directory nested in the one
137
+ * named, or reached through a symlink among the path's directories leading
138
+ * there (`other_mount`), whose message names the mount that holds it and the
139
+ * path to use there. A `mount_unavailable` for a root whose realpath or stat
140
+ * fails otherwise carries that errno, such as EACCES.
116
141
  */
117
142
  export type WorkspaceReadErrorCode =
118
143
  | 'unknown_mount'
119
144
  | 'traversal'
145
+ | 'other_mount'
120
146
  | 'mount_unavailable'
121
147
  | 'not_found'
122
148
  | 'directory'
123
149
  | 'symlink'
150
+ | 'parent_symlink'
124
151
  | 'escape'
125
152
  | 'changed';
126
153
 
127
154
  /** Where in the open sequence a `WorkspaceReadError` originated (diagnostics). */
128
155
  export type WorkspaceReadStage =
129
156
  | 'parse'
157
+ /** The mount root's check: a directory, the one the workspace was using. */
158
+ | 'root'
159
+ | 'readlink'
130
160
  | 'lstat'
131
161
  | 'realpath_root'
132
162
  | 'open'
@@ -136,6 +166,17 @@ export type WorkspaceReadStage =
136
166
  | 'stat'
137
167
  | 'read';
138
168
 
169
+ /**
170
+ * A file operation's path lies in another mount's directory nested in the
171
+ * mount it names. The message names the mount that holds it and the path to
172
+ * use there.
173
+ */
174
+ class NestedMountError extends Error {
175
+ constructor(path: string, mount: string, holder: string, use: string, how: 'lies in' | 'leads into' = 'lies in') {
176
+ super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
177
+ }
178
+ }
179
+
139
180
  /**
140
181
  * Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
141
182
  * mount-prefixed path only — never absolute filesystem paths — so they can be
@@ -156,13 +197,23 @@ export class WorkspaceReadError extends Error {
156
197
 
157
198
  /** Successful `readFileFromDisk()` result. */
158
199
  export interface WorkspaceDiskReadResult {
159
- /** File bytes — the whole file, or its first `maxBytes` when `truncated`. */
200
+ /**
201
+ * File bytes: the whole file, or its first `maxBytes` when `truncated`.
202
+ * Without `maxBytes` the file is read with `readFile`, with no limit. In
203
+ * Node, that reads up to the size its own fstat finds, or to the file's end
204
+ * if that size is 0.
205
+ */
160
206
  bytes: Buffer;
161
- /** Full on-disk size in bytes, regardless of truncation. */
207
+ /**
208
+ * The file's size, agreeing with `bytes` and `truncated`: `bytes.length`,
209
+ * unless `truncated`. Then it's the file's size by an fstat after the read,
210
+ * or `maxBytes + 1` if that's less, since the read found at least that many
211
+ * bytes.
212
+ */
162
213
  size: number;
163
- /** True when `maxBytes` was set and the file was larger; `bytes` is a prefix. */
214
+ /** True when `maxBytes` was set and the read found the file going on past it; `bytes` is a prefix. */
164
215
  truncated: boolean;
165
- /** mtime of the file actually read (for change-detection caches). */
216
+ /** mtime of the file actually read, from its fstat at the open (for change-detection caches). */
166
217
  mtimeMs: number;
167
218
  /** Canonical (realpath) location of the file read — inside the canonical mount root by construction. */
168
219
  realPath: string;
@@ -171,7 +222,12 @@ export interface WorkspaceDiskReadResult {
171
222
  }
172
223
 
173
224
  export interface ReadFileFromDiskOptions {
174
- /** Read at most this many bytes (a bounded prefix read — the rest of the file is never loaded). */
225
+ /**
226
+ * Keep at most this many bytes (a bounded prefix read): the read loads them
227
+ * and one byte past them, which isn't kept and tells whether the file goes
228
+ * on, and never the rest of the file. It bounds the read itself, so it
229
+ * holds for a file that grows while it's read.
230
+ */
175
231
  maxBytes?: number;
176
232
  }
177
233
 
@@ -181,6 +237,25 @@ function errnoOf(err: unknown): string | undefined {
181
237
  : undefined;
182
238
  }
183
239
 
240
+ /**
241
+ * What is at a mount root's path now, followed as the root is: for a
242
+ * directory, undefined; for anything else, ENOTDIR; and where the path
243
+ * can't be followed to anything, the errno its stat failed with, ENOENT
244
+ * when nothing is there.
245
+ */
246
+ async function rootAbsence(root: string): Promise<string | undefined> {
247
+ try {
248
+ return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
249
+ } catch (err) {
250
+ return errnoOf(err);
251
+ }
252
+ }
253
+
254
+ /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
255
+ function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
256
+ return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
257
+ }
258
+
184
259
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
185
260
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
186
261
  const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
@@ -208,6 +283,96 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean {
208
283
  return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer);
209
284
  }
210
285
 
286
+ /**
287
+ * A disk reader's refusal for what linkOnTheWay found on a path's way: the
288
+ * link's reason, as the observations give it, after the path; or, for a
289
+ * directory the walk couldn't ask, a failure to read, coded `not_found` with
290
+ * the readlink's errno, as every failed lookup is. A link's code follows
291
+ * where it leads, so a caller choosing advice by code gets advice that fits:
292
+ * `escape` for a link leading outside the mount, the code a path resolving
293
+ * outside it gets too; `other_mount` for one into a nested mount's directory,
294
+ * the code that mount's own paths get; and `parent_symlink` for any other,
295
+ * which `symlink`, a file that is itself a link, leaves apart.
296
+ */
297
+ async function linkReadError(
298
+ mount: { config: { name: string; path: string } },
299
+ found: LinkFound,
300
+ mountPrefixedPath: string,
301
+ nested: readonly { dir: string; name: string }[],
302
+ ): Promise<WorkspaceReadError> {
303
+ if (!('link' in found)) {
304
+ return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
305
+ }
306
+ const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
307
+ const leads = await linkLeads(mount.config.path, rootReal, found.link);
308
+ const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
309
+ const code = leads.kind === 'outside' ? 'escape'
310
+ : leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
311
+ : 'parent_symlink';
312
+ return new WorkspaceReadError(code, reason, 'readlink');
313
+ }
314
+
315
+ /**
316
+ * `to` beneath `from`, mount-relative and `/`-separated, whatever the
317
+ * platform's separator; undefined where it isn't beneath it. Outside,
318
+ * `relative` gives a path that climbs with a `..` segment, or an absolute one
319
+ * across Windows drives. A name that only begins with two dots, such as
320
+ * `..cache`, is inside.
321
+ */
322
+ function nestedDir(from: string, to: string): string | undefined {
323
+ const rel = relative(from, to);
324
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return undefined;
325
+ return rel.split(sep).join('/');
326
+ }
327
+
328
+ /** A mount nested in another, as the outer mount's otherMounts holds it. */
329
+ interface NestedMountEntry {
330
+ name: string;
331
+ /**
332
+ * Its real root, as realBeneath places it once a start has read it, and
333
+ * its root as resolved until then. None where it's the outer mount's own
334
+ * or holds it (NestedMount).
335
+ */
336
+ real?: string;
337
+ /** Its directory as configured and as the real roots place it. */
338
+ fromRoots: Array<{ dir: string; root: string }>;
339
+ /** Those, and each as disk spells it where that differs. */
340
+ spellings: Array<{ dir: string; root: string }>;
341
+ }
342
+
343
+ /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
344
+ const LINKS_FOLLOWED = 40;
345
+
346
+ /**
347
+ * An absolute path, a mount root's or one beneath it, as realpath resolves
348
+ * it, with the links on its way and in it taken out, read without holding
349
+ * the process. Where nothing is there yet, a link leads where its target
350
+ * would be, and anything else lies beneath its nearest existing directory's
351
+ * real path, with the rest as given. A link's target is read from the
352
+ * link's directory as it is on disk, and nothing is resolved lexically on
353
+ * the way, so a `..` is taken from where the links before it lead, as the
354
+ * kernel takes it. Where realpath fails for any other reason, such as a
355
+ * loop or a lack of permission, the whole path as path.resolve reads it.
356
+ */
357
+ async function realBeneath(given: string, links = 0): Promise<string> {
358
+ // A trailing separator, as a link to a directory is often written, names
359
+ // the same place, but readlink would then follow the link it names.
360
+ const { root } = parse(given);
361
+ let absolute = given;
362
+ while (absolute.length > root.length && (absolute.endsWith('/') || absolute.endsWith(sep))) absolute = absolute.slice(0, -1);
363
+ try {
364
+ return await realpath(absolute);
365
+ } catch (err) {
366
+ const code = errnoOf(err);
367
+ const parent = dirname(absolute);
368
+ if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute) return resolve(absolute);
369
+ const place = await realBeneath(parent, links);
370
+ const target = await readlink(absolute).catch(() => null);
371
+ if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
372
+ return join(place, basename(absolute));
373
+ }
374
+ }
375
+
211
376
  function isContainedPath(root: string, candidate: string): boolean {
212
377
  if (candidate === root) return true;
213
378
  const normalizedRoot = root.endsWith(sep) ? root : root + sep;
@@ -631,6 +796,8 @@ export class WorkspaceModule implements Module {
631
796
  private store: JsStore | null = null;
632
797
  /** Set once stop has drained and saved, cleared by start (running()). */
633
798
  private stopped = false;
799
+ /** Stops begun, so a start that read disk meanwhile can tell that one came. */
800
+ private stopsBegun = 0;
634
801
  private config: WorkspaceConfig;
635
802
  private mounts = new Map<string, MountState>();
636
803
  private watchers = new Map<string, MountWatcher>();
@@ -645,6 +812,26 @@ export class WorkspaceModule implements Module {
645
812
  /** Whether a mount root's filesystem maintains ctime, by canonical root. */
646
813
  /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
647
814
  private ctimeTrust = new Map<string, string | null>();
815
+ /**
816
+ * Each mount's directories that are other mounts nested inside it, with
817
+ * each mount's name and real root, in each spelling that names the
818
+ * directory: as configured; as the real roots place it, where that differs
819
+ * (`fromRoots`); and as disk spells each of those where that differs.
820
+ * Each spelling is mount-relative and `/`-separated, with its root beneath
821
+ * the mount's resolved root. The constructor nests by configured path,
822
+ * with each root as resolved standing for its real one, and each start
823
+ * reads it all again with the roots placed where disk has them
824
+ * (readNesting).
825
+ */
826
+ private otherMounts = new Map<string, NestedMountEntry[]>();
827
+ /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
828
+ private rootFoldsCase = new Map<string, boolean>();
829
+ /**
830
+ * Whether a file's mode is observed, taken in and applied (MountRuntime's
831
+ * `modesObserved`): not on Windows, which reports no POSIX modes. A test
832
+ * sets it false to run that case anywhere.
833
+ */
834
+ private modesObserved = process.platform !== 'win32';
648
835
  /** Persisted state decoded in start(), held until mounts exist to apply it
649
836
  * to — in the Host ordering start() runs before initStore() creates the
650
837
  * mounts, so restoration must be second-callback-safe like watcher
@@ -662,27 +849,27 @@ export class WorkspaceModule implements Module {
662
849
  );
663
850
  }
664
851
  }
665
- // Detect overlapping mount paths: if mount A contains mount B,
666
- // auto-add an ignore rule on A for B's path to prevent syncing
667
- // the sub-mount's directory through the super-mount.
852
+ // Detect overlapping mount paths: a mount nested inside another is that
853
+ // mount's directory, which the outer mount passes over in every pass,
854
+ // named or not, and in its watcher (OTHER_MOUNT). It's kept apart from
855
+ // the outer mount's ignore list, which a sync that names a path sets
856
+ // aside for what it names (#276), so no sync of the outer mount takes in
857
+ // the inner one's files. Here by configured path, before any path is
858
+ // parsed; each start places the roots where disk has them too
859
+ // (readNesting).
668
860
  for (const outer of config.mounts) {
669
861
  for (const inner of config.mounts) {
670
862
  if (outer === inner) continue;
671
863
  const outerPath = resolve(outer.path);
672
864
  const innerPath = resolve(inner.path);
673
- const rel = relative(outerPath, innerPath);
674
- if (rel && !rel.startsWith('..') && !rel.startsWith('/')) {
675
- // inner is nested under outer — add ignore rule
676
- outer.ignore = outer.ignore ?? [];
677
- const pattern = rel + '/**';
678
- if (!outer.ignore.includes(pattern) && !outer.ignore.includes(rel)) {
679
- outer.ignore.push(rel);
680
- console.warn(
681
- `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
682
- `(${rel}/) — auto-ignoring to prevent overlap`,
683
- );
684
- }
685
- }
865
+ const dir = nestedDir(outerPath, innerPath);
866
+ if (dir === undefined) continue;
867
+ const configured = [{ dir, root: innerPath }];
868
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
869
+ console.warn(
870
+ `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
871
+ `at ${dir}/ — passing over it as another mount's directory`,
872
+ );
686
873
  }
687
874
  }
688
875
  this.config = config;
@@ -698,7 +885,11 @@ export class WorkspaceModule implements Module {
698
885
  * second. `ensureRunning()` gates on `ctx && mounts-populated`.
699
886
  */
700
887
  initStore(store: JsStore): void {
701
- this.store = store;
888
+ // Every mount's states are registered before the module takes the store
889
+ // or any mount, so a registration that fails leaves the module as it was
890
+ // before the call, knowing no mount, and a later initStore starts clean.
891
+ const mounts = new Map<string, MountState>();
892
+ const intents = new Map<string, BranchIntents>();
702
893
 
703
894
  // Register tree states for each mount: the workspace tree, and the
704
895
  // branch-local intent that qualifies it.
@@ -706,19 +897,15 @@ export class WorkspaceModule implements Module {
706
897
  const treeStateId = `workspace/${mount.name}/tree`;
707
898
  const intentTreeStateId = `workspace/${mount.name}/intent`;
708
899
  for (const id of [treeStateId, intentTreeStateId]) {
709
- try {
710
- store.registerState({
711
- id,
712
- strategy: 'tree',
713
- deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
714
- fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
715
- });
716
- } catch {
717
- // State already registered (restart scenario)
718
- }
900
+ ensureStateRegistered(store, {
901
+ id,
902
+ strategy: 'tree',
903
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
904
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
905
+ });
719
906
  }
720
907
 
721
- const mountState: MountState = {
908
+ mounts.set(mount.name, {
722
909
  config: mount,
723
910
  treeStateId,
724
911
  intentTreeStateId,
@@ -728,14 +915,17 @@ export class WorkspaceModule implements Module {
728
915
  lastMaterializedBranchId: null,
729
916
  watcherReadyAt: null,
730
917
  watcherError: null,
731
- };
732
- this.mounts.set(mount.name, mountState);
733
- this.intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
918
+ });
919
+ intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
734
920
  }
735
921
 
922
+ this.store = store;
923
+ for (const [name, state] of mounts) this.mounts.set(name, state);
924
+ for (const [name, intent] of intents) this.intents.set(name, intent);
925
+
736
926
  // What disk last agreed with, per mount and path: global records, so a
737
927
  // branch switch never rewinds it.
738
- this.agreement = new DiskAgreement(store);
928
+ this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
739
929
  this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
740
930
 
741
931
  this.applySavedState();
@@ -766,6 +956,25 @@ export class WorkspaceModule implements Module {
766
956
  return this.agreement;
767
957
  }
768
958
 
959
+ /**
960
+ * A mount as observation reads it, with the root identity the workspace
961
+ * recorded for it, if any: what a pass reads beneath (runtime), with
962
+ * rootIdentity's, and what a disk read checks its root against (readRoot),
963
+ * with rootIdentityForRead's.
964
+ */
965
+ private viewOf(mount: MountState, rootIdentity: MountView['rootIdentity']): MountView {
966
+ return {
967
+ root: resolve(mount.config.path),
968
+ name: mount.config.name,
969
+ rootIdentity,
970
+ followSymlinks: mount.config.followSymlinks === true,
971
+ maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
972
+ ignore: mount.config.ignore ?? [],
973
+ others: this.othersOf(mount.config.name),
974
+ nested: this.nestedOf(mount.config.name),
975
+ };
976
+ }
977
+
769
978
  /** The view reconciliation needs of a mount, refreshed per pass. */
770
979
  private async runtime(mount: MountState): Promise<MountRuntime> {
771
980
  const root = resolve(mount.config.path);
@@ -778,13 +987,7 @@ export class WorkspaceModule implements Module {
778
987
  }
779
988
  return {
780
989
  name: mount.config.name,
781
- view: {
782
- root,
783
- rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
784
- followSymlinks: mount.config.followSymlinks === true,
785
- maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
786
- ignore: mount.config.ignore ?? [],
787
- },
990
+ view: this.viewOf(mount, this.agreementOrThrow().rootIdentity(mount.config.name)),
788
991
  rootReal,
789
992
  ctimeTrustedDev: trustedDev,
790
993
  treeStateId: mount.treeStateId,
@@ -792,13 +995,52 @@ export class WorkspaceModule implements Module {
792
995
  readOnly: mount.config.mode === 'read-only',
793
996
  walkCap: mount.config.walkCap ?? WALK_CAP,
794
997
  catchUpScan: (mount.config.watch ?? 'always') === 'always',
998
+ modesObserved: this.modesObserved,
795
999
  };
796
1000
  }
797
1001
 
1002
+ /**
1003
+ * Where a search's walk starts: `base`, its path as parsePath spells it,
1004
+ * then each segment of `beneath` while disk lists it, in the directory
1005
+ * reached so far, as a directory with exactly that spelling. The walk names
1006
+ * what it finds from its start's spelling, and a case-insensitive disk
1007
+ * would let it go on under a spelling disk doesn't list, taking files in
1008
+ * under that spelling beside their own. So the start stops before a segment
1009
+ * spelled any other way, not there, or not a directory, and before a link,
1010
+ * which a walk of the whole mount doesn't go through either. It lists only
1011
+ * directories it has checked lie inside the mount, and none the mount
1012
+ * passes over by design, which the walk then answers for.
1013
+ */
1014
+ private async walkStart(mount: MountState, base: string, beneath: string): Promise<string> {
1015
+ if (beneath === '') return base;
1016
+ const { view, rootReal } = await this.runtime(mount);
1017
+ let start = base;
1018
+ for (const segment of beneath.split('/')) {
1019
+ if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined) break;
1020
+ const here = await checkDir(view, rootReal, start);
1021
+ if (here.kind !== 'checked') break;
1022
+ const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
1023
+ if (entry === undefined || !entry.isDirectory()) break;
1024
+ start = start ? `${start}/${segment}` : segment;
1025
+ }
1026
+ return start;
1027
+ }
1028
+
798
1029
  /** One disk→store pass; the caller holds the mount's turn. Emits its events. */
799
1030
  private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
800
1031
  const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
801
1032
  if (scope.kind === 'dir' && scope.dir === '' && scope.recursive) mount.initialSyncDone = true;
1033
+ // What a pass says is another mount's directory names that mount and
1034
+ // the path to use there, as the file operations' refusal does: a copy's
1035
+ // note, and the region of a scope that lies there. The pass's counts
1036
+ // keep the bare reason.
1037
+ const name = mount.config.name;
1038
+ for (const report of result.reports.values()) {
1039
+ if (report.note === OTHER_MOUNT) report.note = this.otherMountNote(name, report.path, 'list');
1040
+ }
1041
+ for (const region of result.incomplete) {
1042
+ if (region.reason === OTHER_MOUNT) region.reason = this.otherMountNote(name, region.path, 'list');
1043
+ }
802
1044
  this.emitFsEvents(mount.config.name, result.ops, result.newConflicts);
803
1045
  return result;
804
1046
  }
@@ -813,6 +1055,20 @@ export class WorkspaceModule implements Module {
813
1055
  }
814
1056
 
815
1057
  async start(ctx: ModuleContext): Promise<void> {
1058
+ // Which mounts are nested where, with the roots placed where disk has
1059
+ // them and each nested mount's directory as disk spells it, and whether
1060
+ // each mount's root folds case, read where disk has it, before this start
1061
+ // sets anything, so a restart takes no call and walks or watches nothing
1062
+ // before they're known. A stop begun meanwhile stands, and this start then
1063
+ // sets nothing.
1064
+ const stops = this.stopsBegun;
1065
+ const { nesting, reals } = await this.readNesting();
1066
+ const rootFoldsCase = new Map<string, boolean>();
1067
+ for (const mount of this.config.mounts) rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)!));
1068
+ if (this.stopsBegun !== stops) return;
1069
+ this.otherMounts = nesting;
1070
+ this.rootFoldsCase = rootFoldsCase;
1071
+
816
1072
  this.ctx = ctx;
817
1073
  this.stopped = false;
818
1074
 
@@ -961,7 +1217,45 @@ export class WorkspaceModule implements Module {
961
1217
 
962
1218
  const watchMode = mount.config.watch ?? 'always';
963
1219
  if (watchMode === 'always') {
964
- const watcher = new MountWatcher(
1220
+ // Chokidar is started with ignoreInitial:true, so changes made while
1221
+ // the session was down would be invisible. A catch-up pass takes them
1222
+ // in under the three-way rule: only real differences produce tree
1223
+ // changes and events. It takes its turn with the mount's bare syncs,
1224
+ // so on a mount over its walkCap it covers the walkCap entries after
1225
+ // where the last turn stopped, and a change elsewhere waits for the
1226
+ // rotation to reach it, or for a sync of its path.
1227
+ //
1228
+ // The pass runs at each of the watcher's readies, its first and each
1229
+ // re-attach's, when chokidar has called fs.watch on every entry, so a
1230
+ // change made after the pass has looked at a file reaches the watcher.
1231
+ // Started beside chokidar's scan, the pass could finish with a file
1232
+ // before chokidar watched it, and a change between the two reached
1233
+ // neither. A watcher that isn't ready within CATCH_UP_BOUND_MS (from
1234
+ // its start or its re-attach) has the pass started at the bound, and
1235
+ // its ready, if it comes, runs the pass again. Within the mount's
1236
+ // walkCap, that pass covers what the early one missed. Over it, the
1237
+ // pass continues the rotation, and what the early one missed waits for
1238
+ // the rotation to come back, as any change outside a pass's window
1239
+ // does. An error doesn't start the pass: chokidar goes on scanning
1240
+ // after one, and the bound covers a watcher that never becomes ready.
1241
+ // On macOS, a new entry written after ready but before libuv's
1242
+ // FSEventStream exists isn't reported until a later change in its
1243
+ // directory, which this doesn't change.
1244
+ let watcher: MountWatcher | undefined;
1245
+ let bound: ReturnType<typeof setTimeout> | null = null;
1246
+ const catchUp = () => {
1247
+ if (bound) clearTimeout(bound);
1248
+ bound = null;
1249
+ // A watcher the module has since stopped or replaced starts no pass.
1250
+ if (this.watchers.get(name) !== watcher) return;
1251
+ void this.initialScan(name);
1252
+ };
1253
+ const awaitReady = () => {
1254
+ if (bound) clearTimeout(bound);
1255
+ bound = setTimeout(catchUp, CATCH_UP_BOUND_MS);
1256
+ bound.unref?.();
1257
+ };
1258
+ watcher = new MountWatcher(
965
1259
  mount.config,
966
1260
  (changes) => {
967
1261
  this.handleFsChanges(name, changes);
@@ -970,6 +1264,7 @@ export class WorkspaceModule implements Module {
970
1264
  onReady: () => {
971
1265
  mount.watcherReadyAt = Date.now();
972
1266
  mount.watcherError = null;
1267
+ catchUp();
973
1268
  },
974
1269
  onError: (err) => {
975
1270
  mount.watcherError = err.message;
@@ -982,7 +1277,7 @@ export class WorkspaceModule implements Module {
982
1277
  },
983
1278
  onReattach: () => {
984
1279
  mount.watcherError = null;
985
- void this.initialScan(name);
1280
+ awaitReady();
986
1281
  this.ctx?.pushEvent({
987
1282
  type: 'workspace:watcher-reattached',
988
1283
  mount: name,
@@ -990,18 +1285,11 @@ export class WorkspaceModule implements Module {
990
1285
  } as ProcessEvent);
991
1286
  },
992
1287
  },
1288
+ this.othersOf(name),
993
1289
  );
1290
+ awaitReady();
994
1291
  watcher.start();
995
1292
  this.watchers.set(name, watcher);
996
-
997
- // Chokidar is started with ignoreInitial:true, so changes made while
998
- // the session was down would be invisible. A catch-up pass takes them
999
- // in under the three-way rule: only real differences produce tree
1000
- // changes and events. It takes its turn with the mount's bare syncs,
1001
- // so on a mount over its walkCap it covers the walkCap entries after
1002
- // where the last turn stopped, and a change elsewhere waits for the
1003
- // rotation to reach it, or for a sync of its path.
1004
- void this.initialScan(name);
1005
1293
  }
1006
1294
 
1007
1295
  this.ctx.pushEvent({
@@ -1032,27 +1320,39 @@ export class WorkspaceModule implements Module {
1032
1320
  }
1033
1321
 
1034
1322
  async stop(): Promise<void> {
1323
+ this.stopsBegun++;
1035
1324
  // No new watcher-driven passes, then let every pass and write finish
1036
1325
  // before the store goes away — including any queued behind the ones in
1037
1326
  // flight (a tool call, a scan continuing past its deadline): drained
1038
- // until no new tail appears.
1039
- for (const watcher of this.watchers.values()) {
1327
+ // until no new tail appears. The watchers leave this.watchers before they
1328
+ // stop, so a watcher's ready or catch-up bound starts no pass meanwhile.
1329
+ const stopping = [...this.watchers.values()];
1330
+ this.watchers.clear();
1331
+ for (const watcher of stopping) {
1040
1332
  await watcher.stop();
1041
1333
  }
1042
- this.watchers.clear();
1334
+ // A call under way can await disk (parsePath) before it takes its turn,
1335
+ // so the calls are waited for as well as the turns.
1043
1336
  for (;;) {
1044
1337
  const tails = [...this.mountTurns.values()];
1045
- await Promise.all(tails);
1338
+ await Promise.allSettled([...this.callsUnderWay, ...tails]);
1046
1339
  const now = [...this.mountTurns.values()];
1047
- if (now.length === tails.length && now.every((tail, i) => tail === tails[i])) break;
1340
+ if (this.callsUnderWay.size === 0 && now.length === tails.length && now.every((tail, i) => tail === tails[i])) break;
1048
1341
  }
1049
1342
  // Nothing is queued now, and nothing more is taken: what follows saves
1050
1343
  // the pins and watermarks as the last work left them.
1051
1344
  this.stopped = true;
1052
1345
 
1053
- // Persist state
1054
- if (this.ctx) {
1055
- const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
1346
+ // Persist state, once initStore() has run and the state start() was
1347
+ // handed has been applied (savedState is cleared then). Before that this
1348
+ // session has made no pass or write, so it has nothing to save, and the
1349
+ // stored state stays as it was: saving would put empty or default mounts
1350
+ // over the saved pins. A create() that fails after starting this module
1351
+ // stops it before the host can call initStore() (#389), and an
1352
+ // initStore() that throws after taking the store leaves the state
1353
+ // unapplied.
1354
+ if (this.ctx && this.store && this.savedState === null) {
1355
+ const activeBranchId = this.store.currentBranch().id;
1056
1356
  const state: WorkspaceModuleState = { mounts: {}, activeBranchId };
1057
1357
  for (const [name, mount] of this.mounts) {
1058
1358
  state.mounts[name] = {
@@ -1093,8 +1393,8 @@ export class WorkspaceModule implements Module {
1093
1393
  type: 'object' as const,
1094
1394
  properties: {
1095
1395
  path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
1096
- offset: { type: 'number', description: 'Starting line number (1-indexed)' },
1097
- limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
1396
+ offset: { type: 'integer', description: 'Starting line number (1-indexed)' },
1397
+ limit: { type: 'integer', description: 'Maximum number of lines to return (default 2000)' },
1098
1398
  offsetChars: { type: 'integer', description: 'Character paging: zero-based UTF-16 offset (default 0); use nextOffsetChars to continue' },
1099
1399
  limitChars: { type: 'integer', description: 'Character paging: code units to return (default 2000), plus at most one to keep a surrogate pair intact. Use the limit suggested by a spill notice.' },
1100
1400
  },
@@ -1158,7 +1458,8 @@ export class WorkspaceModule implements Module {
1158
1458
  + 'still on disk); not-in-branch (on disk, absent from this branch\'s workspace); disk-only (binary or over '
1159
1459
  + 'the size limit, never stored — shown with size and image type); conflict, with its kind (disk and the '
1160
1460
  + 'workspace both changed; disk holds a different copy and nothing records which is newer; or a write was '
1161
- + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace; '
1461
+ + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace, '
1462
+ + 'with applyDeletions too where the workspace deleted it; '
1162
1463
  + 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
1163
1464
  + 'the listing could not see.',
1164
1465
  inputSchema: {
@@ -1194,8 +1495,8 @@ export class WorkspaceModule implements Module {
1194
1495
  pattern: { type: 'string', description: 'Regular expression pattern' },
1195
1496
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1196
1497
  glob: { type: 'string', description: 'Glob pattern to filter files' },
1197
- contextBefore: { type: 'number', description: 'Context lines before match' },
1198
- contextAfter: { type: 'number', description: 'Context lines after match' },
1498
+ contextBefore: { type: 'integer', description: 'Context lines before match' },
1499
+ contextAfter: { type: 'integer', description: 'Context lines after match' },
1199
1500
  },
1200
1501
  required: ['pattern'],
1201
1502
  },
@@ -1242,13 +1543,18 @@ export class WorkspaceModule implements Module {
1242
1543
  + 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
1243
1544
  + 'directory replaced on purpose, or a drive back under a new device number, never for one still '
1244
1545
  + 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and '
1245
- + 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s '
1246
- + 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a '
1546
+ + 'reported nowhere else is counted under `passedOver`, by mount and reason: another mount\'s '
1547
+ + 'directory nested in this one, entries the mount\'s ignore list covers, and entries with an '
1548
+ + 'editor\'s swap or backup file name (`.name.swp` and `.name.swx` as the whole name, names ending '
1549
+ + 'in `~` or in `.subl…tmp`), that hold nothing the workspace tracks (such a directory counts once, '
1550
+ + 'with what lies beneath it). So a '
1247
1551
  + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
1248
- + 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name '
1249
- + 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory '
1250
- + 'sets aside the patterns that cover it or a directory above it; one that covers only something '
1251
- + 'inside it still applies, and what it covers is passed over.',
1552
+ + 'sync looked everywhere and nothing had changed. A path the mount ignores, or an editor\'s file, is '
1553
+ + 'synced when you name it, and listed under `ignored`: a sync without a path passes over it. A path '
1554
+ + 'in another mount\'s directory isn\'t synced even when you name it: use the mount that holds it. Naming '
1555
+ + 'an ignored directory sets aside the patterns that cover it or a directory above it; one that '
1556
+ + 'covers only something inside it still applies, and what it covers is passed over, as are editors\' '
1557
+ + 'files inside it.',
1252
1558
  inputSchema: {
1253
1559
  type: 'object' as const,
1254
1560
  properties: {
@@ -1281,8 +1587,10 @@ export class WorkspaceModule implements Module {
1281
1587
  const mountName = slash < 0 ? path : path.slice(0, slash);
1282
1588
  const mount = this.config.mounts.find(m => m.name === mountName);
1283
1589
  if (!mount) throw new Error(`Unknown mount: "${mountName}"`);
1284
- const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1));
1285
- if (!isContainedPath(mount.path, resolved)) throw new Error(`Path traversal detected: "${path}"`);
1590
+ // Against the resolved root, as parsePath judges it.
1591
+ const root = resolve(mount.path);
1592
+ const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1));
1593
+ if (!isContainedPath(root, resolved)) throw new Error(`Path traversal detected: "${path}"`);
1286
1594
  return resolved;
1287
1595
  }
1288
1596
 
@@ -1307,7 +1615,33 @@ export class WorkspaceModule implements Module {
1307
1615
 
1308
1616
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
1309
1617
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1618
+ return this.underWay(this.runToolCall(call));
1619
+ }
1620
+
1621
+ /**
1622
+ * Tool calls, and the public reads and writes that take a mount's turn,
1623
+ * under way. Each can await disk (parsePath) before it takes its turn, so
1624
+ * stop waits for these as well as for the turns.
1625
+ */
1626
+ private callsUnderWay = new Set<Promise<unknown>>();
1627
+
1628
+ /** `work`, recorded as under way until it settles (callsUnderWay). */
1629
+ private underWay<T>(work: Promise<T>): Promise<T> {
1630
+ this.callsUnderWay.add(work);
1631
+ const done = (): void => { this.callsUnderWay.delete(work); };
1632
+ work.then(done, done);
1633
+ return work;
1634
+ }
1635
+
1636
+ private async runToolCall(call: ToolCall): Promise<ToolResult> {
1310
1637
  try {
1638
+ // Every handler reads its input as its schema describes it; a call
1639
+ // that sends something else is refused here, before anything is read.
1640
+ const schema = this.getTools().find((tool) => tool.name === call.name)?.inputSchema;
1641
+ const problem = schema ? inputProblem(schema, call.input, INPUT_MINIMUMS[call.name]) : null;
1642
+ if (problem !== null) {
1643
+ return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
1644
+ }
1311
1645
  const input = call.input as Record<string, unknown>;
1312
1646
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1313
1647
  if (generated) {
@@ -1332,7 +1666,7 @@ export class WorkspaceModule implements Module {
1332
1666
  return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
1333
1667
  }
1334
1668
  } catch (err) {
1335
- return { success: false, error: String(err), isError: true };
1669
+ return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
1336
1670
  }
1337
1671
  }
1338
1672
 
@@ -1423,7 +1757,10 @@ export class WorkspaceModule implements Module {
1423
1757
  */
1424
1758
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
1425
1759
  try {
1426
- const { mount, relativePath } = this.parsePath(mountPrefixedPath);
1760
+ // Synchronous, so the path as given: it reaches the same file, and
1761
+ // the nested-mount check matches it as configured or as disk spells
1762
+ // the nested mount's directory (readNesting).
1763
+ const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
1427
1764
  return resolve(mount.config.path, relativePath);
1428
1765
  } catch {
1429
1766
  return null;
@@ -1458,10 +1795,19 @@ export class WorkspaceModule implements Module {
1458
1795
  options: { branch?: string } = {},
1459
1796
  ): Promise<ToolResult> {
1460
1797
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1798
+ return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
1799
+ }
1800
+
1801
+ private async writeBinaryUnderWay(
1802
+ mountPrefixedPath: string,
1803
+ data: Buffer,
1804
+ mimeType: string,
1805
+ options: { branch?: string },
1806
+ ): Promise<ToolResult> {
1461
1807
  let mount: MountState;
1462
1808
  let relativePath: string;
1463
1809
  try {
1464
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1810
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1465
1811
  } catch (error) {
1466
1812
  return {
1467
1813
  success: false,
@@ -1485,13 +1831,17 @@ export class WorkspaceModule implements Module {
1485
1831
  return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
1486
1832
  }
1487
1833
  const blobHash = store.storeBlob(data, mimeType);
1834
+ // An overwrite keeps the file's mode, as the write tool's does.
1835
+ const existing = store.treeGet(mount.treeStateId, relativePath);
1488
1836
  let committed = false;
1489
1837
  let failure: string | null;
1838
+ let modeKept: string | null = null;
1490
1839
  try {
1491
- const materializeError = await this.commitToolChange(mount, relativePath,
1492
- { kind: 'set', blobHash, size: data.byteLength }, () => { committed = true; });
1493
- failure = materializeError === null ? null
1494
- : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`;
1840
+ const answer = await this.commitToolChange(mount, relativePath,
1841
+ { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
1842
+ modeKept = answer.modeKept;
1843
+ failure = answer.refused === null ? null
1844
+ : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
1495
1845
  } catch (error) {
1496
1846
  if (!committed) throw error;
1497
1847
  failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
@@ -1504,7 +1854,7 @@ export class WorkspaceModule implements Module {
1504
1854
  }
1505
1855
  return {
1506
1856
  success: true,
1507
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
1857
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
1508
1858
  };
1509
1859
  });
1510
1860
  }
@@ -1518,10 +1868,16 @@ export class WorkspaceModule implements Module {
1518
1868
  mountPrefixedPath: string,
1519
1869
  ): Promise<{ data: Buffer } | { error: string }> {
1520
1870
  if (!this.running()) return { error: NOT_RUNNING };
1871
+ return this.underWay(this.readBinaryUnderWay(mountPrefixedPath));
1872
+ }
1873
+
1874
+ private async readBinaryUnderWay(
1875
+ mountPrefixedPath: string,
1876
+ ): Promise<{ data: Buffer } | { error: string }> {
1521
1877
  let mount: MountState;
1522
1878
  let relativePath: string;
1523
1879
  try {
1524
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1880
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1525
1881
  } catch (error) {
1526
1882
  return { error: error instanceof Error ? error.message : String(error) };
1527
1883
  }
@@ -1548,8 +1904,16 @@ export class WorkspaceModule implements Module {
1548
1904
  * permanent cost to send something once is the wrong trade.
1549
1905
  *
1550
1906
  * Same mount boundary and traversal guard as every other path here: only
1551
- * declared mounts, nothing above their root. Deliberately does NOT sync,
1552
- * register, or hash anything — it reads and returns.
1907
+ * declared mounts, nothing above their root, and the file reached as
1908
+ * readFileFromDisk reaches it (openContainedFile), so the mount's
1909
+ * `followSymlinks` holds for the file and the directories on its way, and
1910
+ * what it reaches stays canonically inside the mount. Deliberately does NOT
1911
+ * sync, register, or hash anything — it reads and returns.
1912
+ *
1913
+ * `maxBytes` refuses a larger file as "File too large", having loaded at
1914
+ * most `maxBytes` bytes and one more: the reads decide where the file ends,
1915
+ * not the size fstat gave at the open, so the bound holds for a file that
1916
+ * grows while it's read.
1553
1917
  */
1554
1918
  async readBinaryFromDisk(
1555
1919
  mountPrefixedPath: string,
@@ -1558,29 +1922,267 @@ export class WorkspaceModule implements Module {
1558
1922
  let mount: MountState;
1559
1923
  let relativePath: string;
1560
1924
  try {
1561
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1925
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1562
1926
  } catch (error) {
1563
1927
  return { error: error instanceof Error ? error.message : String(error) };
1564
1928
  }
1565
1929
  const absolutePath = resolve(mount.config.path, relativePath);
1930
+ const maxBytes = opts.maxBytes;
1931
+ let tooLarge: string | undefined;
1566
1932
  try {
1567
- const info = await stat(absolutePath);
1568
- if (!info.isFile()) return { error: `Not a file: ${mountPrefixedPath}` };
1569
- if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
1570
- return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
1933
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
1934
+ if (maxBytes !== undefined && fileStat.size > maxBytes) {
1935
+ tooLarge = `File too large: ${fileStat.size} > ${maxBytes} bytes`;
1936
+ throw new Error(tooLarge);
1937
+ }
1938
+ });
1939
+ try {
1940
+ if (maxBytes === undefined) return { data: await handle.readFile(), absolutePath };
1941
+ // No more than maxBytes is loaded: a file grown past it since the
1942
+ // fstat above is refused here, as one already over it was there. Its
1943
+ // size is an fstat's after the read, and at least one more than the
1944
+ // bytes kept, so the refusal's size is always past maxBytes.
1945
+ const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
1946
+ if (more) return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
1947
+ return { data: bytes, absolutePath };
1948
+ } finally {
1949
+ await handle.close();
1571
1950
  }
1572
- return { data: await readFile(absolutePath), absolutePath };
1573
1951
  } catch (error) {
1574
- const code = (error as { code?: string }).code;
1575
- if (code === 'ENOENT') return { error: `File not found on disk: ${mountPrefixedPath}` };
1952
+ if (tooLarge !== undefined) return { error: tooLarge };
1953
+ // A directory, or a file that isn't regular (the reader's refusal of
1954
+ // one carries no errno), and a missing file keep this reader's words.
1955
+ if (error instanceof WorkspaceReadError && (error.code === 'directory' || (error.code === 'not_found' && error.errno === undefined))) {
1956
+ return { error: `Not a file: ${mountPrefixedPath}` };
1957
+ }
1958
+ if (error instanceof WorkspaceReadError && error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) {
1959
+ return { error: `File not found on disk: ${mountPrefixedPath}` };
1960
+ }
1576
1961
  return { error: error instanceof Error ? error.message : String(error) };
1577
1962
  }
1578
1963
  }
1579
1964
 
1965
+ /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
1966
+ private othersOf(mountName: string): string[] {
1967
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
1968
+ }
1969
+
1970
+ /**
1971
+ * Which mounts are nested in which, read afresh at each start. A mount is
1972
+ * nested in another where its configured path lies beneath the other's
1973
+ * root, as configured or as it lies on disk, whatever links the path
1974
+ * reaches that root through, or where its real root lies beneath the
1975
+ * other's. Each root, and each directory above a configured path, is
1976
+ * placed by realBeneath, awaited, so a root on a share that stops
1977
+ * answering holds this start and not the process, and a root moved while
1978
+ * the module was stopped is placed where it is now. A mount whose real
1979
+ * root is the other's own, or holds it, is nested by its configured path
1980
+ * alone, with no real root, since through it it would claim the other's
1981
+ * every path.
1982
+ *
1983
+ * Each way it lies there is a spelling of its directory (`fromRoots`), and
1984
+ * so is the spelling disk lists each of those by, where that differs. On a
1985
+ * disk that folds names, the outer mount's walks name the directory as
1986
+ * disk does, and so does a path parsePath has respelled, while a path given
1987
+ * as configured still names it as configured (resolveAbsolutePath). A
1988
+ * directory not on disk keeps the spellings the roots give alone. Each
1989
+ * mount's real root comes back beside the nesting (`reals`), for what
1990
+ * start reads there.
1991
+ */
1992
+ private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string> }> {
1993
+ const reals = new Map<string, string>();
1994
+ // Each directory above a mount's configured path, with where it lies on
1995
+ // disk, so a path that reaches another mount's root through a link, as
1996
+ // one under macOS's /var reaches /private/var, is still read beneath it.
1997
+ const above = new Map<string, Array<{ dir: string; real: string }>>();
1998
+ for (const mount of this.config.mounts) {
1999
+ const given = resolve(mount.path);
2000
+ reals.set(mount.name, await realBeneath(given));
2001
+ const dirs: Array<{ dir: string; real: string }> = [];
2002
+ for (let dir = dirname(given); ; dir = dirname(dir)) {
2003
+ dirs.push({ dir, real: await realBeneath(dir) });
2004
+ if (dirname(dir) === dir) break;
2005
+ }
2006
+ above.set(mount.name, dirs);
2007
+ }
2008
+ const nesting = new Map<string, NestedMountEntry[]>();
2009
+ for (const outer of this.config.mounts) {
2010
+ const outerPath = resolve(outer.path);
2011
+ const outerReal = reals.get(outer.name)!;
2012
+ for (const inner of this.config.mounts) {
2013
+ if (outer === inner) continue;
2014
+ const real = reals.get(inner.name)!;
2015
+ const innerPath = resolve(inner.path);
2016
+ const configured = nestedDir(outerPath, innerPath);
2017
+ const fromRoots: Array<{ dir: string; root: string }> = [];
2018
+ // Its directory as configured, read against this mount's root as
2019
+ // configured and from each directory above it that is this mount's
2020
+ // root on disk, and its real root against this mount's.
2021
+ const throughRoot = above.get(inner.name)!.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
2022
+ for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
2023
+ if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
2024
+ }
2025
+ if (fromRoots.length === 0) continue;
2026
+ const spellings = [...fromRoots];
2027
+ for (const found of fromRoots) {
2028
+ const dir = await diskSpelling(outerPath, found.dir);
2029
+ if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
2030
+ }
2031
+ const claims = !isContainedPath(real, outerReal);
2032
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2033
+ const placed = fromRoots.filter((s) => s.dir !== configured);
2034
+ if (placed.length > 0) {
2035
+ console.warn(
2036
+ `[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2037
+ `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`,
2038
+ );
2039
+ }
2040
+ }
2041
+ }
2042
+ return { nesting, reals };
2043
+ }
2044
+
2045
+ /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
2046
+ private nestedOf(mountName: string): NestedMount[] {
2047
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
2048
+ }
2049
+
2050
+ /**
2051
+ * The innermost mount nested in `mountName` whose directory holds a path
2052
+ * of that mount, judged lexically where the path lands (`.`, `..` and
2053
+ * repeated separators resolved, as for the traversal guard; a link of the
2054
+ * outer mount isn't followed), with the path to use there; undefined when
2055
+ * no nested mount's directory holds it.
2056
+ */
2057
+ private holderOf(mountName: string, relativePath: string): { name: string; use: string } | undefined {
2058
+ const mount = this.mounts.get(mountName);
2059
+ if (!mount) return undefined;
2060
+ const resolved = resolve(mount.config.path, relativePath);
2061
+ const holder = (this.otherMounts.get(mountName) ?? [])
2062
+ .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
2063
+ .sort((a, b) => b.root.length - a.root.length)[0];
2064
+ if (!holder) return undefined;
2065
+ const rest = relative(holder.root, resolved).split(sep).join('/');
2066
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2067
+ }
2068
+
2069
+ /**
2070
+ * Why a mount leaves a path in a nested mount's directory, naming that
2071
+ * mount and the path to use: for a sync or a push, which of them leaves
2072
+ * it; for a listing, whose the path is.
2073
+ */
2074
+ private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list'): string {
2075
+ const holder = this.holderOf(mountName, relativePath);
2076
+ const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2077
+ return holder
2078
+ ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2079
+ : `it lies in another mount's directory${what}`;
2080
+ }
2081
+
2082
+ /**
2083
+ * parsePath for the file operations (read, write, edit, delete, and the
2084
+ * module's binary and disk reads and writes). A path in another mount's
2085
+ * directory nested in this one is that mount's: this mount's passes don't
2086
+ * observe it and its pushes don't write it, so its file operations refuse
2087
+ * it, naming the innermost mount that holds it and the path to use there.
2088
+ */
2089
+ private async parseFilePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
2090
+ const parsed = this.refuseNested(await this.parsePath(path));
2091
+ // A path can also reach a nested mount's directory through a symlink on
2092
+ // its way. No push of this mount writes it there and no read reads it,
2093
+ // so it's refused here, before anything is stored, and the checks where
2094
+ // disk is acted on refuse it again.
2095
+ const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
2096
+ if (refusal) throw refusal;
2097
+ return parsed;
2098
+ }
2099
+
2100
+ /**
2101
+ * The refusal for a path of `mount` that reaches a nested mount's directory
2102
+ * through a symlink on its way (nestedAtReal), or null where it doesn't. On
2103
+ * a mount that doesn't follow the link, the refusal is the link's own
2104
+ * (linkWords), as the readers and pushes give it. A mount with nothing
2105
+ * nested has nothing to find, so it pays nothing.
2106
+ */
2107
+ private async leadsIntoNested(mount: MountState, relativePath: string, path: string): Promise<Error | null> {
2108
+ const nested = this.nestedOf(mount.config.name);
2109
+ if (nested.length === 0) return null;
2110
+ const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
2111
+ if (!holder) return null;
2112
+ if (mount.config.followSymlinks !== true) {
2113
+ const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
2114
+ if (found !== null) return await linkReadError(mount, found, path, nested);
2115
+ }
2116
+ return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2117
+ }
2118
+
2119
+ /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
2120
+ private refuseNested<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P): P {
2121
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
2122
+ if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2123
+ return parsed;
2124
+ }
2125
+
2126
+ /**
2127
+ * Parse a mount-prefixed path into its mount and mount-relative path, as
2128
+ * disk spells it (diskSpelling), with `path` the mount-prefixed path so
2129
+ * spelled. On a disk that folds names, a path spelled otherwise reaches
2130
+ * disk's file, so every tool takes it by disk's name: one name per file,
2131
+ * and the names its answers give are disk's, except that a path that names
2132
+ * a workspace entry exactly, or does once its directories take disk's
2133
+ * spelling, keeps the entry's spelling. A path the workspace records an
2134
+ * intent at, such as a deletion disk hasn't been given yet, counts as an
2135
+ * entry here, so the calls that settle it reach it. The workspace's
2136
+ * entries (entriesOf) stand in for disk where it has nothing yet, so a
2137
+ * draft has one name too. Neither applies to a path that disk's spelling
2138
+ * puts in a nested mount's directory.
2139
+ * A handler that must read something at the moment it's asked, as
2140
+ * handleSync reads the branch a path sync adopts on, reads it before
2141
+ * calling this, since this awaits disk. The entries are read when the
2142
+ * step needs them, some after those awaits, from the branch current then.
2143
+ */
2144
+ private async parsePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
2145
+ const given = this.parsePathAsGiven(path);
2146
+ const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
2147
+ return {
2148
+ mount: given.mount,
2149
+ relativePath,
2150
+ path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
2151
+ };
2152
+ }
2153
+
2154
+ /**
2155
+ * A mount's entries on the current branch, and the paths it records an
2156
+ * intent at there, as diskSpelling reads them when it does, whether its
2157
+ * root folds case as the last start read it (false before the first), and
2158
+ * which nested mount's directory holds a path, by holderOf, as the
2159
+ * nested-mount checks read it. diskSpelling keeps disk's spelling of a
2160
+ * path that lies in a nested mount's directory as disk spells it, so the
2161
+ * checks refuse at least what they would refuse without the entries.
2162
+ */
2163
+ private entriesOf(mount: MountState): WorkspaceEntries {
2164
+ const store = this.getStore();
2165
+ const intents = this.intents.get(mount.config.name)!;
2166
+ return {
2167
+ // An intent recorded at a path, such as a deletion disk hasn't been
2168
+ // given yet or a conflict, is the workspace's own name for the file
2169
+ // there, as an entry is, so the calls that settle it reach it.
2170
+ has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
2171
+ under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
2172
+ holder: (path) => this.holderOf(mount.config.name, path)?.name,
2173
+ rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
2174
+ };
2175
+ }
2176
+
1580
2177
  /**
1581
- * Parse a mount-prefixed path into (mountName, relativePath).
2178
+ * Parse a mount-prefixed path into (mountName, relativePath), as spelled.
1582
2179
  */
1583
- private parsePath(path: string): { mount: MountState; relativePath: string } {
2180
+ private parsePathAsGiven(path: string): { mount: MountState; relativePath: string } {
2181
+ // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
2182
+ // store would keep one name and disk another.
2183
+ if (LONE_SURROGATE.test(path)) {
2184
+ throw new Error(`The path ${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Nothing was done.`);
2185
+ }
1584
2186
  const slashIdx = path.indexOf('/');
1585
2187
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
1586
2188
  const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
@@ -1594,8 +2196,11 @@ export class WorkspaceModule implements Module {
1594
2196
  // Containment must compare with the PLATFORM separator (isContainedPath):
1595
2197
  // resolve() emits backslashes on Windows, so a '/'-suffixed root never
1596
2198
  // prefix-matches there and every in-mount path was rejected as traversal.
1597
- const resolved = resolve(mount.config.path, relativePath);
1598
- if (!isContainedPath(mount.config.path, resolved)) {
2199
+ // Against the resolved root, so a root configured with a trailing
2200
+ // separator, or relative, contains its own paths, the root included.
2201
+ const root = resolve(mount.config.path);
2202
+ const resolved = resolve(root, relativePath);
2203
+ if (!isContainedPath(root, resolved)) {
1599
2204
  throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
1600
2205
  }
1601
2206
 
@@ -1627,12 +2232,7 @@ export class WorkspaceModule implements Module {
1627
2232
  if (bytes.byteLength === 0) {
1628
2233
  throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1629
2234
  }
1630
- if (bytes.byteLength > maxSize) {
1631
- throw new WorkspaceImageReadError(
1632
- 'too_large',
1633
- `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
1634
- );
1635
- }
2235
+ if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
1636
2236
  return {
1637
2237
  bytes,
1638
2238
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
@@ -1657,35 +2257,137 @@ export class WorkspaceModule implements Module {
1657
2257
  return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
1658
2258
  }
1659
2259
 
2260
+ /**
2261
+ * The mount root a disk read reads beneath: resolved, then checked as
2262
+ * observation checks it (checkRoot), so the read and the evidence agree on
2263
+ * which directory the mount is. Once the workspace has recorded the root's
2264
+ * identity, a root that resolves to any other directory is refused, in
2265
+ * ROOT_REPLACED's words; with none recorded yet, it need only be a
2266
+ * directory there. A refusal where no directory is at the root's path
2267
+ * carries the errno that gives (rootAbsence: ENOENT when nothing is there
2268
+ * and ENOTDIR when something else is, which a caller can read as no file,
2269
+ * or the errno the root's stat failed with otherwise, such as EACCES). A
2270
+ * root whose realpath fails carries realpath's errno, and one that is
2271
+ * another directory carries none. The identity is rootIdentityForRead's,
2272
+ * which answers while the evidence awaits reopening; where even it can't
2273
+ * be read, as when the evidence was never read back whole, the read is
2274
+ * refused too, with an errno only where no directory is at the root's path.
2275
+ */
2276
+ private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
2277
+ const name = mount.config.name;
2278
+ const root = resolve(mount.config.path);
2279
+ let real: string;
2280
+ try {
2281
+ real = await realpath(root);
2282
+ } catch (err) {
2283
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
2284
+ }
2285
+ let rootIdentity: MountView['rootIdentity'];
2286
+ try {
2287
+ rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2288
+ } catch (err) {
2289
+ // The evidence can't be read back, so neither can the check be made.
2290
+ // Where no directory is at the root's path, its errno still says so.
2291
+ throw new WorkspaceReadError('mount_unavailable',
2292
+ `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2293
+ }
2294
+ const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2295
+ if ('dir' in checked) return { real, dir: checked.dir };
2296
+ // Another directory is no absence. For any other refusal, what is at the
2297
+ // root's path now decides: nothing, something that isn't a directory, or
2298
+ // a directory the path has come to name since it was resolved (no errno).
2299
+ const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2300
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2301
+ }
2302
+
2303
+ /**
2304
+ * The refusal for a root that stopped being the directory a read checked,
2305
+ * with the code and errno readRoot gives what is at its path now (its stage
2306
+ * and words are this check's): gone, or not a directory, with ENOENT or
2307
+ * ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
2308
+ * another directory, with none.
2309
+ */
2310
+ private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
2311
+ const errno = await rootAbsence(resolve(mount.config.path));
2312
+ const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
2313
+ return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2314
+ }
2315
+
1660
2316
  /**
1661
2317
  * Open a mount-relative file for reading with the mount boundary enforced on
1662
2318
  * the *filesystem*, not just the path string:
1663
2319
  *
1664
- * 1. `lstat` the lexical path — refuse directories; refuse a final-component
1665
- * symlink when the mount does not `followSymlinks`.
1666
- * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
2320
+ * 0. Check the root (readRoot) before anything beneath it: under a root
2321
+ * that isn't the mount's directory, no path says anything about the
2322
+ * mount's files, an absence included.
2323
+ * 1. On a mount that does not `followSymlinks`, refuse a path with a symlink
2324
+ * among the directories on its way beneath the mount's root, wherever it
2325
+ * leads (linkOnTheWay), before anything resolves it: as `escape` for a
2326
+ * link leading outside the mount, as `other_mount` for one into a nested
2327
+ * mount's directory, and as `parent_symlink` for any other.
2328
+ * 2. `lstat` the lexical path — refuse directories; refuse a final-component
2329
+ * symlink when the mount does not `followSymlinks`; refuse anything else
2330
+ * that isn't a regular file, such as a FIFO, without opening it.
2331
+ * 3. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
1667
2332
  * holds against a symlink swapped in between lstat and open; where it
1668
- * doesn't (Windows), re-`lstat` after opening instead.
1669
- * 3. `realpath` both the mount root and the opened path and require canonical
1670
- * containment — this is what catches an *intermediate* symlinked
1671
- * directory escaping the mount, and a followed symlink whose target lands
1672
- * outside, on every platform (junctions included).
1673
- * 4. Confirm the descriptor and the canonical path are the same inode.
2333
+ * doesn't (Windows), re-`lstat` after opening instead. `O_NONBLOCK`, so a
2334
+ * FIFO swapped in meanwhile doesn't wait for a writer.
2335
+ * 4. `realpath` the opened path. On a mount that does not `followSymlinks`,
2336
+ * refuse it again for a symlinked directory on its way, which catches one
2337
+ * swapped in since step 1 (linkRefusal). Then require canonical
2338
+ * containment in the root step 0 resolved — this is what catches an
2339
+ * *intermediate* symlinked directory escaping the mount, and a followed
2340
+ * symlink whose target lands outside, on every platform (junctions
2341
+ * included).
2342
+ * 5. Confirm the descriptor and the canonical path are the same inode, and
2343
+ * the root still the directory step 0 checked.
1674
2344
  *
1675
- * `inspect` runs right after the descriptor is stat'd and before the
1676
- * containment work, so callers can impose size policy in the same order the
1677
- * image reader always has. The returned handle is the caller's to close.
2345
+ * Whatever steps 1 to 5 refuse, a missing file included, is said of the
2346
+ * mount's files only while the root is still that directory. If it isn't,
2347
+ * the read is refused with the code and errno step 0 gives what is at the
2348
+ * root's path then (rootMoved), as step 5's own check of the root does.
2349
+ *
2350
+ * `inspect` runs last, after the boundary's checks, so a caller's own check
2351
+ * of the file, such as a size limit, never answers for a file the boundary
2352
+ * refuses. The returned handle is the caller's to close.
1678
2353
  */
1679
2354
  private async openContainedFile(
1680
2355
  mount: MountState,
1681
2356
  relativePath: string,
1682
2357
  mountPrefixedPath: string,
1683
2358
  inspect?: (fileStat: Stats) => void,
2359
+ ): Promise<{ handle: Awaited<ReturnType<typeof open>>; fileStat: Stats; realFilePath: string }> {
2360
+ const root = await this.readRoot(mount);
2361
+ try {
2362
+ return await this.openBeneathRoot(mount, root, relativePath, mountPrefixedPath, inspect);
2363
+ } catch (err) {
2364
+ if (err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && !(await root.dir.stillHere())) {
2365
+ throw await this.rootMoved(mount);
2366
+ }
2367
+ throw err;
2368
+ }
2369
+ }
2370
+
2371
+ /** Steps 1 to 5 of openContainedFile, beneath the root that step 0 checked. */
2372
+ private async openBeneathRoot(
2373
+ mount: MountState,
2374
+ { real: realMountRoot, dir: rootDir }: { real: string; dir: CheckedDir },
2375
+ relativePath: string,
2376
+ mountPrefixedPath: string,
2377
+ inspect?: (fileStat: Stats) => void,
1684
2378
  ): Promise<{ handle: Awaited<ReturnType<typeof open>>; fileStat: Stats; realFilePath: string }> {
1685
2379
  const lexicalPath = resolve(mount.config.path, relativePath);
1686
2380
  const follow = mount.config.followSymlinks === true;
1687
2381
  const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
1688
2382
 
2383
+ // Before anything resolves the path, so a link's own answer comes first: a
2384
+ // mount that doesn't follow symlinks goes through none among the
2385
+ // directories on the way, wherever it leads. Asked again of the path's
2386
+ // resolution after the open, below.
2387
+ const linkView = { root: mount.config.path, followSymlinks: follow };
2388
+ const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
2389
+ if (onTheWay !== null) throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
2390
+
1689
2391
  let fileInfo: Awaited<ReturnType<typeof lstat>>;
1690
2392
  try {
1691
2393
  fileInfo = await lstat(lexicalPath);
@@ -1702,17 +2404,16 @@ export class WorkspaceModule implements Module {
1702
2404
  if (fileInfo.isSymbolicLink() && !follow) {
1703
2405
  throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
1704
2406
  }
1705
-
1706
- let realMountRoot: string;
1707
- try {
1708
- realMountRoot = await realpath(mount.config.path);
1709
- } catch (err) {
1710
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
2407
+ if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2408
+ // A FIFO, socket or device is never opened.
2409
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
1711
2410
  }
1712
2411
 
1713
2412
  let handle: Awaited<ReturnType<typeof open>>;
1714
2413
  try {
1715
- handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
2414
+ // O_NONBLOCK: a FIFO swapped in since the lstat opens at once instead of
2415
+ // waiting for a writer, and the regular-file check below refuses it.
2416
+ handle = await open(lexicalPath, fsConstants.O_RDONLY | NONBLOCK | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
1716
2417
  } catch (err) {
1717
2418
  const code = errnoOf(err);
1718
2419
  if (code === 'ENOENT' || code === 'ENOTDIR') {
@@ -1741,9 +2442,8 @@ export class WorkspaceModule implements Module {
1741
2442
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
1742
2443
  }
1743
2444
  if (!fileStat.isFile()) {
1744
- throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
2445
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
1745
2446
  }
1746
- inspect?.(fileStat);
1747
2447
 
1748
2448
  if (!follow && !useNoFollow) {
1749
2449
  let postOpenInfo: Awaited<ReturnType<typeof lstat>>;
@@ -1772,9 +2472,16 @@ export class WorkspaceModule implements Module {
1772
2472
  throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
1773
2473
  }
1774
2474
 
2475
+ // A directory swapped for a link since the walk above is caught here, by
2476
+ // what the path resolved to after the open.
2477
+ const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
2478
+ if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
2479
+
1775
2480
  if (!isContainedPath(realMountRoot, realFilePath)) {
1776
2481
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
1777
2482
  }
2483
+ const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
2484
+ if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
1778
2485
 
1779
2486
  let pathStat: Stats;
1780
2487
  try {
@@ -1789,7 +2496,16 @@ export class WorkspaceModule implements Module {
1789
2496
  if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
1790
2497
  throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
1791
2498
  }
1792
-
2499
+ // Containment compares paths, so it can't see another directory put at
2500
+ // the root's own path since step 0 (a drive unmounted mid-read): the
2501
+ // root is asked again, as an effect asks it (effects.ts moved()). This
2502
+ // sees the root as it is now, so a root replaced and put back before it
2503
+ // isn't seen (observe.ts's header says why no check can be sure).
2504
+ if (!(await rootDir.stillHere())) throw await this.rootMoved(mount);
2505
+
2506
+ // The caller's checks of the file, such as a size limit, come after the
2507
+ // boundary's, so they never answer for a file the boundary refuses.
2508
+ inspect?.(fileStat);
1793
2509
  return { handle, fileStat, realFilePath };
1794
2510
  } catch (err) {
1795
2511
  await handle.close();
@@ -1809,11 +2525,25 @@ export class WorkspaceModule implements Module {
1809
2525
  * - With symlinks allowed, the canonical target must stay beneath the
1810
2526
  * canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
1811
2527
  * - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
1812
- * ever loaded, and `truncated` reports that the file was larger.
2528
+ * kept, and one byte past them is read to tell whether the file goes on
2529
+ * (`truncated`). The reads decide where the file ends, not the size fstat
2530
+ * gave at the open, so the bound holds for a file that grows while it's
2531
+ * read.
2532
+ * - Once the workspace has recorded the mount root's identity, with the
2533
+ * first evidence it gathers there, a root that is another directory is
2534
+ * an unavailable mount: a replaced directory, a link swapped in for it,
2535
+ * or an unmounted drive's mountpoint. In any mount, recorded or not, a
2536
+ * root that stops being the directory the read checked until the file
2537
+ * has been opened and checked is unavailable too: with ENOENT or
2538
+ * ENOTDIR where no directory is there, as before the read, and with no
2539
+ * errno for another directory. The file's bytes are then read through
2540
+ * the descriptor that was checked.
1813
2541
  *
1814
2542
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
1815
- * mount, lexical traversal, unavailable mount, missing file, directory,
1816
- * policy-denied symlink, outside-mount target, and file-changed-during-read.
2543
+ * mount, lexical traversal, a path in another mount's directory nested in
2544
+ * the one named (`other_mount`), unavailable mount, missing or non-regular
2545
+ * file, directory, policy-denied symlink, outside-mount target, and
2546
+ * file-changed-during-read.
1817
2547
  */
1818
2548
  async readFileFromDisk(
1819
2549
  mountPrefixedPath: string,
@@ -1825,10 +2555,13 @@ export class WorkspaceModule implements Module {
1825
2555
  let mount: MountState;
1826
2556
  let relativePath: string;
1827
2557
  try {
1828
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
2558
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1829
2559
  } catch (err) {
2560
+ // A link's refusal (parseFilePath) carries its own code.
2561
+ if (err instanceof WorkspaceReadError) throw err;
1830
2562
  const message = err instanceof Error ? err.message : String(err);
1831
- const code: WorkspaceReadErrorCode = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2563
+ const code: WorkspaceReadErrorCode = err instanceof NestedMountError ? 'other_mount'
2564
+ : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
1832
2565
  throw new WorkspaceReadError(code, message, 'parse');
1833
2566
  }
1834
2567
  if (!relativePath) {
@@ -1837,23 +2570,17 @@ export class WorkspaceModule implements Module {
1837
2570
 
1838
2571
  const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
1839
2572
  try {
1840
- const size = fileStat.size;
1841
2573
  const cap = options.maxBytes;
1842
2574
  let bytes: Buffer;
1843
2575
  let truncated = false;
1844
- if (cap !== undefined && size > cap) {
1845
- truncated = true;
1846
- bytes = Buffer.alloc(cap);
1847
- let offset = 0;
1848
- while (offset < cap) {
1849
- const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
1850
- if (bytesRead === 0) break;
1851
- offset += bytesRead;
1852
- }
1853
- if (offset < cap) bytes = bytes.subarray(0, offset);
1854
- } else {
2576
+ if (cap === undefined) {
1855
2577
  bytes = await handle.readFile();
2578
+ } else {
2579
+ ({ bytes, more: truncated } = await readPrefix(handle, cap, fileStat.size));
1856
2580
  }
2581
+ // What the read found, not what the fstat before it said, so `size`,
2582
+ // `bytes` and `truncated` agree even for a file that changed in between.
2583
+ const size = truncated ? Math.max((await handle.stat()).size, bytes.length + 1) : bytes.length;
1857
2584
  return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
1858
2585
  } catch (err) {
1859
2586
  if (err instanceof WorkspaceReadError) throw err;
@@ -1868,17 +2595,39 @@ export class WorkspaceModule implements Module {
1868
2595
  const p = mountPrefixedPath;
1869
2596
  switch (err.code) {
1870
2597
  case 'mount_unavailable':
1871
- return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
2598
+ // With why, where the root's check says, such as a directory that
2599
+ // isn't the mount's.
2600
+ return new WorkspaceImageReadError('mount_unavailable', err.message);
1872
2601
  case 'directory':
1873
2602
  return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
1874
2603
  case 'symlink':
1875
- return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
2604
+ // The core reader's own words for a link at the path: "Symlinks are not
2605
+ // allowed: <path>".
2606
+ return new WorkspaceImageReadError('symlink', err.message);
2607
+ case 'parent_symlink':
2608
+ // The core reader's own words for a link on its way: which link and
2609
+ // where it leads, with the setting for a link to a directory.
2610
+ return new WorkspaceImageReadError('parent_symlink', err.message);
2611
+ case 'other_mount':
2612
+ // The core reader's own words for a path a link takes into a nested
2613
+ // mount's directory: that mount and the path to use there. (#460's
2614
+ // own other_mount, a path in that directory, is refused at parse,
2615
+ // and so is a link found there before the read.)
2616
+ return new WorkspaceImageReadError('other_mount', err.message);
1876
2617
  case 'escape':
1877
- return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
2618
+ // The core reader's own words: "Symlink escape detected: <path>" for a
2619
+ // followed link that resolves outside the mount, and for a link on the
2620
+ // way that leads outside a mount that doesn't follow links, which link.
2621
+ return new WorkspaceImageReadError('escape', err.message);
1878
2622
  case 'changed':
1879
2623
  return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
1880
2624
  case 'not_found':
1881
2625
  default:
2626
+ // The core reader's own words for a file that isn't regular, and for
2627
+ // a directory on the way it couldn't ask about.
2628
+ if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
2629
+ return new WorkspaceImageReadError('not_found', err.message);
2630
+ }
1882
2631
  if (err.message.startsWith('File not found')) {
1883
2632
  return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
1884
2633
  }
@@ -1904,12 +2653,7 @@ export class WorkspaceModule implements Module {
1904
2653
  if (fileStat.size === 0) {
1905
2654
  throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1906
2655
  }
1907
- if (fileStat.size > maxSize) {
1908
- throw new WorkspaceImageReadError(
1909
- 'too_large',
1910
- `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
1911
- );
1912
- }
2656
+ if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
1913
2657
  });
1914
2658
  } catch (err) {
1915
2659
  if (err instanceof WorkspaceImageReadError) throw err;
@@ -1919,9 +2663,12 @@ export class WorkspaceModule implements Module {
1919
2663
  }
1920
2664
  throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1921
2665
  }
1922
- const { handle } = opened;
2666
+ const { handle, fileStat } = opened;
1923
2667
  try {
1924
- const bytes = await handle.readFile();
2668
+ // No more than maxSize is loaded: a file grown past it since the fstat
2669
+ // above is refused here, as one already over it was there.
2670
+ const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
2671
+ if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
1925
2672
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
1926
2673
  } catch (err) {
1927
2674
  if (err instanceof WorkspaceImageReadError) throw err;
@@ -1934,32 +2681,48 @@ export class WorkspaceModule implements Module {
1934
2681
  }
1935
2682
  }
1936
2683
 
2684
+ /**
2685
+ * Whether a tool's change on this mount goes to disk as it commits: the
2686
+ * push commitToolChange makes, and what an edit's answer says of it.
2687
+ */
2688
+ private pushesOnCommit(mount: MountState): boolean {
2689
+ return mount.config.autoMaterialize === true && mount.config.mode !== 'read-only';
2690
+ }
2691
+
1937
2692
  /**
1938
2693
  * Commit a tool's write or delete to the workspace tree, with the branch
1939
- * intent it implies (store origin for a path disk never agreed with; a
1940
- * tombstone for a deletion), and push it to disk when the mount opts in via
2694
+ * intent it implies: store origin for a path disk never agreed with; for a
2695
+ * deletion, a tombstone, unless the evidence holds no agreed disk copy (P
2696
+ * absent, or no P and store origin), when it clears the path's intent and
2697
+ * pushes nothing. It pushes the change to disk when the mount opts in via
1941
2698
  * `autoMaterialize` — required for cross-agent pipelines, since another
1942
2699
  * agent's watcher on the same directory only sees real filesystem events.
1943
2700
  * The push follows materialize's rule: a disk copy that changed since it
1944
2701
  * last agreed is not overwritten or unlinked, and the refusal says why.
1945
2702
  *
1946
- * Returns the reason when the change reached the workspace but not disk, so
1947
- * callers surface it in the tool result: the autoMaterialize contract is
1948
- * that disk is the source of truth for downstream agents. The caller holds
1949
- * the mount's turn and has settled the path first (settleBeforeMutation).
2703
+ * Answers, so callers surface them in the tool result, the reason when the
2704
+ * change reached the workspace but not disk (`refused`): the
2705
+ * autoMaterialize contract is that disk is the source of truth for
2706
+ * downstream agents. And, apart from it, why a write whose bytes reached
2707
+ * disk left the file's mode as disk had it (`modeKept`), and, for a
2708
+ * deletion that cleared a conflict naming a disk file the workspace never
2709
+ * took in, that nothing was unlinked (`note`). The caller holds the mount's
2710
+ * turn and has settled the path first (settleBeforeMutation, or a pass over
2711
+ * the path, as an edit makes).
1950
2712
  */
1951
2713
  private async commitToolChange(
1952
2714
  mount: MountState,
1953
2715
  relativePath: string,
1954
2716
  change: { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string },
1955
2717
  onCommitted?: () => void,
1956
- ): Promise<string | null> {
2718
+ ): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
1957
2719
  const store = this.getStore();
1958
2720
  const intents = this.intents.get(mount.config.name)!;
1959
2721
  // The mutation's precondition, enforced here, synchronously with the
1960
2722
  // commit: whatever settleBeforeMutation's pass could or couldn't settle.
1961
2723
  settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
1962
- const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
2724
+ const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
2725
+ const known = evidence !== undefined;
1963
2726
  if (change.kind === 'set') {
1964
2727
  store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
1965
2728
  // A later intent update or disk push can throw; the tree entry is
@@ -1977,20 +2740,50 @@ export class WorkspaceModule implements Module {
1977
2740
  });
1978
2741
  } else {
1979
2742
  store.treeRemove(mount.treeStateId, relativePath);
2743
+ onCommitted?.();
2744
+ // A deletion whose evidence holds no agreed disk copy (disk last agreed
2745
+ // it had nothing here, or has never agreed with this entry, which the
2746
+ // workspace created) puts the store back at the agreement. Nothing on
2747
+ // disk is the workspace's to unlink, so it pushes nothing, and it
2748
+ // clears the path's whole intent: no tombstone, so it needs no proof of
2749
+ // absence, which a path disk can't show, such as one beneath a symlink
2750
+ // the mount doesn't follow, would never give; and no conflict, which
2751
+ // has nothing left to be about once the workspace has given up its
2752
+ // side. Whatever disk holds there is a sync's to observe, a file the
2753
+ // workspace never saw included. Any other deletion keeps its
2754
+ // tombstone, which guards it: a copy disk agreed with, a push of ours
2755
+ // that may have landed (pending or interrupted), or an entry from
2756
+ // before the evidence.
2757
+ let agreed = true;
2758
+ let note: string | null = null;
1980
2759
  intents.update(relativePath, (cur) => {
1981
- const next = { ...cur, tombstone: { hash: change.previousHash } };
1982
- delete next.origin;
1983
- return next;
2760
+ agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
2761
+ if (agreed) {
2762
+ const next = { ...cur, tombstone: { hash: change.previousHash } };
2763
+ delete next.origin;
2764
+ return next;
2765
+ }
2766
+ // A conflict it clears may name a file disk held there, which the
2767
+ // workspace never took in: the answer says that file wasn't unlinked,
2768
+ // where the deletion alone would read as having removed it. The
2769
+ // deletion doesn't look at disk, so a file nothing recorded gets no
2770
+ // note.
2771
+ if (cur.conflict?.disk) {
2772
+ note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
2773
+ `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
2774
+ }
2775
+ return {};
1984
2776
  });
2777
+ if (!agreed) return { refused: null, modeKept: null, note };
1985
2778
  }
1986
- if (!mount.config.autoMaterialize || mount.config.mode === 'read-only') return null;
2779
+ if (!this.pushesOnCommit(mount)) return { refused: null, modeKept: null, note: null };
1987
2780
 
1988
2781
  const pushed = await this.pushUnlocked(mount, [relativePath], {
1989
2782
  applyDeletions: change.kind === 'remove',
1990
2783
  branchId: store.currentBranch().id,
1991
2784
  });
1992
2785
  const refused = pushed.skipped.find((s) => s.path === relativePath);
1993
- if (!refused) return null;
2786
+ if (!refused) return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
1994
2787
  this.ctx?.pushEvent({
1995
2788
  type: 'workspace:materialize-failed',
1996
2789
  mount: mount.config.name,
@@ -1998,7 +2791,7 @@ export class WorkspaceModule implements Module {
1998
2791
  op: change.kind === 'set' ? 'write' : 'delete',
1999
2792
  error: refused.reason,
2000
2793
  } as ProcessEvent);
2001
- return refused.reason;
2794
+ return { refused: refused.reason, modeKept: null, note: null };
2002
2795
  }
2003
2796
 
2004
2797
  // ==========================================================================
@@ -2011,24 +2804,29 @@ export class WorkspaceModule implements Module {
2011
2804
  * disk now holds (an edit sees a disk change the settled adoption took in).
2012
2805
  * The pass may not settle it (a branch that keeps changing, an unreadable
2013
2806
  * disk copy); the precondition that matters is enforced again,
2014
- * synchronously, where the change commits (commitToolChange). The caller
2015
- * holds the mount's turn.
2807
+ * synchronously, where the change commits (commitToolChange). The pass
2808
+ * names the path, as a file tool names the one it acts on, so a path the
2809
+ * mount passes over by design is observed there, as a sync naming it would
2810
+ * observe it (#276). The caller holds the mount's turn.
2016
2811
  */
2017
2812
  private async settleBeforeMutation(mount: MountState, relativePath: string): Promise<void> {
2018
2813
  if (this.agreementOrThrow().get(mount.config.name, relativePath)?.kind !== 'pending') return;
2019
- await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2814
+ await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2020
2815
  }
2021
2816
 
2022
2817
  /**
2023
2818
  * Before a read: a path the workspace doesn't hold is observed under the
2024
2819
  * three-way rule, which ingests a new disk file but never brings back a
2025
- * workspace deletion or a file absent from this branch. The caller holds
2026
- * the mount's turn.
2820
+ * workspace deletion or a file absent from this branch. The pass names the
2821
+ * path, as a file tool names the one it acts on: a path the mount passes
2822
+ * over by design is observed, as a sync naming it would observe it (#276),
2823
+ * while a path in another mount's directory is refused before this, and
2824
+ * passed over by any pass. The caller holds the mount's turn.
2027
2825
  */
2028
2826
  private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
2029
2827
  const store = this.getStore();
2030
2828
  if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
2031
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2829
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2032
2830
  return pass.reports.get(relativePath);
2033
2831
  }
2034
2832
 
@@ -2060,19 +2858,15 @@ export class WorkspaceModule implements Module {
2060
2858
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
2061
2859
  const offsetChars = input.offsetChars ?? 0;
2062
2860
  const limitChars = input.limitChars ?? 2000;
2063
- if (characterPaging) {
2064
- if (input.offset !== undefined || input.limit !== undefined) {
2065
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
2066
- }
2067
- if (!Number.isSafeInteger(offsetChars) || offsetChars < 0
2068
- || !Number.isSafeInteger(limitChars) || limitChars < 1
2069
- || input.offsetChars === null || input.limitChars === null) {
2070
- return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
2071
- }
2861
+ // Each number is a whole number of at least its schema's minimum by now
2862
+ // (inputProblem).
2863
+ if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
2864
+ return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
2072
2865
  }
2073
2866
  let content = generatedContent;
2074
2867
  if (content === undefined) {
2075
- const { mount, relativePath } = this.parsePath(input.path);
2868
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2869
+ input = { ...input, path };
2076
2870
  const store = this.getStore();
2077
2871
  const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
2078
2872
  const report = await this.ensureSynced(mount, relativePath);
@@ -2107,7 +2901,7 @@ export class WorkspaceModule implements Module {
2107
2901
  },
2108
2902
  };
2109
2903
  }
2110
- const lines = content.split('\n');
2904
+ const lines = linesOf(content);
2111
2905
 
2112
2906
  // Apply offset/limit. An unlimited read of a large file would inject the
2113
2907
  // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
@@ -2115,6 +2909,22 @@ export class WorkspaceModule implements Module {
2115
2909
  // default cap; the result reports total/from/to so the agent can page.
2116
2910
  const DEFAULT_READ_LINE_LIMIT = 2000;
2117
2911
  const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
2912
+ if (startLine >= lines.length) {
2913
+ // Nothing to show: an empty file, or an offset past the last line. No
2914
+ // range is claimed, and the line count says where the file ends.
2915
+ return {
2916
+ success: true,
2917
+ data: {
2918
+ path: input.path,
2919
+ totalLines: lines.length,
2920
+ // Reading an empty file from its start is reading it whole.
2921
+ ...(startLine > 0
2922
+ ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
2923
+ : {}),
2924
+ content: '',
2925
+ },
2926
+ };
2927
+ }
2118
2928
  const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
2119
2929
  const endLine = startLine + effectiveLimit;
2120
2930
  const slice = lines.slice(startLine, endLine);
@@ -2144,7 +2954,9 @@ export class WorkspaceModule implements Module {
2144
2954
  let mount: MountState;
2145
2955
  let relativePath: string;
2146
2956
  try {
2147
- ({ mount, relativePath } = this.parsePath(input.path));
2957
+ let path: string;
2958
+ ({ mount, relativePath, path } = await this.parseFilePath(input.path));
2959
+ input = { ...input, path };
2148
2960
  } catch (err) {
2149
2961
  return {
2150
2962
  success: false,
@@ -2213,7 +3025,11 @@ export class WorkspaceModule implements Module {
2213
3025
  }
2214
3026
 
2215
3027
  private async handleWrite(input: WriteInput): Promise<ToolResult> {
2216
- const { mount, relativePath } = this.parsePath(input.path);
3028
+ if (LONE_SURROGATE.test(input.content)) {
3029
+ return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
3030
+ }
3031
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
3032
+ input = { ...input, path };
2217
3033
  if (mount.config.mode === 'read-only') {
2218
3034
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2219
3035
  }
@@ -2225,126 +3041,217 @@ export class WorkspaceModule implements Module {
2225
3041
  }
2226
3042
 
2227
3043
  const buffer = Buffer.from(input.content, 'utf-8');
2228
- const materializeError = await this.withMount(mount, async () => {
2229
- await this.settleBeforeMutation(mount, relativePath);
2230
- const blobHash = store.storeBlob(buffer, 'text/plain');
2231
- return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength });
2232
- });
2233
- if (materializeError) {
2234
- return {
2235
- success: false,
2236
- error: `Wrote to Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
2237
- isError: true,
2238
- };
3044
+ const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
3045
+ let committed = false;
3046
+ let refused: string | null;
3047
+ let modeKept: string | null;
3048
+ try {
3049
+ ({ refused, modeKept } = await this.withMount(mount, async () => {
3050
+ await this.settleBeforeMutation(mount, relativePath);
3051
+ const blobHash = store.storeBlob(buffer, 'text/plain');
3052
+ // An overwrite keeps the file's mode, as an edit does.
3053
+ const existing = store.treeGet(mount.treeStateId, relativePath);
3054
+ return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength, ...(existing ? { mode: existing.mode } : {}) },
3055
+ () => { committed = true; });
3056
+ }));
3057
+ } catch (error) {
3058
+ if (!committed) throw error;
3059
+ return storedButUnfinished('write', input.path, { threw: error instanceof Error ? error.message : String(error) });
2239
3060
  }
3061
+ if (refused !== null) return storedButUnfinished('write', input.path, { refused });
2240
3062
 
2241
- return {
2242
- success: true,
2243
- data: {
2244
- path: input.path,
2245
- size: Buffer.byteLength(input.content),
2246
- hash: hashContent(input.content),
2247
- },
2248
- };
3063
+ return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
2249
3064
  }
2250
3065
 
2251
3066
  private async handleEdit(input: EditInput): Promise<ToolResult> {
2252
- const { mount, relativePath } = this.parsePath(input.path);
3067
+ // Refused before anything is read: an empty oldString matches between
3068
+ // every character, and UTF-8 can't store a lone surrogate.
3069
+ if (input.oldString === '') {
3070
+ return { success: false, isError: true, error: 'The edit did nothing: "oldString" is empty, and an empty string matches between every character. Give the text to replace.' };
3071
+ }
3072
+ for (const field of ['oldString', 'newString'] as const) {
3073
+ if (LONE_SURROGATE.test(input[field])) {
3074
+ return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
3075
+ }
3076
+ }
3077
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
3078
+ input = { ...input, path };
2253
3079
  if (mount.config.mode === 'read-only') {
2254
3080
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2255
3081
  }
2256
3082
 
2257
3083
  const store = this.getStore();
2258
- return this.withMount(mount, async (): Promise<ToolResult> => {
2259
- const report = await this.ensureSynced(mount, relativePath);
2260
- await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry
3084
+ let committed = false;
3085
+ let appliedNote: string | null = null;
3086
+ try {
3087
+ return await this.withMount(mount, async (): Promise<ToolResult> => {
3088
+ // The edit applies to the path as disk holds it now: a change on disk
3089
+ // to a file the workspace holds is taken in first, by the rule a
3090
+ // listing uses. The pass settles a pending intent on the path too, as
3091
+ // settleBeforeMutation's would, and the commit enforces the rest
3092
+ // (commitToolChange). A write passes over its path only to settle a
3093
+ // pending intent, since it replaces the whole file. As every file
3094
+ // tool's pass does, it names the path it acts on, so a file the
3095
+ // mount passes over by design is observed (see PassOptions.named).
3096
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3097
+ const report = pass.reports.get(relativePath);
3098
+ // A mode taken in alone isn't a change to the text: it brings nothing
3099
+ // new to read, as it makes no event (emitFsEvents), and the edit then
3100
+ // writes at the mode disk has, so it leaves nothing to say.
3101
+ const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
3102
+ // Which copy the edit used, when it isn't the one the workspace last
3103
+ // agreed on with disk: disk's newer text, or the workspace's copy
3104
+ // because disk's couldn't be read or disk has none. A file the
3105
+ // workspace didn't hold has no older copy, so taking it in needs no
3106
+ // word.
3107
+ const whichCopy = (verb: string): string | null => took('modified')
3108
+ ? `Disk's copy had changed since the workspace last agreed with it, so it was taken in first, and the edit ${verb} that text.`
3109
+ : report?.state === 'unverified'
3110
+ ? `The path couldn't be checked against disk (${report.note ?? 'not observed'}), so the edit ${verb} the workspace's copy.`
3111
+ : report?.state === 'disk-missing-provenance-unknown'
3112
+ ? `Disk has no copy of this file, and nothing records whether it was deleted there, so the edit ${verb} the workspace's copy.`
3113
+ : null;
3114
+ // A conflict the pass found is named when the edit is refused for the
3115
+ // text it found, on either kind of mount, and on a success where the
3116
+ // mount doesn't push, since such a mount keeps the conflict until
3117
+ // materialize or sync settles it. A pushing mount's success met none
3118
+ // (below). A stored edit that couldn't finish leaves it to the push's
3119
+ // reason, which names the conflict, or to ls, which its answer points to.
3120
+ const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
3121
+ // Where disk has no copy now, whatever the conflict's kind, a sync of
3122
+ // the path takes disk's deletion and a forced materialize writes the
3123
+ // workspace's copy back. Where it has one, a sync keeps it and gives
3124
+ // up the workspace's, whether or not the workspace can hold disk's
3125
+ // copy: one it can't hold stays on disk alone.
3126
+ const conflictNote = (where: string): string | null => conflict === null ? null
3127
+ : `The path is in conflict with disk (${conflict}): the edit ${where} the workspace's copy. ` + (report?.diskNow === 'absent'
3128
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy, or materialize it with force to write the workspace's copy back to disk."
3129
+ : "Sync the path to keep disk's copy and give up the workspace's, or materialize it with force to write the workspace's over disk's.");
3130
+ const suffix = [whichCopy('looked in'), conflictNote('looked in')].filter((n) => n !== null).map((n) => ` ${n}`).join('');
3131
+ appliedNote = whichCopy('applied to');
2261
3132
 
2262
- const entry = store.treeGet(mount.treeStateId, relativePath);
2263
- if (!entry) {
2264
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2265
- }
3133
+ const entry = store.treeGet(mount.treeStateId, relativePath);
3134
+ if (!entry) {
3135
+ // A pass also drops a copy disk made binary or too large, which
3136
+ // the disk-only answer describes; any other drop is disk's deletion.
3137
+ const error = took('deleted') && report?.state !== 'disk-only'
3138
+ ? `File not found: ${input.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3139
+ : this.notInWorkspace(input.path, report);
3140
+ return { success: false, error, isError: true };
3141
+ }
2266
3142
 
2267
- const blob = store.getBlob(entry.blobHash);
2268
- if (!blob) {
2269
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2270
- }
3143
+ const blob = store.getBlob(entry.blobHash);
3144
+ if (!blob) {
3145
+ return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
3146
+ }
3147
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
3148
+ // the text back would change bytes the edit never named.
3149
+ if (!isUtf8(blob)) {
3150
+ return {
3151
+ success: false,
3152
+ isError: true,
3153
+ error: `The edit did nothing: ${input.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
3154
+ + `Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.${suffix}`,
3155
+ };
3156
+ }
2271
3157
 
2272
- let content = blob.toString('utf-8');
3158
+ let content = blob.toString('utf-8');
2273
3159
 
2274
- // Validate uniqueness
2275
- if (!input.replaceAll) {
2276
- const count = content.split(input.oldString).length - 1;
3160
+ // An edit that would replace nothing is refused, replaceAll or not;
3161
+ // without replaceAll, the text must occur once, and the refusal says
3162
+ // how often it does. With replaceAll, whether it occurs is enough.
3163
+ const count = input.replaceAll
3164
+ ? Number(content.includes(input.oldString))
3165
+ : content.split(input.oldString).length - 1;
2277
3166
  if (count === 0) {
2278
- return { success: false, error: `String not found in ${input.path}`, isError: true };
3167
+ return { success: false, error: `String not found in ${input.path}.${suffix}`, isError: true };
2279
3168
  }
2280
- if (count > 1) {
3169
+ if (count > 1 && !input.replaceAll) {
2281
3170
  return {
2282
3171
  success: false,
2283
- error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
3172
+ error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.${suffix}`,
2284
3173
  isError: true,
2285
3174
  };
2286
3175
  }
2287
- }
2288
-
2289
- content = input.replaceAll
2290
- ? content.replaceAll(input.oldString, input.newString)
2291
- : content.replace(input.oldString, input.newString);
2292
3176
 
2293
- const newBuffer = Buffer.from(content, 'utf-8');
2294
- const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2295
- const materializeError = await this.commitToolChange(mount, relativePath, {
2296
- kind: 'set',
2297
- blobHash: newBlobHash,
2298
- size: newBuffer.byteLength,
2299
- mode: entry.mode,
2300
- });
2301
- if (materializeError) {
3177
+ // A replacer function, so that the new text goes in as given: in a
3178
+ // replacement string, $&, $$, $' and $` are patterns.
3179
+ content = input.replaceAll
3180
+ ? content.replaceAll(input.oldString, () => input.newString)
3181
+ : content.replace(input.oldString, () => input.newString);
3182
+
3183
+ const newBuffer = Buffer.from(content, 'utf-8');
3184
+ const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
3185
+ const { refused, modeKept } = await this.commitToolChange(mount, relativePath, {
3186
+ kind: 'set',
3187
+ blobHash: newBlobHash,
3188
+ size: newBuffer.byteLength,
3189
+ mode: entry.mode,
3190
+ }, () => { committed = true; });
3191
+ if (refused !== null) return storedButUnfinished('edit', input.path, { refused }, appliedNote);
3192
+
3193
+ // The edit makes the path the workspace's own change (commitToolChange),
3194
+ // so a file disk lacked goes back with this edit's push, or with the
3195
+ // next materialize where the mount doesn't push. A push that didn't
3196
+ // complete answers above, with its reason. One that completed met no
3197
+ // conflict, since this push, unforced, stops at one: the edit made the
3198
+ // two copies agree, which settles it, or there was none. So only a
3199
+ // mount that doesn't push still holds the conflict the pass found.
3200
+ const pushes = this.pushesOnCommit(mount);
3201
+ const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
3202
+ ? `${pushes ? 'Its push put the file back on disk' : 'A materialize of the path puts it back on disk'}; if it was deleted there on purpose, delete it here too.`
3203
+ : null;
3204
+ const notes = [appliedNote, backOnDisk, pushes ? null : conflictNote('is in')].filter((n): n is string => n !== null);
2302
3205
  return {
2303
- success: false,
2304
- error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
2305
- isError: true,
3206
+ success: true,
3207
+ data: {
3208
+ path: input.path,
3209
+ size: newBuffer.byteLength,
3210
+ ...(notes.length > 0 ? { note: notes.join(' ') } : {}),
3211
+ ...(modeKept ? { modeKept } : {}),
3212
+ },
2306
3213
  };
2307
- }
2308
-
2309
- return {
2310
- success: true,
2311
- data: {
2312
- path: input.path,
2313
- size: newBuffer.byteLength,
2314
- },
2315
- };
2316
- });
3214
+ });
3215
+ } catch (error) {
3216
+ if (!committed) throw error;
3217
+ return storedButUnfinished('edit', input.path, { threw: error instanceof Error ? error.message : String(error) }, appliedNote);
3218
+ }
2317
3219
  }
2318
3220
 
2319
3221
  private async handleDelete(input: DeleteInput): Promise<ToolResult> {
2320
- const { mount, relativePath } = this.parsePath(input.path);
3222
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
3223
+ input = { ...input, path };
2321
3224
  if (mount.config.mode === 'read-only') {
2322
3225
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2323
3226
  }
2324
3227
 
2325
3228
  const store = this.getStore();
2326
- return this.withMount(mount, async (): Promise<ToolResult> => {
2327
- const report = await this.ensureSynced(mount, relativePath);
2328
- await this.settleBeforeMutation(mount, relativePath);
2329
- const entry = store.treeGet(mount.treeStateId, relativePath);
2330
- if (!entry) {
2331
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2332
- }
3229
+ let committed = false;
3230
+ try {
3231
+ return await this.withMount(mount, async (): Promise<ToolResult> => {
3232
+ const report = await this.ensureSynced(mount, relativePath);
3233
+ await this.settleBeforeMutation(mount, relativePath);
3234
+ const entry = store.treeGet(mount.treeStateId, relativePath);
3235
+ if (!entry) {
3236
+ return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
3237
+ }
3238
+
3239
+ const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
3240
+ () => { committed = true; });
3241
+ if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
2333
3242
 
2334
- const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash });
2335
- if (materializeError) {
3243
+ // Its whole receipt is what it changed: kept in the stub if the guard
3244
+ // withholds the result (agent-framework #277).
2336
3245
  return {
2337
- success: false,
2338
- error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` +
2339
- 'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.',
2340
- isError: true,
3246
+ success: true,
3247
+ data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
3248
+ keepWhenWithheld: ['path', 'deleted', 'note'],
2341
3249
  };
2342
- }
2343
-
2344
- // Its whole receipt is what it changed: kept in the stub if the guard
2345
- // withholds the result (agent-framework #277).
2346
- return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
2347
- });
3250
+ });
3251
+ } catch (error) {
3252
+ if (!committed) throw error;
3253
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
3254
+ }
2348
3255
  }
2349
3256
 
2350
3257
  private async handleLs(input: LsInput): Promise<ToolResult> {
@@ -2360,7 +3267,8 @@ export class WorkspaceModule implements Module {
2360
3267
  return { success: true, data: { mounts } };
2361
3268
  }
2362
3269
 
2363
- const { mount, relativePath } = this.parsePath(input.path);
3270
+ const { mount, relativePath, path } = await this.parsePath(input.path);
3271
+ input = { ...input, path };
2364
3272
  const recursive = input.recursive === true;
2365
3273
 
2366
3274
  // Every listing checks the paths it shows against disk first, under the
@@ -2413,12 +3321,17 @@ export class WorkspaceModule implements Module {
2413
3321
 
2414
3322
  // Search across mounts
2415
3323
  const mountsToSearch = input.path
2416
- ? [this.parsePath(input.path)]
3324
+ ? [await this.parsePath(input.path)]
2417
3325
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2418
3326
 
3327
+ // Every match lies beneath the pattern's literal directory, so the walk
3328
+ // starts there: past the walk's cap, a walk of the whole scope can stop
3329
+ // before it gets there.
3330
+ const patternDir = literalDirOf(input.pattern);
2419
3331
  for (const { mount, relativePath } of mountsToSearch) {
3332
+ const start = await this.walkStart(mount, relativePath, patternDir);
2420
3333
  const pass = await this.withMount(mount, () =>
2421
- this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
3334
+ this.passUnlocked(mount, { kind: 'dir', dir: start, recursive: true }));
2422
3335
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
2423
3336
  const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
2424
3337
  if (regex.test(testPath)) {
@@ -2447,15 +3360,20 @@ export class WorkspaceModule implements Module {
2447
3360
  try {
2448
3361
  regex = new RegExp(input.pattern);
2449
3362
  } catch (e) {
2450
- return { success: false, error: `Invalid regex: ${input.pattern}`, isError: true };
3363
+ // The parser's reason says where the pattern goes wrong.
3364
+ return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
2451
3365
  }
2452
3366
 
2453
3367
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
3368
+ // The filter matches mount-relative paths, so every file it lets through
3369
+ // lies beneath its literal directory, and the walk starts at the deeper of
3370
+ // that and `path`.
3371
+ const filterDir = input.glob ? literalDirOf(input.glob) : '';
2454
3372
  const contextBefore = input.contextBefore ?? 0;
2455
3373
  const contextAfter = input.contextAfter ?? 0;
2456
3374
 
2457
3375
  const mountsToSearch = input.path
2458
- ? [this.parsePath(input.path)]
3376
+ ? [await this.parsePath(input.path)]
2459
3377
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2460
3378
 
2461
3379
  type GrepMatch = { line: number; text: string; context?: string[] };
@@ -2464,7 +3382,7 @@ export class WorkspaceModule implements Module {
2464
3382
  const incomplete: Array<{ path: string; reason: string }> = [];
2465
3383
 
2466
3384
  const search = (content: string): GrepMatch[] => {
2467
- const lines = content.split('\n');
3385
+ const lines = linesOf(content);
2468
3386
  const found: GrepMatch[] = [];
2469
3387
  for (let i = 0; i < lines.length; i++) {
2470
3388
  if (regex.test(lines[i])) {
@@ -2481,6 +3399,18 @@ export class WorkspaceModule implements Module {
2481
3399
  };
2482
3400
 
2483
3401
  for (const { mount, relativePath } of mountsToSearch) {
3402
+ // A path named in another mount's directory, as holderOf resolves it,
3403
+ // is that mount's: grep searches nothing of this mount's there, and
3404
+ // says whose it is.
3405
+ if (this.holderOf(mount.config.name, relativePath)) {
3406
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list') });
3407
+ continue;
3408
+ }
3409
+ // A `path` and a filter whose directories don't overlap leave no file
3410
+ // the filter can let through, so nothing is searched.
3411
+ const deeper = deeperOf(relativePath, filterDir);
3412
+ if (deeper === null) continue;
3413
+ const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
2484
3414
  await this.withMount(mount, async () => {
2485
3415
  // A `path` naming a single file greps just that file; otherwise it is a
2486
3416
  // directory prefix. (A file path once produced the prefix "notes.md/"
@@ -2494,12 +3424,29 @@ export class WorkspaceModule implements Module {
2494
3424
  // on: one selected in between would label its content with the other
2495
3425
  // branch's states. So a pass whose branch is no longer selected runs
2496
3426
  // again, and a branch that keeps changing leaves the scope unsearched.
3427
+ //
3428
+ // A copy held at a path that now leads into a nested mount's directory
3429
+ // through a link is that mount's file too: read refuses it
3430
+ // (parseFilePath), so grep searches none of it and says why. The pass
3431
+ // can't check such a path's disk, so only an unverified one can be
3432
+ // one. Asking awaits disk, so it's done before the branch is
3433
+ // compared, and a branch selected meanwhile runs the pass again.
2497
3434
  let pass: PassResult | null = null;
3435
+ let refused = new Map<string, string>();
2498
3436
  for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
2499
3437
  const next = await this.passUnlocked(mount, fileScope
2500
3438
  ? { kind: 'paths', paths: [relativePath] }
2501
- : { kind: 'dir', dir: relativePath, recursive: true });
2502
- if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next;
3439
+ : { kind: 'dir', dir: start, recursive: true });
3440
+ const asked = new Map<string, string>();
3441
+ for (const report of next.reports.values()) {
3442
+ if (report.state !== 'unverified') continue;
3443
+ const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
3444
+ if (refusal) asked.set(report.path, refusal.message);
3445
+ }
3446
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
3447
+ pass = next;
3448
+ refused = asked;
3449
+ }
2503
3450
  }
2504
3451
  if (pass === null) {
2505
3452
  incomplete.push({
@@ -2525,6 +3472,15 @@ export class WorkspaceModule implements Module {
2525
3472
  });
2526
3473
  continue;
2527
3474
  }
3475
+ // A path in another mount's directory is that mount's: grep gives
3476
+ // none of this mount's copy of it, as read refuses it. Beneath a
3477
+ // scope it's passed over by design, like the directory.
3478
+ if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
3479
+ const refusal = refused.get(report.path);
3480
+ if (refusal !== undefined) {
3481
+ skipped.push({ file, reason: refusal });
3482
+ continue;
3483
+ }
2528
3484
  // The workspace's version, labelled with the path's state.
2529
3485
  const entry = store.treeGet(mount.treeStateId, report.path);
2530
3486
  const blob = entry ? store.getBlob(entry.blobHash) : null;
@@ -2584,7 +3540,8 @@ export class WorkspaceModule implements Module {
2584
3540
  if (input.mount !== undefined && name !== input.mount) continue;
2585
3541
  const entries = store.treeList(mount.treeStateId);
2586
3542
  const currentSeq = store.currentSequence();
2587
- const intents = this.intents.get(name)!.list();
3543
+ const own = this.ownPaths(mount);
3544
+ const intents = this.intents.get(name)!.list().filter(([p]) => own(p));
2588
3545
  const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
2589
3546
  // A workspace deletion is pending until a push confirms it on disk:
2590
3547
  // left there without applyDeletions, or unlinked but unconfirmed.
@@ -2630,12 +3587,13 @@ export class WorkspaceModule implements Module {
2630
3587
  */
2631
3588
  private materializeSelection(mount: MountState, explicitPath: string): { paths: string[]; inPlace: boolean } {
2632
3589
  const store = this.getStore();
2633
- const intents = new Map(this.intents.get(mount.config.name)!.list(explicitPath));
3590
+ const own = this.ownPaths(mount);
3591
+ const intents = new Map(this.intents.get(mount.config.name)!.list(explicitPath).filter(([p]) => own(p)));
2634
3592
  const selected = new Set<string>();
2635
3593
 
2636
3594
  if (explicitPath !== '') {
2637
- if (store.treeGet(mount.treeStateId, explicitPath)) selected.add(explicitPath);
2638
- for (const e of store.treeList(mount.treeStateId, explicitPath + '/')) selected.add(e.path);
3595
+ if (store.treeGet(mount.treeStateId, explicitPath) && own(explicitPath)) selected.add(explicitPath);
3596
+ for (const e of store.treeList(mount.treeStateId, explicitPath + '/')) if (own(e.path)) selected.add(e.path);
2639
3597
  for (const [p, bi] of intents) if (bi.tombstone || bi.conflict) selected.add(p);
2640
3598
  return { paths: [...selected], inPlace: false };
2641
3599
  }
@@ -2646,10 +3604,25 @@ export class WorkspaceModule implements Module {
2646
3604
  return { paths: [...selected], inPlace: agreed > 0 };
2647
3605
  }
2648
3606
 
3607
+ /**
3608
+ * Whether a path of the mount is its own to push and to count as owed:
3609
+ * not in another mount's directory nested in it. Copies the mount's tree
3610
+ * holds there (stored before the nesting) stay in its history and its
3611
+ * listings (and in status's fileCount, which counts the tree), passed over
3612
+ * by its syncs, and left out of what it pushes and of status's pending
3613
+ * changes, conflicts and workspace deletions.
3614
+ */
3615
+ private ownPaths(mount: MountState): (path: string) => boolean {
3616
+ const others = this.othersOf(mount.config.name);
3617
+ return (path) => !inOtherMount(path, others);
3618
+ }
3619
+
2649
3620
  /**
2650
3621
  * The workspace entries by what the evidence says of disk. `owed`: P
2651
- * differs from the entry (a draft, or a push still pending), or there is no
2652
- * P and the entry came from the workspace (a draft disk never had).
3622
+ * differs from the entry (a draft, or a push still pending), the entry
3623
+ * records a mode other than the one P agreed (a mode owed to disk), or
3624
+ * there is no P and the entry came from the workspace (a draft disk never
3625
+ * had).
2653
3626
  * `unchecked`: no P and no workspace origin, an entry from before the
2654
3627
  * evidence; a push agrees it, writes it where disk lacks it, or refuses it
2655
3628
  * as a conflict. `agreed`: how many have P = S, so disk holds them. Status
@@ -2664,10 +3637,15 @@ export class WorkspaceModule implements Module {
2664
3637
  const owed: string[] = [];
2665
3638
  const unchecked: string[] = [];
2666
3639
  let agreed = 0;
3640
+ const own = this.ownPaths(mount);
2667
3641
  for (const e of store.treeList(mount.treeStateId)) {
3642
+ if (!own(e.path)) continue;
2668
3643
  const p = agreement.get(name, e.path);
2669
3644
  if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
2670
3645
  else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
3646
+ // A mode the store records, other than the one disk last agreed with,
3647
+ // where modes are observed: elsewhere no push applies one.
3648
+ else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
2671
3649
  else agreed++;
2672
3650
  }
2673
3651
  return { owed, unchecked, agreed };
@@ -2679,9 +3657,10 @@ export class WorkspaceModule implements Module {
2679
3657
  const allWritten: Array<{ mount: string; path: string }> = [];
2680
3658
  const allDeleted: Array<{ mount: string; path: string }> = [];
2681
3659
  const pendingDeletions: Array<{ mount: string; path: string }> = [];
3660
+ const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
2682
3661
 
2683
3662
  let explicit: { mount: MountState; relativePath: string } | null = null;
2684
- if (input.path) explicit = this.parsePath(input.path);
3663
+ if (input.path) explicit = await this.parsePath(input.path);
2685
3664
 
2686
3665
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
2687
3666
  if (explicit) {
@@ -2717,6 +3696,13 @@ export class WorkspaceModule implements Module {
2717
3696
  blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' });
2718
3697
  continue;
2719
3698
  }
3699
+ // A path named in another mount's directory, as holderOf resolves it,
3700
+ // is that mount's: nothing of this mount's is pushed there, and the
3701
+ // caller is told whose it is.
3702
+ if (explicit && this.holderOf(name, explicit.relativePath)) {
3703
+ blocked.push({ mount: name, reason: `${explicit.relativePath}: ${this.otherMountNote(name, explicit.relativePath, 'push')}` });
3704
+ continue;
3705
+ }
2720
3706
  const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean }> => {
2721
3707
  const branchId = store.currentBranch().id;
2722
3708
  const reason = this.mountMaterializeBlockReason(store, mount);
@@ -2746,8 +3732,12 @@ export class WorkspaceModule implements Module {
2746
3732
  // Refusals ride the same skipped list as branch blocks: divergence must
2747
3733
  // be visible, not resolved silently either way.
2748
3734
  for (const s of pushed.skipped) {
2749
- blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` });
3735
+ const reason = s.reason === NOT_PUSHED_IN_OTHER_MOUNT ? this.otherMountNote(name, s.path, 'push') : s.reason;
3736
+ blocked.push({ mount: name, reason: `${s.path}: ${reason}` });
2750
3737
  }
3738
+ // Written paths whose mode stayed as disk had it: no refusal, so not
3739
+ // among what was skipped.
3740
+ for (const k of pushed.modesKept) modesKept.push({ mount: name, ...k });
2751
3741
 
2752
3742
  // What disk still owes is kept by its evidence, so the watermark and
2753
3743
  // the pin only feed the branch guard. Both are the push's own: what it
@@ -2790,10 +3780,11 @@ export class WorkspaceModule implements Module {
2790
3780
  }
2791
3781
  : {}),
2792
3782
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
3783
+ ...(modesKept.length > 0 ? { modesKept } : {}),
2793
3784
  },
2794
3785
  // What disk lost, then what it was written: kept in the stub if the
2795
- // guard withholds the result (agent-framework #277). What was left or
2796
- // skipped, and why, is in the full result.
3786
+ // guard withholds the result (agent-framework #277). What was left,
3787
+ // skipped or written at disk's mode, and why, is in the full result.
2797
3788
  keepWhenWithheld: ['deleted', 'materialized'],
2798
3789
  };
2799
3790
  }
@@ -2855,7 +3846,7 @@ export class WorkspaceModule implements Module {
2855
3846
  // A path names its own mount; otherwise the given mount, or every mount.
2856
3847
  let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
2857
3848
  if (input.path) {
2858
- const { mount, relativePath } = this.parsePath(input.path);
3849
+ const { mount, relativePath } = await this.parsePath(input.path);
2859
3850
  targets = [{ name: mount.config.name, mount, relativePath }];
2860
3851
  } else if (input.mount) {
2861
3852
  const m = this.mounts.get(input.mount);
@@ -2936,13 +3927,20 @@ export class WorkspaceModule implements Module {
2936
3927
  for (const region of pass.incomplete) allIncomplete.push({ mount: name, ...region });
2937
3928
  for (const [reason, count] of pass.passedOver) allPassedOver.push({ mount: name, reason, count });
2938
3929
  // A path named explicitly is synced even where the mount's ignore list
2939
- // covers it, and the result says so: a sync without a path passes
2940
- // over it, so nothing else keeps it current (agent-framework #276).
2941
- if (coveredByIgnore(relativePath, mount.config.ignore ?? [])) {
3930
+ // or an editor's file name covers it, and the result says so: a sync
3931
+ // without a path passes over it, so nothing else keeps it current
3932
+ // (agent-framework #276). A path in another mount's directory isn't
3933
+ // synced at all, and the note says so.
3934
+ const passedOver = passedOverAt(relativePath, mount.config.ignore ?? [], this.othersOf(name));
3935
+ if (passedOver !== undefined) {
2942
3936
  allIgnored.push({
2943
3937
  mount: name,
2944
3938
  path: relativePath,
2945
- note: 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it',
3939
+ note: passedOver === OTHER_MOUNT
3940
+ ? this.otherMountNote(name, relativePath, 'sync')
3941
+ : passedOver === IGNORED
3942
+ ? 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it'
3943
+ : 'it has an editor\'s swap or backup file name, or lies under one, so a sync without a path won\'t maintain it',
2946
3944
  });
2947
3945
  }
2948
3946
  }
@@ -3083,10 +4081,10 @@ export class WorkspaceModule implements Module {
3083
4081
  const scan: AgentActionScan = { late: false, settled: Promise.resolve() };
3084
4082
  const pass = this.withMount(mount, () => {
3085
4083
  if (mount.agentActionScanWaiting === scan) mount.agentActionScanWaiting = undefined;
3086
- // From the mount's start, as a listing walks, keeping no place: on a
3087
- // mount over its walkCap, every batch's scan takes the first walkCap
3088
- // entries in the walk's order, and the place that syncs and catch-up
3089
- // scans resume from stays theirs.
4084
+ // From the mount's root, keeping no place, as a listing keeps none:
4085
+ // on a mount over its walkCap, every batch's scan takes the first
4086
+ // walkCap entries in the walk's order, and the place that syncs and
4087
+ // catch-up scans resume from stays theirs.
3090
4088
  return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
3091
4089
  });
3092
4090
  scan.settled = pass.then(
@@ -3131,13 +4129,15 @@ export class WorkspaceModule implements Module {
3131
4129
  }
3132
4130
 
3133
4131
  /**
3134
- * Push one ProcessEvent per op for a pass's tree changes and new conflicts.
3135
- * A conflict rides the event for the disk change behind it (modified, or
3136
- * deleted for a disk deletion of a file with newer workspace changes).
4132
+ * Push one ProcessEvent per op for a pass's tree changes and new conflicts,
4133
+ * apart from an op that changes only a file's mode, which brings nothing
4134
+ * new to read and makes none. A conflict rides the event for the disk
4135
+ * change behind it (modified, or deleted for a disk deletion of a file with
4136
+ * newer workspace changes).
3137
4137
  */
3138
4138
  private emitFsEvents(
3139
4139
  mountName: string,
3140
- ops: Array<{ path: string; op: TreeOp }>,
4140
+ ops: Array<{ path: string; op: TreeOp; modeOnly?: true }>,
3141
4141
  newConflicts: Array<{ path: string; op: TreeOp }> = [],
3142
4142
  ): void {
3143
4143
  if (!this.ctx || (ops.length === 0 && newConflicts.length === 0)) return;
@@ -3150,7 +4150,10 @@ export class WorkspaceModule implements Module {
3150
4150
  if (conflict) group.conflicts.push(full);
3151
4151
  byOp.set(op, group);
3152
4152
  };
3153
- for (const { path, op } of ops) add(op, path, false);
4153
+ // A path whose mode alone changed, on bytes already held, makes no
4154
+ // event: it brings nothing new to read, and a `chmod -R` over a tree
4155
+ // would otherwise reach every policy that matches its paths.
4156
+ for (const { path, op, modeOnly } of ops) if (!modeOnly) add(op, path, false);
3154
4157
  for (const { path, op } of newConflicts) add(op, path, true);
3155
4158
 
3156
4159
  for (const [op, { paths, conflicts }] of byOp) {
@@ -3181,8 +4184,103 @@ function stateView(report: PathReport): Record<string, unknown> {
3181
4184
  // Utilities
3182
4185
  // ==========================================================================
3183
4186
 
4187
+ /** A lone UTF-16 surrogate, which UTF-8 can't store: in `u` mode, a pair is one code point. */
4188
+ const LONE_SURROGATE = /\p{Cs}/u;
4189
+
4190
+ /**
4191
+ * The answer to a write, edit or delete that the workspace stored but
4192
+ * couldn't finish: its push to disk didn't complete, or what follows the tree
4193
+ * entry threw. It stays an error, since part of the call failed, but it leads
4194
+ * with what the caller must know before acting on it: the change is stored,
4195
+ * and sending it again could apply it twice. The push's reason says what disk
4196
+ * holds and the way out, as materialize takes it. The text carries all of it,
4197
+ * since an error's data reaches neither the model nor a code_execution
4198
+ * script. An edit's note on which copy it applied to comes last.
4199
+ */
4200
+ function storedButUnfinished(
4201
+ tool: 'write' | 'edit' | 'delete',
4202
+ path: string,
4203
+ outcome: { refused: string } | { threw: string },
4204
+ note: string | null = null,
4205
+ ): ToolResult {
4206
+ const change = tool === 'delete' ? 'deletion' : tool;
4207
+ const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
4208
+ + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
4209
+ const rest = 'refused' in outcome
4210
+ ? ` Its push to disk didn't complete: ${outcome.refused}.`
4211
+ : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
4212
+ return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
4213
+ }
4214
+
4215
+ /**
4216
+ * A text's lines, as read and grep number them: a final line end ends the
4217
+ * last line rather than starting an empty one, so "a\nb\n" is two lines,
4218
+ * and an empty text has none.
4219
+ */
4220
+ function linesOf(content: string): string[] {
4221
+ const lines = content.split('\n');
4222
+ if (lines.at(-1) === '') lines.pop();
4223
+ return lines;
4224
+ }
4225
+
4226
+ /**
4227
+ * The least each tool's whole-number fields may be, beside their schemas,
4228
+ * whose type (membrane's ToolParameter) has no `minimum`.
4229
+ */
4230
+ const INPUT_MINIMUMS: Record<string, Record<string, number>> = {
4231
+ read: { offset: 1, limit: 1, offsetChars: 0, limitChars: 1 },
4232
+ grep: { contextBefore: 0, contextAfter: 0 },
4233
+ };
4234
+
3184
4235
  /**
3185
- * Convert a glob pattern to a RegExp.
4236
+ * Why a tool call's input doesn't fit its tool's schema, or null when it does:
4237
+ * an object, each required field present, and each field its schema calls a
4238
+ * string, a boolean or an integer of that type, an integer at least its
4239
+ * minimum. The workspace's schemas use no other types. Without it, an edit
4240
+ * missing `newString` wrote the word "undefined", and an offset of 1.5
4241
+ * numbered lines 1.5, 2.5 and on. A field the schema doesn't describe is left
4242
+ * to its handler.
4243
+ */
4244
+ function inputProblem(
4245
+ schema: { properties?: Record<string, unknown>; required?: string[] },
4246
+ input: unknown,
4247
+ minimums: Record<string, number> = {},
4248
+ ): string | null {
4249
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
4250
+ return 'its input must be an object of the fields its schema lists';
4251
+ }
4252
+ const fields = input as Record<string, unknown>;
4253
+ for (const name of schema.required ?? []) {
4254
+ if (fields[name] === undefined) return `"${name}" is required`;
4255
+ }
4256
+ for (const [name, value] of Object.entries(fields)) {
4257
+ const type = (schema.properties?.[name] as { type?: string } | undefined)?.type;
4258
+ if (value === undefined || type === undefined) continue;
4259
+ const minimum = minimums[name];
4260
+ const fits = type === 'string' ? typeof value === 'string'
4261
+ : type === 'boolean' ? typeof value === 'boolean'
4262
+ : type === 'integer'
4263
+ ? Number.isSafeInteger(value) && (minimum === undefined || (value as number) >= minimum)
4264
+ : true;
4265
+ if (!fits) {
4266
+ const wanted = type === 'string' ? 'a string'
4267
+ : type === 'boolean' ? 'true or false'
4268
+ : minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
4269
+ const got = value === null ? 'null'
4270
+ : typeof value === 'number' ? String(value)
4271
+ : Array.isArray(value) ? 'an array'
4272
+ : typeof value === 'object' ? 'an object'
4273
+ : `a ${typeof value}`;
4274
+ return `"${name}" must be ${wanted}, not ${got}`;
4275
+ }
4276
+ }
4277
+ return null;
4278
+ }
4279
+
4280
+ /**
4281
+ * Convert a glob pattern to a RegExp. Throws, naming the pattern, for a brace
4282
+ * group it can't read: a "{" that never closes, a group inside another, or a
4283
+ * group with no ",".
3186
4284
  */
3187
4285
  function globToRegex(pattern: string): RegExp {
3188
4286
  // Split pattern into segments, handling {a,b,c} alternation
@@ -3190,13 +4288,33 @@ function globToRegex(pattern: string): RegExp {
3190
4288
  let i = 0;
3191
4289
  while (i < pattern.length) {
3192
4290
  if (pattern[i] === '{') {
3193
- const closeIdx = pattern.indexOf('}', i);
3194
- if (closeIdx > i) {
3195
- const alternatives = pattern.slice(i + 1, closeIdx).split(',');
3196
- regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
3197
- i = closeIdx + 1;
3198
- continue;
4291
+ // A brace form this can't read is refused. A `{` that never closes, read
4292
+ // as a literal, would almost always answer with no matches, and with it
4293
+ // refused, each pass of this loop moves past at least one character. A
4294
+ // group closes at the `}` that brings the braces open since its `{` back
4295
+ // to none, so a `{` that never closes is named as that, even with a
4296
+ // closed group after it. A group inside another would be split at the
4297
+ // inner `}`, so `{a,{b,c}}` would match `a}` but not `a`, and a group
4298
+ // with no `,` would lose its braces, so `{name}` would match `name` and
4299
+ // never a file named `{name}`, and `{1..3}` would match only `1..3`.
4300
+ let depth = 0;
4301
+ let closeIdx = -1;
4302
+ for (let j = i; j < pattern.length && closeIdx < 0; j++) {
4303
+ if (pattern[j] === '{') depth++;
4304
+ else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
4305
+ }
4306
+ if (closeIdx < 0) throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
4307
+ const group = pattern.slice(i + 1, closeIdx);
4308
+ if (group.includes('{')) {
4309
+ throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
3199
4310
  }
4311
+ if (!group.includes(',')) {
4312
+ throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
4313
+ }
4314
+ const alternatives = group.split(',');
4315
+ regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
4316
+ i = closeIdx + 1;
4317
+ continue;
3200
4318
  }
3201
4319
  // Accumulate non-brace characters, convert as a chunk
3202
4320
  let chunk = '';
@@ -3211,6 +4329,36 @@ function globToRegex(pattern: string): RegExp {
3211
4329
  return new RegExp(`^${regex}$`);
3212
4330
  }
3213
4331
 
4332
+ /**
4333
+ * The directory a glob pattern names before its first wildcard, beneath
4334
+ * which every path it matches lies: its leading segments that hold none of
4335
+ * globToRegex's wildcards (`*`, `?`, `{`), short of the last segment, which
4336
+ * names the files. '' when the pattern starts with a wildcard or has a
4337
+ * single segment. A segment that's empty, `.` or `..`, which no path
4338
+ * matched holds, is one no directory listing holds either, so walkStart
4339
+ * stops before it.
4340
+ */
4341
+ function literalDirOf(pattern: string): string {
4342
+ const dir: string[] = [];
4343
+ for (const segment of pattern.split('/').slice(0, -1)) {
4344
+ if (/[*?{]/.test(segment)) break;
4345
+ dir.push(segment);
4346
+ }
4347
+ return dir.join('/');
4348
+ }
4349
+
4350
+ /** The deeper of two mount-relative directories, '' being the root, or null when neither holds the other. */
4351
+ function deeperOf(a: string, b: string): string | null {
4352
+ if (a === '' || b === a || b.startsWith(a + '/')) return b;
4353
+ if (b === '' || a.startsWith(b + '/')) return a;
4354
+ return null;
4355
+ }
4356
+
4357
+ /** A glob's refusal of a brace form it can't read, with the way to match a literal "{". */
4358
+ function braceRefusal(pattern: string, problem: string): string {
4359
+ return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`;
4360
+ }
4361
+
3214
4362
  function globPartToRegex(part: string): string {
3215
4363
  return part
3216
4364
  .replace(/[.+^$()|[\]\\]/g, '\\$&')