@animalabs/agent-framework 0.22.2 → 0.22.3

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 (935) hide show
  1. package/README.md +3 -1
  2. package/dist/src/agent.d.ts +77 -12
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +136 -40
  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/py-runner.d.ts +16 -5
  22. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  23. package/dist/src/code-execution/py-runner.js +186 -74
  24. package/dist/src/code-execution/py-runner.js.map +1 -1
  25. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  26. package/dist/src/code-execution/runtime-py.js +49 -3
  27. package/dist/src/code-execution/runtime-py.js.map +1 -1
  28. package/dist/src/code-execution/tool-definition.d.ts +12 -1
  29. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  30. package/dist/src/code-execution/tool-definition.js +21 -3
  31. package/dist/src/code-execution/tool-definition.js.map +1 -1
  32. package/dist/src/config-checks.d.ts +28 -0
  33. package/dist/src/config-checks.d.ts.map +1 -0
  34. package/dist/src/config-checks.js +52 -0
  35. package/dist/src/config-checks.js.map +1 -0
  36. package/dist/src/deadline-timer.d.ts +21 -0
  37. package/dist/src/deadline-timer.d.ts.map +1 -0
  38. package/dist/src/deadline-timer.js +43 -0
  39. package/dist/src/deadline-timer.js.map +1 -0
  40. package/dist/src/error-message.d.ts +30 -0
  41. package/dist/src/error-message.d.ts.map +1 -0
  42. package/dist/src/error-message.js +50 -0
  43. package/dist/src/error-message.js.map +1 -0
  44. package/dist/src/framework.d.ts +828 -88
  45. package/dist/src/framework.d.ts.map +1 -1
  46. package/dist/src/framework.js +5011 -1326
  47. package/dist/src/framework.js.map +1 -1
  48. package/dist/src/gate/event-gate.d.ts +8 -0
  49. package/dist/src/gate/event-gate.d.ts.map +1 -1
  50. package/dist/src/gate/event-gate.js +27 -3
  51. package/dist/src/gate/event-gate.js.map +1 -1
  52. package/dist/src/index.d.ts +4 -2
  53. package/dist/src/index.d.ts.map +1 -1
  54. package/dist/src/index.js +4 -2
  55. package/dist/src/index.js.map +1 -1
  56. package/dist/src/mcpl/channel-registry.d.ts +144 -6
  57. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  58. package/dist/src/mcpl/channel-registry.js +424 -263
  59. package/dist/src/mcpl/channel-registry.js.map +1 -1
  60. package/dist/src/mcpl/checkpoint-manager.d.ts +21 -1
  61. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  62. package/dist/src/mcpl/checkpoint-manager.js +91 -30
  63. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  64. package/dist/src/mcpl/conversation-router.d.ts +19 -3
  65. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  66. package/dist/src/mcpl/conversation-router.js +23 -8
  67. package/dist/src/mcpl/conversation-router.js.map +1 -1
  68. package/dist/src/mcpl/convert-block.d.ts +20 -0
  69. package/dist/src/mcpl/convert-block.d.ts.map +1 -0
  70. package/dist/src/mcpl/convert-block.js +89 -0
  71. package/dist/src/mcpl/convert-block.js.map +1 -0
  72. package/dist/src/mcpl/errors.d.ts +13 -0
  73. package/dist/src/mcpl/errors.d.ts.map +1 -1
  74. package/dist/src/mcpl/errors.js +28 -0
  75. package/dist/src/mcpl/errors.js.map +1 -1
  76. package/dist/src/mcpl/hook-orchestrator.d.ts +23 -0
  77. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  78. package/dist/src/mcpl/hook-orchestrator.js +83 -72
  79. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  80. package/dist/src/mcpl/inbound-source.d.ts +114 -0
  81. package/dist/src/mcpl/inbound-source.d.ts.map +1 -0
  82. package/dist/src/mcpl/inbound-source.js +83 -0
  83. package/dist/src/mcpl/inbound-source.js.map +1 -0
  84. package/dist/src/mcpl/index.d.ts +3 -1
  85. package/dist/src/mcpl/index.d.ts.map +1 -1
  86. package/dist/src/mcpl/index.js +3 -1
  87. package/dist/src/mcpl/index.js.map +1 -1
  88. package/dist/src/mcpl/inference-router.d.ts +15 -11
  89. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  90. package/dist/src/mcpl/inference-router.js +153 -84
  91. package/dist/src/mcpl/inference-router.js.map +1 -1
  92. package/dist/src/mcpl/push-coalescer.d.ts +33 -11
  93. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  94. package/dist/src/mcpl/push-coalescer.js +52 -20
  95. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  96. package/dist/src/mcpl/push-handler.d.ts +16 -9
  97. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  98. package/dist/src/mcpl/push-handler.js +18 -83
  99. package/dist/src/mcpl/push-handler.js.map +1 -1
  100. package/dist/src/mcpl/reference-fetcher.d.ts +14 -2
  101. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  102. package/dist/src/mcpl/reference-fetcher.js +72 -30
  103. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  104. package/dist/src/mcpl/references.d.ts +47 -12
  105. package/dist/src/mcpl/references.d.ts.map +1 -1
  106. package/dist/src/mcpl/references.js +74 -21
  107. package/dist/src/mcpl/references.js.map +1 -1
  108. package/dist/src/mcpl/server-connection.d.ts +89 -10
  109. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  110. package/dist/src/mcpl/server-connection.js +405 -129
  111. package/dist/src/mcpl/server-connection.js.map +1 -1
  112. package/dist/src/mcpl/server-registry.d.ts +10 -1
  113. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  114. package/dist/src/mcpl/server-registry.js +14 -3
  115. package/dist/src/mcpl/server-registry.js.map +1 -1
  116. package/dist/src/mcpl/skip-reply-wake.d.ts +36 -0
  117. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -0
  118. package/dist/src/mcpl/skip-reply-wake.js +55 -0
  119. package/dist/src/mcpl/skip-reply-wake.js.map +1 -0
  120. package/dist/src/mcpl/transport.d.ts +51 -2
  121. package/dist/src/mcpl/transport.d.ts.map +1 -1
  122. package/dist/src/mcpl/transport.js +114 -20
  123. package/dist/src/mcpl/transport.js.map +1 -1
  124. package/dist/src/mcpl/types.d.ts +14 -3
  125. package/dist/src/mcpl/types.d.ts.map +1 -1
  126. package/dist/src/mcpl/types.js.map +1 -1
  127. package/dist/src/mcpl/visible-content.d.ts +15 -0
  128. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  129. package/dist/src/mcpl/visible-content.js +25 -0
  130. package/dist/src/mcpl/visible-content.js.map +1 -1
  131. package/dist/src/module-registry.d.ts +19 -1
  132. package/dist/src/module-registry.d.ts.map +1 -1
  133. package/dist/src/module-registry.js +54 -25
  134. package/dist/src/module-registry.js.map +1 -1
  135. package/dist/src/modules/api/index.js +1 -1
  136. package/dist/src/modules/api/index.js.map +1 -1
  137. package/dist/src/modules/discord/discord-js-client.d.ts +1 -0
  138. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  139. package/dist/src/modules/discord/discord-js-client.js +25 -9
  140. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  141. package/dist/src/modules/discord/index.d.ts +29 -3
  142. package/dist/src/modules/discord/index.d.ts.map +1 -1
  143. package/dist/src/modules/discord/index.js +379 -143
  144. package/dist/src/modules/discord/index.js.map +1 -1
  145. package/dist/src/modules/discord/types.d.ts +14 -1
  146. package/dist/src/modules/discord/types.d.ts.map +1 -1
  147. package/dist/src/modules/health/index.d.ts.map +1 -1
  148. package/dist/src/modules/health/index.js +2 -1
  149. package/dist/src/modules/health/index.js.map +1 -1
  150. package/dist/src/modules/history/index.d.ts +3 -1
  151. package/dist/src/modules/history/index.d.ts.map +1 -1
  152. package/dist/src/modules/history/index.js +359 -127
  153. package/dist/src/modules/history/index.js.map +1 -1
  154. package/dist/src/modules/history/semantic.d.ts +28 -1
  155. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  156. package/dist/src/modules/history/semantic.js +177 -32
  157. package/dist/src/modules/history/semantic.js.map +1 -1
  158. package/dist/src/modules/unknown-tool.d.ts +10 -0
  159. package/dist/src/modules/unknown-tool.d.ts.map +1 -0
  160. package/dist/src/modules/unknown-tool.js +17 -0
  161. package/dist/src/modules/unknown-tool.js.map +1 -0
  162. package/dist/src/modules/workspace/disk-agreement.d.ts +32 -2
  163. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  164. package/dist/src/modules/workspace/disk-agreement.js +30 -2
  165. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  166. package/dist/src/modules/workspace/effects.d.ts +64 -6
  167. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  168. package/dist/src/modules/workspace/effects.js +291 -44
  169. package/dist/src/modules/workspace/effects.js.map +1 -1
  170. package/dist/src/modules/workspace/index.d.ts +119 -23
  171. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  172. package/dist/src/modules/workspace/index.js +793 -247
  173. package/dist/src/modules/workspace/index.js.map +1 -1
  174. package/dist/src/modules/workspace/observe.d.ts +75 -8
  175. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  176. package/dist/src/modules/workspace/observe.js +174 -14
  177. package/dist/src/modules/workspace/observe.js.map +1 -1
  178. package/dist/src/modules/workspace/reconcile.d.ts +58 -7
  179. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  180. package/dist/src/modules/workspace/reconcile.js +521 -56
  181. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  182. package/dist/src/modules/workspace/types.d.ts +18 -4
  183. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  184. package/dist/src/modules/workspace/types.js.map +1 -1
  185. package/dist/src/modules/workspace/watcher.d.ts +7 -2
  186. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  187. package/dist/src/modules/workspace/watcher.js +28 -4
  188. package/dist/src/modules/workspace/watcher.js.map +1 -1
  189. package/dist/src/prose-drafts.d.ts +240 -0
  190. package/dist/src/prose-drafts.d.ts.map +1 -0
  191. package/dist/src/prose-drafts.js +362 -0
  192. package/dist/src/prose-drafts.js.map +1 -0
  193. package/dist/src/prose-segments.d.ts +21 -2
  194. package/dist/src/prose-segments.d.ts.map +1 -1
  195. package/dist/src/prose-segments.js +46 -9
  196. package/dist/src/prose-segments.js.map +1 -1
  197. package/dist/src/recovery/offline-branch.d.ts +1 -0
  198. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  199. package/dist/src/recovery/offline-branch.js +3 -1
  200. package/dist/src/recovery/offline-branch.js.map +1 -1
  201. package/dist/src/safe-slice.d.ts +5 -1
  202. package/dist/src/safe-slice.d.ts.map +1 -1
  203. package/dist/src/safe-slice.js +9 -1
  204. package/dist/src/safe-slice.js.map +1 -1
  205. package/dist/src/tool-image-ledger.d.ts +87 -19
  206. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  207. package/dist/src/tool-image-ledger.js +135 -32
  208. package/dist/src/tool-image-ledger.js.map +1 -1
  209. package/dist/src/tool-presentation.d.ts +9 -0
  210. package/dist/src/tool-presentation.d.ts.map +1 -1
  211. package/dist/src/tool-presentation.js +257 -60
  212. package/dist/src/tool-presentation.js.map +1 -1
  213. package/dist/src/tool-result-guard.d.ts.map +1 -1
  214. package/dist/src/tool-result-guard.js +7 -10
  215. package/dist/src/tool-result-guard.js.map +1 -1
  216. package/dist/src/tool-result-history.d.ts +32 -3
  217. package/dist/src/tool-result-history.d.ts.map +1 -1
  218. package/dist/src/tool-result-history.js +106 -19
  219. package/dist/src/tool-result-history.js.map +1 -1
  220. package/dist/src/tune-out/coordinator.d.ts +25 -1
  221. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  222. package/dist/src/tune-out/coordinator.js +260 -65
  223. package/dist/src/tune-out/coordinator.js.map +1 -1
  224. package/dist/src/types/agent.d.ts +17 -5
  225. package/dist/src/types/agent.d.ts.map +1 -1
  226. package/dist/src/types/events.d.ts +17 -0
  227. package/dist/src/types/events.d.ts.map +1 -1
  228. package/dist/src/types/framework.d.ts +21 -5
  229. package/dist/src/types/framework.d.ts.map +1 -1
  230. package/dist/src/types/inference-log.d.ts +1 -1
  231. package/dist/src/types/inference-log.d.ts.map +1 -1
  232. package/dist/src/types/module.d.ts +23 -4
  233. package/dist/src/types/module.d.ts.map +1 -1
  234. package/dist/src/types/trace.d.ts +95 -6
  235. package/dist/src/types/trace.d.ts.map +1 -1
  236. package/dist/src/usage/usage-tracker.d.ts +5 -0
  237. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  238. package/dist/src/usage/usage-tracker.js +35 -8
  239. package/dist/src/usage/usage-tracker.js.map +1 -1
  240. package/package.json +3 -4
  241. package/src/agent.ts +162 -40
  242. package/src/api/mcp-server-core.ts +1 -1
  243. package/src/api/server.ts +4 -1
  244. package/src/api/types.ts +1 -1
  245. package/src/branch-names.ts +19 -0
  246. package/src/closest-names.ts +94 -0
  247. package/src/code-execution/py-runner.ts +200 -76
  248. package/src/code-execution/runtime-py.ts +49 -3
  249. package/src/code-execution/tool-definition.ts +28 -4
  250. package/src/config-checks.ts +48 -0
  251. package/src/deadline-timer.ts +44 -0
  252. package/src/error-message.ts +48 -0
  253. package/src/framework.ts +5352 -1388
  254. package/src/gate/event-gate.ts +28 -3
  255. package/src/index.ts +11 -2
  256. package/src/mcpl/channel-registry.ts +517 -286
  257. package/src/mcpl/checkpoint-manager.ts +104 -29
  258. package/src/mcpl/conversation-router.ts +28 -8
  259. package/src/mcpl/convert-block.ts +97 -0
  260. package/src/mcpl/errors.ts +28 -0
  261. package/src/mcpl/hook-orchestrator.ts +84 -77
  262. package/src/mcpl/inbound-source.ts +156 -0
  263. package/src/mcpl/index.ts +10 -1
  264. package/src/mcpl/inference-router.ts +174 -99
  265. package/src/mcpl/push-coalescer.ts +60 -24
  266. package/src/mcpl/push-handler.ts +20 -90
  267. package/src/mcpl/reference-fetcher.ts +71 -26
  268. package/src/mcpl/references.ts +81 -22
  269. package/src/mcpl/server-connection.ts +427 -132
  270. package/src/mcpl/server-registry.ts +15 -4
  271. package/src/mcpl/skip-reply-wake.ts +73 -0
  272. package/src/mcpl/transport.ts +120 -18
  273. package/src/mcpl/types.ts +14 -3
  274. package/src/mcpl/visible-content.ts +27 -0
  275. package/src/module-registry.ts +58 -23
  276. package/src/modules/api/index.ts +1 -1
  277. package/src/modules/discord/discord-js-client.ts +26 -10
  278. package/src/modules/discord/index.ts +391 -158
  279. package/src/modules/discord/types.ts +15 -2
  280. package/src/modules/health/index.ts +2 -1
  281. package/src/modules/history/index.ts +410 -120
  282. package/src/modules/history/semantic.ts +189 -32
  283. package/src/modules/unknown-tool.ts +16 -0
  284. package/src/modules/workspace/disk-agreement.ts +51 -3
  285. package/src/modules/workspace/effects.ts +279 -35
  286. package/src/modules/workspace/index.ts +810 -260
  287. package/src/modules/workspace/observe.ts +194 -20
  288. package/src/modules/workspace/reconcile.ts +619 -64
  289. package/src/modules/workspace/types.ts +18 -4
  290. package/src/modules/workspace/watcher.ts +27 -4
  291. package/src/prose-drafts.ts +481 -0
  292. package/src/prose-segments.ts +51 -8
  293. package/src/recovery/offline-branch.ts +4 -1
  294. package/src/safe-slice.ts +9 -1
  295. package/src/tool-image-ledger.ts +153 -34
  296. package/src/tool-presentation.ts +224 -40
  297. package/src/tool-result-guard.ts +7 -8
  298. package/src/tool-result-history.ts +124 -20
  299. package/src/tune-out/coordinator.ts +259 -65
  300. package/src/types/agent.ts +17 -7
  301. package/src/types/events.ts +17 -0
  302. package/src/types/framework.ts +21 -5
  303. package/src/types/inference-log.ts +1 -1
  304. package/src/types/module.ts +24 -4
  305. package/src/types/trace.ts +101 -6
  306. package/src/usage/usage-tracker.ts +32 -8
  307. package/dist/test/activation-request-sanitize.test.d.ts +0 -2
  308. package/dist/test/activation-request-sanitize.test.d.ts.map +0 -1
  309. package/dist/test/activation-request-sanitize.test.js +0 -89
  310. package/dist/test/activation-request-sanitize.test.js.map +0 -1
  311. package/dist/test/agent-cache-ttl.test.d.ts +0 -2
  312. package/dist/test/agent-cache-ttl.test.d.ts.map +0 -1
  313. package/dist/test/agent-cache-ttl.test.js +0 -41
  314. package/dist/test/agent-cache-ttl.test.js.map +0 -1
  315. package/dist/test/agent-runtime-settings.test.d.ts +0 -2
  316. package/dist/test/agent-runtime-settings.test.d.ts.map +0 -1
  317. package/dist/test/agent-runtime-settings.test.js +0 -1598
  318. package/dist/test/agent-runtime-settings.test.js.map +0 -1
  319. package/dist/test/api-admin-gate.test.d.ts +0 -2
  320. package/dist/test/api-admin-gate.test.d.ts.map +0 -1
  321. package/dist/test/api-admin-gate.test.js +0 -116
  322. package/dist/test/api-admin-gate.test.js.map +0 -1
  323. package/dist/test/assistant-persist-refusal.test.d.ts +0 -2
  324. package/dist/test/assistant-persist-refusal.test.d.ts.map +0 -1
  325. package/dist/test/assistant-persist-refusal.test.js +0 -63
  326. package/dist/test/assistant-persist-refusal.test.js.map +0 -1
  327. package/dist/test/batched-wake-locus.test.d.ts +0 -2
  328. package/dist/test/batched-wake-locus.test.d.ts.map +0 -1
  329. package/dist/test/batched-wake-locus.test.js +0 -166
  330. package/dist/test/batched-wake-locus.test.js.map +0 -1
  331. package/dist/test/busy-agent-scheduler.test.d.ts +0 -2
  332. package/dist/test/busy-agent-scheduler.test.d.ts.map +0 -1
  333. package/dist/test/busy-agent-scheduler.test.js +0 -92
  334. package/dist/test/busy-agent-scheduler.test.js.map +0 -1
  335. package/dist/test/channel-incoming-targeting.test.d.ts +0 -2
  336. package/dist/test/channel-incoming-targeting.test.d.ts.map +0 -1
  337. package/dist/test/channel-incoming-targeting.test.js +0 -81
  338. package/dist/test/channel-incoming-targeting.test.js.map +0 -1
  339. package/dist/test/channel-label-history.test.d.ts +0 -2
  340. package/dist/test/channel-label-history.test.d.ts.map +0 -1
  341. package/dist/test/channel-label-history.test.js +0 -424
  342. package/dist/test/channel-label-history.test.js.map +0 -1
  343. package/dist/test/channel-lifecycle-provenance.test.d.ts +0 -2
  344. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +0 -1
  345. package/dist/test/channel-lifecycle-provenance.test.js +0 -47
  346. package/dist/test/channel-lifecycle-provenance.test.js.map +0 -1
  347. package/dist/test/channel-registry-routing.test.d.ts +0 -2
  348. package/dist/test/channel-registry-routing.test.d.ts.map +0 -1
  349. package/dist/test/channel-registry-routing.test.js +0 -446
  350. package/dist/test/channel-registry-routing.test.js.map +0 -1
  351. package/dist/test/channel-registry-typing.test.d.ts +0 -2
  352. package/dist/test/channel-registry-typing.test.d.ts.map +0 -1
  353. package/dist/test/channel-registry-typing.test.js +0 -91
  354. package/dist/test/channel-registry-typing.test.js.map +0 -1
  355. package/dist/test/channels-changed-respond-first.test.d.ts +0 -2
  356. package/dist/test/channels-changed-respond-first.test.d.ts.map +0 -1
  357. package/dist/test/channels-changed-respond-first.test.js +0 -74
  358. package/dist/test/channels-changed-respond-first.test.js.map +0 -1
  359. package/dist/test/checkpoint-attribution.test.d.ts +0 -2
  360. package/dist/test/checkpoint-attribution.test.d.ts.map +0 -1
  361. package/dist/test/checkpoint-attribution.test.js +0 -200
  362. package/dist/test/checkpoint-attribution.test.js.map +0 -1
  363. package/dist/test/code-execution.test.d.ts +0 -11
  364. package/dist/test/code-execution.test.d.ts.map +0 -1
  365. package/dist/test/code-execution.test.js +0 -1272
  366. package/dist/test/code-execution.test.js.map +0 -1
  367. package/dist/test/component-defaults.test.d.ts +0 -2
  368. package/dist/test/component-defaults.test.d.ts.map +0 -1
  369. package/dist/test/component-defaults.test.js +0 -54
  370. package/dist/test/component-defaults.test.js.map +0 -1
  371. package/dist/test/conversation-fork-injection-scoping.test.d.ts +0 -2
  372. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +0 -1
  373. package/dist/test/conversation-fork-injection-scoping.test.js +0 -200
  374. package/dist/test/conversation-fork-injection-scoping.test.js.map +0 -1
  375. package/dist/test/conversation-router.test.d.ts +0 -2
  376. package/dist/test/conversation-router.test.d.ts.map +0 -1
  377. package/dist/test/conversation-router.test.js +0 -174
  378. package/dist/test/conversation-router.test.js.map +0 -1
  379. package/dist/test/conversation-routing.test.d.ts +0 -7
  380. package/dist/test/conversation-routing.test.d.ts.map +0 -1
  381. package/dist/test/conversation-routing.test.js +0 -621
  382. package/dist/test/conversation-routing.test.js.map +0 -1
  383. package/dist/test/deferred-wake-integrity.test.d.ts +0 -29
  384. package/dist/test/deferred-wake-integrity.test.d.ts.map +0 -1
  385. package/dist/test/deferred-wake-integrity.test.js +0 -320
  386. package/dist/test/deferred-wake-integrity.test.js.map +0 -1
  387. package/dist/test/discord-awareness-outbox.test.d.ts +0 -2
  388. package/dist/test/discord-awareness-outbox.test.d.ts.map +0 -1
  389. package/dist/test/discord-awareness-outbox.test.js +0 -371
  390. package/dist/test/discord-awareness-outbox.test.js.map +0 -1
  391. package/dist/test/discord-history-delete-window.test.d.ts +0 -2
  392. package/dist/test/discord-history-delete-window.test.d.ts.map +0 -1
  393. package/dist/test/discord-history-delete-window.test.js +0 -81
  394. package/dist/test/discord-history-delete-window.test.js.map +0 -1
  395. package/dist/test/discord-history-sync.test.d.ts +0 -2
  396. package/dist/test/discord-history-sync.test.d.ts.map +0 -1
  397. package/dist/test/discord-history-sync.test.js +0 -56
  398. package/dist/test/discord-history-sync.test.js.map +0 -1
  399. package/dist/test/discord-typing-keepalive.test.d.ts +0 -2
  400. package/dist/test/discord-typing-keepalive.test.d.ts.map +0 -1
  401. package/dist/test/discord-typing-keepalive.test.js +0 -77
  402. package/dist/test/discord-typing-keepalive.test.js.map +0 -1
  403. package/dist/test/ephemeral-settle.test.d.ts +0 -2
  404. package/dist/test/ephemeral-settle.test.d.ts.map +0 -1
  405. package/dist/test/ephemeral-settle.test.js +0 -354
  406. package/dist/test/ephemeral-settle.test.js.map +0 -1
  407. package/dist/test/event-gate-passthrough.test.d.ts +0 -15
  408. package/dist/test/event-gate-passthrough.test.d.ts.map +0 -1
  409. package/dist/test/event-gate-passthrough.test.js +0 -289
  410. package/dist/test/event-gate-passthrough.test.js.map +0 -1
  411. package/dist/test/event-gate-policy-mutation.test.d.ts +0 -2
  412. package/dist/test/event-gate-policy-mutation.test.d.ts.map +0 -1
  413. package/dist/test/event-gate-policy-mutation.test.js +0 -137
  414. package/dist/test/event-gate-policy-mutation.test.js.map +0 -1
  415. package/dist/test/event-gate-rate-limit.test.d.ts +0 -2
  416. package/dist/test/event-gate-rate-limit.test.d.ts.map +0 -1
  417. package/dist/test/event-gate-rate-limit.test.js +0 -627
  418. package/dist/test/event-gate-rate-limit.test.js.map +0 -1
  419. package/dist/test/event-gate-script.test.d.ts +0 -2
  420. package/dist/test/event-gate-script.test.d.ts.map +0 -1
  421. package/dist/test/event-gate-script.test.js +0 -95
  422. package/dist/test/event-gate-script.test.js.map +0 -1
  423. package/dist/test/event-gate-selfwake.test.d.ts +0 -15
  424. package/dist/test/event-gate-selfwake.test.d.ts.map +0 -1
  425. package/dist/test/event-gate-selfwake.test.js +0 -125
  426. package/dist/test/event-gate-selfwake.test.js.map +0 -1
  427. package/dist/test/event-gate-tags.test.d.ts +0 -2
  428. package/dist/test/event-gate-tags.test.d.ts.map +0 -1
  429. package/dist/test/event-gate-tags.test.js +0 -76
  430. package/dist/test/event-gate-tags.test.js.map +0 -1
  431. package/dist/test/event-gate.test.d.ts +0 -2
  432. package/dist/test/event-gate.test.d.ts.map +0 -1
  433. package/dist/test/event-gate.test.js +0 -919
  434. package/dist/test/event-gate.test.js.map +0 -1
  435. package/dist/test/explicit-prose-routing.test.d.ts +0 -11
  436. package/dist/test/explicit-prose-routing.test.d.ts.map +0 -1
  437. package/dist/test/explicit-prose-routing.test.js +0 -1715
  438. package/dist/test/explicit-prose-routing.test.js.map +0 -1
  439. package/dist/test/framework.test.d.ts +0 -5
  440. package/dist/test/framework.test.d.ts.map +0 -1
  441. package/dist/test/framework.test.js +0 -1048
  442. package/dist/test/framework.test.js.map +0 -1
  443. package/dist/test/gather-context-timeout.test.d.ts +0 -2
  444. package/dist/test/gather-context-timeout.test.d.ts.map +0 -1
  445. package/dist/test/gather-context-timeout.test.js +0 -120
  446. package/dist/test/gather-context-timeout.test.js.map +0 -1
  447. package/dist/test/health-snapshot.test.d.ts +0 -2
  448. package/dist/test/health-snapshot.test.d.ts.map +0 -1
  449. package/dist/test/health-snapshot.test.js +0 -74
  450. package/dist/test/health-snapshot.test.js.map +0 -1
  451. package/dist/test/helpers/coalescing-fixture.d.ts +0 -74
  452. package/dist/test/helpers/coalescing-fixture.d.ts.map +0 -1
  453. package/dist/test/helpers/coalescing-fixture.js +0 -152
  454. package/dist/test/helpers/coalescing-fixture.js.map +0 -1
  455. package/dist/test/helpers/coalescing-kill-child.d.ts +0 -2
  456. package/dist/test/helpers/coalescing-kill-child.d.ts.map +0 -1
  457. package/dist/test/helpers/coalescing-kill-child.js +0 -24
  458. package/dist/test/helpers/coalescing-kill-child.js.map +0 -1
  459. package/dist/test/helpers/mock-membrane.d.ts +0 -53
  460. package/dist/test/helpers/mock-membrane.d.ts.map +0 -1
  461. package/dist/test/helpers/mock-membrane.js +0 -168
  462. package/dist/test/helpers/mock-membrane.js.map +0 -1
  463. package/dist/test/helpers/pytc-stop-rows.d.ts +0 -11
  464. package/dist/test/helpers/pytc-stop-rows.d.ts.map +0 -1
  465. package/dist/test/helpers/pytc-stop-rows.js +0 -143
  466. package/dist/test/helpers/pytc-stop-rows.js.map +0 -1
  467. package/dist/test/helpers/quiesce-crash-child.d.ts +0 -2
  468. package/dist/test/helpers/quiesce-crash-child.d.ts.map +0 -1
  469. package/dist/test/helpers/quiesce-crash-child.js +0 -156
  470. package/dist/test/helpers/quiesce-crash-child.js.map +0 -1
  471. package/dist/test/hide-then-turn.test.d.ts +0 -2
  472. package/dist/test/hide-then-turn.test.d.ts.map +0 -1
  473. package/dist/test/hide-then-turn.test.js +0 -101
  474. package/dist/test/hide-then-turn.test.js.map +0 -1
  475. package/dist/test/history-module-overview.test.d.ts +0 -2
  476. package/dist/test/history-module-overview.test.d.ts.map +0 -1
  477. package/dist/test/history-module-overview.test.js +0 -711
  478. package/dist/test/history-module-overview.test.js.map +0 -1
  479. package/dist/test/history-module-realstore.test.d.ts +0 -2
  480. package/dist/test/history-module-realstore.test.d.ts.map +0 -1
  481. package/dist/test/history-module-realstore.test.js +0 -363
  482. package/dist/test/history-module-realstore.test.js.map +0 -1
  483. package/dist/test/history-module-semantic.test.d.ts +0 -2
  484. package/dist/test/history-module-semantic.test.d.ts.map +0 -1
  485. package/dist/test/history-module-semantic.test.js +0 -213
  486. package/dist/test/history-module-semantic.test.js.map +0 -1
  487. package/dist/test/history-module-ux.test.d.ts +0 -2
  488. package/dist/test/history-module-ux.test.d.ts.map +0 -1
  489. package/dist/test/history-module-ux.test.js +0 -304
  490. package/dist/test/history-module-ux.test.js.map +0 -1
  491. package/dist/test/history-module.test.d.ts +0 -2
  492. package/dist/test/history-module.test.d.ts.map +0 -1
  493. package/dist/test/history-module.test.js +0 -619
  494. package/dist/test/history-module.test.js.map +0 -1
  495. package/dist/test/history-semantic-review-findings.test.d.ts +0 -2
  496. package/dist/test/history-semantic-review-findings.test.d.ts.map +0 -1
  497. package/dist/test/history-semantic-review-findings.test.js +0 -338
  498. package/dist/test/history-semantic-review-findings.test.js.map +0 -1
  499. package/dist/test/history-semantic-sync-converges.test.d.ts +0 -2
  500. package/dist/test/history-semantic-sync-converges.test.d.ts.map +0 -1
  501. package/dist/test/history-semantic-sync-converges.test.js +0 -109
  502. package/dist/test/history-semantic-sync-converges.test.js.map +0 -1
  503. package/dist/test/host-quiesce.test.d.ts +0 -2
  504. package/dist/test/host-quiesce.test.d.ts.map +0 -1
  505. package/dist/test/host-quiesce.test.js +0 -988
  506. package/dist/test/host-quiesce.test.js.map +0 -1
  507. package/dist/test/inference-exhaustion-rewind.test.d.ts +0 -2
  508. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +0 -1
  509. package/dist/test/inference-exhaustion-rewind.test.js +0 -387
  510. package/dist/test/inference-exhaustion-rewind.test.js.map +0 -1
  511. package/dist/test/inference-failure-bounds.test.d.ts +0 -2
  512. package/dist/test/inference-failure-bounds.test.d.ts.map +0 -1
  513. package/dist/test/inference-failure-bounds.test.js +0 -1202
  514. package/dist/test/inference-failure-bounds.test.js.map +0 -1
  515. package/dist/test/inference-failure-observability.test.d.ts +0 -2
  516. package/dist/test/inference-failure-observability.test.d.ts.map +0 -1
  517. package/dist/test/inference-failure-observability.test.js +0 -84
  518. package/dist/test/inference-failure-observability.test.js.map +0 -1
  519. package/dist/test/journal-tool.test.d.ts +0 -2
  520. package/dist/test/journal-tool.test.d.ts.map +0 -1
  521. package/dist/test/journal-tool.test.js +0 -33
  522. package/dist/test/journal-tool.test.js.map +0 -1
  523. package/dist/test/kv-unified-open-flight-retry.test.d.ts +0 -2
  524. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +0 -1
  525. package/dist/test/kv-unified-open-flight-retry.test.js +0 -189
  526. package/dist/test/kv-unified-open-flight-retry.test.js.map +0 -1
  527. package/dist/test/kv-unified-wiring.test.d.ts +0 -2
  528. package/dist/test/kv-unified-wiring.test.d.ts.map +0 -1
  529. package/dist/test/kv-unified-wiring.test.js +0 -95
  530. package/dist/test/kv-unified-wiring.test.js.map +0 -1
  531. package/dist/test/late-events-after-stop.test.d.ts +0 -2
  532. package/dist/test/late-events-after-stop.test.d.ts.map +0 -1
  533. package/dist/test/late-events-after-stop.test.js +0 -122
  534. package/dist/test/late-events-after-stop.test.js.map +0 -1
  535. package/dist/test/liveness-watchdog.test.d.ts +0 -2
  536. package/dist/test/liveness-watchdog.test.d.ts.map +0 -1
  537. package/dist/test/liveness-watchdog.test.js +0 -94
  538. package/dist/test/liveness-watchdog.test.js.map +0 -1
  539. package/dist/test/machine-close-provenance.test.d.ts +0 -2
  540. package/dist/test/machine-close-provenance.test.d.ts.map +0 -1
  541. package/dist/test/machine-close-provenance.test.js +0 -116
  542. package/dist/test/machine-close-provenance.test.js.map +0 -1
  543. package/dist/test/maintenance-timer.test.d.ts +0 -2
  544. package/dist/test/maintenance-timer.test.d.ts.map +0 -1
  545. package/dist/test/maintenance-timer.test.js +0 -1827
  546. package/dist/test/maintenance-timer.test.js.map +0 -1
  547. package/dist/test/mcp-server-bin.test.d.ts +0 -2
  548. package/dist/test/mcp-server-bin.test.d.ts.map +0 -1
  549. package/dist/test/mcp-server-bin.test.js +0 -142
  550. package/dist/test/mcp-server-bin.test.js.map +0 -1
  551. package/dist/test/mcpl-access-provider.test.d.ts +0 -2
  552. package/dist/test/mcpl-access-provider.test.d.ts.map +0 -1
  553. package/dist/test/mcpl-access-provider.test.js +0 -20
  554. package/dist/test/mcpl-access-provider.test.js.map +0 -1
  555. package/dist/test/mcpl-awareness-barrier.test.d.ts +0 -2
  556. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +0 -1
  557. package/dist/test/mcpl-awareness-barrier.test.js +0 -769
  558. package/dist/test/mcpl-awareness-barrier.test.js.map +0 -1
  559. package/dist/test/mcpl-capability-grant.test.d.ts +0 -10
  560. package/dist/test/mcpl-capability-grant.test.d.ts.map +0 -1
  561. package/dist/test/mcpl-capability-grant.test.js +0 -111
  562. package/dist/test/mcpl-capability-grant.test.js.map +0 -1
  563. package/dist/test/mcpl-capability-scoping.test.d.ts +0 -2
  564. package/dist/test/mcpl-capability-scoping.test.d.ts.map +0 -1
  565. package/dist/test/mcpl-capability-scoping.test.js +0 -266
  566. package/dist/test/mcpl-capability-scoping.test.js.map +0 -1
  567. package/dist/test/mcpl-child-env.test.d.ts +0 -2
  568. package/dist/test/mcpl-child-env.test.d.ts.map +0 -1
  569. package/dist/test/mcpl-child-env.test.js +0 -158
  570. package/dist/test/mcpl-child-env.test.js.map +0 -1
  571. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +0 -2
  572. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +0 -1
  573. package/dist/test/mcpl-coalesced-content-parity.test.js +0 -532
  574. package/dist/test/mcpl-coalesced-content-parity.test.js.map +0 -1
  575. package/dist/test/mcpl-coalescing.test.d.ts +0 -2
  576. package/dist/test/mcpl-coalescing.test.d.ts.map +0 -1
  577. package/dist/test/mcpl-coalescing.test.js +0 -1125
  578. package/dist/test/mcpl-coalescing.test.js.map +0 -1
  579. package/dist/test/mcpl-config-diagnostics.test.d.ts +0 -2
  580. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +0 -1
  581. package/dist/test/mcpl-config-diagnostics.test.js +0 -452
  582. package/dist/test/mcpl-config-diagnostics.test.js.map +0 -1
  583. package/dist/test/mcpl-empty-content.test.d.ts +0 -2
  584. package/dist/test/mcpl-empty-content.test.d.ts.map +0 -1
  585. package/dist/test/mcpl-empty-content.test.js +0 -368
  586. package/dist/test/mcpl-empty-content.test.js.map +0 -1
  587. package/dist/test/mcpl-gate-roundtrip.test.d.ts +0 -2
  588. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +0 -1
  589. package/dist/test/mcpl-gate-roundtrip.test.js +0 -285
  590. package/dist/test/mcpl-gate-roundtrip.test.js.map +0 -1
  591. package/dist/test/mcpl-list-status.test.d.ts +0 -2
  592. package/dist/test/mcpl-list-status.test.d.ts.map +0 -1
  593. package/dist/test/mcpl-list-status.test.js +0 -82
  594. package/dist/test/mcpl-list-status.test.js.map +0 -1
  595. package/dist/test/mcpl-manifest-changed.test.d.ts +0 -2
  596. package/dist/test/mcpl-manifest-changed.test.d.ts.map +0 -1
  597. package/dist/test/mcpl-manifest-changed.test.js +0 -178
  598. package/dist/test/mcpl-manifest-changed.test.js.map +0 -1
  599. package/dist/test/mcpl-push-convert.test.d.ts +0 -2
  600. package/dist/test/mcpl-push-convert.test.d.ts.map +0 -1
  601. package/dist/test/mcpl-push-convert.test.js +0 -42
  602. package/dist/test/mcpl-push-convert.test.js.map +0 -1
  603. package/dist/test/mcpl-reconnect-events.test.d.ts +0 -2
  604. package/dist/test/mcpl-reconnect-events.test.d.ts.map +0 -1
  605. package/dist/test/mcpl-reconnect-events.test.js +0 -144
  606. package/dist/test/mcpl-reconnect-events.test.js.map +0 -1
  607. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +0 -2
  608. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +0 -1
  609. package/dist/test/mcpl-reconnect-reregistration.test.js +0 -238
  610. package/dist/test/mcpl-reconnect-reregistration.test.js.map +0 -1
  611. package/dist/test/mcpl-reference-fetcher.test.d.ts +0 -2
  612. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +0 -1
  613. package/dist/test/mcpl-reference-fetcher.test.js +0 -427
  614. package/dist/test/mcpl-reference-fetcher.test.js.map +0 -1
  615. package/dist/test/mcpl-reference-fields.test.d.ts +0 -2
  616. package/dist/test/mcpl-reference-fields.test.d.ts.map +0 -1
  617. package/dist/test/mcpl-reference-fields.test.js +0 -149
  618. package/dist/test/mcpl-reference-fields.test.js.map +0 -1
  619. package/dist/test/mcpl-rejection-log-lines.test.d.ts +0 -2
  620. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +0 -1
  621. package/dist/test/mcpl-rejection-log-lines.test.js +0 -349
  622. package/dist/test/mcpl-rejection-log-lines.test.js.map +0 -1
  623. package/dist/test/mcpl-request-timeout.test.d.ts +0 -2
  624. package/dist/test/mcpl-request-timeout.test.d.ts.map +0 -1
  625. package/dist/test/mcpl-request-timeout.test.js +0 -138
  626. package/dist/test/mcpl-request-timeout.test.js.map +0 -1
  627. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +0 -7
  628. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +0 -1
  629. package/dist/test/mcpl-runtime-lifecycle.test.js +0 -142
  630. package/dist/test/mcpl-runtime-lifecycle.test.js.map +0 -1
  631. package/dist/test/mcpl-stderr-tail.test.d.ts +0 -2
  632. package/dist/test/mcpl-stderr-tail.test.d.ts.map +0 -1
  633. package/dist/test/mcpl-stderr-tail.test.js +0 -210
  634. package/dist/test/mcpl-stderr-tail.test.js.map +0 -1
  635. package/dist/test/mcpl-tool-class-listing.test.d.ts +0 -2
  636. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +0 -1
  637. package/dist/test/mcpl-tool-class-listing.test.js +0 -147
  638. package/dist/test/mcpl-tool-class-listing.test.js.map +0 -1
  639. package/dist/test/mcpl-tool-classes.test.d.ts +0 -7
  640. package/dist/test/mcpl-tool-classes.test.d.ts.map +0 -1
  641. package/dist/test/mcpl-tool-classes.test.js +0 -80
  642. package/dist/test/mcpl-tool-classes.test.js.map +0 -1
  643. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +0 -2
  644. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +0 -1
  645. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +0 -237
  646. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +0 -1
  647. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +0 -11
  648. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +0 -1
  649. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +0 -412
  650. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +0 -1
  651. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +0 -2
  652. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +0 -1
  653. package/dist/test/mcpl-tool-lifecycle-script.test.js +0 -288
  654. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +0 -1
  655. package/dist/test/mcpl-tool-lifecycle.test.d.ts +0 -11
  656. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +0 -1
  657. package/dist/test/mcpl-tool-lifecycle.test.js +0 -270
  658. package/dist/test/mcpl-tool-lifecycle.test.js.map +0 -1
  659. package/dist/test/mcpl-transport-close.test.d.ts +0 -2
  660. package/dist/test/mcpl-transport-close.test.d.ts.map +0 -1
  661. package/dist/test/mcpl-transport-close.test.js +0 -373
  662. package/dist/test/mcpl-transport-close.test.js.map +0 -1
  663. package/dist/test/mcpl-transport-spawn.test.d.ts +0 -2
  664. package/dist/test/mcpl-transport-spawn.test.d.ts.map +0 -1
  665. package/dist/test/mcpl-transport-spawn.test.js +0 -58
  666. package/dist/test/mcpl-transport-spawn.test.js.map +0 -1
  667. package/dist/test/mcpl-ws-transport.test.d.ts +0 -2
  668. package/dist/test/mcpl-ws-transport.test.d.ts.map +0 -1
  669. package/dist/test/mcpl-ws-transport.test.js +0 -259
  670. package/dist/test/mcpl-ws-transport.test.js.map +0 -1
  671. package/dist/test/module-context-integration.test.d.ts +0 -18
  672. package/dist/test/module-context-integration.test.d.ts.map +0 -1
  673. package/dist/test/module-context-integration.test.js +0 -181
  674. package/dist/test/module-context-integration.test.js.map +0 -1
  675. package/dist/test/offline-recovery-branch.test.d.ts +0 -2
  676. package/dist/test/offline-recovery-branch.test.d.ts.map +0 -1
  677. package/dist/test/offline-recovery-branch.test.js +0 -570
  678. package/dist/test/offline-recovery-branch.test.js.map +0 -1
  679. package/dist/test/operator-surgery.test.d.ts +0 -2
  680. package/dist/test/operator-surgery.test.d.ts.map +0 -1
  681. package/dist/test/operator-surgery.test.js +0 -595
  682. package/dist/test/operator-surgery.test.js.map +0 -1
  683. package/dist/test/ops-alert.test.d.ts +0 -2
  684. package/dist/test/ops-alert.test.d.ts.map +0 -1
  685. package/dist/test/ops-alert.test.js +0 -153
  686. package/dist/test/ops-alert.test.js.map +0 -1
  687. package/dist/test/overbudget-drain-breaker.test.d.ts +0 -2
  688. package/dist/test/overbudget-drain-breaker.test.d.ts.map +0 -1
  689. package/dist/test/overbudget-drain-breaker.test.js +0 -198
  690. package/dist/test/overbudget-drain-breaker.test.js.map +0 -1
  691. package/dist/test/pack-installs-alone.test.d.ts +0 -2
  692. package/dist/test/pack-installs-alone.test.d.ts.map +0 -1
  693. package/dist/test/pack-installs-alone.test.js +0 -284
  694. package/dist/test/pack-installs-alone.test.js.map +0 -1
  695. package/dist/test/per-agent-delivery.test.d.ts +0 -2
  696. package/dist/test/per-agent-delivery.test.d.ts.map +0 -1
  697. package/dist/test/per-agent-delivery.test.js +0 -108
  698. package/dist/test/per-agent-delivery.test.js.map +0 -1
  699. package/dist/test/physical-window-restart.test.d.ts +0 -16
  700. package/dist/test/physical-window-restart.test.d.ts.map +0 -1
  701. package/dist/test/physical-window-restart.test.js +0 -487
  702. package/dist/test/physical-window-restart.test.js.map +0 -1
  703. package/dist/test/placed-reaction-baseline.test.d.ts +0 -2
  704. package/dist/test/placed-reaction-baseline.test.d.ts.map +0 -1
  705. package/dist/test/placed-reaction-baseline.test.js +0 -312
  706. package/dist/test/placed-reaction-baseline.test.js.map +0 -1
  707. package/dist/test/present-while-acting.test.d.ts +0 -19
  708. package/dist/test/present-while-acting.test.d.ts.map +0 -1
  709. package/dist/test/present-while-acting.test.js +0 -1649
  710. package/dist/test/present-while-acting.test.js.map +0 -1
  711. package/dist/test/prose-segments.test.d.ts +0 -2
  712. package/dist/test/prose-segments.test.d.ts.map +0 -1
  713. package/dist/test/prose-segments.test.js +0 -52
  714. package/dist/test/prose-segments.test.js.map +0 -1
  715. package/dist/test/prose-stream-router.test.d.ts +0 -2
  716. package/dist/test/prose-stream-router.test.d.ts.map +0 -1
  717. package/dist/test/prose-stream-router.test.js +0 -171
  718. package/dist/test/prose-stream-router.test.js.map +0 -1
  719. package/dist/test/provider-acceleration-cooldown.test.d.ts +0 -2
  720. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +0 -1
  721. package/dist/test/provider-acceleration-cooldown.test.js +0 -435
  722. package/dist/test/provider-acceleration-cooldown.test.js.map +0 -1
  723. package/dist/test/provider-waits.test.d.ts +0 -2
  724. package/dist/test/provider-waits.test.d.ts.map +0 -1
  725. package/dist/test/provider-waits.test.js +0 -2259
  726. package/dist/test/provider-waits.test.js.map +0 -1
  727. package/dist/test/puppet-tool-call.test.d.ts +0 -2
  728. package/dist/test/puppet-tool-call.test.d.ts.map +0 -1
  729. package/dist/test/puppet-tool-call.test.js +0 -226
  730. package/dist/test/puppet-tool-call.test.js.map +0 -1
  731. package/dist/test/puppet-turn-reservation.test.d.ts +0 -2
  732. package/dist/test/puppet-turn-reservation.test.d.ts.map +0 -1
  733. package/dist/test/puppet-turn-reservation.test.js +0 -139
  734. package/dist/test/puppet-turn-reservation.test.js.map +0 -1
  735. package/dist/test/reaction-baseline-platform.test.d.ts +0 -2
  736. package/dist/test/reaction-baseline-platform.test.d.ts.map +0 -1
  737. package/dist/test/reaction-baseline-platform.test.js +0 -124
  738. package/dist/test/reaction-baseline-platform.test.js.map +0 -1
  739. package/dist/test/record-journal.test.d.ts +0 -6
  740. package/dist/test/record-journal.test.d.ts.map +0 -1
  741. package/dist/test/record-journal.test.js +0 -269
  742. package/dist/test/record-journal.test.js.map +0 -1
  743. package/dist/test/refusal-reaction-baseline.test.d.ts +0 -2
  744. package/dist/test/refusal-reaction-baseline.test.d.ts.map +0 -1
  745. package/dist/test/refusal-reaction-baseline.test.js +0 -81
  746. package/dist/test/refusal-reaction-baseline.test.js.map +0 -1
  747. package/dist/test/refusal-rewind.test.d.ts +0 -2
  748. package/dist/test/refusal-rewind.test.d.ts.map +0 -1
  749. package/dist/test/refusal-rewind.test.js +0 -117
  750. package/dist/test/refusal-rewind.test.js.map +0 -1
  751. package/dist/test/refusal-single-call.test.d.ts +0 -2
  752. package/dist/test/refusal-single-call.test.d.ts.map +0 -1
  753. package/dist/test/refusal-single-call.test.js +0 -177
  754. package/dist/test/refusal-single-call.test.js.map +0 -1
  755. package/dist/test/release-bump.test.d.ts +0 -2
  756. package/dist/test/release-bump.test.d.ts.map +0 -1
  757. package/dist/test/release-bump.test.js +0 -248
  758. package/dist/test/release-bump.test.js.map +0 -1
  759. package/dist/test/release-changelog.test.d.ts +0 -2
  760. package/dist/test/release-changelog.test.d.ts.map +0 -1
  761. package/dist/test/release-changelog.test.js +0 -159
  762. package/dist/test/release-changelog.test.js.map +0 -1
  763. package/dist/test/release-holds.test.d.ts +0 -2
  764. package/dist/test/release-holds.test.d.ts.map +0 -1
  765. package/dist/test/release-holds.test.js +0 -456
  766. package/dist/test/release-holds.test.js.map +0 -1
  767. package/dist/test/same-round-think-policy.test.d.ts +0 -2
  768. package/dist/test/same-round-think-policy.test.d.ts.map +0 -1
  769. package/dist/test/same-round-think-policy.test.js +0 -853
  770. package/dist/test/same-round-think-policy.test.js.map +0 -1
  771. package/dist/test/save-recent-image.test.d.ts +0 -26
  772. package/dist/test/save-recent-image.test.d.ts.map +0 -1
  773. package/dist/test/save-recent-image.test.js +0 -974
  774. package/dist/test/save-recent-image.test.js.map +0 -1
  775. package/dist/test/shutdown-errors.test.d.ts +0 -2
  776. package/dist/test/shutdown-errors.test.d.ts.map +0 -1
  777. package/dist/test/shutdown-errors.test.js +0 -662
  778. package/dist/test/shutdown-errors.test.js.map +0 -1
  779. package/dist/test/silent-heartbeat-rows.test.d.ts +0 -2
  780. package/dist/test/silent-heartbeat-rows.test.d.ts.map +0 -1
  781. package/dist/test/silent-heartbeat-rows.test.js +0 -451
  782. package/dist/test/silent-heartbeat-rows.test.js.map +0 -1
  783. package/dist/test/silent-heartbeat.test.d.ts +0 -2
  784. package/dist/test/silent-heartbeat.test.d.ts.map +0 -1
  785. package/dist/test/silent-heartbeat.test.js +0 -165
  786. package/dist/test/silent-heartbeat.test.js.map +0 -1
  787. package/dist/test/state-scaling-e2e.test.d.ts +0 -22
  788. package/dist/test/state-scaling-e2e.test.d.ts.map +0 -1
  789. package/dist/test/state-scaling-e2e.test.js +0 -206
  790. package/dist/test/state-scaling-e2e.test.js.map +0 -1
  791. package/dist/test/state-scaling.test.d.ts +0 -12
  792. package/dist/test/state-scaling.test.d.ts.map +0 -1
  793. package/dist/test/state-scaling.test.js +0 -251
  794. package/dist/test/state-scaling.test.js.map +0 -1
  795. package/dist/test/stop-with-host-operations.test.d.ts +0 -20
  796. package/dist/test/stop-with-host-operations.test.d.ts.map +0 -1
  797. package/dist/test/stop-with-host-operations.test.js +0 -1174
  798. package/dist/test/stop-with-host-operations.test.js.map +0 -1
  799. package/dist/test/stop-with-work-in-flight.test.d.ts +0 -23
  800. package/dist/test/stop-with-work-in-flight.test.d.ts.map +0 -1
  801. package/dist/test/stop-with-work-in-flight.test.js +0 -784
  802. package/dist/test/stop-with-work-in-flight.test.js.map +0 -1
  803. package/dist/test/surgery-marks-receipt.test.d.ts +0 -2
  804. package/dist/test/surgery-marks-receipt.test.d.ts.map +0 -1
  805. package/dist/test/surgery-marks-receipt.test.js +0 -540
  806. package/dist/test/surgery-marks-receipt.test.js.map +0 -1
  807. package/dist/test/thinking-token-stamp.test.d.ts +0 -12
  808. package/dist/test/thinking-token-stamp.test.d.ts.map +0 -1
  809. package/dist/test/thinking-token-stamp.test.js +0 -129
  810. package/dist/test/thinking-token-stamp.test.js.map +0 -1
  811. package/dist/test/timezone.test.d.ts +0 -2
  812. package/dist/test/timezone.test.d.ts.map +0 -1
  813. package/dist/test/timezone.test.js +0 -18
  814. package/dist/test/timezone.test.js.map +0 -1
  815. package/dist/test/tool-batch-hook.test.d.ts +0 -8
  816. package/dist/test/tool-batch-hook.test.d.ts.map +0 -1
  817. package/dist/test/tool-batch-hook.test.js +0 -275
  818. package/dist/test/tool-batch-hook.test.js.map +0 -1
  819. package/dist/test/tool-policy.test.d.ts +0 -2
  820. package/dist/test/tool-policy.test.d.ts.map +0 -1
  821. package/dist/test/tool-policy.test.js +0 -51
  822. package/dist/test/tool-policy.test.js.map +0 -1
  823. package/dist/test/tool-presentation.test.d.ts +0 -2
  824. package/dist/test/tool-presentation.test.d.ts.map +0 -1
  825. package/dist/test/tool-presentation.test.js +0 -268
  826. package/dist/test/tool-presentation.test.js.map +0 -1
  827. package/dist/test/tool-result-content.test.d.ts +0 -2
  828. package/dist/test/tool-result-content.test.d.ts.map +0 -1
  829. package/dist/test/tool-result-content.test.js +0 -37
  830. package/dist/test/tool-result-content.test.js.map +0 -1
  831. package/dist/test/tool-result-guard.test.d.ts +0 -2
  832. package/dist/test/tool-result-guard.test.d.ts.map +0 -1
  833. package/dist/test/tool-result-guard.test.js +0 -3729
  834. package/dist/test/tool-result-guard.test.js.map +0 -1
  835. package/dist/test/tool-result-history.test.d.ts +0 -7
  836. package/dist/test/tool-result-history.test.d.ts.map +0 -1
  837. package/dist/test/tool-result-history.test.js +0 -156
  838. package/dist/test/tool-result-history.test.js.map +0 -1
  839. package/dist/test/tool-result-spill.test.d.ts +0 -13
  840. package/dist/test/tool-result-spill.test.d.ts.map +0 -1
  841. package/dist/test/tool-result-spill.test.js +0 -586
  842. package/dist/test/tool-result-spill.test.js.map +0 -1
  843. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +0 -2
  844. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +0 -1
  845. package/dist/test/tool-wrapper-prose-guard-integration.test.js +0 -485
  846. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +0 -1
  847. package/dist/test/tool-wrapper-prose-guard.test.d.ts +0 -2
  848. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +0 -1
  849. package/dist/test/tool-wrapper-prose-guard.test.js +0 -34
  850. package/dist/test/tool-wrapper-prose-guard.test.js.map +0 -1
  851. package/dist/test/tools-observe-parser-conformance.test.d.ts +0 -2
  852. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +0 -1
  853. package/dist/test/tools-observe-parser-conformance.test.js +0 -129
  854. package/dist/test/tools-observe-parser-conformance.test.js.map +0 -1
  855. package/dist/test/trunk-channel-routing.test.d.ts +0 -19
  856. package/dist/test/trunk-channel-routing.test.d.ts.map +0 -1
  857. package/dist/test/trunk-channel-routing.test.js +0 -652
  858. package/dist/test/trunk-channel-routing.test.js.map +0 -1
  859. package/dist/test/tune-out-e2e.test.d.ts +0 -2
  860. package/dist/test/tune-out-e2e.test.d.ts.map +0 -1
  861. package/dist/test/tune-out-e2e.test.js +0 -142
  862. package/dist/test/tune-out-e2e.test.js.map +0 -1
  863. package/dist/test/tune-out-gate-composition.test.d.ts +0 -2
  864. package/dist/test/tune-out-gate-composition.test.d.ts.map +0 -1
  865. package/dist/test/tune-out-gate-composition.test.js +0 -221
  866. package/dist/test/tune-out-gate-composition.test.js.map +0 -1
  867. package/dist/test/tune-out-lifecycle.test.d.ts +0 -2
  868. package/dist/test/tune-out-lifecycle.test.d.ts.map +0 -1
  869. package/dist/test/tune-out-lifecycle.test.js +0 -121
  870. package/dist/test/tune-out-lifecycle.test.js.map +0 -1
  871. package/dist/test/turn-trigger-provenance.test.d.ts +0 -2
  872. package/dist/test/turn-trigger-provenance.test.d.ts.map +0 -1
  873. package/dist/test/turn-trigger-provenance.test.js +0 -137
  874. package/dist/test/turn-trigger-provenance.test.js.map +0 -1
  875. package/dist/test/unconvertible-tool-result.test.d.ts +0 -2
  876. package/dist/test/unconvertible-tool-result.test.d.ts.map +0 -1
  877. package/dist/test/unconvertible-tool-result.test.js +0 -358
  878. package/dist/test/unconvertible-tool-result.test.js.map +0 -1
  879. package/dist/test/unsent-compiles.test.d.ts +0 -2
  880. package/dist/test/unsent-compiles.test.d.ts.map +0 -1
  881. package/dist/test/unsent-compiles.test.js +0 -139
  882. package/dist/test/unsent-compiles.test.js.map +0 -1
  883. package/dist/test/usage-accounting.test.d.ts +0 -2
  884. package/dist/test/usage-accounting.test.d.ts.map +0 -1
  885. package/dist/test/usage-accounting.test.js +0 -13
  886. package/dist/test/usage-accounting.test.js.map +0 -1
  887. package/dist/test/usage-tracker.test.d.ts +0 -2
  888. package/dist/test/usage-tracker.test.d.ts.map +0 -1
  889. package/dist/test/usage-tracker.test.js +0 -129
  890. package/dist/test/usage-tracker.test.js.map +0 -1
  891. package/dist/test/utils-surface.test.d.ts +0 -2
  892. package/dist/test/utils-surface.test.d.ts.map +0 -1
  893. package/dist/test/utils-surface.test.js +0 -145
  894. package/dist/test/utils-surface.test.js.map +0 -1
  895. package/dist/test/workspace-branch-continuation.test.d.ts +0 -15
  896. package/dist/test/workspace-branch-continuation.test.d.ts.map +0 -1
  897. package/dist/test/workspace-branch-continuation.test.js +0 -183
  898. package/dist/test/workspace-branch-continuation.test.js.map +0 -1
  899. package/dist/test/workspace-materialize-freshness.test.d.ts +0 -16
  900. package/dist/test/workspace-materialize-freshness.test.d.ts.map +0 -1
  901. package/dist/test/workspace-materialize-freshness.test.js +0 -252
  902. package/dist/test/workspace-materialize-freshness.test.js.map +0 -1
  903. package/dist/test/workspace-path-containment.test.d.ts +0 -19
  904. package/dist/test/workspace-path-containment.test.d.ts.map +0 -1
  905. package/dist/test/workspace-path-containment.test.js +0 -91
  906. package/dist/test/workspace-path-containment.test.js.map +0 -1
  907. package/dist/test/workspace-read-file-from-disk.test.d.ts +0 -17
  908. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +0 -1
  909. package/dist/test/workspace-read-file-from-disk.test.js +0 -174
  910. package/dist/test/workspace-read-file-from-disk.test.js.map +0 -1
  911. package/dist/test/workspace-read-image.test.d.ts +0 -2
  912. package/dist/test/workspace-read-image.test.d.ts.map +0 -1
  913. package/dist/test/workspace-read-image.test.js +0 -597
  914. package/dist/test/workspace-read-image.test.js.map +0 -1
  915. package/dist/test/workspace-read-text.test.d.ts +0 -2
  916. package/dist/test/workspace-read-text.test.d.ts.map +0 -1
  917. package/dist/test/workspace-read-text.test.js +0 -101
  918. package/dist/test/workspace-read-text.test.js.map +0 -1
  919. package/dist/test/workspace-restore-order.test.d.ts +0 -16
  920. package/dist/test/workspace-restore-order.test.d.ts.map +0 -1
  921. package/dist/test/workspace-restore-order.test.js +0 -140
  922. package/dist/test/workspace-restore-order.test.js.map +0 -1
  923. package/dist/test/workspace-three-way.test.d.ts +0 -12
  924. package/dist/test/workspace-three-way.test.d.ts.map +0 -1
  925. package/dist/test/workspace-three-way.test.js +0 -4094
  926. package/dist/test/workspace-three-way.test.js.map +0 -1
  927. package/dist/test/workspace-watcher.test.d.ts +0 -2
  928. package/dist/test/workspace-watcher.test.d.ts.map +0 -1
  929. package/dist/test/workspace-watcher.test.js +0 -281
  930. package/dist/test/workspace-watcher.test.js.map +0 -1
  931. package/dist/test/xml-round-persist.test.d.ts +0 -19
  932. package/dist/test/xml-round-persist.test.d.ts.map +0 -1
  933. package/dist/test/xml-round-persist.test.js +0 -235
  934. package/dist/test/xml-round-persist.test.js.map +0 -1
  935. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -5,10 +5,11 @@
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
7
 
8
+ import { isUtf8 } from 'node:buffer';
8
9
  import { constants as fsConstants } from 'node:fs';
9
10
  import type { Stats } from 'node:fs';
10
- import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
11
- import { join, resolve, relative, sep } from 'node:path';
11
+ import { open, readFile, readdir, stat, lstat, realpath } from 'node:fs/promises';
12
+ import { isAbsolute, join, resolve, relative, sep } from 'node:path';
12
13
  import type { JsStore } from '@animalabs/chronicle';
13
14
  import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
14
15
  import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
@@ -39,8 +40,8 @@ import { MountWatcher, type FsChange } from './watcher.js';
39
40
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
40
41
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
41
42
  import { BranchIntents } from './branch-intent.js';
42
- import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js';
43
- import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
43
+ import { IGNORED, OTHER_MOUNT, checkDir, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js';
44
+ import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
44
45
  import {
45
46
  type MountRuntime,
46
47
  type PassOptions,
@@ -50,7 +51,9 @@ import {
50
51
  type PushResult,
51
52
  type Scope,
52
53
  type TreeOp,
54
+ NOT_PUSHED_IN_OTHER_MOUNT,
53
55
  WALK_CAP,
56
+ entryMode,
54
57
  pushPaths,
55
58
  reconcilePass,
56
59
  settleAdoptionBeforeMutation,
@@ -58,6 +61,14 @@ import {
58
61
 
59
62
  /** Default for how long the after-batch scan may hold the agent's next inference. */
60
63
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
64
+ /**
65
+ * How long a mount's catch-up pass waits for its watcher to become ready
66
+ * before it starts anyway (ensureRunning). In a probe on Linux at a load near
67
+ * 40, chokidar's first scan of 10,000 entries took 1.3 s, about a twentieth of
68
+ * this. A scan slower than the bound has the pass started beside it, and the
69
+ * watcher's ready runs the pass again once the scan ends.
70
+ */
71
+ const CATCH_UP_BOUND_MS = 30_000;
61
72
  /** Why a call is refused once the module has stopped. */
62
73
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
63
74
  /** The workspace's own deadline must end before the framework's hook bound. */
@@ -112,11 +123,14 @@ class WorkspaceImageReadError extends Error {
112
123
  * (`mount_unavailable`), an ordinary missing file (`not_found`), a directory
113
124
  * (`directory`), a symlink refused by the mount's `followSymlinks` policy
114
125
  * (`symlink`), a symlink whose canonical target lands outside the canonical
115
- * mount root (`escape`), and a file swapped underneath the read (`changed`).
126
+ * mount root (`escape`), a file swapped underneath the read (`changed`), and a
127
+ * path in another mount's directory nested in the one named (`other_mount`),
128
+ * whose message names the mount that holds it and the path to use there.
116
129
  */
117
130
  export type WorkspaceReadErrorCode =
118
131
  | 'unknown_mount'
119
132
  | 'traversal'
133
+ | 'other_mount'
120
134
  | 'mount_unavailable'
121
135
  | 'not_found'
122
136
  | 'directory'
@@ -136,6 +150,17 @@ export type WorkspaceReadStage =
136
150
  | 'stat'
137
151
  | 'read';
138
152
 
153
+ /**
154
+ * A file operation's path lies in another mount's directory nested in the
155
+ * mount it names. The message names the mount that holds it and the path to
156
+ * use there.
157
+ */
158
+ class NestedMountError extends Error {
159
+ constructor(path: string, mount: string, holder: string, use: string) {
160
+ super(`"${path}" lies in mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
161
+ }
162
+ }
163
+
139
164
  /**
140
165
  * Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
141
166
  * mount-prefixed path only — never absolute filesystem paths — so they can be
@@ -156,13 +181,23 @@ export class WorkspaceReadError extends Error {
156
181
 
157
182
  /** Successful `readFileFromDisk()` result. */
158
183
  export interface WorkspaceDiskReadResult {
159
- /** File bytes — the whole file, or its first `maxBytes` when `truncated`. */
184
+ /**
185
+ * File bytes: the whole file, or its first `maxBytes` when `truncated`.
186
+ * Without `maxBytes` the file is read with `readFile`, with no limit. In
187
+ * Node, that reads up to the size its own fstat finds, or to the file's end
188
+ * if that size is 0.
189
+ */
160
190
  bytes: Buffer;
161
- /** Full on-disk size in bytes, regardless of truncation. */
191
+ /**
192
+ * The file's size, agreeing with `bytes` and `truncated`: `bytes.length`,
193
+ * unless `truncated`. Then it's the file's size by an fstat after the read,
194
+ * or `maxBytes + 1` if that's less, since the read found at least that many
195
+ * bytes.
196
+ */
162
197
  size: number;
163
- /** True when `maxBytes` was set and the file was larger; `bytes` is a prefix. */
198
+ /** True when `maxBytes` was set and the read found the file going on past it; `bytes` is a prefix. */
164
199
  truncated: boolean;
165
- /** mtime of the file actually read (for change-detection caches). */
200
+ /** mtime of the file actually read, from its fstat at the open (for change-detection caches). */
166
201
  mtimeMs: number;
167
202
  /** Canonical (realpath) location of the file read — inside the canonical mount root by construction. */
168
203
  realPath: string;
@@ -171,7 +206,12 @@ export interface WorkspaceDiskReadResult {
171
206
  }
172
207
 
173
208
  export interface ReadFileFromDiskOptions {
174
- /** Read at most this many bytes (a bounded prefix read — the rest of the file is never loaded). */
209
+ /**
210
+ * Keep at most this many bytes (a bounded prefix read): the read loads them
211
+ * and one byte past them, which isn't kept and tells whether the file goes
212
+ * on, and never the rest of the file. It bounds the read itself, so it
213
+ * holds for a file that grows while it's read.
214
+ */
175
215
  maxBytes?: number;
176
216
  }
177
217
 
@@ -181,6 +221,11 @@ function errnoOf(err: unknown): string | undefined {
181
221
  : undefined;
182
222
  }
183
223
 
224
+ /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
225
+ function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
226
+ return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
227
+ }
228
+
184
229
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
185
230
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
186
231
  const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
@@ -645,6 +690,17 @@ export class WorkspaceModule implements Module {
645
690
  /** Whether a mount root's filesystem maintains ctime, by canonical root. */
646
691
  /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
647
692
  private ctimeTrust = new Map<string, string | null>();
693
+ /**
694
+ * Each mount's directories that are other mounts nested inside it:
695
+ * mount-relative, `/`-separated, with each mount's name and resolved root.
696
+ */
697
+ private otherMounts = new Map<string, Array<{ dir: string; name: string; root: string }>>();
698
+ /**
699
+ * Whether a file's mode is observed, taken in and applied (MountRuntime's
700
+ * `modesObserved`): not on Windows, which reports no POSIX modes. A test
701
+ * sets it false to run that case anywhere.
702
+ */
703
+ private modesObserved = process.platform !== 'win32';
648
704
  /** Persisted state decoded in start(), held until mounts exist to apply it
649
705
  * to — in the Host ordering start() runs before initStore() creates the
650
706
  * mounts, so restoration must be second-callback-safe like watcher
@@ -662,26 +718,31 @@ export class WorkspaceModule implements Module {
662
718
  );
663
719
  }
664
720
  }
665
- // Detect overlapping mount paths: if mount A contains mount B,
666
- // auto-add an ignore rule on A for B's path to prevent syncing
667
- // the sub-mount's directory through the super-mount.
721
+ // Detect overlapping mount paths: a mount nested inside another is that
722
+ // mount's directory, which the outer mount passes over in every pass,
723
+ // named or not, and in its watcher (OTHER_MOUNT). It's kept apart from
724
+ // the outer mount's ignore list, which a sync that names a path sets
725
+ // aside for what it names (#276), so no sync of the outer mount takes in
726
+ // the inner one's files.
668
727
  for (const outer of config.mounts) {
669
728
  for (const inner of config.mounts) {
670
729
  if (outer === inner) continue;
671
730
  const outerPath = resolve(outer.path);
672
731
  const innerPath = resolve(inner.path);
673
732
  const rel = relative(outerPath, innerPath);
674
- if (rel && !rel.startsWith('..') && !rel.startsWith('/')) {
675
- // inner is nested under outer — add ignore rule
676
- outer.ignore = outer.ignore ?? [];
677
- const pattern = rel + '/**';
678
- if (!outer.ignore.includes(pattern) && !outer.ignore.includes(rel)) {
679
- outer.ignore.push(rel);
680
- console.warn(
681
- `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
682
- `(${rel}/) — auto-ignoring to prevent overlap`,
683
- );
684
- }
733
+ // Outside the outer mount, `relative` gives a path that climbs with a
734
+ // `..` segment, or an absolute one across Windows drives. A name that
735
+ // only begins with two dots, such as `..cache`, is inside it.
736
+ if (rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)) {
737
+ // inner is nested under outer: passed over as exactly that
738
+ // directory, read against `/`-separated paths, whatever the
739
+ // platform's.
740
+ const relPath = rel.split(sep).join('/');
741
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { dir: relPath, name: inner.name, root: innerPath }]);
742
+ console.warn(
743
+ `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
744
+ `at ${relPath}/ — passing over it as another mount's directory`,
745
+ );
685
746
  }
686
747
  }
687
748
  }
@@ -698,7 +759,11 @@ export class WorkspaceModule implements Module {
698
759
  * second. `ensureRunning()` gates on `ctx && mounts-populated`.
699
760
  */
700
761
  initStore(store: JsStore): void {
701
- this.store = store;
762
+ // Every mount's states are registered before the module takes the store
763
+ // or any mount, so a registration that fails leaves the module as it was
764
+ // before the call, knowing no mount, and a later initStore starts clean.
765
+ const mounts = new Map<string, MountState>();
766
+ const intents = new Map<string, BranchIntents>();
702
767
 
703
768
  // Register tree states for each mount: the workspace tree, and the
704
769
  // branch-local intent that qualifies it.
@@ -706,19 +771,15 @@ export class WorkspaceModule implements Module {
706
771
  const treeStateId = `workspace/${mount.name}/tree`;
707
772
  const intentTreeStateId = `workspace/${mount.name}/intent`;
708
773
  for (const id of [treeStateId, intentTreeStateId]) {
709
- try {
710
- store.registerState({
711
- id,
712
- strategy: 'tree',
713
- deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
714
- fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
715
- });
716
- } catch {
717
- // State already registered (restart scenario)
718
- }
774
+ ensureStateRegistered(store, {
775
+ id,
776
+ strategy: 'tree',
777
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
778
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
779
+ });
719
780
  }
720
781
 
721
- const mountState: MountState = {
782
+ mounts.set(mount.name, {
722
783
  config: mount,
723
784
  treeStateId,
724
785
  intentTreeStateId,
@@ -728,14 +789,17 @@ export class WorkspaceModule implements Module {
728
789
  lastMaterializedBranchId: null,
729
790
  watcherReadyAt: null,
730
791
  watcherError: null,
731
- };
732
- this.mounts.set(mount.name, mountState);
733
- this.intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
792
+ });
793
+ intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
734
794
  }
735
795
 
796
+ this.store = store;
797
+ for (const [name, state] of mounts) this.mounts.set(name, state);
798
+ for (const [name, intent] of intents) this.intents.set(name, intent);
799
+
736
800
  // What disk last agreed with, per mount and path: global records, so a
737
801
  // branch switch never rewinds it.
738
- this.agreement = new DiskAgreement(store);
802
+ this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
739
803
  this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
740
804
 
741
805
  this.applySavedState();
@@ -784,6 +848,7 @@ export class WorkspaceModule implements Module {
784
848
  followSymlinks: mount.config.followSymlinks === true,
785
849
  maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
786
850
  ignore: mount.config.ignore ?? [],
851
+ others: this.othersOf(mount.config.name),
787
852
  },
788
853
  rootReal,
789
854
  ctimeTrustedDev: trustedDev,
@@ -792,13 +857,52 @@ export class WorkspaceModule implements Module {
792
857
  readOnly: mount.config.mode === 'read-only',
793
858
  walkCap: mount.config.walkCap ?? WALK_CAP,
794
859
  catchUpScan: (mount.config.watch ?? 'always') === 'always',
860
+ modesObserved: this.modesObserved,
795
861
  };
796
862
  }
797
863
 
864
+ /**
865
+ * Where a search's walk starts: `base`, its path as given, then each
866
+ * segment of `beneath` while disk lists it, in the directory reached so
867
+ * far, as a directory with exactly that spelling. The walk names what it
868
+ * finds from its start's spelling, and a case-insensitive disk would let
869
+ * it go on under a spelling disk doesn't list, taking files in under that
870
+ * spelling beside their own. So the start stops before a segment spelled
871
+ * any other way, not there, or not a directory, and before a link, which
872
+ * a walk of the whole mount doesn't go through either. It lists only
873
+ * directories it has checked lie inside the mount, and none the mount
874
+ * passes over by design, which the walk then answers for.
875
+ */
876
+ private async walkStart(mount: MountState, base: string, beneath: string): Promise<string> {
877
+ if (beneath === '') return base;
878
+ const { view, rootReal } = await this.runtime(mount);
879
+ let start = base;
880
+ for (const segment of beneath.split('/')) {
881
+ if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined) break;
882
+ const here = await checkDir(view, rootReal, start);
883
+ if (here.kind !== 'checked') break;
884
+ const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
885
+ if (entry === undefined || !entry.isDirectory()) break;
886
+ start = start ? `${start}/${segment}` : segment;
887
+ }
888
+ return start;
889
+ }
890
+
798
891
  /** One disk→store pass; the caller holds the mount's turn. Emits its events. */
799
892
  private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
800
893
  const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
801
894
  if (scope.kind === 'dir' && scope.dir === '' && scope.recursive) mount.initialSyncDone = true;
895
+ // What a pass says is another mount's directory names that mount and
896
+ // the path to use there, as the file operations' refusal does: a copy's
897
+ // note, and the region of a scope that lies there. The pass's counts
898
+ // keep the bare reason.
899
+ const name = mount.config.name;
900
+ for (const report of result.reports.values()) {
901
+ if (report.note === OTHER_MOUNT) report.note = this.otherMountNote(name, report.path, 'list');
902
+ }
903
+ for (const region of result.incomplete) {
904
+ if (region.reason === OTHER_MOUNT) region.reason = this.otherMountNote(name, region.path, 'list');
905
+ }
802
906
  this.emitFsEvents(mount.config.name, result.ops, result.newConflicts);
803
907
  return result;
804
908
  }
@@ -961,7 +1065,45 @@ export class WorkspaceModule implements Module {
961
1065
 
962
1066
  const watchMode = mount.config.watch ?? 'always';
963
1067
  if (watchMode === 'always') {
964
- const watcher = new MountWatcher(
1068
+ // Chokidar is started with ignoreInitial:true, so changes made while
1069
+ // the session was down would be invisible. A catch-up pass takes them
1070
+ // in under the three-way rule: only real differences produce tree
1071
+ // changes and events. It takes its turn with the mount's bare syncs,
1072
+ // so on a mount over its walkCap it covers the walkCap entries after
1073
+ // where the last turn stopped, and a change elsewhere waits for the
1074
+ // rotation to reach it, or for a sync of its path.
1075
+ //
1076
+ // The pass runs at each of the watcher's readies, its first and each
1077
+ // re-attach's, when chokidar has called fs.watch on every entry, so a
1078
+ // change made after the pass has looked at a file reaches the watcher.
1079
+ // Started beside chokidar's scan, the pass could finish with a file
1080
+ // before chokidar watched it, and a change between the two reached
1081
+ // neither. A watcher that isn't ready within CATCH_UP_BOUND_MS (from
1082
+ // its start or its re-attach) has the pass started at the bound, and
1083
+ // its ready, if it comes, runs the pass again. Within the mount's
1084
+ // walkCap, that pass covers what the early one missed. Over it, the
1085
+ // pass continues the rotation, and what the early one missed waits for
1086
+ // the rotation to come back, as any change outside a pass's window
1087
+ // does. An error doesn't start the pass: chokidar goes on scanning
1088
+ // after one, and the bound covers a watcher that never becomes ready.
1089
+ // On macOS, a new entry written after ready but before libuv's
1090
+ // FSEventStream exists isn't reported until a later change in its
1091
+ // directory, which this doesn't change.
1092
+ let watcher: MountWatcher | undefined;
1093
+ let bound: ReturnType<typeof setTimeout> | null = null;
1094
+ const catchUp = () => {
1095
+ if (bound) clearTimeout(bound);
1096
+ bound = null;
1097
+ // A watcher the module has since stopped or replaced starts no pass.
1098
+ if (this.watchers.get(name) !== watcher) return;
1099
+ void this.initialScan(name);
1100
+ };
1101
+ const awaitReady = () => {
1102
+ if (bound) clearTimeout(bound);
1103
+ bound = setTimeout(catchUp, CATCH_UP_BOUND_MS);
1104
+ bound.unref?.();
1105
+ };
1106
+ watcher = new MountWatcher(
965
1107
  mount.config,
966
1108
  (changes) => {
967
1109
  this.handleFsChanges(name, changes);
@@ -970,6 +1112,7 @@ export class WorkspaceModule implements Module {
970
1112
  onReady: () => {
971
1113
  mount.watcherReadyAt = Date.now();
972
1114
  mount.watcherError = null;
1115
+ catchUp();
973
1116
  },
974
1117
  onError: (err) => {
975
1118
  mount.watcherError = err.message;
@@ -982,7 +1125,7 @@ export class WorkspaceModule implements Module {
982
1125
  },
983
1126
  onReattach: () => {
984
1127
  mount.watcherError = null;
985
- void this.initialScan(name);
1128
+ awaitReady();
986
1129
  this.ctx?.pushEvent({
987
1130
  type: 'workspace:watcher-reattached',
988
1131
  mount: name,
@@ -990,18 +1133,11 @@ export class WorkspaceModule implements Module {
990
1133
  } as ProcessEvent);
991
1134
  },
992
1135
  },
1136
+ this.othersOf(name),
993
1137
  );
1138
+ awaitReady();
994
1139
  watcher.start();
995
1140
  this.watchers.set(name, watcher);
996
-
997
- // Chokidar is started with ignoreInitial:true, so changes made while
998
- // the session was down would be invisible. A catch-up pass takes them
999
- // in under the three-way rule: only real differences produce tree
1000
- // changes and events. It takes its turn with the mount's bare syncs,
1001
- // so on a mount over its walkCap it covers the walkCap entries after
1002
- // where the last turn stopped, and a change elsewhere waits for the
1003
- // rotation to reach it, or for a sync of its path.
1004
- void this.initialScan(name);
1005
1141
  }
1006
1142
 
1007
1143
  this.ctx.pushEvent({
@@ -1035,11 +1171,13 @@ export class WorkspaceModule implements Module {
1035
1171
  // No new watcher-driven passes, then let every pass and write finish
1036
1172
  // before the store goes away — including any queued behind the ones in
1037
1173
  // flight (a tool call, a scan continuing past its deadline): drained
1038
- // until no new tail appears.
1039
- for (const watcher of this.watchers.values()) {
1174
+ // until no new tail appears. The watchers leave this.watchers before they
1175
+ // stop, so a watcher's ready or catch-up bound starts no pass meanwhile.
1176
+ const stopping = [...this.watchers.values()];
1177
+ this.watchers.clear();
1178
+ for (const watcher of stopping) {
1040
1179
  await watcher.stop();
1041
1180
  }
1042
- this.watchers.clear();
1043
1181
  for (;;) {
1044
1182
  const tails = [...this.mountTurns.values()];
1045
1183
  await Promise.all(tails);
@@ -1050,9 +1188,16 @@ export class WorkspaceModule implements Module {
1050
1188
  // the pins and watermarks as the last work left them.
1051
1189
  this.stopped = true;
1052
1190
 
1053
- // Persist state
1054
- if (this.ctx) {
1055
- const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
1191
+ // Persist state, once initStore() has run and the state start() was
1192
+ // handed has been applied (savedState is cleared then). Before that this
1193
+ // session has made no pass or write, so it has nothing to save, and the
1194
+ // stored state stays as it was: saving would put empty or default mounts
1195
+ // over the saved pins. A create() that fails after starting this module
1196
+ // stops it before the host can call initStore() (#389), and an
1197
+ // initStore() that throws after taking the store leaves the state
1198
+ // unapplied.
1199
+ if (this.ctx && this.store && this.savedState === null) {
1200
+ const activeBranchId = this.store.currentBranch().id;
1056
1201
  const state: WorkspaceModuleState = { mounts: {}, activeBranchId };
1057
1202
  for (const [name, mount] of this.mounts) {
1058
1203
  state.mounts[name] = {
@@ -1093,8 +1238,8 @@ export class WorkspaceModule implements Module {
1093
1238
  type: 'object' as const,
1094
1239
  properties: {
1095
1240
  path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
1096
- offset: { type: 'number', description: 'Starting line number (1-indexed)' },
1097
- limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
1241
+ offset: { type: 'integer', description: 'Starting line number (1-indexed)' },
1242
+ limit: { type: 'integer', description: 'Maximum number of lines to return (default 2000)' },
1098
1243
  offsetChars: { type: 'integer', description: 'Character paging: zero-based UTF-16 offset (default 0); use nextOffsetChars to continue' },
1099
1244
  limitChars: { type: 'integer', description: 'Character paging: code units to return (default 2000), plus at most one to keep a surrogate pair intact. Use the limit suggested by a spill notice.' },
1100
1245
  },
@@ -1158,7 +1303,8 @@ export class WorkspaceModule implements Module {
1158
1303
  + 'still on disk); not-in-branch (on disk, absent from this branch\'s workspace); disk-only (binary or over '
1159
1304
  + 'the size limit, never stored — shown with size and image type); conflict, with its kind (disk and the '
1160
1305
  + 'workspace both changed; disk holds a different copy and nothing records which is newer; or a write was '
1161
- + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace; '
1306
+ + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace, '
1307
+ + 'with applyDeletions too where the workspace deleted it; '
1162
1308
  + 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
1163
1309
  + 'the listing could not see.',
1164
1310
  inputSchema: {
@@ -1194,8 +1340,8 @@ export class WorkspaceModule implements Module {
1194
1340
  pattern: { type: 'string', description: 'Regular expression pattern' },
1195
1341
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1196
1342
  glob: { type: 'string', description: 'Glob pattern to filter files' },
1197
- contextBefore: { type: 'number', description: 'Context lines before match' },
1198
- contextAfter: { type: 'number', description: 'Context lines after match' },
1343
+ contextBefore: { type: 'integer', description: 'Context lines before match' },
1344
+ contextAfter: { type: 'integer', description: 'Context lines after match' },
1199
1345
  },
1200
1346
  required: ['pattern'],
1201
1347
  },
@@ -1242,13 +1388,18 @@ export class WorkspaceModule implements Module {
1242
1388
  + 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
1243
1389
  + 'directory replaced on purpose, or a drive back under a new device number, never for one still '
1244
1390
  + 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and '
1245
- + 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s '
1246
- + 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a '
1391
+ + 'reported nowhere else is counted under `passedOver`, by mount and reason: another mount\'s '
1392
+ + 'directory nested in this one, entries the mount\'s ignore list covers, and entries with an '
1393
+ + 'editor\'s swap or backup file name (`.name.swp` and `.name.swx` as the whole name, names ending '
1394
+ + 'in `~` or in `.subl…tmp`), that hold nothing the workspace tracks (such a directory counts once, '
1395
+ + 'with what lies beneath it). So a '
1247
1396
  + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
1248
- + 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name '
1249
- + 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory '
1250
- + 'sets aside the patterns that cover it or a directory above it; one that covers only something '
1251
- + 'inside it still applies, and what it covers is passed over.',
1397
+ + 'sync looked everywhere and nothing had changed. A path the mount ignores, or an editor\'s file, is '
1398
+ + 'synced when you name it, and listed under `ignored`: a sync without a path passes over it. A path '
1399
+ + 'in another mount\'s directory isn\'t synced even when you name it: use the mount that holds it. Naming '
1400
+ + 'an ignored directory sets aside the patterns that cover it or a directory above it; one that '
1401
+ + 'covers only something inside it still applies, and what it covers is passed over, as are editors\' '
1402
+ + 'files inside it.',
1252
1403
  inputSchema: {
1253
1404
  type: 'object' as const,
1254
1405
  properties: {
@@ -1281,8 +1432,10 @@ export class WorkspaceModule implements Module {
1281
1432
  const mountName = slash < 0 ? path : path.slice(0, slash);
1282
1433
  const mount = this.config.mounts.find(m => m.name === mountName);
1283
1434
  if (!mount) throw new Error(`Unknown mount: "${mountName}"`);
1284
- const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1));
1285
- if (!isContainedPath(mount.path, resolved)) throw new Error(`Path traversal detected: "${path}"`);
1435
+ // Against the resolved root, as parsePath judges it.
1436
+ const root = resolve(mount.path);
1437
+ const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1));
1438
+ if (!isContainedPath(root, resolved)) throw new Error(`Path traversal detected: "${path}"`);
1286
1439
  return resolved;
1287
1440
  }
1288
1441
 
@@ -1308,6 +1461,13 @@ export class WorkspaceModule implements Module {
1308
1461
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
1309
1462
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1310
1463
  try {
1464
+ // Every handler reads its input as its schema describes it; a call
1465
+ // that sends something else is refused here, before anything is read.
1466
+ const schema = this.getTools().find((tool) => tool.name === call.name)?.inputSchema;
1467
+ const problem = schema ? inputProblem(schema, call.input, INPUT_MINIMUMS[call.name]) : null;
1468
+ if (problem !== null) {
1469
+ return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
1470
+ }
1311
1471
  const input = call.input as Record<string, unknown>;
1312
1472
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1313
1473
  if (generated) {
@@ -1332,7 +1492,7 @@ export class WorkspaceModule implements Module {
1332
1492
  return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
1333
1493
  }
1334
1494
  } catch (err) {
1335
- return { success: false, error: String(err), isError: true };
1495
+ return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
1336
1496
  }
1337
1497
  }
1338
1498
 
@@ -1423,7 +1583,7 @@ export class WorkspaceModule implements Module {
1423
1583
  */
1424
1584
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
1425
1585
  try {
1426
- const { mount, relativePath } = this.parsePath(mountPrefixedPath);
1586
+ const { mount, relativePath } = this.parseFilePath(mountPrefixedPath);
1427
1587
  return resolve(mount.config.path, relativePath);
1428
1588
  } catch {
1429
1589
  return null;
@@ -1461,7 +1621,7 @@ export class WorkspaceModule implements Module {
1461
1621
  let mount: MountState;
1462
1622
  let relativePath: string;
1463
1623
  try {
1464
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1624
+ ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1465
1625
  } catch (error) {
1466
1626
  return {
1467
1627
  success: false,
@@ -1485,13 +1645,17 @@ export class WorkspaceModule implements Module {
1485
1645
  return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
1486
1646
  }
1487
1647
  const blobHash = store.storeBlob(data, mimeType);
1648
+ // An overwrite keeps the file's mode, as the write tool's does.
1649
+ const existing = store.treeGet(mount.treeStateId, relativePath);
1488
1650
  let committed = false;
1489
1651
  let failure: string | null;
1652
+ let modeKept: string | null = null;
1490
1653
  try {
1491
- const materializeError = await this.commitToolChange(mount, relativePath,
1492
- { kind: 'set', blobHash, size: data.byteLength }, () => { committed = true; });
1493
- failure = materializeError === null ? null
1494
- : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`;
1654
+ const answer = await this.commitToolChange(mount, relativePath,
1655
+ { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
1656
+ modeKept = answer.modeKept;
1657
+ failure = answer.refused === null ? null
1658
+ : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
1495
1659
  } catch (error) {
1496
1660
  if (!committed) throw error;
1497
1661
  failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
@@ -1504,7 +1668,7 @@ export class WorkspaceModule implements Module {
1504
1668
  }
1505
1669
  return {
1506
1670
  success: true,
1507
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
1671
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
1508
1672
  };
1509
1673
  });
1510
1674
  }
@@ -1521,7 +1685,7 @@ export class WorkspaceModule implements Module {
1521
1685
  let mount: MountState;
1522
1686
  let relativePath: string;
1523
1687
  try {
1524
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1688
+ ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1525
1689
  } catch (error) {
1526
1690
  return { error: error instanceof Error ? error.message : String(error) };
1527
1691
  }
@@ -1558,7 +1722,7 @@ export class WorkspaceModule implements Module {
1558
1722
  let mount: MountState;
1559
1723
  let relativePath: string;
1560
1724
  try {
1561
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
1725
+ ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1562
1726
  } catch (error) {
1563
1727
  return { error: error instanceof Error ? error.message : String(error) };
1564
1728
  }
@@ -1577,10 +1741,66 @@ export class WorkspaceModule implements Module {
1577
1741
  }
1578
1742
  }
1579
1743
 
1744
+ /** The directories of the mounts nested inside a mount, mount-relative. */
1745
+ private othersOf(mountName: string): string[] {
1746
+ return (this.otherMounts.get(mountName) ?? []).map((o) => o.dir);
1747
+ }
1748
+
1749
+ /**
1750
+ * The innermost mount nested in `mountName` whose directory holds a path
1751
+ * of that mount, judged lexically where the path lands (`.`, `..` and
1752
+ * repeated separators resolved, as for the traversal guard; a link of the
1753
+ * outer mount isn't followed), with the path to use there; undefined when
1754
+ * no nested mount's directory holds it.
1755
+ */
1756
+ private holderOf(mountName: string, relativePath: string): { name: string; use: string } | undefined {
1757
+ const mount = this.mounts.get(mountName);
1758
+ if (!mount) return undefined;
1759
+ const resolved = resolve(mount.config.path, relativePath);
1760
+ const holder = (this.otherMounts.get(mountName) ?? [])
1761
+ .filter((o) => isContainedPath(o.root, resolved))
1762
+ .sort((a, b) => b.root.length - a.root.length)[0];
1763
+ if (!holder) return undefined;
1764
+ const rest = relative(holder.root, resolved).split(sep).join('/');
1765
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
1766
+ }
1767
+
1768
+ /**
1769
+ * Why a mount leaves a path in a nested mount's directory, naming that
1770
+ * mount and the path to use: for a sync or a push, which of them leaves
1771
+ * it; for a listing, whose the path is.
1772
+ */
1773
+ private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list'): string {
1774
+ const holder = this.holderOf(mountName, relativePath);
1775
+ const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
1776
+ return holder
1777
+ ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
1778
+ : `it lies in another mount's directory${what}`;
1779
+ }
1780
+
1781
+ /**
1782
+ * parsePath for the file operations (read, write, edit, delete, and the
1783
+ * module's binary and disk reads and writes). A path in another mount's
1784
+ * directory nested in this one is that mount's: this mount's passes don't
1785
+ * observe it and its pushes don't write it, so its file operations refuse
1786
+ * it, naming the innermost mount that holds it and the path to use there.
1787
+ */
1788
+ private parseFilePath(path: string): { mount: MountState; relativePath: string } {
1789
+ const parsed = this.parsePath(path);
1790
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
1791
+ if (holder) throw new NestedMountError(path, parsed.mount.config.name, holder.name, holder.use);
1792
+ return parsed;
1793
+ }
1794
+
1580
1795
  /**
1581
1796
  * Parse a mount-prefixed path into (mountName, relativePath).
1582
1797
  */
1583
1798
  private parsePath(path: string): { mount: MountState; relativePath: string } {
1799
+ // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
1800
+ // store would keep one name and disk another.
1801
+ if (LONE_SURROGATE.test(path)) {
1802
+ 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.`);
1803
+ }
1584
1804
  const slashIdx = path.indexOf('/');
1585
1805
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
1586
1806
  const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
@@ -1594,8 +1814,11 @@ export class WorkspaceModule implements Module {
1594
1814
  // Containment must compare with the PLATFORM separator (isContainedPath):
1595
1815
  // resolve() emits backslashes on Windows, so a '/'-suffixed root never
1596
1816
  // prefix-matches there and every in-mount path was rejected as traversal.
1597
- const resolved = resolve(mount.config.path, relativePath);
1598
- if (!isContainedPath(mount.config.path, resolved)) {
1817
+ // Against the resolved root, so a root configured with a trailing
1818
+ // separator, or relative, contains its own paths, the root included.
1819
+ const root = resolve(mount.config.path);
1820
+ const resolved = resolve(root, relativePath);
1821
+ if (!isContainedPath(root, resolved)) {
1599
1822
  throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
1600
1823
  }
1601
1824
 
@@ -1627,12 +1850,7 @@ export class WorkspaceModule implements Module {
1627
1850
  if (bytes.byteLength === 0) {
1628
1851
  throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1629
1852
  }
1630
- if (bytes.byteLength > maxSize) {
1631
- throw new WorkspaceImageReadError(
1632
- 'too_large',
1633
- `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
1634
- );
1635
- }
1853
+ if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
1636
1854
  return {
1637
1855
  bytes,
1638
1856
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
@@ -1809,11 +2027,16 @@ export class WorkspaceModule implements Module {
1809
2027
  * - With symlinks allowed, the canonical target must stay beneath the
1810
2028
  * canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
1811
2029
  * - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
1812
- * ever loaded, and `truncated` reports that the file was larger.
2030
+ * kept, and one byte past them is read to tell whether the file goes on
2031
+ * (`truncated`). The reads decide where the file ends, not the size fstat
2032
+ * gave at the open, so the bound holds for a file that grows while it's
2033
+ * read.
1813
2034
  *
1814
2035
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
1815
- * mount, lexical traversal, unavailable mount, missing file, directory,
1816
- * policy-denied symlink, outside-mount target, and file-changed-during-read.
2036
+ * mount, lexical traversal, a path in another mount's directory nested in
2037
+ * the one named (`other_mount`), unavailable mount, missing file,
2038
+ * directory, policy-denied symlink, outside-mount target, and
2039
+ * file-changed-during-read.
1817
2040
  */
1818
2041
  async readFileFromDisk(
1819
2042
  mountPrefixedPath: string,
@@ -1825,10 +2048,11 @@ export class WorkspaceModule implements Module {
1825
2048
  let mount: MountState;
1826
2049
  let relativePath: string;
1827
2050
  try {
1828
- ({ mount, relativePath } = this.parsePath(mountPrefixedPath));
2051
+ ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1829
2052
  } catch (err) {
1830
2053
  const message = err instanceof Error ? err.message : String(err);
1831
- const code: WorkspaceReadErrorCode = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2054
+ const code: WorkspaceReadErrorCode = err instanceof NestedMountError ? 'other_mount'
2055
+ : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
1832
2056
  throw new WorkspaceReadError(code, message, 'parse');
1833
2057
  }
1834
2058
  if (!relativePath) {
@@ -1837,23 +2061,17 @@ export class WorkspaceModule implements Module {
1837
2061
 
1838
2062
  const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
1839
2063
  try {
1840
- const size = fileStat.size;
1841
2064
  const cap = options.maxBytes;
1842
2065
  let bytes: Buffer;
1843
2066
  let truncated = false;
1844
- if (cap !== undefined && size > cap) {
1845
- truncated = true;
1846
- bytes = Buffer.alloc(cap);
1847
- let offset = 0;
1848
- while (offset < cap) {
1849
- const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
1850
- if (bytesRead === 0) break;
1851
- offset += bytesRead;
1852
- }
1853
- if (offset < cap) bytes = bytes.subarray(0, offset);
1854
- } else {
2067
+ if (cap === undefined) {
1855
2068
  bytes = await handle.readFile();
2069
+ } else {
2070
+ ({ bytes, more: truncated } = await readPrefix(handle, cap, fileStat.size));
1856
2071
  }
2072
+ // What the read found, not what the fstat before it said, so `size`,
2073
+ // `bytes` and `truncated` agree even for a file that changed in between.
2074
+ const size = truncated ? Math.max((await handle.stat()).size, bytes.length + 1) : bytes.length;
1857
2075
  return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
1858
2076
  } catch (err) {
1859
2077
  if (err instanceof WorkspaceReadError) throw err;
@@ -1904,12 +2122,7 @@ export class WorkspaceModule implements Module {
1904
2122
  if (fileStat.size === 0) {
1905
2123
  throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
1906
2124
  }
1907
- if (fileStat.size > maxSize) {
1908
- throw new WorkspaceImageReadError(
1909
- 'too_large',
1910
- `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
1911
- );
1912
- }
2125
+ if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
1913
2126
  });
1914
2127
  } catch (err) {
1915
2128
  if (err instanceof WorkspaceImageReadError) throw err;
@@ -1919,9 +2132,12 @@ export class WorkspaceModule implements Module {
1919
2132
  }
1920
2133
  throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
1921
2134
  }
1922
- const { handle } = opened;
2135
+ const { handle, fileStat } = opened;
1923
2136
  try {
1924
- const bytes = await handle.readFile();
2137
+ // No more than maxSize is loaded: a file grown past it since the fstat
2138
+ // above is refused here, as one already over it was there.
2139
+ const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
2140
+ if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
1925
2141
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
1926
2142
  } catch (err) {
1927
2143
  if (err instanceof WorkspaceImageReadError) throw err;
@@ -1934,6 +2150,14 @@ export class WorkspaceModule implements Module {
1934
2150
  }
1935
2151
  }
1936
2152
 
2153
+ /**
2154
+ * Whether a tool's change on this mount goes to disk as it commits: the
2155
+ * push commitToolChange makes, and what an edit's answer says of it.
2156
+ */
2157
+ private pushesOnCommit(mount: MountState): boolean {
2158
+ return mount.config.autoMaterialize === true && mount.config.mode !== 'read-only';
2159
+ }
2160
+
1937
2161
  /**
1938
2162
  * Commit a tool's write or delete to the workspace tree, with the branch
1939
2163
  * intent it implies (store origin for a path disk never agreed with; a
@@ -1943,17 +2167,20 @@ export class WorkspaceModule implements Module {
1943
2167
  * The push follows materialize's rule: a disk copy that changed since it
1944
2168
  * last agreed is not overwritten or unlinked, and the refusal says why.
1945
2169
  *
1946
- * Returns the reason when the change reached the workspace but not disk, so
1947
- * callers surface it in the tool result: the autoMaterialize contract is
1948
- * that disk is the source of truth for downstream agents. The caller holds
1949
- * the mount's turn and has settled the path first (settleBeforeMutation).
2170
+ * Answers, so callers surface them in the tool result, the reason when the
2171
+ * change reached the workspace but not disk (`refused`): the
2172
+ * autoMaterialize contract is that disk is the source of truth for
2173
+ * downstream agents. And, apart from it, why a write whose bytes reached
2174
+ * disk left the file's mode as disk had it (`modeKept`). The caller holds
2175
+ * the mount's turn and has settled the path first (settleBeforeMutation, or
2176
+ * a pass over the path, as an edit makes).
1950
2177
  */
1951
2178
  private async commitToolChange(
1952
2179
  mount: MountState,
1953
2180
  relativePath: string,
1954
2181
  change: { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string },
1955
2182
  onCommitted?: () => void,
1956
- ): Promise<string | null> {
2183
+ ): Promise<{ refused: string | null; modeKept: string | null }> {
1957
2184
  const store = this.getStore();
1958
2185
  const intents = this.intents.get(mount.config.name)!;
1959
2186
  // The mutation's precondition, enforced here, synchronously with the
@@ -1977,20 +2204,21 @@ export class WorkspaceModule implements Module {
1977
2204
  });
1978
2205
  } else {
1979
2206
  store.treeRemove(mount.treeStateId, relativePath);
2207
+ onCommitted?.();
1980
2208
  intents.update(relativePath, (cur) => {
1981
2209
  const next = { ...cur, tombstone: { hash: change.previousHash } };
1982
2210
  delete next.origin;
1983
2211
  return next;
1984
2212
  });
1985
2213
  }
1986
- if (!mount.config.autoMaterialize || mount.config.mode === 'read-only') return null;
2214
+ if (!this.pushesOnCommit(mount)) return { refused: null, modeKept: null };
1987
2215
 
1988
2216
  const pushed = await this.pushUnlocked(mount, [relativePath], {
1989
2217
  applyDeletions: change.kind === 'remove',
1990
2218
  branchId: store.currentBranch().id,
1991
2219
  });
1992
2220
  const refused = pushed.skipped.find((s) => s.path === relativePath);
1993
- if (!refused) return null;
2221
+ if (!refused) return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null };
1994
2222
  this.ctx?.pushEvent({
1995
2223
  type: 'workspace:materialize-failed',
1996
2224
  mount: mount.config.name,
@@ -1998,7 +2226,7 @@ export class WorkspaceModule implements Module {
1998
2226
  op: change.kind === 'set' ? 'write' : 'delete',
1999
2227
  error: refused.reason,
2000
2228
  } as ProcessEvent);
2001
- return refused.reason;
2229
+ return { refused: refused.reason, modeKept: null };
2002
2230
  }
2003
2231
 
2004
2232
  // ==========================================================================
@@ -2011,24 +2239,29 @@ export class WorkspaceModule implements Module {
2011
2239
  * disk now holds (an edit sees a disk change the settled adoption took in).
2012
2240
  * The pass may not settle it (a branch that keeps changing, an unreadable
2013
2241
  * disk copy); the precondition that matters is enforced again,
2014
- * synchronously, where the change commits (commitToolChange). The caller
2015
- * holds the mount's turn.
2242
+ * synchronously, where the change commits (commitToolChange). The pass
2243
+ * names the path, as a file tool names the one it acts on, so a path the
2244
+ * mount passes over by design is observed there, as a sync naming it would
2245
+ * observe it (#276). The caller holds the mount's turn.
2016
2246
  */
2017
2247
  private async settleBeforeMutation(mount: MountState, relativePath: string): Promise<void> {
2018
2248
  if (this.agreementOrThrow().get(mount.config.name, relativePath)?.kind !== 'pending') return;
2019
- await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2249
+ await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2020
2250
  }
2021
2251
 
2022
2252
  /**
2023
2253
  * Before a read: a path the workspace doesn't hold is observed under the
2024
2254
  * three-way rule, which ingests a new disk file but never brings back a
2025
- * workspace deletion or a file absent from this branch. The caller holds
2026
- * the mount's turn.
2255
+ * workspace deletion or a file absent from this branch. The pass names the
2256
+ * path, as a file tool names the one it acts on: a path the mount passes
2257
+ * over by design is observed, as a sync naming it would observe it (#276),
2258
+ * while a path in another mount's directory is refused before this, and
2259
+ * passed over by any pass. The caller holds the mount's turn.
2027
2260
  */
2028
2261
  private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
2029
2262
  const store = this.getStore();
2030
2263
  if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
2031
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2264
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2032
2265
  return pass.reports.get(relativePath);
2033
2266
  }
2034
2267
 
@@ -2060,19 +2293,14 @@ export class WorkspaceModule implements Module {
2060
2293
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
2061
2294
  const offsetChars = input.offsetChars ?? 0;
2062
2295
  const limitChars = input.limitChars ?? 2000;
2063
- if (characterPaging) {
2064
- if (input.offset !== undefined || input.limit !== undefined) {
2065
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
2066
- }
2067
- if (!Number.isSafeInteger(offsetChars) || offsetChars < 0
2068
- || !Number.isSafeInteger(limitChars) || limitChars < 1
2069
- || input.offsetChars === null || input.limitChars === null) {
2070
- return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
2071
- }
2296
+ // Each number is a whole number of at least its schema's minimum by now
2297
+ // (inputProblem).
2298
+ if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
2299
+ return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
2072
2300
  }
2073
2301
  let content = generatedContent;
2074
2302
  if (content === undefined) {
2075
- const { mount, relativePath } = this.parsePath(input.path);
2303
+ const { mount, relativePath } = this.parseFilePath(input.path);
2076
2304
  const store = this.getStore();
2077
2305
  const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
2078
2306
  const report = await this.ensureSynced(mount, relativePath);
@@ -2107,7 +2335,7 @@ export class WorkspaceModule implements Module {
2107
2335
  },
2108
2336
  };
2109
2337
  }
2110
- const lines = content.split('\n');
2338
+ const lines = linesOf(content);
2111
2339
 
2112
2340
  // Apply offset/limit. An unlimited read of a large file would inject the
2113
2341
  // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
@@ -2115,6 +2343,22 @@ export class WorkspaceModule implements Module {
2115
2343
  // default cap; the result reports total/from/to so the agent can page.
2116
2344
  const DEFAULT_READ_LINE_LIMIT = 2000;
2117
2345
  const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
2346
+ if (startLine >= lines.length) {
2347
+ // Nothing to show: an empty file, or an offset past the last line. No
2348
+ // range is claimed, and the line count says where the file ends.
2349
+ return {
2350
+ success: true,
2351
+ data: {
2352
+ path: input.path,
2353
+ totalLines: lines.length,
2354
+ // Reading an empty file from its start is reading it whole.
2355
+ ...(startLine > 0
2356
+ ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
2357
+ : {}),
2358
+ content: '',
2359
+ },
2360
+ };
2361
+ }
2118
2362
  const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
2119
2363
  const endLine = startLine + effectiveLimit;
2120
2364
  const slice = lines.slice(startLine, endLine);
@@ -2144,7 +2388,7 @@ export class WorkspaceModule implements Module {
2144
2388
  let mount: MountState;
2145
2389
  let relativePath: string;
2146
2390
  try {
2147
- ({ mount, relativePath } = this.parsePath(input.path));
2391
+ ({ mount, relativePath } = this.parseFilePath(input.path));
2148
2392
  } catch (err) {
2149
2393
  return {
2150
2394
  success: false,
@@ -2213,7 +2457,10 @@ export class WorkspaceModule implements Module {
2213
2457
  }
2214
2458
 
2215
2459
  private async handleWrite(input: WriteInput): Promise<ToolResult> {
2216
- const { mount, relativePath } = this.parsePath(input.path);
2460
+ if (LONE_SURROGATE.test(input.content)) {
2461
+ return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
2462
+ }
2463
+ const { mount, relativePath } = this.parseFilePath(input.path);
2217
2464
  if (mount.config.mode === 'read-only') {
2218
2465
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2219
2466
  }
@@ -2225,126 +2472,211 @@ export class WorkspaceModule implements Module {
2225
2472
  }
2226
2473
 
2227
2474
  const buffer = Buffer.from(input.content, 'utf-8');
2228
- const materializeError = await this.withMount(mount, async () => {
2229
- await this.settleBeforeMutation(mount, relativePath);
2230
- const blobHash = store.storeBlob(buffer, 'text/plain');
2231
- return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength });
2232
- });
2233
- if (materializeError) {
2234
- return {
2235
- success: false,
2236
- error: `Wrote to Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
2237
- isError: true,
2238
- };
2475
+ const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
2476
+ let committed = false;
2477
+ let refused: string | null;
2478
+ let modeKept: string | null;
2479
+ try {
2480
+ ({ refused, modeKept } = await this.withMount(mount, async () => {
2481
+ await this.settleBeforeMutation(mount, relativePath);
2482
+ const blobHash = store.storeBlob(buffer, 'text/plain');
2483
+ // An overwrite keeps the file's mode, as an edit does.
2484
+ const existing = store.treeGet(mount.treeStateId, relativePath);
2485
+ return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength, ...(existing ? { mode: existing.mode } : {}) },
2486
+ () => { committed = true; });
2487
+ }));
2488
+ } catch (error) {
2489
+ if (!committed) throw error;
2490
+ return storedButUnfinished('write', input.path, { threw: error instanceof Error ? error.message : String(error) });
2239
2491
  }
2492
+ if (refused !== null) return storedButUnfinished('write', input.path, { refused });
2240
2493
 
2241
- return {
2242
- success: true,
2243
- data: {
2244
- path: input.path,
2245
- size: Buffer.byteLength(input.content),
2246
- hash: hashContent(input.content),
2247
- },
2248
- };
2494
+ return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
2249
2495
  }
2250
2496
 
2251
2497
  private async handleEdit(input: EditInput): Promise<ToolResult> {
2252
- const { mount, relativePath } = this.parsePath(input.path);
2498
+ // Refused before anything is read: an empty oldString matches between
2499
+ // every character, and UTF-8 can't store a lone surrogate.
2500
+ if (input.oldString === '') {
2501
+ 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.' };
2502
+ }
2503
+ for (const field of ['oldString', 'newString'] as const) {
2504
+ if (LONE_SURROGATE.test(input[field])) {
2505
+ return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
2506
+ }
2507
+ }
2508
+ const { mount, relativePath } = this.parseFilePath(input.path);
2253
2509
  if (mount.config.mode === 'read-only') {
2254
2510
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2255
2511
  }
2256
2512
 
2257
2513
  const store = this.getStore();
2258
- return this.withMount(mount, async (): Promise<ToolResult> => {
2259
- const report = await this.ensureSynced(mount, relativePath);
2260
- await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry
2514
+ let committed = false;
2515
+ let appliedNote: string | null = null;
2516
+ try {
2517
+ return await this.withMount(mount, async (): Promise<ToolResult> => {
2518
+ // The edit applies to the path as disk holds it now: a change on disk
2519
+ // to a file the workspace holds is taken in first, by the rule a
2520
+ // listing uses. The pass settles a pending intent on the path too, as
2521
+ // settleBeforeMutation's would, and the commit enforces the rest
2522
+ // (commitToolChange). A write passes over its path only to settle a
2523
+ // pending intent, since it replaces the whole file. As every file
2524
+ // tool's pass does, it names the path it acts on, so a file the
2525
+ // mount passes over by design is observed (see PassOptions.named).
2526
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2527
+ const report = pass.reports.get(relativePath);
2528
+ // A mode taken in alone isn't a change to the text: it brings nothing
2529
+ // new to read, as it makes no event (emitFsEvents), and the edit then
2530
+ // writes at the mode disk has, so it leaves nothing to say.
2531
+ const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
2532
+ // Which copy the edit used, when it isn't the one the workspace last
2533
+ // agreed on with disk: disk's newer text, or the workspace's copy
2534
+ // because disk's couldn't be read or disk has none. A file the
2535
+ // workspace didn't hold has no older copy, so taking it in needs no
2536
+ // word.
2537
+ const whichCopy = (verb: string): string | null => took('modified')
2538
+ ? `Disk's copy had changed since the workspace last agreed with it, so it was taken in first, and the edit ${verb} that text.`
2539
+ : report?.state === 'unverified'
2540
+ ? `The path couldn't be checked against disk (${report.note ?? 'not observed'}), so the edit ${verb} the workspace's copy.`
2541
+ : report?.state === 'disk-missing-provenance-unknown'
2542
+ ? `Disk has no copy of this file, and nothing records whether it was deleted there, so the edit ${verb} the workspace's copy.`
2543
+ : null;
2544
+ // A conflict the pass found is named when the edit is refused for the
2545
+ // text it found, on either kind of mount, and on a success where the
2546
+ // mount doesn't push, since such a mount keeps the conflict until
2547
+ // materialize or sync settles it. A pushing mount's success met none
2548
+ // (below). A stored edit that couldn't finish leaves it to the push's
2549
+ // reason, which names the conflict, or to ls, which its answer points to.
2550
+ const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
2551
+ // Where disk has no copy now, whatever the conflict's kind, a sync of
2552
+ // the path takes disk's deletion and a forced materialize writes the
2553
+ // workspace's copy back. Where it has one, a sync keeps it and gives
2554
+ // up the workspace's, whether or not the workspace can hold disk's
2555
+ // copy: one it can't hold stays on disk alone.
2556
+ const conflictNote = (where: string): string | null => conflict === null ? null
2557
+ : `The path is in conflict with disk (${conflict}): the edit ${where} the workspace's copy. ` + (report?.diskNow === 'absent'
2558
+ ? "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."
2559
+ : "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.");
2560
+ const suffix = [whichCopy('looked in'), conflictNote('looked in')].filter((n) => n !== null).map((n) => ` ${n}`).join('');
2561
+ appliedNote = whichCopy('applied to');
2261
2562
 
2262
- const entry = store.treeGet(mount.treeStateId, relativePath);
2263
- if (!entry) {
2264
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2265
- }
2563
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2564
+ if (!entry) {
2565
+ // A pass also drops a copy disk made binary or too large, which
2566
+ // the disk-only answer describes; any other drop is disk's deletion.
2567
+ const error = took('deleted') && report?.state !== 'disk-only'
2568
+ ? `File not found: ${input.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
2569
+ : this.notInWorkspace(input.path, report);
2570
+ return { success: false, error, isError: true };
2571
+ }
2266
2572
 
2267
- const blob = store.getBlob(entry.blobHash);
2268
- if (!blob) {
2269
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2270
- }
2573
+ const blob = store.getBlob(entry.blobHash);
2574
+ if (!blob) {
2575
+ return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2576
+ }
2577
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
2578
+ // the text back would change bytes the edit never named.
2579
+ if (!isUtf8(blob)) {
2580
+ return {
2581
+ success: false,
2582
+ isError: true,
2583
+ error: `The edit did nothing: ${input.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
2584
+ + `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}`,
2585
+ };
2586
+ }
2271
2587
 
2272
- let content = blob.toString('utf-8');
2588
+ let content = blob.toString('utf-8');
2273
2589
 
2274
- // Validate uniqueness
2275
- if (!input.replaceAll) {
2276
- const count = content.split(input.oldString).length - 1;
2590
+ // An edit that would replace nothing is refused, replaceAll or not;
2591
+ // without replaceAll, the text must occur once, and the refusal says
2592
+ // how often it does. With replaceAll, whether it occurs is enough.
2593
+ const count = input.replaceAll
2594
+ ? Number(content.includes(input.oldString))
2595
+ : content.split(input.oldString).length - 1;
2277
2596
  if (count === 0) {
2278
- return { success: false, error: `String not found in ${input.path}`, isError: true };
2597
+ return { success: false, error: `String not found in ${input.path}.${suffix}`, isError: true };
2279
2598
  }
2280
- if (count > 1) {
2599
+ if (count > 1 && !input.replaceAll) {
2281
2600
  return {
2282
2601
  success: false,
2283
- error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
2602
+ error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.${suffix}`,
2284
2603
  isError: true,
2285
2604
  };
2286
2605
  }
2287
- }
2288
-
2289
- content = input.replaceAll
2290
- ? content.replaceAll(input.oldString, input.newString)
2291
- : content.replace(input.oldString, input.newString);
2292
2606
 
2293
- const newBuffer = Buffer.from(content, 'utf-8');
2294
- const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2295
- const materializeError = await this.commitToolChange(mount, relativePath, {
2296
- kind: 'set',
2297
- blobHash: newBlobHash,
2298
- size: newBuffer.byteLength,
2299
- mode: entry.mode,
2300
- });
2301
- if (materializeError) {
2607
+ // A replacer function, so that the new text goes in as given: in a
2608
+ // replacement string, $&, $$, $' and $` are patterns.
2609
+ content = input.replaceAll
2610
+ ? content.replaceAll(input.oldString, () => input.newString)
2611
+ : content.replace(input.oldString, () => input.newString);
2612
+
2613
+ const newBuffer = Buffer.from(content, 'utf-8');
2614
+ const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2615
+ const { refused, modeKept } = await this.commitToolChange(mount, relativePath, {
2616
+ kind: 'set',
2617
+ blobHash: newBlobHash,
2618
+ size: newBuffer.byteLength,
2619
+ mode: entry.mode,
2620
+ }, () => { committed = true; });
2621
+ if (refused !== null) return storedButUnfinished('edit', input.path, { refused }, appliedNote);
2622
+
2623
+ // The edit makes the path the workspace's own change (commitToolChange),
2624
+ // so a file disk lacked goes back with this edit's push, or with the
2625
+ // next materialize where the mount doesn't push. A push that didn't
2626
+ // complete answers above, with its reason. One that completed met no
2627
+ // conflict, since this push, unforced, stops at one: the edit made the
2628
+ // two copies agree, which settles it, or there was none. So only a
2629
+ // mount that doesn't push still holds the conflict the pass found.
2630
+ const pushes = this.pushesOnCommit(mount);
2631
+ const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
2632
+ ? `${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.`
2633
+ : null;
2634
+ const notes = [appliedNote, backOnDisk, pushes ? null : conflictNote('is in')].filter((n): n is string => n !== null);
2302
2635
  return {
2303
- success: false,
2304
- error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
2305
- isError: true,
2636
+ success: true,
2637
+ data: {
2638
+ path: input.path,
2639
+ size: newBuffer.byteLength,
2640
+ ...(notes.length > 0 ? { note: notes.join(' ') } : {}),
2641
+ ...(modeKept ? { modeKept } : {}),
2642
+ },
2306
2643
  };
2307
- }
2308
-
2309
- return {
2310
- success: true,
2311
- data: {
2312
- path: input.path,
2313
- size: newBuffer.byteLength,
2314
- },
2315
- };
2316
- });
2644
+ });
2645
+ } catch (error) {
2646
+ if (!committed) throw error;
2647
+ return storedButUnfinished('edit', input.path, { threw: error instanceof Error ? error.message : String(error) }, appliedNote);
2648
+ }
2317
2649
  }
2318
2650
 
2319
2651
  private async handleDelete(input: DeleteInput): Promise<ToolResult> {
2320
- const { mount, relativePath } = this.parsePath(input.path);
2652
+ const { mount, relativePath } = this.parseFilePath(input.path);
2321
2653
  if (mount.config.mode === 'read-only') {
2322
2654
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2323
2655
  }
2324
2656
 
2325
2657
  const store = this.getStore();
2326
- return this.withMount(mount, async (): Promise<ToolResult> => {
2327
- const report = await this.ensureSynced(mount, relativePath);
2328
- await this.settleBeforeMutation(mount, relativePath);
2329
- const entry = store.treeGet(mount.treeStateId, relativePath);
2330
- if (!entry) {
2331
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2332
- }
2658
+ let committed = false;
2659
+ try {
2660
+ return await this.withMount(mount, async (): Promise<ToolResult> => {
2661
+ const report = await this.ensureSynced(mount, relativePath);
2662
+ await this.settleBeforeMutation(mount, relativePath);
2663
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2664
+ if (!entry) {
2665
+ return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2666
+ }
2333
2667
 
2334
- const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash });
2335
- if (materializeError) {
2336
- return {
2337
- success: false,
2338
- error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` +
2339
- 'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.',
2340
- isError: true,
2341
- };
2342
- }
2668
+ const { refused } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
2669
+ () => { committed = true; });
2670
+ if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
2343
2671
 
2344
- // Its whole receipt is what it changed: kept in the stub if the guard
2345
- // withholds the result (agent-framework #277).
2346
- return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
2347
- });
2672
+ // Its whole receipt is what it changed: kept in the stub if the guard
2673
+ // withholds the result (agent-framework #277).
2674
+ return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
2675
+ });
2676
+ } catch (error) {
2677
+ if (!committed) throw error;
2678
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
2679
+ }
2348
2680
  }
2349
2681
 
2350
2682
  private async handleLs(input: LsInput): Promise<ToolResult> {
@@ -2416,9 +2748,14 @@ export class WorkspaceModule implements Module {
2416
2748
  ? [this.parsePath(input.path)]
2417
2749
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2418
2750
 
2751
+ // Every match lies beneath the pattern's literal directory, so the walk
2752
+ // starts there: past the walk's cap, a walk of the whole scope can stop
2753
+ // before it gets there.
2754
+ const patternDir = literalDirOf(input.pattern);
2419
2755
  for (const { mount, relativePath } of mountsToSearch) {
2756
+ const start = await this.walkStart(mount, relativePath, patternDir);
2420
2757
  const pass = await this.withMount(mount, () =>
2421
- this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
2758
+ this.passUnlocked(mount, { kind: 'dir', dir: start, recursive: true }));
2422
2759
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
2423
2760
  const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
2424
2761
  if (regex.test(testPath)) {
@@ -2447,10 +2784,15 @@ export class WorkspaceModule implements Module {
2447
2784
  try {
2448
2785
  regex = new RegExp(input.pattern);
2449
2786
  } catch (e) {
2450
- return { success: false, error: `Invalid regex: ${input.pattern}`, isError: true };
2787
+ // The parser's reason says where the pattern goes wrong.
2788
+ return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
2451
2789
  }
2452
2790
 
2453
2791
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
2792
+ // The filter matches mount-relative paths, so every file it lets through
2793
+ // lies beneath its literal directory, and the walk starts at the deeper of
2794
+ // that and `path`.
2795
+ const filterDir = input.glob ? literalDirOf(input.glob) : '';
2454
2796
  const contextBefore = input.contextBefore ?? 0;
2455
2797
  const contextAfter = input.contextAfter ?? 0;
2456
2798
 
@@ -2464,7 +2806,7 @@ export class WorkspaceModule implements Module {
2464
2806
  const incomplete: Array<{ path: string; reason: string }> = [];
2465
2807
 
2466
2808
  const search = (content: string): GrepMatch[] => {
2467
- const lines = content.split('\n');
2809
+ const lines = linesOf(content);
2468
2810
  const found: GrepMatch[] = [];
2469
2811
  for (let i = 0; i < lines.length; i++) {
2470
2812
  if (regex.test(lines[i])) {
@@ -2481,6 +2823,18 @@ export class WorkspaceModule implements Module {
2481
2823
  };
2482
2824
 
2483
2825
  for (const { mount, relativePath } of mountsToSearch) {
2826
+ // A path named in another mount's directory, as holderOf resolves it,
2827
+ // is that mount's: grep searches nothing of this mount's there, and
2828
+ // says whose it is.
2829
+ if (this.holderOf(mount.config.name, relativePath)) {
2830
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list') });
2831
+ continue;
2832
+ }
2833
+ // A `path` and a filter whose directories don't overlap leave no file
2834
+ // the filter can let through, so nothing is searched.
2835
+ const deeper = deeperOf(relativePath, filterDir);
2836
+ if (deeper === null) continue;
2837
+ const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
2484
2838
  await this.withMount(mount, async () => {
2485
2839
  // A `path` naming a single file greps just that file; otherwise it is a
2486
2840
  // directory prefix. (A file path once produced the prefix "notes.md/"
@@ -2498,7 +2852,7 @@ export class WorkspaceModule implements Module {
2498
2852
  for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
2499
2853
  const next = await this.passUnlocked(mount, fileScope
2500
2854
  ? { kind: 'paths', paths: [relativePath] }
2501
- : { kind: 'dir', dir: relativePath, recursive: true });
2855
+ : { kind: 'dir', dir: start, recursive: true });
2502
2856
  if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next;
2503
2857
  }
2504
2858
  if (pass === null) {
@@ -2525,6 +2879,10 @@ export class WorkspaceModule implements Module {
2525
2879
  });
2526
2880
  continue;
2527
2881
  }
2882
+ // A path in another mount's directory is that mount's: grep gives
2883
+ // none of this mount's copy of it, as read refuses it. Beneath a
2884
+ // scope it's passed over by design, like the directory.
2885
+ if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
2528
2886
  // The workspace's version, labelled with the path's state.
2529
2887
  const entry = store.treeGet(mount.treeStateId, report.path);
2530
2888
  const blob = entry ? store.getBlob(entry.blobHash) : null;
@@ -2584,7 +2942,8 @@ export class WorkspaceModule implements Module {
2584
2942
  if (input.mount !== undefined && name !== input.mount) continue;
2585
2943
  const entries = store.treeList(mount.treeStateId);
2586
2944
  const currentSeq = store.currentSequence();
2587
- const intents = this.intents.get(name)!.list();
2945
+ const own = this.ownPaths(mount);
2946
+ const intents = this.intents.get(name)!.list().filter(([p]) => own(p));
2588
2947
  const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
2589
2948
  // A workspace deletion is pending until a push confirms it on disk:
2590
2949
  // left there without applyDeletions, or unlinked but unconfirmed.
@@ -2630,12 +2989,13 @@ export class WorkspaceModule implements Module {
2630
2989
  */
2631
2990
  private materializeSelection(mount: MountState, explicitPath: string): { paths: string[]; inPlace: boolean } {
2632
2991
  const store = this.getStore();
2633
- const intents = new Map(this.intents.get(mount.config.name)!.list(explicitPath));
2992
+ const own = this.ownPaths(mount);
2993
+ const intents = new Map(this.intents.get(mount.config.name)!.list(explicitPath).filter(([p]) => own(p)));
2634
2994
  const selected = new Set<string>();
2635
2995
 
2636
2996
  if (explicitPath !== '') {
2637
- if (store.treeGet(mount.treeStateId, explicitPath)) selected.add(explicitPath);
2638
- for (const e of store.treeList(mount.treeStateId, explicitPath + '/')) selected.add(e.path);
2997
+ if (store.treeGet(mount.treeStateId, explicitPath) && own(explicitPath)) selected.add(explicitPath);
2998
+ for (const e of store.treeList(mount.treeStateId, explicitPath + '/')) if (own(e.path)) selected.add(e.path);
2639
2999
  for (const [p, bi] of intents) if (bi.tombstone || bi.conflict) selected.add(p);
2640
3000
  return { paths: [...selected], inPlace: false };
2641
3001
  }
@@ -2646,10 +3006,25 @@ export class WorkspaceModule implements Module {
2646
3006
  return { paths: [...selected], inPlace: agreed > 0 };
2647
3007
  }
2648
3008
 
3009
+ /**
3010
+ * Whether a path of the mount is its own to push and to count as owed:
3011
+ * not in another mount's directory nested in it. Copies the mount's tree
3012
+ * holds there (stored before the nesting) stay in its history and its
3013
+ * listings (and in status's fileCount, which counts the tree), passed over
3014
+ * by its syncs, and left out of what it pushes and of status's pending
3015
+ * changes, conflicts and workspace deletions.
3016
+ */
3017
+ private ownPaths(mount: MountState): (path: string) => boolean {
3018
+ const others = this.othersOf(mount.config.name);
3019
+ return (path) => !inOtherMount(path, others);
3020
+ }
3021
+
2649
3022
  /**
2650
3023
  * The workspace entries by what the evidence says of disk. `owed`: P
2651
- * differs from the entry (a draft, or a push still pending), or there is no
2652
- * P and the entry came from the workspace (a draft disk never had).
3024
+ * differs from the entry (a draft, or a push still pending), the entry
3025
+ * records a mode other than the one P agreed (a mode owed to disk), or
3026
+ * there is no P and the entry came from the workspace (a draft disk never
3027
+ * had).
2653
3028
  * `unchecked`: no P and no workspace origin, an entry from before the
2654
3029
  * evidence; a push agrees it, writes it where disk lacks it, or refuses it
2655
3030
  * as a conflict. `agreed`: how many have P = S, so disk holds them. Status
@@ -2664,10 +3039,15 @@ export class WorkspaceModule implements Module {
2664
3039
  const owed: string[] = [];
2665
3040
  const unchecked: string[] = [];
2666
3041
  let agreed = 0;
3042
+ const own = this.ownPaths(mount);
2667
3043
  for (const e of store.treeList(mount.treeStateId)) {
3044
+ if (!own(e.path)) continue;
2668
3045
  const p = agreement.get(name, e.path);
2669
3046
  if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
2670
3047
  else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
3048
+ // A mode the store records, other than the one disk last agreed with,
3049
+ // where modes are observed: elsewhere no push applies one.
3050
+ else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
2671
3051
  else agreed++;
2672
3052
  }
2673
3053
  return { owed, unchecked, agreed };
@@ -2679,6 +3059,7 @@ export class WorkspaceModule implements Module {
2679
3059
  const allWritten: Array<{ mount: string; path: string }> = [];
2680
3060
  const allDeleted: Array<{ mount: string; path: string }> = [];
2681
3061
  const pendingDeletions: Array<{ mount: string; path: string }> = [];
3062
+ const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
2682
3063
 
2683
3064
  let explicit: { mount: MountState; relativePath: string } | null = null;
2684
3065
  if (input.path) explicit = this.parsePath(input.path);
@@ -2717,6 +3098,13 @@ export class WorkspaceModule implements Module {
2717
3098
  blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' });
2718
3099
  continue;
2719
3100
  }
3101
+ // A path named in another mount's directory, as holderOf resolves it,
3102
+ // is that mount's: nothing of this mount's is pushed there, and the
3103
+ // caller is told whose it is.
3104
+ if (explicit && this.holderOf(name, explicit.relativePath)) {
3105
+ blocked.push({ mount: name, reason: `${explicit.relativePath}: ${this.otherMountNote(name, explicit.relativePath, 'push')}` });
3106
+ continue;
3107
+ }
2720
3108
  const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean }> => {
2721
3109
  const branchId = store.currentBranch().id;
2722
3110
  const reason = this.mountMaterializeBlockReason(store, mount);
@@ -2746,8 +3134,12 @@ export class WorkspaceModule implements Module {
2746
3134
  // Refusals ride the same skipped list as branch blocks: divergence must
2747
3135
  // be visible, not resolved silently either way.
2748
3136
  for (const s of pushed.skipped) {
2749
- blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` });
3137
+ const reason = s.reason === NOT_PUSHED_IN_OTHER_MOUNT ? this.otherMountNote(name, s.path, 'push') : s.reason;
3138
+ blocked.push({ mount: name, reason: `${s.path}: ${reason}` });
2750
3139
  }
3140
+ // Written paths whose mode stayed as disk had it: no refusal, so not
3141
+ // among what was skipped.
3142
+ for (const k of pushed.modesKept) modesKept.push({ mount: name, ...k });
2751
3143
 
2752
3144
  // What disk still owes is kept by its evidence, so the watermark and
2753
3145
  // the pin only feed the branch guard. Both are the push's own: what it
@@ -2790,10 +3182,11 @@ export class WorkspaceModule implements Module {
2790
3182
  }
2791
3183
  : {}),
2792
3184
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
3185
+ ...(modesKept.length > 0 ? { modesKept } : {}),
2793
3186
  },
2794
3187
  // What disk lost, then what it was written: kept in the stub if the
2795
- // guard withholds the result (agent-framework #277). What was left or
2796
- // skipped, and why, is in the full result.
3188
+ // guard withholds the result (agent-framework #277). What was left,
3189
+ // skipped or written at disk's mode, and why, is in the full result.
2797
3190
  keepWhenWithheld: ['deleted', 'materialized'],
2798
3191
  };
2799
3192
  }
@@ -2936,13 +3329,20 @@ export class WorkspaceModule implements Module {
2936
3329
  for (const region of pass.incomplete) allIncomplete.push({ mount: name, ...region });
2937
3330
  for (const [reason, count] of pass.passedOver) allPassedOver.push({ mount: name, reason, count });
2938
3331
  // A path named explicitly is synced even where the mount's ignore list
2939
- // covers it, and the result says so: a sync without a path passes
2940
- // over it, so nothing else keeps it current (agent-framework #276).
2941
- if (coveredByIgnore(relativePath, mount.config.ignore ?? [])) {
3332
+ // or an editor's file name covers it, and the result says so: a sync
3333
+ // without a path passes over it, so nothing else keeps it current
3334
+ // (agent-framework #276). A path in another mount's directory isn't
3335
+ // synced at all, and the note says so.
3336
+ const passedOver = passedOverAt(relativePath, mount.config.ignore ?? [], this.othersOf(name));
3337
+ if (passedOver !== undefined) {
2942
3338
  allIgnored.push({
2943
3339
  mount: name,
2944
3340
  path: relativePath,
2945
- note: 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it',
3341
+ note: passedOver === OTHER_MOUNT
3342
+ ? this.otherMountNote(name, relativePath, 'sync')
3343
+ : passedOver === IGNORED
3344
+ ? 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it'
3345
+ : 'it has an editor\'s swap or backup file name, or lies under one, so a sync without a path won\'t maintain it',
2946
3346
  });
2947
3347
  }
2948
3348
  }
@@ -3083,10 +3483,10 @@ export class WorkspaceModule implements Module {
3083
3483
  const scan: AgentActionScan = { late: false, settled: Promise.resolve() };
3084
3484
  const pass = this.withMount(mount, () => {
3085
3485
  if (mount.agentActionScanWaiting === scan) mount.agentActionScanWaiting = undefined;
3086
- // From the mount's start, as a listing walks, keeping no place: on a
3087
- // mount over its walkCap, every batch's scan takes the first walkCap
3088
- // entries in the walk's order, and the place that syncs and catch-up
3089
- // scans resume from stays theirs.
3486
+ // From the mount's root, keeping no place, as a listing keeps none:
3487
+ // on a mount over its walkCap, every batch's scan takes the first
3488
+ // walkCap entries in the walk's order, and the place that syncs and
3489
+ // catch-up scans resume from stays theirs.
3090
3490
  return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
3091
3491
  });
3092
3492
  scan.settled = pass.then(
@@ -3131,13 +3531,15 @@ export class WorkspaceModule implements Module {
3131
3531
  }
3132
3532
 
3133
3533
  /**
3134
- * Push one ProcessEvent per op for a pass's tree changes and new conflicts.
3135
- * A conflict rides the event for the disk change behind it (modified, or
3136
- * deleted for a disk deletion of a file with newer workspace changes).
3534
+ * Push one ProcessEvent per op for a pass's tree changes and new conflicts,
3535
+ * apart from an op that changes only a file's mode, which brings nothing
3536
+ * new to read and makes none. A conflict rides the event for the disk
3537
+ * change behind it (modified, or deleted for a disk deletion of a file with
3538
+ * newer workspace changes).
3137
3539
  */
3138
3540
  private emitFsEvents(
3139
3541
  mountName: string,
3140
- ops: Array<{ path: string; op: TreeOp }>,
3542
+ ops: Array<{ path: string; op: TreeOp; modeOnly?: true }>,
3141
3543
  newConflicts: Array<{ path: string; op: TreeOp }> = [],
3142
3544
  ): void {
3143
3545
  if (!this.ctx || (ops.length === 0 && newConflicts.length === 0)) return;
@@ -3150,7 +3552,10 @@ export class WorkspaceModule implements Module {
3150
3552
  if (conflict) group.conflicts.push(full);
3151
3553
  byOp.set(op, group);
3152
3554
  };
3153
- for (const { path, op } of ops) add(op, path, false);
3555
+ // A path whose mode alone changed, on bytes already held, makes no
3556
+ // event: it brings nothing new to read, and a `chmod -R` over a tree
3557
+ // would otherwise reach every policy that matches its paths.
3558
+ for (const { path, op, modeOnly } of ops) if (!modeOnly) add(op, path, false);
3154
3559
  for (const { path, op } of newConflicts) add(op, path, true);
3155
3560
 
3156
3561
  for (const [op, { paths, conflicts }] of byOp) {
@@ -3181,8 +3586,103 @@ function stateView(report: PathReport): Record<string, unknown> {
3181
3586
  // Utilities
3182
3587
  // ==========================================================================
3183
3588
 
3589
+ /** A lone UTF-16 surrogate, which UTF-8 can't store: in `u` mode, a pair is one code point. */
3590
+ const LONE_SURROGATE = /\p{Cs}/u;
3591
+
3592
+ /**
3593
+ * The answer to a write, edit or delete that the workspace stored but
3594
+ * couldn't finish: its push to disk didn't complete, or what follows the tree
3595
+ * entry threw. It stays an error, since part of the call failed, but it leads
3596
+ * with what the caller must know before acting on it: the change is stored,
3597
+ * and sending it again could apply it twice. The push's reason says what disk
3598
+ * holds and the way out, as materialize takes it. The text carries all of it,
3599
+ * since an error's data reaches neither the model nor a code_execution
3600
+ * script. An edit's note on which copy it applied to comes last.
3601
+ */
3602
+ function storedButUnfinished(
3603
+ tool: 'write' | 'edit' | 'delete',
3604
+ path: string,
3605
+ outcome: { refused: string } | { threw: string },
3606
+ note: string | null = null,
3607
+ ): ToolResult {
3608
+ const change = tool === 'delete' ? 'deletion' : tool;
3609
+ const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
3610
+ + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
3611
+ const rest = 'refused' in outcome
3612
+ ? ` Its push to disk didn't complete: ${outcome.refused}.`
3613
+ : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
3614
+ return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
3615
+ }
3616
+
3617
+ /**
3618
+ * A text's lines, as read and grep number them: a final line end ends the
3619
+ * last line rather than starting an empty one, so "a\nb\n" is two lines,
3620
+ * and an empty text has none.
3621
+ */
3622
+ function linesOf(content: string): string[] {
3623
+ const lines = content.split('\n');
3624
+ if (lines.at(-1) === '') lines.pop();
3625
+ return lines;
3626
+ }
3627
+
3628
+ /**
3629
+ * The least each tool's whole-number fields may be, beside their schemas,
3630
+ * whose type (membrane's ToolParameter) has no `minimum`.
3631
+ */
3632
+ const INPUT_MINIMUMS: Record<string, Record<string, number>> = {
3633
+ read: { offset: 1, limit: 1, offsetChars: 0, limitChars: 1 },
3634
+ grep: { contextBefore: 0, contextAfter: 0 },
3635
+ };
3636
+
3637
+ /**
3638
+ * Why a tool call's input doesn't fit its tool's schema, or null when it does:
3639
+ * an object, each required field present, and each field its schema calls a
3640
+ * string, a boolean or an integer of that type, an integer at least its
3641
+ * minimum. The workspace's schemas use no other types. Without it, an edit
3642
+ * missing `newString` wrote the word "undefined", and an offset of 1.5
3643
+ * numbered lines 1.5, 2.5 and on. A field the schema doesn't describe is left
3644
+ * to its handler.
3645
+ */
3646
+ function inputProblem(
3647
+ schema: { properties?: Record<string, unknown>; required?: string[] },
3648
+ input: unknown,
3649
+ minimums: Record<string, number> = {},
3650
+ ): string | null {
3651
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
3652
+ return 'its input must be an object of the fields its schema lists';
3653
+ }
3654
+ const fields = input as Record<string, unknown>;
3655
+ for (const name of schema.required ?? []) {
3656
+ if (fields[name] === undefined) return `"${name}" is required`;
3657
+ }
3658
+ for (const [name, value] of Object.entries(fields)) {
3659
+ const type = (schema.properties?.[name] as { type?: string } | undefined)?.type;
3660
+ if (value === undefined || type === undefined) continue;
3661
+ const minimum = minimums[name];
3662
+ const fits = type === 'string' ? typeof value === 'string'
3663
+ : type === 'boolean' ? typeof value === 'boolean'
3664
+ : type === 'integer'
3665
+ ? Number.isSafeInteger(value) && (minimum === undefined || (value as number) >= minimum)
3666
+ : true;
3667
+ if (!fits) {
3668
+ const wanted = type === 'string' ? 'a string'
3669
+ : type === 'boolean' ? 'true or false'
3670
+ : minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
3671
+ const got = value === null ? 'null'
3672
+ : typeof value === 'number' ? String(value)
3673
+ : Array.isArray(value) ? 'an array'
3674
+ : typeof value === 'object' ? 'an object'
3675
+ : `a ${typeof value}`;
3676
+ return `"${name}" must be ${wanted}, not ${got}`;
3677
+ }
3678
+ }
3679
+ return null;
3680
+ }
3681
+
3184
3682
  /**
3185
- * Convert a glob pattern to a RegExp.
3683
+ * Convert a glob pattern to a RegExp. Throws, naming the pattern, for a brace
3684
+ * group it can't read: a "{" that never closes, a group inside another, or a
3685
+ * group with no ",".
3186
3686
  */
3187
3687
  function globToRegex(pattern: string): RegExp {
3188
3688
  // Split pattern into segments, handling {a,b,c} alternation
@@ -3190,13 +3690,33 @@ function globToRegex(pattern: string): RegExp {
3190
3690
  let i = 0;
3191
3691
  while (i < pattern.length) {
3192
3692
  if (pattern[i] === '{') {
3193
- const closeIdx = pattern.indexOf('}', i);
3194
- if (closeIdx > i) {
3195
- const alternatives = pattern.slice(i + 1, closeIdx).split(',');
3196
- regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
3197
- i = closeIdx + 1;
3198
- continue;
3199
- }
3693
+ // A brace form this can't read is refused. A `{` that never closes, read
3694
+ // as a literal, would almost always answer with no matches, and with it
3695
+ // refused, each pass of this loop moves past at least one character. A
3696
+ // group closes at the `}` that brings the braces open since its `{` back
3697
+ // to none, so a `{` that never closes is named as that, even with a
3698
+ // closed group after it. A group inside another would be split at the
3699
+ // inner `}`, so `{a,{b,c}}` would match `a}` but not `a`, and a group
3700
+ // with no `,` would lose its braces, so `{name}` would match `name` and
3701
+ // never a file named `{name}`, and `{1..3}` would match only `1..3`.
3702
+ let depth = 0;
3703
+ let closeIdx = -1;
3704
+ for (let j = i; j < pattern.length && closeIdx < 0; j++) {
3705
+ if (pattern[j] === '{') depth++;
3706
+ else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
3707
+ }
3708
+ if (closeIdx < 0) throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
3709
+ const group = pattern.slice(i + 1, closeIdx);
3710
+ if (group.includes('{')) {
3711
+ throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
3712
+ }
3713
+ if (!group.includes(',')) {
3714
+ throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
3715
+ }
3716
+ const alternatives = group.split(',');
3717
+ regex += '(?:' + alternatives.map(a => globPartToRegex(a)).join('|') + ')';
3718
+ i = closeIdx + 1;
3719
+ continue;
3200
3720
  }
3201
3721
  // Accumulate non-brace characters, convert as a chunk
3202
3722
  let chunk = '';
@@ -3211,6 +3731,36 @@ function globToRegex(pattern: string): RegExp {
3211
3731
  return new RegExp(`^${regex}$`);
3212
3732
  }
3213
3733
 
3734
+ /**
3735
+ * The directory a glob pattern names before its first wildcard, beneath
3736
+ * which every path it matches lies: its leading segments that hold none of
3737
+ * globToRegex's wildcards (`*`, `?`, `{`), short of the last segment, which
3738
+ * names the files. '' when the pattern starts with a wildcard or has a
3739
+ * single segment. A segment that's empty, `.` or `..`, which no path
3740
+ * matched holds, is one no directory listing holds either, so walkStart
3741
+ * stops before it.
3742
+ */
3743
+ function literalDirOf(pattern: string): string {
3744
+ const dir: string[] = [];
3745
+ for (const segment of pattern.split('/').slice(0, -1)) {
3746
+ if (/[*?{]/.test(segment)) break;
3747
+ dir.push(segment);
3748
+ }
3749
+ return dir.join('/');
3750
+ }
3751
+
3752
+ /** The deeper of two mount-relative directories, '' being the root, or null when neither holds the other. */
3753
+ function deeperOf(a: string, b: string): string | null {
3754
+ if (a === '' || b === a || b.startsWith(a + '/')) return b;
3755
+ if (b === '' || a.startsWith(b + '/')) return a;
3756
+ return null;
3757
+ }
3758
+
3759
+ /** A glob's refusal of a brace form it can't read, with the way to match a literal "{". */
3760
+ function braceRefusal(pattern: string, problem: string): string {
3761
+ return `glob pattern ${JSON.stringify(pattern)} ${problem}. To match a "{" in a name, put "?" in its place.`;
3762
+ }
3763
+
3214
3764
  function globPartToRegex(part: string): string {
3215
3765
  return part
3216
3766
  .replace(/[.+^$()|[\]\\]/g, '\\$&')