@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
@@ -4,19 +4,28 @@
4
4
  * Provides a unified workspace with mount-based filesystem access,
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
+ import { isUtf8 } from 'node:buffer';
7
8
  import { constants as fsConstants } from 'node:fs';
8
- import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
9
- import { join, resolve, relative, sep } from 'node:path';
9
+ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
10
+ import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
10
11
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
11
12
  import { MountWatcher } from './watcher.js';
12
13
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
13
14
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
14
15
  import { BranchIntents } from './branch-intent.js';
15
- import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js';
16
- import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
17
- import { WALK_CAP, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
16
+ 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 } from './observe.js';
17
+ import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
18
+ import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
18
19
  /** Default for how long the after-batch scan may hold the agent's next inference. */
19
20
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
21
+ /**
22
+ * How long a mount's catch-up pass waits for its watcher to become ready
23
+ * before it starts anyway (ensureRunning). In a probe on Linux at a load near
24
+ * 40, chokidar's first scan of 10,000 entries took 1.3 s, about a twentieth of
25
+ * this. A scan slower than the bound has the pass started beside it, and the
26
+ * watcher's ready runs the pass again once the scan ends.
27
+ */
28
+ const CATCH_UP_BOUND_MS = 30_000;
20
29
  /** Why a call is refused once the module has stopped. */
21
30
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
22
31
  /** The workspace's own deadline must end before the framework's hook bound. */
@@ -29,6 +38,16 @@ class WorkspaceImageReadError extends Error {
29
38
  this.name = 'WorkspaceImageReadError';
30
39
  }
31
40
  }
41
+ /**
42
+ * A file operation's path lies in another mount's directory nested in the
43
+ * mount it names. The message names the mount that holds it and the path to
44
+ * use there.
45
+ */
46
+ class NestedMountError extends Error {
47
+ constructor(path, mount, holder, use, how = 'lies in') {
48
+ super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
49
+ }
50
+ }
32
51
  /**
33
52
  * Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
34
53
  * mount-prefixed path only — never absolute filesystem paths — so they can be
@@ -53,6 +72,24 @@ function errnoOf(err) {
53
72
  ? err.code
54
73
  : undefined;
55
74
  }
75
+ /**
76
+ * What is at a mount root's path now, followed as the root is: for a
77
+ * directory, undefined; for anything else, ENOTDIR; and where the path
78
+ * can't be followed to anything, the errno its stat failed with, ENOENT
79
+ * when nothing is there.
80
+ */
81
+ async function rootAbsence(root) {
82
+ try {
83
+ return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
84
+ }
85
+ catch (err) {
86
+ return errnoOf(err);
87
+ }
88
+ }
89
+ /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
90
+ function imageTooLarge(maxSize, mountPrefixedPath) {
91
+ return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
92
+ }
56
93
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
57
94
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
58
95
  const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
@@ -76,6 +113,77 @@ function startsWithBytes(buffer, prefix) {
76
113
  function matchesPartialPrefix(buffer, prefix) {
77
114
  return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer);
78
115
  }
116
+ /**
117
+ * A disk reader's refusal for what linkOnTheWay found on a path's way: the
118
+ * link's reason, as the observations give it, after the path; or, for a
119
+ * directory the walk couldn't ask, a failure to read, coded `not_found` with
120
+ * the readlink's errno, as every failed lookup is. A link's code follows
121
+ * where it leads, so a caller choosing advice by code gets advice that fits:
122
+ * `escape` for a link leading outside the mount, the code a path resolving
123
+ * outside it gets too; `other_mount` for one into a nested mount's directory,
124
+ * the code that mount's own paths get; and `parent_symlink` for any other,
125
+ * which `symlink`, a file that is itself a link, leaves apart.
126
+ */
127
+ async function linkReadError(mount, found, mountPrefixedPath, nested) {
128
+ if (!('link' in found)) {
129
+ return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
130
+ }
131
+ const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
132
+ const leads = await linkLeads(mount.config.path, rootReal, found.link);
133
+ const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
134
+ const code = leads.kind === 'outside' ? 'escape'
135
+ : leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
136
+ : 'parent_symlink';
137
+ return new WorkspaceReadError(code, reason, 'readlink');
138
+ }
139
+ /**
140
+ * `to` beneath `from`, mount-relative and `/`-separated, whatever the
141
+ * platform's separator; undefined where it isn't beneath it. Outside,
142
+ * `relative` gives a path that climbs with a `..` segment, or an absolute one
143
+ * across Windows drives. A name that only begins with two dots, such as
144
+ * `..cache`, is inside.
145
+ */
146
+ function nestedDir(from, to) {
147
+ const rel = relative(from, to);
148
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
149
+ return undefined;
150
+ return rel.split(sep).join('/');
151
+ }
152
+ /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
153
+ const LINKS_FOLLOWED = 40;
154
+ /**
155
+ * An absolute path, a mount root's or one beneath it, as realpath resolves
156
+ * it, with the links on its way and in it taken out, read without holding
157
+ * the process. Where nothing is there yet, a link leads where its target
158
+ * would be, and anything else lies beneath its nearest existing directory's
159
+ * real path, with the rest as given. A link's target is read from the
160
+ * link's directory as it is on disk, and nothing is resolved lexically on
161
+ * the way, so a `..` is taken from where the links before it lead, as the
162
+ * kernel takes it. Where realpath fails for any other reason, such as a
163
+ * loop or a lack of permission, the whole path as path.resolve reads it.
164
+ */
165
+ async function realBeneath(given, links = 0) {
166
+ // A trailing separator, as a link to a directory is often written, names
167
+ // the same place, but readlink would then follow the link it names.
168
+ const { root } = parse(given);
169
+ let absolute = given;
170
+ while (absolute.length > root.length && (absolute.endsWith('/') || absolute.endsWith(sep)))
171
+ absolute = absolute.slice(0, -1);
172
+ try {
173
+ return await realpath(absolute);
174
+ }
175
+ catch (err) {
176
+ const code = errnoOf(err);
177
+ const parent = dirname(absolute);
178
+ if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute)
179
+ return resolve(absolute);
180
+ const place = await realBeneath(parent, links);
181
+ const target = await readlink(absolute).catch(() => null);
182
+ if (target !== null)
183
+ return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
184
+ return join(place, basename(absolute));
185
+ }
186
+ }
79
187
  function isContainedPath(root, candidate) {
80
188
  if (candidate === root)
81
189
  return true;
@@ -451,6 +559,8 @@ export class WorkspaceModule {
451
559
  store = null;
452
560
  /** Set once stop has drained and saved, cleared by start (running()). */
453
561
  stopped = false;
562
+ /** Stops begun, so a start that read disk meanwhile can tell that one came. */
563
+ stopsBegun = 0;
454
564
  config;
455
565
  mounts = new Map();
456
566
  watchers = new Map();
@@ -465,6 +575,26 @@ export class WorkspaceModule {
465
575
  /** Whether a mount root's filesystem maintains ctime, by canonical root. */
466
576
  /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
467
577
  ctimeTrust = new Map();
578
+ /**
579
+ * Each mount's directories that are other mounts nested inside it, with
580
+ * each mount's name and real root, in each spelling that names the
581
+ * directory: as configured; as the real roots place it, where that differs
582
+ * (`fromRoots`); and as disk spells each of those where that differs.
583
+ * Each spelling is mount-relative and `/`-separated, with its root beneath
584
+ * the mount's resolved root. The constructor nests by configured path,
585
+ * with each root as resolved standing for its real one, and each start
586
+ * reads it all again with the roots placed where disk has them
587
+ * (readNesting).
588
+ */
589
+ otherMounts = new Map();
590
+ /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
591
+ rootFoldsCase = new Map();
592
+ /**
593
+ * Whether a file's mode is observed, taken in and applied (MountRuntime's
594
+ * `modesObserved`): not on Windows, which reports no POSIX modes. A test
595
+ * sets it false to run that case anywhere.
596
+ */
597
+ modesObserved = process.platform !== 'win32';
468
598
  /** Persisted state decoded in start(), held until mounts exist to apply it
469
599
  * to — in the Host ordering start() runs before initStore() creates the
470
600
  * mounts, so restoration must be second-callback-safe like watcher
@@ -479,26 +609,27 @@ export class WorkspaceModule {
479
609
  throw new Error(`[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${shown}`);
480
610
  }
481
611
  }
482
- // Detect overlapping mount paths: if mount A contains mount B,
483
- // auto-add an ignore rule on A for B's path to prevent syncing
484
- // the sub-mount's directory through the super-mount.
612
+ // Detect overlapping mount paths: a mount nested inside another is that
613
+ // mount's directory, which the outer mount passes over in every pass,
614
+ // named or not, and in its watcher (OTHER_MOUNT). It's kept apart from
615
+ // the outer mount's ignore list, which a sync that names a path sets
616
+ // aside for what it names (#276), so no sync of the outer mount takes in
617
+ // the inner one's files. Here by configured path, before any path is
618
+ // parsed; each start places the roots where disk has them too
619
+ // (readNesting).
485
620
  for (const outer of config.mounts) {
486
621
  for (const inner of config.mounts) {
487
622
  if (outer === inner)
488
623
  continue;
489
624
  const outerPath = resolve(outer.path);
490
625
  const innerPath = resolve(inner.path);
491
- const rel = relative(outerPath, innerPath);
492
- if (rel && !rel.startsWith('..') && !rel.startsWith('/')) {
493
- // inner is nested under outer — add ignore rule
494
- outer.ignore = outer.ignore ?? [];
495
- const pattern = rel + '/**';
496
- if (!outer.ignore.includes(pattern) && !outer.ignore.includes(rel)) {
497
- outer.ignore.push(rel);
498
- console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
499
- `(${rel}/) — auto-ignoring to prevent overlap`);
500
- }
501
- }
626
+ const dir = nestedDir(outerPath, innerPath);
627
+ if (dir === undefined)
628
+ continue;
629
+ const configured = [{ dir, root: innerPath }];
630
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
631
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
632
+ `at ${dir}/ — passing over it as another mount's directory`);
502
633
  }
503
634
  }
504
635
  this.config = config;
@@ -513,26 +644,25 @@ export class WorkspaceModule {
513
644
  * second. `ensureRunning()` gates on `ctx && mounts-populated`.
514
645
  */
515
646
  initStore(store) {
516
- this.store = store;
647
+ // Every mount's states are registered before the module takes the store
648
+ // or any mount, so a registration that fails leaves the module as it was
649
+ // before the call, knowing no mount, and a later initStore starts clean.
650
+ const mounts = new Map();
651
+ const intents = new Map();
517
652
  // Register tree states for each mount: the workspace tree, and the
518
653
  // branch-local intent that qualifies it.
519
654
  for (const mount of this.config.mounts) {
520
655
  const treeStateId = `workspace/${mount.name}/tree`;
521
656
  const intentTreeStateId = `workspace/${mount.name}/intent`;
522
657
  for (const id of [treeStateId, intentTreeStateId]) {
523
- try {
524
- store.registerState({
525
- id,
526
- strategy: 'tree',
527
- deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
528
- fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
529
- });
530
- }
531
- catch {
532
- // State already registered (restart scenario)
533
- }
658
+ ensureStateRegistered(store, {
659
+ id,
660
+ strategy: 'tree',
661
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
662
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
663
+ });
534
664
  }
535
- const mountState = {
665
+ mounts.set(mount.name, {
536
666
  config: mount,
537
667
  treeStateId,
538
668
  intentTreeStateId,
@@ -542,13 +672,17 @@ export class WorkspaceModule {
542
672
  lastMaterializedBranchId: null,
543
673
  watcherReadyAt: null,
544
674
  watcherError: null,
545
- };
546
- this.mounts.set(mount.name, mountState);
547
- this.intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
675
+ });
676
+ intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
548
677
  }
678
+ this.store = store;
679
+ for (const [name, state] of mounts)
680
+ this.mounts.set(name, state);
681
+ for (const [name, intent] of intents)
682
+ this.intents.set(name, intent);
549
683
  // What disk last agreed with, per mount and path: global records, so a
550
684
  // branch switch never rewinds it.
551
- this.agreement = new DiskAgreement(store);
685
+ this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
552
686
  this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
553
687
  this.applySavedState();
554
688
  this.ensureRunning();
@@ -575,6 +709,24 @@ export class WorkspaceModule {
575
709
  throw new Error('Workspace store not initialized');
576
710
  return this.agreement;
577
711
  }
712
+ /**
713
+ * A mount as observation reads it, with the root identity the workspace
714
+ * recorded for it, if any: what a pass reads beneath (runtime), with
715
+ * rootIdentity's, and what a disk read checks its root against (readRoot),
716
+ * with rootIdentityForRead's.
717
+ */
718
+ viewOf(mount, rootIdentity) {
719
+ return {
720
+ root: resolve(mount.config.path),
721
+ name: mount.config.name,
722
+ rootIdentity,
723
+ followSymlinks: mount.config.followSymlinks === true,
724
+ maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
725
+ ignore: mount.config.ignore ?? [],
726
+ others: this.othersOf(mount.config.name),
727
+ nested: this.nestedOf(mount.config.name),
728
+ };
729
+ }
578
730
  /** The view reconciliation needs of a mount, refreshed per pass. */
579
731
  async runtime(mount) {
580
732
  const root = resolve(mount.config.path);
@@ -587,13 +739,7 @@ export class WorkspaceModule {
587
739
  }
588
740
  return {
589
741
  name: mount.config.name,
590
- view: {
591
- root,
592
- rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
593
- followSymlinks: mount.config.followSymlinks === true,
594
- maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
595
- ignore: mount.config.ignore ?? [],
596
- },
742
+ view: this.viewOf(mount, this.agreementOrThrow().rootIdentity(mount.config.name)),
597
743
  rootReal,
598
744
  ctimeTrustedDev: trustedDev,
599
745
  treeStateId: mount.treeStateId,
@@ -601,13 +747,57 @@ export class WorkspaceModule {
601
747
  readOnly: mount.config.mode === 'read-only',
602
748
  walkCap: mount.config.walkCap ?? WALK_CAP,
603
749
  catchUpScan: (mount.config.watch ?? 'always') === 'always',
750
+ modesObserved: this.modesObserved,
604
751
  };
605
752
  }
753
+ /**
754
+ * Where a search's walk starts: `base`, its path as parsePath spells it,
755
+ * then each segment of `beneath` while disk lists it, in the directory
756
+ * reached so far, as a directory with exactly that spelling. The walk names
757
+ * what it finds from its start's spelling, and a case-insensitive disk
758
+ * would let it go on under a spelling disk doesn't list, taking files in
759
+ * under that spelling beside their own. So the start stops before a segment
760
+ * spelled any other way, not there, or not a directory, and before a link,
761
+ * which a walk of the whole mount doesn't go through either. It lists only
762
+ * directories it has checked lie inside the mount, and none the mount
763
+ * passes over by design, which the walk then answers for.
764
+ */
765
+ async walkStart(mount, base, beneath) {
766
+ if (beneath === '')
767
+ return base;
768
+ const { view, rootReal } = await this.runtime(mount);
769
+ let start = base;
770
+ for (const segment of beneath.split('/')) {
771
+ if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined)
772
+ break;
773
+ const here = await checkDir(view, rootReal, start);
774
+ if (here.kind !== 'checked')
775
+ break;
776
+ const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
777
+ if (entry === undefined || !entry.isDirectory())
778
+ break;
779
+ start = start ? `${start}/${segment}` : segment;
780
+ }
781
+ return start;
782
+ }
606
783
  /** One disk→store pass; the caller holds the mount's turn. Emits its events. */
607
784
  async passUnlocked(mount, scope, opts = {}) {
608
785
  const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
609
786
  if (scope.kind === 'dir' && scope.dir === '' && scope.recursive)
610
787
  mount.initialSyncDone = true;
788
+ // What a pass says is another mount's directory names that mount and
789
+ // the path to use there, as the file operations' refusal does: a copy's
790
+ // note, and the region of a scope that lies there. The pass's counts
791
+ // keep the bare reason.
792
+ const name = mount.config.name;
793
+ for (const report of result.reports.values()) {
794
+ if (report.note === OTHER_MOUNT)
795
+ report.note = this.otherMountNote(name, report.path, 'list');
796
+ }
797
+ for (const region of result.incomplete) {
798
+ if (region.reason === OTHER_MOUNT)
799
+ region.reason = this.otherMountNote(name, region.path, 'list');
800
+ }
611
801
  this.emitFsEvents(mount.config.name, result.ops, result.newConflicts);
612
802
  return result;
613
803
  }
@@ -620,6 +810,21 @@ export class WorkspaceModule {
620
810
  });
621
811
  }
622
812
  async start(ctx) {
813
+ // Which mounts are nested where, with the roots placed where disk has
814
+ // them and each nested mount's directory as disk spells it, and whether
815
+ // each mount's root folds case, read where disk has it, before this start
816
+ // sets anything, so a restart takes no call and walks or watches nothing
817
+ // before they're known. A stop begun meanwhile stands, and this start then
818
+ // sets nothing.
819
+ const stops = this.stopsBegun;
820
+ const { nesting, reals } = await this.readNesting();
821
+ const rootFoldsCase = new Map();
822
+ for (const mount of this.config.mounts)
823
+ rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)));
824
+ if (this.stopsBegun !== stops)
825
+ return;
826
+ this.otherMounts = nesting;
827
+ this.rootFoldsCase = rootFoldsCase;
623
828
  this.ctx = ctx;
624
829
  this.stopped = false;
625
830
  // Decode persisted state if restarting; application waits until mounts
@@ -758,12 +963,54 @@ export class WorkspaceModule {
758
963
  continue;
759
964
  const watchMode = mount.config.watch ?? 'always';
760
965
  if (watchMode === 'always') {
761
- const watcher = new MountWatcher(mount.config, (changes) => {
966
+ // Chokidar is started with ignoreInitial:true, so changes made while
967
+ // the session was down would be invisible. A catch-up pass takes them
968
+ // in under the three-way rule: only real differences produce tree
969
+ // changes and events. It takes its turn with the mount's bare syncs,
970
+ // so on a mount over its walkCap it covers the walkCap entries after
971
+ // where the last turn stopped, and a change elsewhere waits for the
972
+ // rotation to reach it, or for a sync of its path.
973
+ //
974
+ // The pass runs at each of the watcher's readies, its first and each
975
+ // re-attach's, when chokidar has called fs.watch on every entry, so a
976
+ // change made after the pass has looked at a file reaches the watcher.
977
+ // Started beside chokidar's scan, the pass could finish with a file
978
+ // before chokidar watched it, and a change between the two reached
979
+ // neither. A watcher that isn't ready within CATCH_UP_BOUND_MS (from
980
+ // its start or its re-attach) has the pass started at the bound, and
981
+ // its ready, if it comes, runs the pass again. Within the mount's
982
+ // walkCap, that pass covers what the early one missed. Over it, the
983
+ // pass continues the rotation, and what the early one missed waits for
984
+ // the rotation to come back, as any change outside a pass's window
985
+ // does. An error doesn't start the pass: chokidar goes on scanning
986
+ // after one, and the bound covers a watcher that never becomes ready.
987
+ // On macOS, a new entry written after ready but before libuv's
988
+ // FSEventStream exists isn't reported until a later change in its
989
+ // directory, which this doesn't change.
990
+ let watcher;
991
+ let bound = null;
992
+ const catchUp = () => {
993
+ if (bound)
994
+ clearTimeout(bound);
995
+ bound = null;
996
+ // A watcher the module has since stopped or replaced starts no pass.
997
+ if (this.watchers.get(name) !== watcher)
998
+ return;
999
+ void this.initialScan(name);
1000
+ };
1001
+ const awaitReady = () => {
1002
+ if (bound)
1003
+ clearTimeout(bound);
1004
+ bound = setTimeout(catchUp, CATCH_UP_BOUND_MS);
1005
+ bound.unref?.();
1006
+ };
1007
+ watcher = new MountWatcher(mount.config, (changes) => {
762
1008
  this.handleFsChanges(name, changes);
763
1009
  }, {
764
1010
  onReady: () => {
765
1011
  mount.watcherReadyAt = Date.now();
766
1012
  mount.watcherError = null;
1013
+ catchUp();
767
1014
  },
768
1015
  onError: (err) => {
769
1016
  mount.watcherError = err.message;
@@ -776,24 +1023,17 @@ export class WorkspaceModule {
776
1023
  },
777
1024
  onReattach: () => {
778
1025
  mount.watcherError = null;
779
- void this.initialScan(name);
1026
+ awaitReady();
780
1027
  this.ctx?.pushEvent({
781
1028
  type: 'workspace:watcher-reattached',
782
1029
  mount: name,
783
1030
  path: mount.config.path,
784
1031
  });
785
1032
  },
786
- });
1033
+ }, this.othersOf(name));
1034
+ awaitReady();
787
1035
  watcher.start();
788
1036
  this.watchers.set(name, watcher);
789
- // Chokidar is started with ignoreInitial:true, so changes made while
790
- // the session was down would be invisible. A catch-up pass takes them
791
- // in under the three-way rule: only real differences produce tree
792
- // changes and events. It takes its turn with the mount's bare syncs,
793
- // so on a mount over its walkCap it covers the walkCap entries after
794
- // where the last turn stopped, and a change elsewhere waits for the
795
- // rotation to reach it, or for a sync of its path.
796
- void this.initialScan(name);
797
1037
  }
798
1038
  this.ctx.pushEvent({
799
1039
  type: 'workspace:mounted',
@@ -822,27 +1062,39 @@ export class WorkspaceModule {
822
1062
  }
823
1063
  }
824
1064
  async stop() {
1065
+ this.stopsBegun++;
825
1066
  // No new watcher-driven passes, then let every pass and write finish
826
1067
  // before the store goes away — including any queued behind the ones in
827
1068
  // flight (a tool call, a scan continuing past its deadline): drained
828
- // until no new tail appears.
829
- for (const watcher of this.watchers.values()) {
1069
+ // until no new tail appears. The watchers leave this.watchers before they
1070
+ // stop, so a watcher's ready or catch-up bound starts no pass meanwhile.
1071
+ const stopping = [...this.watchers.values()];
1072
+ this.watchers.clear();
1073
+ for (const watcher of stopping) {
830
1074
  await watcher.stop();
831
1075
  }
832
- this.watchers.clear();
1076
+ // A call under way can await disk (parsePath) before it takes its turn,
1077
+ // so the calls are waited for as well as the turns.
833
1078
  for (;;) {
834
1079
  const tails = [...this.mountTurns.values()];
835
- await Promise.all(tails);
1080
+ await Promise.allSettled([...this.callsUnderWay, ...tails]);
836
1081
  const now = [...this.mountTurns.values()];
837
- if (now.length === tails.length && now.every((tail, i) => tail === tails[i]))
1082
+ if (this.callsUnderWay.size === 0 && now.length === tails.length && now.every((tail, i) => tail === tails[i]))
838
1083
  break;
839
1084
  }
840
1085
  // Nothing is queued now, and nothing more is taken: what follows saves
841
1086
  // the pins and watermarks as the last work left them.
842
1087
  this.stopped = true;
843
- // Persist state
844
- if (this.ctx) {
845
- const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
1088
+ // Persist state, once initStore() has run and the state start() was
1089
+ // handed has been applied (savedState is cleared then). Before that this
1090
+ // session has made no pass or write, so it has nothing to save, and the
1091
+ // stored state stays as it was: saving would put empty or default mounts
1092
+ // over the saved pins. A create() that fails after starting this module
1093
+ // stops it before the host can call initStore() (#389), and an
1094
+ // initStore() that throws after taking the store leaves the state
1095
+ // unapplied.
1096
+ if (this.ctx && this.store && this.savedState === null) {
1097
+ const activeBranchId = this.store.currentBranch().id;
846
1098
  const state = { mounts: {}, activeBranchId };
847
1099
  for (const [name, mount] of this.mounts) {
848
1100
  state.mounts[name] = {
@@ -880,8 +1132,8 @@ export class WorkspaceModule {
880
1132
  type: 'object',
881
1133
  properties: {
882
1134
  path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
883
- offset: { type: 'number', description: 'Starting line number (1-indexed)' },
884
- limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
1135
+ offset: { type: 'integer', description: 'Starting line number (1-indexed)' },
1136
+ limit: { type: 'integer', description: 'Maximum number of lines to return (default 2000)' },
885
1137
  offsetChars: { type: 'integer', description: 'Character paging: zero-based UTF-16 offset (default 0); use nextOffsetChars to continue' },
886
1138
  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.' },
887
1139
  },
@@ -945,7 +1197,8 @@ export class WorkspaceModule {
945
1197
  + 'still on disk); not-in-branch (on disk, absent from this branch\'s workspace); disk-only (binary or over '
946
1198
  + 'the size limit, never stored — shown with size and image type); conflict, with its kind (disk and the '
947
1199
  + 'workspace both changed; disk holds a different copy and nothing records which is newer; or a write was '
948
- + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace; '
1200
+ + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace, '
1201
+ + 'with applyDeletions too where the workspace deleted it; '
949
1202
  + 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
950
1203
  + 'the listing could not see.',
951
1204
  inputSchema: {
@@ -981,8 +1234,8 @@ export class WorkspaceModule {
981
1234
  pattern: { type: 'string', description: 'Regular expression pattern' },
982
1235
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
983
1236
  glob: { type: 'string', description: 'Glob pattern to filter files' },
984
- contextBefore: { type: 'number', description: 'Context lines before match' },
985
- contextAfter: { type: 'number', description: 'Context lines after match' },
1237
+ contextBefore: { type: 'integer', description: 'Context lines before match' },
1238
+ contextAfter: { type: 'integer', description: 'Context lines after match' },
986
1239
  },
987
1240
  required: ['pattern'],
988
1241
  },
@@ -1029,13 +1282,18 @@ export class WorkspaceModule {
1029
1282
  + 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
1030
1283
  + 'directory replaced on purpose, or a drive back under a new device number, never for one still '
1031
1284
  + 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and '
1032
- + 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s '
1033
- + 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a '
1285
+ + 'reported nowhere else is counted under `passedOver`, by mount and reason: another mount\'s '
1286
+ + 'directory nested in this one, entries the mount\'s ignore list covers, and entries with an '
1287
+ + 'editor\'s swap or backup file name (`.name.swp` and `.name.swx` as the whole name, names ending '
1288
+ + 'in `~` or in `.subl…tmp`), that hold nothing the workspace tracks (such a directory counts once, '
1289
+ + 'with what lies beneath it). So a '
1034
1290
  + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
1035
- + 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name '
1036
- + 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory '
1037
- + 'sets aside the patterns that cover it or a directory above it; one that covers only something '
1038
- + 'inside it still applies, and what it covers is passed over.',
1291
+ + 'sync looked everywhere and nothing had changed. A path the mount ignores, or an editor\'s file, is '
1292
+ + 'synced when you name it, and listed under `ignored`: a sync without a path passes over it. A path '
1293
+ + 'in another mount\'s directory isn\'t synced even when you name it: use the mount that holds it. Naming '
1294
+ + 'an ignored directory sets aside the patterns that cover it or a directory above it; one that '
1295
+ + 'covers only something inside it still applies, and what it covers is passed over, as are editors\' '
1296
+ + 'files inside it.',
1039
1297
  inputSchema: {
1040
1298
  type: 'object',
1041
1299
  properties: {
@@ -1066,8 +1324,10 @@ export class WorkspaceModule {
1066
1324
  const mount = this.config.mounts.find(m => m.name === mountName);
1067
1325
  if (!mount)
1068
1326
  throw new Error(`Unknown mount: "${mountName}"`);
1069
- const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1));
1070
- if (!isContainedPath(mount.path, resolved))
1327
+ // Against the resolved root, as parsePath judges it.
1328
+ const root = resolve(mount.path);
1329
+ const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1));
1330
+ if (!isContainedPath(root, resolved))
1071
1331
  throw new Error(`Path traversal detected: "${path}"`);
1072
1332
  return resolved;
1073
1333
  }
@@ -1094,7 +1354,30 @@ export class WorkspaceModule {
1094
1354
  async handleToolCall(call) {
1095
1355
  if (!this.running())
1096
1356
  return { success: false, isError: true, error: NOT_RUNNING };
1357
+ return this.underWay(this.runToolCall(call));
1358
+ }
1359
+ /**
1360
+ * Tool calls, and the public reads and writes that take a mount's turn,
1361
+ * under way. Each can await disk (parsePath) before it takes its turn, so
1362
+ * stop waits for these as well as for the turns.
1363
+ */
1364
+ callsUnderWay = new Set();
1365
+ /** `work`, recorded as under way until it settles (callsUnderWay). */
1366
+ underWay(work) {
1367
+ this.callsUnderWay.add(work);
1368
+ const done = () => { this.callsUnderWay.delete(work); };
1369
+ work.then(done, done);
1370
+ return work;
1371
+ }
1372
+ async runToolCall(call) {
1097
1373
  try {
1374
+ // Every handler reads its input as its schema describes it; a call
1375
+ // that sends something else is refused here, before anything is read.
1376
+ const schema = this.getTools().find((tool) => tool.name === call.name)?.inputSchema;
1377
+ const problem = schema ? inputProblem(schema, call.input, INPUT_MINIMUMS[call.name]) : null;
1378
+ if (problem !== null) {
1379
+ return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
1380
+ }
1098
1381
  const input = call.input;
1099
1382
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1100
1383
  if (generated) {
@@ -1121,7 +1404,7 @@ export class WorkspaceModule {
1121
1404
  }
1122
1405
  }
1123
1406
  catch (err) {
1124
- return { success: false, error: String(err), isError: true };
1407
+ return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
1125
1408
  }
1126
1409
  }
1127
1410
  async onProcess(event, _state) {
@@ -1210,7 +1493,10 @@ export class WorkspaceModule {
1210
1493
  */
1211
1494
  resolveAbsolutePath(mountPrefixedPath) {
1212
1495
  try {
1213
- const { mount, relativePath } = this.parsePath(mountPrefixedPath);
1496
+ // Synchronous, so the path as given: it reaches the same file, and
1497
+ // the nested-mount check matches it as configured or as disk spells
1498
+ // the nested mount's directory (readNesting).
1499
+ const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
1214
1500
  return resolve(mount.config.path, relativePath);
1215
1501
  }
1216
1502
  catch {
@@ -1241,10 +1527,13 @@ export class WorkspaceModule {
1241
1527
  async writeBinary(mountPrefixedPath, data, mimeType, options = {}) {
1242
1528
  if (!this.running())
1243
1529
  return { success: false, isError: true, error: NOT_RUNNING };
1530
+ return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
1531
+ }
1532
+ async writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options) {
1244
1533
  let mount;
1245
1534
  let relativePath;
1246
1535
  try {
1247
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1536
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1248
1537
  }
1249
1538
  catch (error) {
1250
1539
  return {
@@ -1269,12 +1558,16 @@ export class WorkspaceModule {
1269
1558
  return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
1270
1559
  }
1271
1560
  const blobHash = store.storeBlob(data, mimeType);
1561
+ // An overwrite keeps the file's mode, as the write tool's does.
1562
+ const existing = store.treeGet(mount.treeStateId, relativePath);
1272
1563
  let committed = false;
1273
1564
  let failure;
1565
+ let modeKept = null;
1274
1566
  try {
1275
- const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength }, () => { committed = true; });
1276
- failure = materializeError === null ? null
1277
- : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`;
1567
+ const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
1568
+ modeKept = answer.modeKept;
1569
+ failure = answer.refused === null ? null
1570
+ : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
1278
1571
  }
1279
1572
  catch (error) {
1280
1573
  if (!committed)
@@ -1289,7 +1582,7 @@ export class WorkspaceModule {
1289
1582
  }
1290
1583
  return {
1291
1584
  success: true,
1292
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
1585
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
1293
1586
  };
1294
1587
  });
1295
1588
  }
@@ -1301,10 +1594,13 @@ export class WorkspaceModule {
1301
1594
  async readBinary(mountPrefixedPath) {
1302
1595
  if (!this.running())
1303
1596
  return { error: NOT_RUNNING };
1597
+ return this.underWay(this.readBinaryUnderWay(mountPrefixedPath));
1598
+ }
1599
+ async readBinaryUnderWay(mountPrefixedPath) {
1304
1600
  let mount;
1305
1601
  let relativePath;
1306
1602
  try {
1307
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1603
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1308
1604
  }
1309
1605
  catch (error) {
1310
1606
  return { error: error instanceof Error ? error.message : String(error) };
@@ -1333,39 +1629,287 @@ export class WorkspaceModule {
1333
1629
  * permanent cost to send something once is the wrong trade.
1334
1630
  *
1335
1631
  * Same mount boundary and traversal guard as every other path here: only
1336
- * declared mounts, nothing above their root. Deliberately does NOT sync,
1337
- * register, or hash anything — it reads and returns.
1632
+ * declared mounts, nothing above their root, and the file reached as
1633
+ * readFileFromDisk reaches it (openContainedFile), so the mount's
1634
+ * `followSymlinks` holds for the file and the directories on its way, and
1635
+ * what it reaches stays canonically inside the mount. Deliberately does NOT
1636
+ * sync, register, or hash anything — it reads and returns.
1637
+ *
1638
+ * `maxBytes` refuses a larger file as "File too large", having loaded at
1639
+ * most `maxBytes` bytes and one more: the reads decide where the file ends,
1640
+ * not the size fstat gave at the open, so the bound holds for a file that
1641
+ * grows while it's read.
1338
1642
  */
1339
1643
  async readBinaryFromDisk(mountPrefixedPath, opts = {}) {
1340
1644
  let mount;
1341
1645
  let relativePath;
1342
1646
  try {
1343
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1647
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1344
1648
  }
1345
1649
  catch (error) {
1346
1650
  return { error: error instanceof Error ? error.message : String(error) };
1347
1651
  }
1348
1652
  const absolutePath = resolve(mount.config.path, relativePath);
1653
+ const maxBytes = opts.maxBytes;
1654
+ let tooLarge;
1349
1655
  try {
1350
- const info = await stat(absolutePath);
1351
- if (!info.isFile())
1352
- return { error: `Not a file: ${mountPrefixedPath}` };
1353
- if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
1354
- return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
1656
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
1657
+ if (maxBytes !== undefined && fileStat.size > maxBytes) {
1658
+ tooLarge = `File too large: ${fileStat.size} > ${maxBytes} bytes`;
1659
+ throw new Error(tooLarge);
1660
+ }
1661
+ });
1662
+ try {
1663
+ if (maxBytes === undefined)
1664
+ return { data: await handle.readFile(), absolutePath };
1665
+ // No more than maxBytes is loaded: a file grown past it since the
1666
+ // fstat above is refused here, as one already over it was there. Its
1667
+ // size is an fstat's after the read, and at least one more than the
1668
+ // bytes kept, so the refusal's size is always past maxBytes.
1669
+ const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
1670
+ if (more)
1671
+ return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
1672
+ return { data: bytes, absolutePath };
1673
+ }
1674
+ finally {
1675
+ await handle.close();
1355
1676
  }
1356
- return { data: await readFile(absolutePath), absolutePath };
1357
1677
  }
1358
1678
  catch (error) {
1359
- const code = error.code;
1360
- if (code === 'ENOENT')
1679
+ if (tooLarge !== undefined)
1680
+ return { error: tooLarge };
1681
+ // A directory, or a file that isn't regular (the reader's refusal of
1682
+ // one carries no errno), and a missing file keep this reader's words.
1683
+ if (error instanceof WorkspaceReadError && (error.code === 'directory' || (error.code === 'not_found' && error.errno === undefined))) {
1684
+ return { error: `Not a file: ${mountPrefixedPath}` };
1685
+ }
1686
+ if (error instanceof WorkspaceReadError && error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) {
1361
1687
  return { error: `File not found on disk: ${mountPrefixedPath}` };
1688
+ }
1362
1689
  return { error: error instanceof Error ? error.message : String(error) };
1363
1690
  }
1364
1691
  }
1692
+ /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
1693
+ othersOf(mountName) {
1694
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
1695
+ }
1696
+ /**
1697
+ * Which mounts are nested in which, read afresh at each start. A mount is
1698
+ * nested in another where its configured path lies beneath the other's
1699
+ * root, as configured or as it lies on disk, whatever links the path
1700
+ * reaches that root through, or where its real root lies beneath the
1701
+ * other's. Each root, and each directory above a configured path, is
1702
+ * placed by realBeneath, awaited, so a root on a share that stops
1703
+ * answering holds this start and not the process, and a root moved while
1704
+ * the module was stopped is placed where it is now. A mount whose real
1705
+ * root is the other's own, or holds it, is nested by its configured path
1706
+ * alone, with no real root, since through it it would claim the other's
1707
+ * every path.
1708
+ *
1709
+ * Each way it lies there is a spelling of its directory (`fromRoots`), and
1710
+ * so is the spelling disk lists each of those by, where that differs. On a
1711
+ * disk that folds names, the outer mount's walks name the directory as
1712
+ * disk does, and so does a path parsePath has respelled, while a path given
1713
+ * as configured still names it as configured (resolveAbsolutePath). A
1714
+ * directory not on disk keeps the spellings the roots give alone. Each
1715
+ * mount's real root comes back beside the nesting (`reals`), for what
1716
+ * start reads there.
1717
+ */
1718
+ async readNesting() {
1719
+ const reals = new Map();
1720
+ // Each directory above a mount's configured path, with where it lies on
1721
+ // disk, so a path that reaches another mount's root through a link, as
1722
+ // one under macOS's /var reaches /private/var, is still read beneath it.
1723
+ const above = new Map();
1724
+ for (const mount of this.config.mounts) {
1725
+ const given = resolve(mount.path);
1726
+ reals.set(mount.name, await realBeneath(given));
1727
+ const dirs = [];
1728
+ for (let dir = dirname(given);; dir = dirname(dir)) {
1729
+ dirs.push({ dir, real: await realBeneath(dir) });
1730
+ if (dirname(dir) === dir)
1731
+ break;
1732
+ }
1733
+ above.set(mount.name, dirs);
1734
+ }
1735
+ const nesting = new Map();
1736
+ for (const outer of this.config.mounts) {
1737
+ const outerPath = resolve(outer.path);
1738
+ const outerReal = reals.get(outer.name);
1739
+ for (const inner of this.config.mounts) {
1740
+ if (outer === inner)
1741
+ continue;
1742
+ const real = reals.get(inner.name);
1743
+ const innerPath = resolve(inner.path);
1744
+ const configured = nestedDir(outerPath, innerPath);
1745
+ const fromRoots = [];
1746
+ // Its directory as configured, read against this mount's root as
1747
+ // configured and from each directory above it that is this mount's
1748
+ // root on disk, and its real root against this mount's.
1749
+ const throughRoot = above.get(inner.name).filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
1750
+ for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
1751
+ if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
1752
+ fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
1753
+ }
1754
+ if (fromRoots.length === 0)
1755
+ continue;
1756
+ const spellings = [...fromRoots];
1757
+ for (const found of fromRoots) {
1758
+ const dir = await diskSpelling(outerPath, found.dir);
1759
+ if (!spellings.some((s) => s.dir === dir))
1760
+ spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
1761
+ }
1762
+ const claims = !isContainedPath(real, outerReal);
1763
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
1764
+ const placed = fromRoots.filter((s) => s.dir !== configured);
1765
+ if (placed.length > 0) {
1766
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
1767
+ `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`);
1768
+ }
1769
+ }
1770
+ }
1771
+ return { nesting, reals };
1772
+ }
1773
+ /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
1774
+ nestedOf(mountName) {
1775
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
1776
+ }
1777
+ /**
1778
+ * The innermost mount nested in `mountName` whose directory holds a path
1779
+ * of that mount, judged lexically where the path lands (`.`, `..` and
1780
+ * repeated separators resolved, as for the traversal guard; a link of the
1781
+ * outer mount isn't followed), with the path to use there; undefined when
1782
+ * no nested mount's directory holds it.
1783
+ */
1784
+ holderOf(mountName, relativePath) {
1785
+ const mount = this.mounts.get(mountName);
1786
+ if (!mount)
1787
+ return undefined;
1788
+ const resolved = resolve(mount.config.path, relativePath);
1789
+ const holder = (this.otherMounts.get(mountName) ?? [])
1790
+ .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
1791
+ .sort((a, b) => b.root.length - a.root.length)[0];
1792
+ if (!holder)
1793
+ return undefined;
1794
+ const rest = relative(holder.root, resolved).split(sep).join('/');
1795
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
1796
+ }
1365
1797
  /**
1366
- * Parse a mount-prefixed path into (mountName, relativePath).
1798
+ * Why a mount leaves a path in a nested mount's directory, naming that
1799
+ * mount and the path to use: for a sync or a push, which of them leaves
1800
+ * it; for a listing, whose the path is.
1367
1801
  */
1368
- parsePath(path) {
1802
+ otherMountNote(mountName, relativePath, by) {
1803
+ const holder = this.holderOf(mountName, relativePath);
1804
+ const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
1805
+ return holder
1806
+ ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
1807
+ : `it lies in another mount's directory${what}`;
1808
+ }
1809
+ /**
1810
+ * parsePath for the file operations (read, write, edit, delete, and the
1811
+ * module's binary and disk reads and writes). A path in another mount's
1812
+ * directory nested in this one is that mount's: this mount's passes don't
1813
+ * observe it and its pushes don't write it, so its file operations refuse
1814
+ * it, naming the innermost mount that holds it and the path to use there.
1815
+ */
1816
+ async parseFilePath(path) {
1817
+ const parsed = this.refuseNested(await this.parsePath(path));
1818
+ // A path can also reach a nested mount's directory through a symlink on
1819
+ // its way. No push of this mount writes it there and no read reads it,
1820
+ // so it's refused here, before anything is stored, and the checks where
1821
+ // disk is acted on refuse it again.
1822
+ const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
1823
+ if (refusal)
1824
+ throw refusal;
1825
+ return parsed;
1826
+ }
1827
+ /**
1828
+ * The refusal for a path of `mount` that reaches a nested mount's directory
1829
+ * through a symlink on its way (nestedAtReal), or null where it doesn't. On
1830
+ * a mount that doesn't follow the link, the refusal is the link's own
1831
+ * (linkWords), as the readers and pushes give it. A mount with nothing
1832
+ * nested has nothing to find, so it pays nothing.
1833
+ */
1834
+ async leadsIntoNested(mount, relativePath, path) {
1835
+ const nested = this.nestedOf(mount.config.name);
1836
+ if (nested.length === 0)
1837
+ return null;
1838
+ const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
1839
+ if (!holder)
1840
+ return null;
1841
+ if (mount.config.followSymlinks !== true) {
1842
+ const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
1843
+ if (found !== null)
1844
+ return await linkReadError(mount, found, path, nested);
1845
+ }
1846
+ return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
1847
+ }
1848
+ /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
1849
+ refuseNested(parsed) {
1850
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
1851
+ if (holder)
1852
+ throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
1853
+ return parsed;
1854
+ }
1855
+ /**
1856
+ * Parse a mount-prefixed path into its mount and mount-relative path, as
1857
+ * disk spells it (diskSpelling), with `path` the mount-prefixed path so
1858
+ * spelled. On a disk that folds names, a path spelled otherwise reaches
1859
+ * disk's file, so every tool takes it by disk's name: one name per file,
1860
+ * and the names its answers give are disk's, except that a path that names
1861
+ * a workspace entry exactly, or does once its directories take disk's
1862
+ * spelling, keeps the entry's spelling. A path the workspace records an
1863
+ * intent at, such as a deletion disk hasn't been given yet, counts as an
1864
+ * entry here, so the calls that settle it reach it. The workspace's
1865
+ * entries (entriesOf) stand in for disk where it has nothing yet, so a
1866
+ * draft has one name too. Neither applies to a path that disk's spelling
1867
+ * puts in a nested mount's directory.
1868
+ * A handler that must read something at the moment it's asked, as
1869
+ * handleSync reads the branch a path sync adopts on, reads it before
1870
+ * calling this, since this awaits disk. The entries are read when the
1871
+ * step needs them, some after those awaits, from the branch current then.
1872
+ */
1873
+ async parsePath(path) {
1874
+ const given = this.parsePathAsGiven(path);
1875
+ const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
1876
+ return {
1877
+ mount: given.mount,
1878
+ relativePath,
1879
+ path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
1880
+ };
1881
+ }
1882
+ /**
1883
+ * A mount's entries on the current branch, and the paths it records an
1884
+ * intent at there, as diskSpelling reads them when it does, whether its
1885
+ * root folds case as the last start read it (false before the first), and
1886
+ * which nested mount's directory holds a path, by holderOf, as the
1887
+ * nested-mount checks read it. diskSpelling keeps disk's spelling of a
1888
+ * path that lies in a nested mount's directory as disk spells it, so the
1889
+ * checks refuse at least what they would refuse without the entries.
1890
+ */
1891
+ entriesOf(mount) {
1892
+ const store = this.getStore();
1893
+ const intents = this.intents.get(mount.config.name);
1894
+ return {
1895
+ // An intent recorded at a path, such as a deletion disk hasn't been
1896
+ // given yet or a conflict, is the workspace's own name for the file
1897
+ // there, as an entry is, so the calls that settle it reach it.
1898
+ has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
1899
+ under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
1900
+ holder: (path) => this.holderOf(mount.config.name, path)?.name,
1901
+ rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
1902
+ };
1903
+ }
1904
+ /**
1905
+ * Parse a mount-prefixed path into (mountName, relativePath), as spelled.
1906
+ */
1907
+ parsePathAsGiven(path) {
1908
+ // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
1909
+ // store would keep one name and disk another.
1910
+ if (LONE_SURROGATE.test(path)) {
1911
+ 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.`);
1912
+ }
1369
1913
  const slashIdx = path.indexOf('/');
1370
1914
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
1371
1915
  const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
@@ -1377,8 +1921,11 @@ export class WorkspaceModule {
1377
1921
  // Containment must compare with the PLATFORM separator (isContainedPath):
1378
1922
  // resolve() emits backslashes on Windows, so a '/'-suffixed root never
1379
1923
  // prefix-matches there and every in-mount path was rejected as traversal.
1380
- const resolved = resolve(mount.config.path, relativePath);
1381
- if (!isContainedPath(mount.config.path, resolved)) {
1924
+ // Against the resolved root, so a root configured with a trailing
1925
+ // separator, or relative, contains its own paths, the root included.
1926
+ const root = resolve(mount.config.path);
1927
+ const resolved = resolve(root, relativePath);
1928
+ if (!isContainedPath(root, resolved)) {
1382
1929
  throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
1383
1930
  }
1384
1931
  return { mount, relativePath };
@@ -1403,9 +1950,8 @@ export class WorkspaceModule {
1403
1950
  if (bytes.byteLength === 0) {
1404
1951
  throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1405
1952
  }
1406
- if (bytes.byteLength > maxSize) {
1407
- throw new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
1408
- }
1953
+ if (bytes.byteLength > maxSize)
1954
+ throw imageTooLarge(maxSize, mountPrefixedPath);
1409
1955
  return {
1410
1956
  bytes,
1411
1957
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
@@ -1422,29 +1968,125 @@ export class WorkspaceModule {
1422
1968
  }
1423
1969
  return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
1424
1970
  }
1971
+ /**
1972
+ * The mount root a disk read reads beneath: resolved, then checked as
1973
+ * observation checks it (checkRoot), so the read and the evidence agree on
1974
+ * which directory the mount is. Once the workspace has recorded the root's
1975
+ * identity, a root that resolves to any other directory is refused, in
1976
+ * ROOT_REPLACED's words; with none recorded yet, it need only be a
1977
+ * directory there. A refusal where no directory is at the root's path
1978
+ * carries the errno that gives (rootAbsence: ENOENT when nothing is there
1979
+ * and ENOTDIR when something else is, which a caller can read as no file,
1980
+ * or the errno the root's stat failed with otherwise, such as EACCES). A
1981
+ * root whose realpath fails carries realpath's errno, and one that is
1982
+ * another directory carries none. The identity is rootIdentityForRead's,
1983
+ * which answers while the evidence awaits reopening; where even it can't
1984
+ * be read, as when the evidence was never read back whole, the read is
1985
+ * refused too, with an errno only where no directory is at the root's path.
1986
+ */
1987
+ async readRoot(mount) {
1988
+ const name = mount.config.name;
1989
+ const root = resolve(mount.config.path);
1990
+ let real;
1991
+ try {
1992
+ real = await realpath(root);
1993
+ }
1994
+ catch (err) {
1995
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
1996
+ }
1997
+ let rootIdentity;
1998
+ try {
1999
+ rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2000
+ }
2001
+ catch (err) {
2002
+ // The evidence can't be read back, so neither can the check be made.
2003
+ // Where no directory is at the root's path, its errno still says so.
2004
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2005
+ }
2006
+ const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2007
+ if ('dir' in checked)
2008
+ return { real, dir: checked.dir };
2009
+ // Another directory is no absence. For any other refusal, what is at the
2010
+ // root's path now decides: nothing, something that isn't a directory, or
2011
+ // a directory the path has come to name since it was resolved (no errno).
2012
+ const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2013
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2014
+ }
2015
+ /**
2016
+ * The refusal for a root that stopped being the directory a read checked,
2017
+ * with the code and errno readRoot gives what is at its path now (its stage
2018
+ * and words are this check's): gone, or not a directory, with ENOENT or
2019
+ * ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
2020
+ * another directory, with none.
2021
+ */
2022
+ async rootMoved(mount) {
2023
+ const errno = await rootAbsence(resolve(mount.config.path));
2024
+ const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
2025
+ return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2026
+ }
1425
2027
  /**
1426
2028
  * Open a mount-relative file for reading with the mount boundary enforced on
1427
2029
  * the *filesystem*, not just the path string:
1428
2030
  *
1429
- * 1. `lstat` the lexical path — refuse directories; refuse a final-component
1430
- * symlink when the mount does not `followSymlinks`.
1431
- * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
2031
+ * 0. Check the root (readRoot) before anything beneath it: under a root
2032
+ * that isn't the mount's directory, no path says anything about the
2033
+ * mount's files, an absence included.
2034
+ * 1. On a mount that does not `followSymlinks`, refuse a path with a symlink
2035
+ * among the directories on its way beneath the mount's root, wherever it
2036
+ * leads (linkOnTheWay), before anything resolves it: as `escape` for a
2037
+ * link leading outside the mount, as `other_mount` for one into a nested
2038
+ * mount's directory, and as `parent_symlink` for any other.
2039
+ * 2. `lstat` the lexical path — refuse directories; refuse a final-component
2040
+ * symlink when the mount does not `followSymlinks`; refuse anything else
2041
+ * that isn't a regular file, such as a FIFO, without opening it.
2042
+ * 3. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
1432
2043
  * holds against a symlink swapped in between lstat and open; where it
1433
- * doesn't (Windows), re-`lstat` after opening instead.
1434
- * 3. `realpath` both the mount root and the opened path and require canonical
1435
- * containment — this is what catches an *intermediate* symlinked
1436
- * directory escaping the mount, and a followed symlink whose target lands
1437
- * outside, on every platform (junctions included).
1438
- * 4. Confirm the descriptor and the canonical path are the same inode.
2044
+ * doesn't (Windows), re-`lstat` after opening instead. `O_NONBLOCK`, so a
2045
+ * FIFO swapped in meanwhile doesn't wait for a writer.
2046
+ * 4. `realpath` the opened path. On a mount that does not `followSymlinks`,
2047
+ * refuse it again for a symlinked directory on its way, which catches one
2048
+ * swapped in since step 1 (linkRefusal). Then require canonical
2049
+ * containment in the root step 0 resolved — this is what catches an
2050
+ * *intermediate* symlinked directory escaping the mount, and a followed
2051
+ * symlink whose target lands outside, on every platform (junctions
2052
+ * included).
2053
+ * 5. Confirm the descriptor and the canonical path are the same inode, and
2054
+ * the root still the directory step 0 checked.
2055
+ *
2056
+ * Whatever steps 1 to 5 refuse, a missing file included, is said of the
2057
+ * mount's files only while the root is still that directory. If it isn't,
2058
+ * the read is refused with the code and errno step 0 gives what is at the
2059
+ * root's path then (rootMoved), as step 5's own check of the root does.
1439
2060
  *
1440
- * `inspect` runs right after the descriptor is stat'd and before the
1441
- * containment work, so callers can impose size policy in the same order the
1442
- * image reader always has. The returned handle is the caller's to close.
2061
+ * `inspect` runs last, after the boundary's checks, so a caller's own check
2062
+ * of the file, such as a size limit, never answers for a file the boundary
2063
+ * refuses. The returned handle is the caller's to close.
1443
2064
  */
1444
2065
  async openContainedFile(mount, relativePath, mountPrefixedPath, inspect) {
2066
+ const root = await this.readRoot(mount);
2067
+ try {
2068
+ return await this.openBeneathRoot(mount, root, relativePath, mountPrefixedPath, inspect);
2069
+ }
2070
+ catch (err) {
2071
+ if (err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && !(await root.dir.stillHere())) {
2072
+ throw await this.rootMoved(mount);
2073
+ }
2074
+ throw err;
2075
+ }
2076
+ }
2077
+ /** Steps 1 to 5 of openContainedFile, beneath the root that step 0 checked. */
2078
+ async openBeneathRoot(mount, { real: realMountRoot, dir: rootDir }, relativePath, mountPrefixedPath, inspect) {
1445
2079
  const lexicalPath = resolve(mount.config.path, relativePath);
1446
2080
  const follow = mount.config.followSymlinks === true;
1447
2081
  const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
2082
+ // Before anything resolves the path, so a link's own answer comes first: a
2083
+ // mount that doesn't follow symlinks goes through none among the
2084
+ // directories on the way, wherever it leads. Asked again of the path's
2085
+ // resolution after the open, below.
2086
+ const linkView = { root: mount.config.path, followSymlinks: follow };
2087
+ const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
2088
+ if (onTheWay !== null)
2089
+ throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
1448
2090
  let fileInfo;
1449
2091
  try {
1450
2092
  fileInfo = await lstat(lexicalPath);
@@ -1462,16 +2104,15 @@ export class WorkspaceModule {
1462
2104
  if (fileInfo.isSymbolicLink() && !follow) {
1463
2105
  throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
1464
2106
  }
1465
- let realMountRoot;
1466
- try {
1467
- realMountRoot = await realpath(mount.config.path);
1468
- }
1469
- catch (err) {
1470
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
2107
+ if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2108
+ // A FIFO, socket or device is never opened.
2109
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
1471
2110
  }
1472
2111
  let handle;
1473
2112
  try {
1474
- handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
2113
+ // O_NONBLOCK: a FIFO swapped in since the lstat opens at once instead of
2114
+ // waiting for a writer, and the regular-file check below refuses it.
2115
+ handle = await open(lexicalPath, fsConstants.O_RDONLY | NONBLOCK | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
1475
2116
  }
1476
2117
  catch (err) {
1477
2118
  const code = errnoOf(err);
@@ -1496,9 +2137,8 @@ export class WorkspaceModule {
1496
2137
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
1497
2138
  }
1498
2139
  if (!fileStat.isFile()) {
1499
- throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
2140
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
1500
2141
  }
1501
- inspect?.(fileStat);
1502
2142
  if (!follow && !useNoFollow) {
1503
2143
  let postOpenInfo;
1504
2144
  try {
@@ -1526,9 +2166,17 @@ export class WorkspaceModule {
1526
2166
  }
1527
2167
  throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
1528
2168
  }
2169
+ // A directory swapped for a link since the walk above is caught here, by
2170
+ // what the path resolved to after the open.
2171
+ const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
2172
+ if (resolvedThrough !== null)
2173
+ throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
1529
2174
  if (!isContainedPath(realMountRoot, realFilePath)) {
1530
2175
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
1531
2176
  }
2177
+ const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
2178
+ if (holder)
2179
+ throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
1532
2180
  let pathStat;
1533
2181
  try {
1534
2182
  pathStat = await stat(realFilePath);
@@ -1543,6 +2191,16 @@ export class WorkspaceModule {
1543
2191
  if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
1544
2192
  throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
1545
2193
  }
2194
+ // Containment compares paths, so it can't see another directory put at
2195
+ // the root's own path since step 0 (a drive unmounted mid-read): the
2196
+ // root is asked again, as an effect asks it (effects.ts moved()). This
2197
+ // sees the root as it is now, so a root replaced and put back before it
2198
+ // isn't seen (observe.ts's header says why no check can be sure).
2199
+ if (!(await rootDir.stillHere()))
2200
+ throw await this.rootMoved(mount);
2201
+ // The caller's checks of the file, such as a size limit, come after the
2202
+ // boundary's, so they never answer for a file the boundary refuses.
2203
+ inspect?.(fileStat);
1546
2204
  return { handle, fileStat, realFilePath };
1547
2205
  }
1548
2206
  catch (err) {
@@ -1562,11 +2220,25 @@ export class WorkspaceModule {
1562
2220
  * - With symlinks allowed, the canonical target must stay beneath the
1563
2221
  * canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
1564
2222
  * - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
1565
- * ever loaded, and `truncated` reports that the file was larger.
2223
+ * kept, and one byte past them is read to tell whether the file goes on
2224
+ * (`truncated`). The reads decide where the file ends, not the size fstat
2225
+ * gave at the open, so the bound holds for a file that grows while it's
2226
+ * read.
2227
+ * - Once the workspace has recorded the mount root's identity, with the
2228
+ * first evidence it gathers there, a root that is another directory is
2229
+ * an unavailable mount: a replaced directory, a link swapped in for it,
2230
+ * or an unmounted drive's mountpoint. In any mount, recorded or not, a
2231
+ * root that stops being the directory the read checked until the file
2232
+ * has been opened and checked is unavailable too: with ENOENT or
2233
+ * ENOTDIR where no directory is there, as before the read, and with no
2234
+ * errno for another directory. The file's bytes are then read through
2235
+ * the descriptor that was checked.
1566
2236
  *
1567
2237
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
1568
- * mount, lexical traversal, unavailable mount, missing file, directory,
1569
- * policy-denied symlink, outside-mount target, and file-changed-during-read.
2238
+ * mount, lexical traversal, a path in another mount's directory nested in
2239
+ * the one named (`other_mount`), unavailable mount, missing or non-regular
2240
+ * file, directory, policy-denied symlink, outside-mount target, and
2241
+ * file-changed-during-read.
1570
2242
  */
1571
2243
  async readFileFromDisk(mountPrefixedPath, options = {}) {
1572
2244
  if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
@@ -1575,11 +2247,15 @@ export class WorkspaceModule {
1575
2247
  let mount;
1576
2248
  let relativePath;
1577
2249
  try {
1578
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
2250
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1579
2251
  }
1580
2252
  catch (err) {
2253
+ // A link's refusal (parseFilePath) carries its own code.
2254
+ if (err instanceof WorkspaceReadError)
2255
+ throw err;
1581
2256
  const message = err instanceof Error ? err.message : String(err);
1582
- const code = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2257
+ const code = err instanceof NestedMountError ? 'other_mount'
2258
+ : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
1583
2259
  throw new WorkspaceReadError(code, message, 'parse');
1584
2260
  }
1585
2261
  if (!relativePath) {
@@ -1587,26 +2263,18 @@ export class WorkspaceModule {
1587
2263
  }
1588
2264
  const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
1589
2265
  try {
1590
- const size = fileStat.size;
1591
2266
  const cap = options.maxBytes;
1592
2267
  let bytes;
1593
2268
  let truncated = false;
1594
- if (cap !== undefined && size > cap) {
1595
- truncated = true;
1596
- bytes = Buffer.alloc(cap);
1597
- let offset = 0;
1598
- while (offset < cap) {
1599
- const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
1600
- if (bytesRead === 0)
1601
- break;
1602
- offset += bytesRead;
1603
- }
1604
- if (offset < cap)
1605
- bytes = bytes.subarray(0, offset);
2269
+ if (cap === undefined) {
2270
+ bytes = await handle.readFile();
1606
2271
  }
1607
2272
  else {
1608
- bytes = await handle.readFile();
2273
+ ({ bytes, more: truncated } = await readPrefix(handle, cap, fileStat.size));
1609
2274
  }
2275
+ // What the read found, not what the fstat before it said, so `size`,
2276
+ // `bytes` and `truncated` agree even for a file that changed in between.
2277
+ const size = truncated ? Math.max((await handle.stat()).size, bytes.length + 1) : bytes.length;
1610
2278
  return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
1611
2279
  }
1612
2280
  catch (err) {
@@ -1623,17 +2291,39 @@ export class WorkspaceModule {
1623
2291
  const p = mountPrefixedPath;
1624
2292
  switch (err.code) {
1625
2293
  case 'mount_unavailable':
1626
- return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
2294
+ // With why, where the root's check says, such as a directory that
2295
+ // isn't the mount's.
2296
+ return new WorkspaceImageReadError('mount_unavailable', err.message);
1627
2297
  case 'directory':
1628
2298
  return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
1629
2299
  case 'symlink':
1630
- return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
2300
+ // The core reader's own words for a link at the path: "Symlinks are not
2301
+ // allowed: <path>".
2302
+ return new WorkspaceImageReadError('symlink', err.message);
2303
+ case 'parent_symlink':
2304
+ // The core reader's own words for a link on its way: which link and
2305
+ // where it leads, with the setting for a link to a directory.
2306
+ return new WorkspaceImageReadError('parent_symlink', err.message);
2307
+ case 'other_mount':
2308
+ // The core reader's own words for a path a link takes into a nested
2309
+ // mount's directory: that mount and the path to use there. (#460's
2310
+ // own other_mount, a path in that directory, is refused at parse,
2311
+ // and so is a link found there before the read.)
2312
+ return new WorkspaceImageReadError('other_mount', err.message);
1631
2313
  case 'escape':
1632
- return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
2314
+ // The core reader's own words: "Symlink escape detected: <path>" for a
2315
+ // followed link that resolves outside the mount, and for a link on the
2316
+ // way that leads outside a mount that doesn't follow links, which link.
2317
+ return new WorkspaceImageReadError('escape', err.message);
1633
2318
  case 'changed':
1634
2319
  return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
1635
2320
  case 'not_found':
1636
2321
  default:
2322
+ // The core reader's own words for a file that isn't regular, and for
2323
+ // a directory on the way it couldn't ask about.
2324
+ if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
2325
+ return new WorkspaceImageReadError('not_found', err.message);
2326
+ }
1637
2327
  if (err.message.startsWith('File not found')) {
1638
2328
  return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
1639
2329
  }
@@ -1653,9 +2343,8 @@ export class WorkspaceModule {
1653
2343
  if (fileStat.size === 0) {
1654
2344
  throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1655
2345
  }
1656
- if (fileStat.size > maxSize) {
1657
- throw new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
1658
- }
2346
+ if (fileStat.size > maxSize)
2347
+ throw imageTooLarge(maxSize, mountPrefixedPath);
1659
2348
  });
1660
2349
  }
1661
2350
  catch (err) {
@@ -1668,9 +2357,13 @@ export class WorkspaceModule {
1668
2357
  }
1669
2358
  throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1670
2359
  }
1671
- const { handle } = opened;
2360
+ const { handle, fileStat } = opened;
1672
2361
  try {
1673
- const bytes = await handle.readFile();
2362
+ // No more than maxSize is loaded: a file grown past it since the fstat
2363
+ // above is refused here, as one already over it was there.
2364
+ const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
2365
+ if (more)
2366
+ throw imageTooLarge(maxSize, mountPrefixedPath);
1674
2367
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
1675
2368
  }
1676
2369
  catch (err) {
@@ -1685,19 +2378,33 @@ export class WorkspaceModule {
1685
2378
  await handle.close();
1686
2379
  }
1687
2380
  }
2381
+ /**
2382
+ * Whether a tool's change on this mount goes to disk as it commits: the
2383
+ * push commitToolChange makes, and what an edit's answer says of it.
2384
+ */
2385
+ pushesOnCommit(mount) {
2386
+ return mount.config.autoMaterialize === true && mount.config.mode !== 'read-only';
2387
+ }
1688
2388
  /**
1689
2389
  * Commit a tool's write or delete to the workspace tree, with the branch
1690
- * intent it implies (store origin for a path disk never agreed with; a
1691
- * tombstone for a deletion), and push it to disk when the mount opts in via
2390
+ * intent it implies: store origin for a path disk never agreed with; for a
2391
+ * deletion, a tombstone, unless the evidence holds no agreed disk copy (P
2392
+ * absent, or no P and store origin), when it clears the path's intent and
2393
+ * pushes nothing. It pushes the change to disk when the mount opts in via
1692
2394
  * `autoMaterialize` — required for cross-agent pipelines, since another
1693
2395
  * agent's watcher on the same directory only sees real filesystem events.
1694
2396
  * The push follows materialize's rule: a disk copy that changed since it
1695
2397
  * last agreed is not overwritten or unlinked, and the refusal says why.
1696
2398
  *
1697
- * Returns the reason when the change reached the workspace but not disk, so
1698
- * callers surface it in the tool result: the autoMaterialize contract is
1699
- * that disk is the source of truth for downstream agents. The caller holds
1700
- * the mount's turn and has settled the path first (settleBeforeMutation).
2399
+ * Answers, so callers surface them in the tool result, the reason when the
2400
+ * change reached the workspace but not disk (`refused`): the
2401
+ * autoMaterialize contract is that disk is the source of truth for
2402
+ * downstream agents. And, apart from it, why a write whose bytes reached
2403
+ * disk left the file's mode as disk had it (`modeKept`), and, for a
2404
+ * deletion that cleared a conflict naming a disk file the workspace never
2405
+ * took in, that nothing was unlinked (`note`). The caller holds the mount's
2406
+ * turn and has settled the path first (settleBeforeMutation, or a pass over
2407
+ * the path, as an edit makes).
1701
2408
  */
1702
2409
  async commitToolChange(mount, relativePath, change, onCommitted) {
1703
2410
  const store = this.getStore();
@@ -1705,7 +2412,8 @@ export class WorkspaceModule {
1705
2412
  // The mutation's precondition, enforced here, synchronously with the
1706
2413
  // commit: whatever settleBeforeMutation's pass could or couldn't settle.
1707
2414
  settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
1708
- const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
2415
+ const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
2416
+ const known = evidence !== undefined;
1709
2417
  if (change.kind === 'set') {
1710
2418
  store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
1711
2419
  // A later intent update or disk push can throw; the tree entry is
@@ -1725,21 +2433,52 @@ export class WorkspaceModule {
1725
2433
  }
1726
2434
  else {
1727
2435
  store.treeRemove(mount.treeStateId, relativePath);
2436
+ onCommitted?.();
2437
+ // A deletion whose evidence holds no agreed disk copy (disk last agreed
2438
+ // it had nothing here, or has never agreed with this entry, which the
2439
+ // workspace created) puts the store back at the agreement. Nothing on
2440
+ // disk is the workspace's to unlink, so it pushes nothing, and it
2441
+ // clears the path's whole intent: no tombstone, so it needs no proof of
2442
+ // absence, which a path disk can't show, such as one beneath a symlink
2443
+ // the mount doesn't follow, would never give; and no conflict, which
2444
+ // has nothing left to be about once the workspace has given up its
2445
+ // side. Whatever disk holds there is a sync's to observe, a file the
2446
+ // workspace never saw included. Any other deletion keeps its
2447
+ // tombstone, which guards it: a copy disk agreed with, a push of ours
2448
+ // that may have landed (pending or interrupted), or an entry from
2449
+ // before the evidence.
2450
+ let agreed = true;
2451
+ let note = null;
1728
2452
  intents.update(relativePath, (cur) => {
1729
- const next = { ...cur, tombstone: { hash: change.previousHash } };
1730
- delete next.origin;
1731
- return next;
2453
+ agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
2454
+ if (agreed) {
2455
+ const next = { ...cur, tombstone: { hash: change.previousHash } };
2456
+ delete next.origin;
2457
+ return next;
2458
+ }
2459
+ // A conflict it clears may name a file disk held there, which the
2460
+ // workspace never took in: the answer says that file wasn't unlinked,
2461
+ // where the deletion alone would read as having removed it. The
2462
+ // deletion doesn't look at disk, so a file nothing recorded gets no
2463
+ // note.
2464
+ if (cur.conflict?.disk) {
2465
+ note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
2466
+ `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
2467
+ }
2468
+ return {};
1732
2469
  });
2470
+ if (!agreed)
2471
+ return { refused: null, modeKept: null, note };
1733
2472
  }
1734
- if (!mount.config.autoMaterialize || mount.config.mode === 'read-only')
1735
- return null;
2473
+ if (!this.pushesOnCommit(mount))
2474
+ return { refused: null, modeKept: null, note: null };
1736
2475
  const pushed = await this.pushUnlocked(mount, [relativePath], {
1737
2476
  applyDeletions: change.kind === 'remove',
1738
2477
  branchId: store.currentBranch().id,
1739
2478
  });
1740
2479
  const refused = pushed.skipped.find((s) => s.path === relativePath);
1741
2480
  if (!refused)
1742
- return null;
2481
+ return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
1743
2482
  this.ctx?.pushEvent({
1744
2483
  type: 'workspace:materialize-failed',
1745
2484
  mount: mount.config.name,
@@ -1747,7 +2486,7 @@ export class WorkspaceModule {
1747
2486
  op: change.kind === 'set' ? 'write' : 'delete',
1748
2487
  error: refused.reason,
1749
2488
  });
1750
- return refused.reason;
2489
+ return { refused: refused.reason, modeKept: null, note: null };
1751
2490
  }
1752
2491
  // ==========================================================================
1753
2492
  // Lazy Sync
@@ -1758,25 +2497,30 @@ export class WorkspaceModule {
1758
2497
  * disk now holds (an edit sees a disk change the settled adoption took in).
1759
2498
  * The pass may not settle it (a branch that keeps changing, an unreadable
1760
2499
  * disk copy); the precondition that matters is enforced again,
1761
- * synchronously, where the change commits (commitToolChange). The caller
1762
- * holds the mount's turn.
2500
+ * synchronously, where the change commits (commitToolChange). The pass
2501
+ * names the path, as a file tool names the one it acts on, so a path the
2502
+ * mount passes over by design is observed there, as a sync naming it would
2503
+ * observe it (#276). The caller holds the mount's turn.
1763
2504
  */
1764
2505
  async settleBeforeMutation(mount, relativePath) {
1765
2506
  if (this.agreementOrThrow().get(mount.config.name, relativePath)?.kind !== 'pending')
1766
2507
  return;
1767
- await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2508
+ await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
1768
2509
  }
1769
2510
  /**
1770
2511
  * Before a read: a path the workspace doesn't hold is observed under the
1771
2512
  * three-way rule, which ingests a new disk file but never brings back a
1772
- * workspace deletion or a file absent from this branch. The caller holds
1773
- * the mount's turn.
2513
+ * workspace deletion or a file absent from this branch. The pass names the
2514
+ * path, as a file tool names the one it acts on: a path the mount passes
2515
+ * over by design is observed, as a sync naming it would observe it (#276),
2516
+ * while a path in another mount's directory is refused before this, and
2517
+ * passed over by any pass. The caller holds the mount's turn.
1774
2518
  */
1775
2519
  async ensureSynced(mount, relativePath) {
1776
2520
  const store = this.getStore();
1777
2521
  if (store.treeGet(mount.treeStateId, relativePath))
1778
2522
  return undefined;
1779
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2523
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
1780
2524
  return pass.reports.get(relativePath);
1781
2525
  }
1782
2526
  /** Why the workspace holds no entry at a path, when disk explains it. */
@@ -1805,19 +2549,15 @@ export class WorkspaceModule {
1805
2549
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
1806
2550
  const offsetChars = input.offsetChars ?? 0;
1807
2551
  const limitChars = input.limitChars ?? 2000;
1808
- if (characterPaging) {
1809
- if (input.offset !== undefined || input.limit !== undefined) {
1810
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
1811
- }
1812
- if (!Number.isSafeInteger(offsetChars) || offsetChars < 0
1813
- || !Number.isSafeInteger(limitChars) || limitChars < 1
1814
- || input.offsetChars === null || input.limitChars === null) {
1815
- return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
1816
- }
2552
+ // Each number is a whole number of at least its schema's minimum by now
2553
+ // (inputProblem).
2554
+ if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
2555
+ return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
1817
2556
  }
1818
2557
  let content = generatedContent;
1819
2558
  if (content === undefined) {
1820
- const { mount, relativePath } = this.parsePath(input.path);
2559
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2560
+ input = { ...input, path };
1821
2561
  const store = this.getStore();
1822
2562
  const found = await this.withMount(mount, async () => {
1823
2563
  const report = await this.ensureSynced(mount, relativePath);
@@ -1855,13 +2595,29 @@ export class WorkspaceModule {
1855
2595
  },
1856
2596
  };
1857
2597
  }
1858
- const lines = content.split('\n');
2598
+ const lines = linesOf(content);
1859
2599
  // Apply offset/limit. An unlimited read of a large file would inject the
1860
2600
  // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
1861
2601
  // past the model context and 400s), so an omitted limit falls back to a
1862
2602
  // default cap; the result reports total/from/to so the agent can page.
1863
2603
  const DEFAULT_READ_LINE_LIMIT = 2000;
1864
2604
  const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
2605
+ if (startLine >= lines.length) {
2606
+ // Nothing to show: an empty file, or an offset past the last line. No
2607
+ // range is claimed, and the line count says where the file ends.
2608
+ return {
2609
+ success: true,
2610
+ data: {
2611
+ path: input.path,
2612
+ totalLines: lines.length,
2613
+ // Reading an empty file from its start is reading it whole.
2614
+ ...(startLine > 0
2615
+ ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
2616
+ : {}),
2617
+ content: '',
2618
+ },
2619
+ };
2620
+ }
1865
2621
  const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
1866
2622
  const endLine = startLine + effectiveLimit;
1867
2623
  const slice = lines.slice(startLine, endLine);
@@ -1888,7 +2644,9 @@ export class WorkspaceModule {
1888
2644
  let mount;
1889
2645
  let relativePath;
1890
2646
  try {
1891
- ({ mount, relativePath } = this.parsePath(input.path));
2647
+ let path;
2648
+ ({ mount, relativePath, path } = await this.parseFilePath(input.path));
2649
+ input = { ...input, path };
1892
2650
  }
1893
2651
  catch (err) {
1894
2652
  return {
@@ -1957,7 +2715,11 @@ export class WorkspaceModule {
1957
2715
  }
1958
2716
  }
1959
2717
  async handleWrite(input) {
1960
- const { mount, relativePath } = this.parsePath(input.path);
2718
+ if (LONE_SURROGATE.test(input.content)) {
2719
+ return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
2720
+ }
2721
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2722
+ input = { ...input, path };
1961
2723
  if (mount.config.mode === 'read-only') {
1962
2724
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
1963
2725
  }
@@ -1967,112 +2729,210 @@ export class WorkspaceModule {
1967
2729
  return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
1968
2730
  }
1969
2731
  const buffer = Buffer.from(input.content, 'utf-8');
1970
- const materializeError = await this.withMount(mount, async () => {
1971
- await this.settleBeforeMutation(mount, relativePath);
1972
- const blobHash = store.storeBlob(buffer, 'text/plain');
1973
- return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength });
1974
- });
1975
- if (materializeError) {
1976
- return {
1977
- success: false,
1978
- 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.`,
1979
- isError: true,
1980
- };
2732
+ const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
2733
+ let committed = false;
2734
+ let refused;
2735
+ let modeKept;
2736
+ try {
2737
+ ({ refused, modeKept } = await this.withMount(mount, async () => {
2738
+ await this.settleBeforeMutation(mount, relativePath);
2739
+ const blobHash = store.storeBlob(buffer, 'text/plain');
2740
+ // An overwrite keeps the file's mode, as an edit does.
2741
+ const existing = store.treeGet(mount.treeStateId, relativePath);
2742
+ return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
2743
+ }));
1981
2744
  }
1982
- return {
1983
- success: true,
1984
- data: {
1985
- path: input.path,
1986
- size: Buffer.byteLength(input.content),
1987
- hash: hashContent(input.content),
1988
- },
1989
- };
2745
+ catch (error) {
2746
+ if (!committed)
2747
+ throw error;
2748
+ return storedButUnfinished('write', input.path, { threw: error instanceof Error ? error.message : String(error) });
2749
+ }
2750
+ if (refused !== null)
2751
+ return storedButUnfinished('write', input.path, { refused });
2752
+ return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
1990
2753
  }
1991
2754
  async handleEdit(input) {
1992
- const { mount, relativePath } = this.parsePath(input.path);
2755
+ // Refused before anything is read: an empty oldString matches between
2756
+ // every character, and UTF-8 can't store a lone surrogate.
2757
+ if (input.oldString === '') {
2758
+ 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.' };
2759
+ }
2760
+ for (const field of ['oldString', 'newString']) {
2761
+ if (LONE_SURROGATE.test(input[field])) {
2762
+ return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
2763
+ }
2764
+ }
2765
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2766
+ input = { ...input, path };
1993
2767
  if (mount.config.mode === 'read-only') {
1994
2768
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
1995
2769
  }
1996
2770
  const store = this.getStore();
1997
- return this.withMount(mount, async () => {
1998
- const report = await this.ensureSynced(mount, relativePath);
1999
- await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry
2000
- const entry = store.treeGet(mount.treeStateId, relativePath);
2001
- if (!entry) {
2002
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2003
- }
2004
- const blob = store.getBlob(entry.blobHash);
2005
- if (!blob) {
2006
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2007
- }
2008
- let content = blob.toString('utf-8');
2009
- // Validate uniqueness
2010
- if (!input.replaceAll) {
2011
- const count = content.split(input.oldString).length - 1;
2771
+ let committed = false;
2772
+ let appliedNote = null;
2773
+ try {
2774
+ return await this.withMount(mount, async () => {
2775
+ // The edit applies to the path as disk holds it now: a change on disk
2776
+ // to a file the workspace holds is taken in first, by the rule a
2777
+ // listing uses. The pass settles a pending intent on the path too, as
2778
+ // settleBeforeMutation's would, and the commit enforces the rest
2779
+ // (commitToolChange). A write passes over its path only to settle a
2780
+ // pending intent, since it replaces the whole file. As every file
2781
+ // tool's pass does, it names the path it acts on, so a file the
2782
+ // mount passes over by design is observed (see PassOptions.named).
2783
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2784
+ const report = pass.reports.get(relativePath);
2785
+ // A mode taken in alone isn't a change to the text: it brings nothing
2786
+ // new to read, as it makes no event (emitFsEvents), and the edit then
2787
+ // writes at the mode disk has, so it leaves nothing to say.
2788
+ const took = (op) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
2789
+ // Which copy the edit used, when it isn't the one the workspace last
2790
+ // agreed on with disk: disk's newer text, or the workspace's copy
2791
+ // because disk's couldn't be read or disk has none. A file the
2792
+ // workspace didn't hold has no older copy, so taking it in needs no
2793
+ // word.
2794
+ const whichCopy = (verb) => took('modified')
2795
+ ? `Disk's copy had changed since the workspace last agreed with it, so it was taken in first, and the edit ${verb} that text.`
2796
+ : report?.state === 'unverified'
2797
+ ? `The path couldn't be checked against disk (${report.note ?? 'not observed'}), so the edit ${verb} the workspace's copy.`
2798
+ : report?.state === 'disk-missing-provenance-unknown'
2799
+ ? `Disk has no copy of this file, and nothing records whether it was deleted there, so the edit ${verb} the workspace's copy.`
2800
+ : null;
2801
+ // A conflict the pass found is named when the edit is refused for the
2802
+ // text it found, on either kind of mount, and on a success where the
2803
+ // mount doesn't push, since such a mount keeps the conflict until
2804
+ // materialize or sync settles it. A pushing mount's success met none
2805
+ // (below). A stored edit that couldn't finish leaves it to the push's
2806
+ // reason, which names the conflict, or to ls, which its answer points to.
2807
+ const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
2808
+ // Where disk has no copy now, whatever the conflict's kind, a sync of
2809
+ // the path takes disk's deletion and a forced materialize writes the
2810
+ // workspace's copy back. Where it has one, a sync keeps it and gives
2811
+ // up the workspace's, whether or not the workspace can hold disk's
2812
+ // copy: one it can't hold stays on disk alone.
2813
+ const conflictNote = (where) => conflict === null ? null
2814
+ : `The path is in conflict with disk (${conflict}): the edit ${where} the workspace's copy. ` + (report?.diskNow === 'absent'
2815
+ ? "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."
2816
+ : "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.");
2817
+ const suffix = [whichCopy('looked in'), conflictNote('looked in')].filter((n) => n !== null).map((n) => ` ${n}`).join('');
2818
+ appliedNote = whichCopy('applied to');
2819
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2820
+ if (!entry) {
2821
+ // A pass also drops a copy disk made binary or too large, which
2822
+ // the disk-only answer describes; any other drop is disk's deletion.
2823
+ const error = took('deleted') && report?.state !== 'disk-only'
2824
+ ? `File not found: ${input.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
2825
+ : this.notInWorkspace(input.path, report);
2826
+ return { success: false, error, isError: true };
2827
+ }
2828
+ const blob = store.getBlob(entry.blobHash);
2829
+ if (!blob) {
2830
+ return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2831
+ }
2832
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
2833
+ // the text back would change bytes the edit never named.
2834
+ if (!isUtf8(blob)) {
2835
+ return {
2836
+ success: false,
2837
+ isError: true,
2838
+ error: `The edit did nothing: ${input.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
2839
+ + `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}`,
2840
+ };
2841
+ }
2842
+ let content = blob.toString('utf-8');
2843
+ // An edit that would replace nothing is refused, replaceAll or not;
2844
+ // without replaceAll, the text must occur once, and the refusal says
2845
+ // how often it does. With replaceAll, whether it occurs is enough.
2846
+ const count = input.replaceAll
2847
+ ? Number(content.includes(input.oldString))
2848
+ : content.split(input.oldString).length - 1;
2012
2849
  if (count === 0) {
2013
- return { success: false, error: `String not found in ${input.path}`, isError: true };
2850
+ return { success: false, error: `String not found in ${input.path}.${suffix}`, isError: true };
2014
2851
  }
2015
- if (count > 1) {
2852
+ if (count > 1 && !input.replaceAll) {
2016
2853
  return {
2017
2854
  success: false,
2018
- error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
2855
+ error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.${suffix}`,
2019
2856
  isError: true,
2020
2857
  };
2021
2858
  }
2022
- }
2023
- content = input.replaceAll
2024
- ? content.replaceAll(input.oldString, input.newString)
2025
- : content.replace(input.oldString, input.newString);
2026
- const newBuffer = Buffer.from(content, 'utf-8');
2027
- const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2028
- const materializeError = await this.commitToolChange(mount, relativePath, {
2029
- kind: 'set',
2030
- blobHash: newBlobHash,
2031
- size: newBuffer.byteLength,
2032
- mode: entry.mode,
2033
- });
2034
- if (materializeError) {
2859
+ // A replacer function, so that the new text goes in as given: in a
2860
+ // replacement string, $&, $$, $' and $` are patterns.
2861
+ content = input.replaceAll
2862
+ ? content.replaceAll(input.oldString, () => input.newString)
2863
+ : content.replace(input.oldString, () => input.newString);
2864
+ const newBuffer = Buffer.from(content, 'utf-8');
2865
+ const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2866
+ const { refused, modeKept } = await this.commitToolChange(mount, relativePath, {
2867
+ kind: 'set',
2868
+ blobHash: newBlobHash,
2869
+ size: newBuffer.byteLength,
2870
+ mode: entry.mode,
2871
+ }, () => { committed = true; });
2872
+ if (refused !== null)
2873
+ return storedButUnfinished('edit', input.path, { refused }, appliedNote);
2874
+ // The edit makes the path the workspace's own change (commitToolChange),
2875
+ // so a file disk lacked goes back with this edit's push, or with the
2876
+ // next materialize where the mount doesn't push. A push that didn't
2877
+ // complete answers above, with its reason. One that completed met no
2878
+ // conflict, since this push, unforced, stops at one: the edit made the
2879
+ // two copies agree, which settles it, or there was none. So only a
2880
+ // mount that doesn't push still holds the conflict the pass found.
2881
+ const pushes = this.pushesOnCommit(mount);
2882
+ const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
2883
+ ? `${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.`
2884
+ : null;
2885
+ const notes = [appliedNote, backOnDisk, pushes ? null : conflictNote('is in')].filter((n) => n !== null);
2035
2886
  return {
2036
- success: false,
2037
- error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
2038
- isError: true,
2887
+ success: true,
2888
+ data: {
2889
+ path: input.path,
2890
+ size: newBuffer.byteLength,
2891
+ ...(notes.length > 0 ? { note: notes.join(' ') } : {}),
2892
+ ...(modeKept ? { modeKept } : {}),
2893
+ },
2039
2894
  };
2040
- }
2041
- return {
2042
- success: true,
2043
- data: {
2044
- path: input.path,
2045
- size: newBuffer.byteLength,
2046
- },
2047
- };
2048
- });
2895
+ });
2896
+ }
2897
+ catch (error) {
2898
+ if (!committed)
2899
+ throw error;
2900
+ return storedButUnfinished('edit', input.path, { threw: error instanceof Error ? error.message : String(error) }, appliedNote);
2901
+ }
2049
2902
  }
2050
2903
  async handleDelete(input) {
2051
- const { mount, relativePath } = this.parsePath(input.path);
2904
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2905
+ input = { ...input, path };
2052
2906
  if (mount.config.mode === 'read-only') {
2053
2907
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2054
2908
  }
2055
2909
  const store = this.getStore();
2056
- return this.withMount(mount, async () => {
2057
- const report = await this.ensureSynced(mount, relativePath);
2058
- await this.settleBeforeMutation(mount, relativePath);
2059
- const entry = store.treeGet(mount.treeStateId, relativePath);
2060
- if (!entry) {
2061
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2062
- }
2063
- const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash });
2064
- if (materializeError) {
2910
+ let committed = false;
2911
+ try {
2912
+ return await this.withMount(mount, async () => {
2913
+ const report = await this.ensureSynced(mount, relativePath);
2914
+ await this.settleBeforeMutation(mount, relativePath);
2915
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2916
+ if (!entry) {
2917
+ return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2918
+ }
2919
+ const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
2920
+ if (refused !== null)
2921
+ return storedButUnfinished('delete', input.path, { refused });
2922
+ // Its whole receipt is what it changed: kept in the stub if the guard
2923
+ // withholds the result (agent-framework #277).
2065
2924
  return {
2066
- success: false,
2067
- error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` +
2068
- 'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.',
2069
- isError: true,
2925
+ success: true,
2926
+ data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
2927
+ keepWhenWithheld: ['path', 'deleted', 'note'],
2070
2928
  };
2071
- }
2072
- // Its whole receipt is what it changed: kept in the stub if the guard
2073
- // withholds the result (agent-framework #277).
2074
- return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
2075
- });
2929
+ });
2930
+ }
2931
+ catch (error) {
2932
+ if (!committed)
2933
+ throw error;
2934
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
2935
+ }
2076
2936
  }
2077
2937
  async handleLs(input) {
2078
2938
  const store = this.getStore();
@@ -2085,7 +2945,8 @@ export class WorkspaceModule {
2085
2945
  }));
2086
2946
  return { success: true, data: { mounts } };
2087
2947
  }
2088
- const { mount, relativePath } = this.parsePath(input.path);
2948
+ const { mount, relativePath, path } = await this.parsePath(input.path);
2949
+ input = { ...input, path };
2089
2950
  const recursive = input.recursive === true;
2090
2951
  // Every listing checks the paths it shows against disk first, under the
2091
2952
  // three-way rule, whatever the mount's watch mode.
@@ -2134,10 +2995,15 @@ export class WorkspaceModule {
2134
2995
  const incomplete = [];
2135
2996
  // Search across mounts
2136
2997
  const mountsToSearch = input.path
2137
- ? [this.parsePath(input.path)]
2998
+ ? [await this.parsePath(input.path)]
2138
2999
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
3000
+ // Every match lies beneath the pattern's literal directory, so the walk
3001
+ // starts there: past the walk's cap, a walk of the whole scope can stop
3002
+ // before it gets there.
3003
+ const patternDir = literalDirOf(input.pattern);
2139
3004
  for (const { mount, relativePath } of mountsToSearch) {
2140
- const pass = await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
3005
+ const start = await this.walkStart(mount, relativePath, patternDir);
3006
+ const pass = await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: start, recursive: true }));
2141
3007
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
2142
3008
  const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
2143
3009
  if (regex.test(testPath)) {
@@ -2165,19 +3031,24 @@ export class WorkspaceModule {
2165
3031
  regex = new RegExp(input.pattern);
2166
3032
  }
2167
3033
  catch (e) {
2168
- return { success: false, error: `Invalid regex: ${input.pattern}`, isError: true };
3034
+ // The parser's reason says where the pattern goes wrong.
3035
+ return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
2169
3036
  }
2170
3037
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
3038
+ // The filter matches mount-relative paths, so every file it lets through
3039
+ // lies beneath its literal directory, and the walk starts at the deeper of
3040
+ // that and `path`.
3041
+ const filterDir = input.glob ? literalDirOf(input.glob) : '';
2171
3042
  const contextBefore = input.contextBefore ?? 0;
2172
3043
  const contextAfter = input.contextAfter ?? 0;
2173
3044
  const mountsToSearch = input.path
2174
- ? [this.parsePath(input.path)]
3045
+ ? [await this.parsePath(input.path)]
2175
3046
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2176
3047
  const results = [];
2177
3048
  const skipped = [];
2178
3049
  const incomplete = [];
2179
3050
  const search = (content) => {
2180
- const lines = content.split('\n');
3051
+ const lines = linesOf(content);
2181
3052
  const found = [];
2182
3053
  for (let i = 0; i < lines.length; i++) {
2183
3054
  if (regex.test(lines[i])) {
@@ -2193,6 +3064,19 @@ export class WorkspaceModule {
2193
3064
  return found;
2194
3065
  };
2195
3066
  for (const { mount, relativePath } of mountsToSearch) {
3067
+ // A path named in another mount's directory, as holderOf resolves it,
3068
+ // is that mount's: grep searches nothing of this mount's there, and
3069
+ // says whose it is.
3070
+ if (this.holderOf(mount.config.name, relativePath)) {
3071
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list') });
3072
+ continue;
3073
+ }
3074
+ // A `path` and a filter whose directories don't overlap leave no file
3075
+ // the filter can let through, so nothing is searched.
3076
+ const deeper = deeperOf(relativePath, filterDir);
3077
+ if (deeper === null)
3078
+ continue;
3079
+ const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
2196
3080
  await this.withMount(mount, async () => {
2197
3081
  // A `path` naming a single file greps just that file; otherwise it is a
2198
3082
  // directory prefix. (A file path once produced the prefix "notes.md/"
@@ -2204,13 +3088,31 @@ export class WorkspaceModule {
2204
3088
  // on: one selected in between would label its content with the other
2205
3089
  // branch's states. So a pass whose branch is no longer selected runs
2206
3090
  // again, and a branch that keeps changing leaves the scope unsearched.
3091
+ //
3092
+ // A copy held at a path that now leads into a nested mount's directory
3093
+ // through a link is that mount's file too: read refuses it
3094
+ // (parseFilePath), so grep searches none of it and says why. The pass
3095
+ // can't check such a path's disk, so only an unverified one can be
3096
+ // one. Asking awaits disk, so it's done before the branch is
3097
+ // compared, and a branch selected meanwhile runs the pass again.
2207
3098
  let pass = null;
3099
+ let refused = new Map();
2208
3100
  for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
2209
3101
  const next = await this.passUnlocked(mount, fileScope
2210
3102
  ? { kind: 'paths', paths: [relativePath] }
2211
- : { kind: 'dir', dir: relativePath, recursive: true });
2212
- if (next.branchId === undefined || next.branchId === store.currentBranch().id)
3103
+ : { kind: 'dir', dir: start, recursive: true });
3104
+ const asked = new Map();
3105
+ for (const report of next.reports.values()) {
3106
+ if (report.state !== 'unverified')
3107
+ continue;
3108
+ const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
3109
+ if (refusal)
3110
+ asked.set(report.path, refusal.message);
3111
+ }
3112
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
2213
3113
  pass = next;
3114
+ refused = asked;
3115
+ }
2214
3116
  }
2215
3117
  if (pass === null) {
2216
3118
  incomplete.push({
@@ -2236,6 +3138,16 @@ export class WorkspaceModule {
2236
3138
  });
2237
3139
  continue;
2238
3140
  }
3141
+ // A path in another mount's directory is that mount's: grep gives
3142
+ // none of this mount's copy of it, as read refuses it. Beneath a
3143
+ // scope it's passed over by design, like the directory.
3144
+ if (inOtherMount(report.path, this.othersOf(mount.config.name)))
3145
+ continue;
3146
+ const refusal = refused.get(report.path);
3147
+ if (refusal !== undefined) {
3148
+ skipped.push({ file, reason: refusal });
3149
+ continue;
3150
+ }
2239
3151
  // The workspace's version, labelled with the path's state.
2240
3152
  const entry = store.treeGet(mount.treeStateId, report.path);
2241
3153
  const blob = entry ? store.getBlob(entry.blobHash) : null;
@@ -2298,7 +3210,8 @@ export class WorkspaceModule {
2298
3210
  continue;
2299
3211
  const entries = store.treeList(mount.treeStateId);
2300
3212
  const currentSeq = store.currentSequence();
2301
- const intents = this.intents.get(name).list();
3213
+ const own = this.ownPaths(mount);
3214
+ const intents = this.intents.get(name).list().filter(([p]) => own(p));
2302
3215
  const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
2303
3216
  // A workspace deletion is pending until a push confirms it on disk:
2304
3217
  // left there without applyDeletions, or unlinked but unconfirmed.
@@ -2341,13 +3254,15 @@ export class WorkspaceModule {
2341
3254
  */
2342
3255
  materializeSelection(mount, explicitPath) {
2343
3256
  const store = this.getStore();
2344
- const intents = new Map(this.intents.get(mount.config.name).list(explicitPath));
3257
+ const own = this.ownPaths(mount);
3258
+ const intents = new Map(this.intents.get(mount.config.name).list(explicitPath).filter(([p]) => own(p)));
2345
3259
  const selected = new Set();
2346
3260
  if (explicitPath !== '') {
2347
- if (store.treeGet(mount.treeStateId, explicitPath))
3261
+ if (store.treeGet(mount.treeStateId, explicitPath) && own(explicitPath))
2348
3262
  selected.add(explicitPath);
2349
3263
  for (const e of store.treeList(mount.treeStateId, explicitPath + '/'))
2350
- selected.add(e.path);
3264
+ if (own(e.path))
3265
+ selected.add(e.path);
2351
3266
  for (const [p, bi] of intents)
2352
3267
  if (bi.tombstone || bi.conflict)
2353
3268
  selected.add(p);
@@ -2361,10 +3276,24 @@ export class WorkspaceModule {
2361
3276
  selected.add(p);
2362
3277
  return { paths: [...selected], inPlace: agreed > 0 };
2363
3278
  }
3279
+ /**
3280
+ * Whether a path of the mount is its own to push and to count as owed:
3281
+ * not in another mount's directory nested in it. Copies the mount's tree
3282
+ * holds there (stored before the nesting) stay in its history and its
3283
+ * listings (and in status's fileCount, which counts the tree), passed over
3284
+ * by its syncs, and left out of what it pushes and of status's pending
3285
+ * changes, conflicts and workspace deletions.
3286
+ */
3287
+ ownPaths(mount) {
3288
+ const others = this.othersOf(mount.config.name);
3289
+ return (path) => !inOtherMount(path, others);
3290
+ }
2364
3291
  /**
2365
3292
  * The workspace entries by what the evidence says of disk. `owed`: P
2366
- * differs from the entry (a draft, or a push still pending), or there is no
2367
- * P and the entry came from the workspace (a draft disk never had).
3293
+ * differs from the entry (a draft, or a push still pending), the entry
3294
+ * records a mode other than the one P agreed (a mode owed to disk), or
3295
+ * there is no P and the entry came from the workspace (a draft disk never
3296
+ * had).
2368
3297
  * `unchecked`: no P and no workspace origin, an entry from before the
2369
3298
  * evidence; a push agrees it, writes it where disk lacks it, or refuses it
2370
3299
  * as a conflict. `agreed`: how many have P = S, so disk holds them. Status
@@ -2379,12 +3308,19 @@ export class WorkspaceModule {
2379
3308
  const owed = [];
2380
3309
  const unchecked = [];
2381
3310
  let agreed = 0;
3311
+ const own = this.ownPaths(mount);
2382
3312
  for (const e of store.treeList(mount.treeStateId)) {
3313
+ if (!own(e.path))
3314
+ continue;
2383
3315
  const p = agreement.get(name, e.path);
2384
3316
  if (p === undefined)
2385
3317
  (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
2386
3318
  else if (p.kind !== 'content' || p.hash !== e.blobHash)
2387
3319
  owed.push(e.path);
3320
+ // A mode the store records, other than the one disk last agreed with,
3321
+ // where modes are observed: elsewhere no push applies one.
3322
+ else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
3323
+ owed.push(e.path);
2388
3324
  else
2389
3325
  agreed++;
2390
3326
  }
@@ -2395,9 +3331,10 @@ export class WorkspaceModule {
2395
3331
  const allWritten = [];
2396
3332
  const allDeleted = [];
2397
3333
  const pendingDeletions = [];
3334
+ const modesKept = [];
2398
3335
  let explicit = null;
2399
3336
  if (input.path)
2400
- explicit = this.parsePath(input.path);
3337
+ explicit = await this.parsePath(input.path);
2401
3338
  let mountsToMaterialize;
2402
3339
  if (explicit) {
2403
3340
  mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
@@ -2432,6 +3369,13 @@ export class WorkspaceModule {
2432
3369
  blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' });
2433
3370
  continue;
2434
3371
  }
3372
+ // A path named in another mount's directory, as holderOf resolves it,
3373
+ // is that mount's: nothing of this mount's is pushed there, and the
3374
+ // caller is told whose it is.
3375
+ if (explicit && this.holderOf(name, explicit.relativePath)) {
3376
+ blocked.push({ mount: name, reason: `${explicit.relativePath}: ${this.otherMountNote(name, explicit.relativePath, 'push')}` });
3377
+ continue;
3378
+ }
2435
3379
  const turn = await this.withMount(mount, async () => {
2436
3380
  const branchId = store.currentBranch().id;
2437
3381
  const reason = this.mountMaterializeBlockReason(store, mount);
@@ -2464,8 +3408,13 @@ export class WorkspaceModule {
2464
3408
  // Refusals ride the same skipped list as branch blocks: divergence must
2465
3409
  // be visible, not resolved silently either way.
2466
3410
  for (const s of pushed.skipped) {
2467
- blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` });
3411
+ const reason = s.reason === NOT_PUSHED_IN_OTHER_MOUNT ? this.otherMountNote(name, s.path, 'push') : s.reason;
3412
+ blocked.push({ mount: name, reason: `${s.path}: ${reason}` });
2468
3413
  }
3414
+ // Written paths whose mode stayed as disk had it: no refusal, so not
3415
+ // among what was skipped.
3416
+ for (const k of pushed.modesKept)
3417
+ modesKept.push({ mount: name, ...k });
2469
3418
  // What disk still owes is kept by its evidence, so the watermark and
2470
3419
  // the pin only feed the branch guard. Both are the push's own: what it
2471
3420
  // planned from is what disk now reflects, whatever branch was selected
@@ -2506,10 +3455,11 @@ export class WorkspaceModule {
2506
3455
  }
2507
3456
  : {}),
2508
3457
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
3458
+ ...(modesKept.length > 0 ? { modesKept } : {}),
2509
3459
  },
2510
3460
  // What disk lost, then what it was written: kept in the stub if the
2511
- // guard withholds the result (agent-framework #277). What was left or
2512
- // skipped, and why, is in the full result.
3461
+ // guard withholds the result (agent-framework #277). What was left,
3462
+ // skipped or written at disk's mode, and why, is in the full result.
2513
3463
  keepWhenWithheld: ['deleted', 'materialized'],
2514
3464
  };
2515
3465
  }
@@ -2561,7 +3511,7 @@ export class WorkspaceModule {
2561
3511
  // A path names its own mount; otherwise the given mount, or every mount.
2562
3512
  let targets;
2563
3513
  if (input.path) {
2564
- const { mount, relativePath } = this.parsePath(input.path);
3514
+ const { mount, relativePath } = await this.parsePath(input.path);
2565
3515
  targets = [{ name: mount.config.name, mount, relativePath }];
2566
3516
  }
2567
3517
  else if (input.mount) {
@@ -2648,13 +3598,20 @@ export class WorkspaceModule {
2648
3598
  for (const [reason, count] of pass.passedOver)
2649
3599
  allPassedOver.push({ mount: name, reason, count });
2650
3600
  // A path named explicitly is synced even where the mount's ignore list
2651
- // covers it, and the result says so: a sync without a path passes
2652
- // over it, so nothing else keeps it current (agent-framework #276).
2653
- if (coveredByIgnore(relativePath, mount.config.ignore ?? [])) {
3601
+ // or an editor's file name covers it, and the result says so: a sync
3602
+ // without a path passes over it, so nothing else keeps it current
3603
+ // (agent-framework #276). A path in another mount's directory isn't
3604
+ // synced at all, and the note says so.
3605
+ const passedOver = passedOverAt(relativePath, mount.config.ignore ?? [], this.othersOf(name));
3606
+ if (passedOver !== undefined) {
2654
3607
  allIgnored.push({
2655
3608
  mount: name,
2656
3609
  path: relativePath,
2657
- note: 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it',
3610
+ note: passedOver === OTHER_MOUNT
3611
+ ? this.otherMountNote(name, relativePath, 'sync')
3612
+ : passedOver === IGNORED
3613
+ ? 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it'
3614
+ : 'it has an editor\'s swap or backup file name, or lies under one, so a sync without a path won\'t maintain it',
2658
3615
  });
2659
3616
  }
2660
3617
  }
@@ -2797,10 +3754,10 @@ export class WorkspaceModule {
2797
3754
  const pass = this.withMount(mount, () => {
2798
3755
  if (mount.agentActionScanWaiting === scan)
2799
3756
  mount.agentActionScanWaiting = undefined;
2800
- // From the mount's start, as a listing walks, keeping no place: on a
2801
- // mount over its walkCap, every batch's scan takes the first walkCap
2802
- // entries in the walk's order, and the place that syncs and catch-up
2803
- // scans resume from stays theirs.
3757
+ // From the mount's root, keeping no place, as a listing keeps none:
3758
+ // on a mount over its walkCap, every batch's scan takes the first
3759
+ // walkCap entries in the walk's order, and the place that syncs and
3760
+ // catch-up scans resume from stays theirs.
2804
3761
  return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
2805
3762
  });
2806
3763
  scan.settled = pass.then((result) => {
@@ -2842,9 +3799,11 @@ export class WorkspaceModule {
2842
3799
  return AGENT_ACTION_SCAN_DEADLINE_MAX_MS;
2843
3800
  }
2844
3801
  /**
2845
- * Push one ProcessEvent per op for a pass's tree changes and new conflicts.
2846
- * A conflict rides the event for the disk change behind it (modified, or
2847
- * deleted for a disk deletion of a file with newer workspace changes).
3802
+ * Push one ProcessEvent per op for a pass's tree changes and new conflicts,
3803
+ * apart from an op that changes only a file's mode, which brings nothing
3804
+ * new to read and makes none. A conflict rides the event for the disk
3805
+ * change behind it (modified, or deleted for a disk deletion of a file with
3806
+ * newer workspace changes).
2848
3807
  */
2849
3808
  emitFsEvents(mountName, ops, newConflicts = []) {
2850
3809
  if (!this.ctx || (ops.length === 0 && newConflicts.length === 0))
@@ -2859,8 +3818,12 @@ export class WorkspaceModule {
2859
3818
  group.conflicts.push(full);
2860
3819
  byOp.set(op, group);
2861
3820
  };
2862
- for (const { path, op } of ops)
2863
- add(op, path, false);
3821
+ // A path whose mode alone changed, on bytes already held, makes no
3822
+ // event: it brings nothing new to read, and a `chmod -R` over a tree
3823
+ // would otherwise reach every policy that matches its paths.
3824
+ for (const { path, op, modeOnly } of ops)
3825
+ if (!modeOnly)
3826
+ add(op, path, false);
2864
3827
  for (const { path, op } of newConflicts)
2865
3828
  add(op, path, true);
2866
3829
  for (const [op, { paths, conflicts }] of byOp) {
@@ -2887,8 +3850,92 @@ function stateView(report) {
2887
3850
  // ==========================================================================
2888
3851
  // Utilities
2889
3852
  // ==========================================================================
3853
+ /** A lone UTF-16 surrogate, which UTF-8 can't store: in `u` mode, a pair is one code point. */
3854
+ const LONE_SURROGATE = /\p{Cs}/u;
2890
3855
  /**
2891
- * Convert a glob pattern to a RegExp.
3856
+ * The answer to a write, edit or delete that the workspace stored but
3857
+ * couldn't finish: its push to disk didn't complete, or what follows the tree
3858
+ * entry threw. It stays an error, since part of the call failed, but it leads
3859
+ * with what the caller must know before acting on it: the change is stored,
3860
+ * and sending it again could apply it twice. The push's reason says what disk
3861
+ * holds and the way out, as materialize takes it. The text carries all of it,
3862
+ * since an error's data reaches neither the model nor a code_execution
3863
+ * script. An edit's note on which copy it applied to comes last.
3864
+ */
3865
+ function storedButUnfinished(tool, path, outcome, note = null) {
3866
+ const change = tool === 'delete' ? 'deletion' : tool;
3867
+ const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
3868
+ + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
3869
+ const rest = 'refused' in outcome
3870
+ ? ` Its push to disk didn't complete: ${outcome.refused}.`
3871
+ : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
3872
+ return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
3873
+ }
3874
+ /**
3875
+ * A text's lines, as read and grep number them: a final line end ends the
3876
+ * last line rather than starting an empty one, so "a\nb\n" is two lines,
3877
+ * and an empty text has none.
3878
+ */
3879
+ function linesOf(content) {
3880
+ const lines = content.split('\n');
3881
+ if (lines.at(-1) === '')
3882
+ lines.pop();
3883
+ return lines;
3884
+ }
3885
+ /**
3886
+ * The least each tool's whole-number fields may be, beside their schemas,
3887
+ * whose type (membrane's ToolParameter) has no `minimum`.
3888
+ */
3889
+ const INPUT_MINIMUMS = {
3890
+ read: { offset: 1, limit: 1, offsetChars: 0, limitChars: 1 },
3891
+ grep: { contextBefore: 0, contextAfter: 0 },
3892
+ };
3893
+ /**
3894
+ * Why a tool call's input doesn't fit its tool's schema, or null when it does:
3895
+ * an object, each required field present, and each field its schema calls a
3896
+ * string, a boolean or an integer of that type, an integer at least its
3897
+ * minimum. The workspace's schemas use no other types. Without it, an edit
3898
+ * missing `newString` wrote the word "undefined", and an offset of 1.5
3899
+ * numbered lines 1.5, 2.5 and on. A field the schema doesn't describe is left
3900
+ * to its handler.
3901
+ */
3902
+ function inputProblem(schema, input, minimums = {}) {
3903
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
3904
+ return 'its input must be an object of the fields its schema lists';
3905
+ }
3906
+ const fields = input;
3907
+ for (const name of schema.required ?? []) {
3908
+ if (fields[name] === undefined)
3909
+ return `"${name}" is required`;
3910
+ }
3911
+ for (const [name, value] of Object.entries(fields)) {
3912
+ const type = schema.properties?.[name]?.type;
3913
+ if (value === undefined || type === undefined)
3914
+ continue;
3915
+ const minimum = minimums[name];
3916
+ const fits = type === 'string' ? typeof value === 'string'
3917
+ : type === 'boolean' ? typeof value === 'boolean'
3918
+ : type === 'integer'
3919
+ ? Number.isSafeInteger(value) && (minimum === undefined || value >= minimum)
3920
+ : true;
3921
+ if (!fits) {
3922
+ const wanted = type === 'string' ? 'a string'
3923
+ : type === 'boolean' ? 'true or false'
3924
+ : minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
3925
+ const got = value === null ? 'null'
3926
+ : typeof value === 'number' ? String(value)
3927
+ : Array.isArray(value) ? 'an array'
3928
+ : typeof value === 'object' ? 'an object'
3929
+ : `a ${typeof value}`;
3930
+ return `"${name}" must be ${wanted}, not ${got}`;
3931
+ }
3932
+ }
3933
+ return null;
3934
+ }
3935
+ /**
3936
+ * Convert a glob pattern to a RegExp. Throws, naming the pattern, for a brace
3937
+ * group it can't read: a "{" that never closes, a group inside another, or a
3938
+ * group with no ",".
2892
3939
  */
2893
3940
  function globToRegex(pattern) {
2894
3941
  // Split pattern into segments, handling {a,b,c} alternation
@@ -2896,13 +3943,36 @@ function globToRegex(pattern) {
2896
3943
  let i = 0;
2897
3944
  while (i < pattern.length) {
2898
3945
  if (pattern[i] === '{') {
2899
- const closeIdx = pattern.indexOf('}', i);
2900
- if (closeIdx > i) {
2901
- const alternatives = pattern.slice(i + 1, closeIdx).split(',');
2902
- regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
2903
- i = closeIdx + 1;
2904
- continue;
2905
- }
3946
+ // A brace form this can't read is refused. A `{` that never closes, read
3947
+ // as a literal, would almost always answer with no matches, and with it
3948
+ // refused, each pass of this loop moves past at least one character. A
3949
+ // group closes at the `}` that brings the braces open since its `{` back
3950
+ // to none, so a `{` that never closes is named as that, even with a
3951
+ // closed group after it. A group inside another would be split at the
3952
+ // inner `}`, so `{a,{b,c}}` would match `a}` but not `a`, and a group
3953
+ // with no `,` would lose its braces, so `{name}` would match `name` and
3954
+ // never a file named `{name}`, and `{1..3}` would match only `1..3`.
3955
+ let depth = 0;
3956
+ let closeIdx = -1;
3957
+ for (let j = i; j < pattern.length && closeIdx < 0; j++) {
3958
+ if (pattern[j] === '{')
3959
+ depth++;
3960
+ else if (pattern[j] === '}' && --depth === 0)
3961
+ closeIdx = j;
3962
+ }
3963
+ if (closeIdx < 0)
3964
+ throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
3965
+ const group = pattern.slice(i + 1, closeIdx);
3966
+ if (group.includes('{')) {
3967
+ throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
3968
+ }
3969
+ if (!group.includes(',')) {
3970
+ throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
3971
+ }
3972
+ const alternatives = group.split(',');
3973
+ regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
3974
+ i = closeIdx + 1;
3975
+ continue;
2906
3976
  }
2907
3977
  // Accumulate non-brace characters, convert as a chunk
2908
3978
  let chunk = '';
@@ -2916,6 +3986,36 @@ function globToRegex(pattern) {
2916
3986
  }
2917
3987
  return new RegExp(`^${regex}$`);
2918
3988
  }
3989
+ /**
3990
+ * The directory a glob pattern names before its first wildcard, beneath
3991
+ * which every path it matches lies: its leading segments that hold none of
3992
+ * globToRegex's wildcards (`*`, `?`, `{`), short of the last segment, which
3993
+ * names the files. '' when the pattern starts with a wildcard or has a
3994
+ * single segment. A segment that's empty, `.` or `..`, which no path
3995
+ * matched holds, is one no directory listing holds either, so walkStart
3996
+ * stops before it.
3997
+ */
3998
+ function literalDirOf(pattern) {
3999
+ const dir = [];
4000
+ for (const segment of pattern.split('/').slice(0, -1)) {
4001
+ if (/[*?{]/.test(segment))
4002
+ break;
4003
+ dir.push(segment);
4004
+ }
4005
+ return dir.join('/');
4006
+ }
4007
+ /** The deeper of two mount-relative directories, '' being the root, or null when neither holds the other. */
4008
+ function deeperOf(a, b) {
4009
+ if (a === '' || b === a || b.startsWith(a + '/'))
4010
+ return b;
4011
+ if (b === '' || a.startsWith(b + '/'))
4012
+ return a;
4013
+ return null;
4014
+ }
4015
+ /** A glob's refusal of a brace form it can't read, with the way to match a literal "{". */
4016
+ function braceRefusal(pattern, problem) {
4017
+ return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`;
4018
+ }
2919
4019
  function globPartToRegex(part) {
2920
4020
  return part
2921
4021
  .replace(/[.+^$()|[\]\\]/g, '\\$&')