@animalabs/agent-framework 0.23.3 → 0.24.1

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 (355) hide show
  1. package/README.md +212 -42
  2. package/dist/src/agent.d.ts +65 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +98 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +14 -12
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/clock-rows.d.ts +115 -0
  10. package/dist/src/clock-rows.d.ts.map +1 -0
  11. package/dist/src/clock-rows.js +222 -0
  12. package/dist/src/clock-rows.js.map +1 -0
  13. package/dist/src/code-execution/engine.d.ts +177 -0
  14. package/dist/src/code-execution/engine.d.ts.map +1 -0
  15. package/dist/src/code-execution/engine.js +494 -0
  16. package/dist/src/code-execution/engine.js.map +1 -0
  17. package/dist/src/code-execution/index.d.ts +5 -10
  18. package/dist/src/code-execution/index.d.ts.map +1 -1
  19. package/dist/src/code-execution/index.js +5 -9
  20. package/dist/src/code-execution/index.js.map +1 -1
  21. package/dist/src/code-execution/isolate/calls.d.ts +114 -0
  22. package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
  23. package/dist/src/code-execution/isolate/calls.js +145 -0
  24. package/dist/src/code-execution/isolate/calls.js.map +1 -0
  25. package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
  26. package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
  27. package/dist/src/code-execution/isolate/child-entry.js +5 -0
  28. package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
  29. package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
  30. package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
  31. package/dist/src/code-execution/isolate/child-main.js +132 -0
  32. package/dist/src/code-execution/isolate/child-main.js.map +1 -0
  33. package/dist/src/code-execution/isolate/child.d.ts +44 -0
  34. package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
  35. package/dist/src/code-execution/isolate/child.js +315 -0
  36. package/dist/src/code-execution/isolate/child.js.map +1 -0
  37. package/dist/src/code-execution/isolate/frames.d.ts +80 -0
  38. package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
  39. package/dist/src/code-execution/isolate/frames.js +210 -0
  40. package/dist/src/code-execution/isolate/frames.js.map +1 -0
  41. package/dist/src/code-execution/isolate/output.d.ts +68 -0
  42. package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
  43. package/dist/src/code-execution/isolate/output.js +143 -0
  44. package/dist/src/code-execution/isolate/output.js.map +1 -0
  45. package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
  46. package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
  47. package/dist/src/code-execution/isolate/prelude.js +401 -0
  48. package/dist/src/code-execution/isolate/prelude.js.map +1 -0
  49. package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
  50. package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
  51. package/dist/src/code-execution/isolate/protocol.js +150 -0
  52. package/dist/src/code-execution/isolate/protocol.js.map +1 -0
  53. package/dist/src/code-execution/isolate/render.d.ts +57 -0
  54. package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
  55. package/dist/src/code-execution/isolate/render.js +214 -0
  56. package/dist/src/code-execution/isolate/render.js.map +1 -0
  57. package/dist/src/code-execution/isolate/runner.d.ts +250 -0
  58. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
  59. package/dist/src/code-execution/isolate/runner.js +854 -0
  60. package/dist/src/code-execution/isolate/runner.js.map +1 -0
  61. package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
  62. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
  63. package/dist/src/code-execution/isolate/scripts.js +357 -0
  64. package/dist/src/code-execution/isolate/scripts.js.map +1 -0
  65. package/dist/src/code-execution/quickjs.wasm +0 -0
  66. package/dist/src/code-execution/tool-definition.d.ts +93 -43
  67. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  68. package/dist/src/code-execution/tool-definition.js +176 -142
  69. package/dist/src/code-execution/tool-definition.js.map +1 -1
  70. package/dist/src/deadline-timer.d.ts +3 -0
  71. package/dist/src/deadline-timer.d.ts.map +1 -1
  72. package/dist/src/deadline-timer.js +1 -1
  73. package/dist/src/deadline-timer.js.map +1 -1
  74. package/dist/src/framework.d.ts +1120 -457
  75. package/dist/src/framework.d.ts.map +1 -1
  76. package/dist/src/framework.js +4635 -2505
  77. package/dist/src/framework.js.map +1 -1
  78. package/dist/src/gate/event-gate.d.ts +28 -4
  79. package/dist/src/gate/event-gate.d.ts.map +1 -1
  80. package/dist/src/gate/event-gate.js +89 -13
  81. package/dist/src/gate/event-gate.js.map +1 -1
  82. package/dist/src/held-writes.d.ts +16 -3
  83. package/dist/src/held-writes.d.ts.map +1 -1
  84. package/dist/src/held-writes.js +16 -3
  85. package/dist/src/held-writes.js.map +1 -1
  86. package/dist/src/index.d.ts +8 -6
  87. package/dist/src/index.d.ts.map +1 -1
  88. package/dist/src/index.js +10 -8
  89. package/dist/src/index.js.map +1 -1
  90. package/dist/src/mcpl/channel-registry.d.ts +483 -115
  91. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  92. package/dist/src/mcpl/channel-registry.js +1735 -425
  93. package/dist/src/mcpl/channel-registry.js.map +1 -1
  94. package/dist/src/mcpl/conversation-router.d.ts +19 -39
  95. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  96. package/dist/src/mcpl/conversation-router.js +27 -59
  97. package/dist/src/mcpl/conversation-router.js.map +1 -1
  98. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  99. package/dist/src/mcpl/convert-block.js +9 -5
  100. package/dist/src/mcpl/convert-block.js.map +1 -1
  101. package/dist/src/mcpl/inbound-source.d.ts +50 -0
  102. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  103. package/dist/src/mcpl/inbound-source.js +130 -0
  104. package/dist/src/mcpl/inbound-source.js.map +1 -1
  105. package/dist/src/mcpl/index.d.ts +2 -1
  106. package/dist/src/mcpl/index.d.ts.map +1 -1
  107. package/dist/src/mcpl/index.js +3 -2
  108. package/dist/src/mcpl/index.js.map +1 -1
  109. package/dist/src/mcpl/log-value.d.ts +6 -0
  110. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  111. package/dist/src/mcpl/log-value.js +8 -0
  112. package/dist/src/mcpl/log-value.js.map +1 -1
  113. package/dist/src/mcpl/modern-connection.d.ts +15 -6
  114. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  115. package/dist/src/mcpl/modern-connection.js +168 -53
  116. package/dist/src/mcpl/modern-connection.js.map +1 -1
  117. package/dist/src/mcpl/prose-grammar.d.ts +4 -5
  118. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  119. package/dist/src/mcpl/prose-grammar.js +4 -5
  120. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  121. package/dist/src/mcpl/protocol-family.d.ts +97 -10
  122. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  123. package/dist/src/mcpl/protocol-family.js +174 -17
  124. package/dist/src/mcpl/protocol-family.js.map +1 -1
  125. package/dist/src/mcpl/push-coalescer.d.ts +132 -14
  126. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  127. package/dist/src/mcpl/push-coalescer.js +227 -50
  128. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  129. package/dist/src/mcpl/push-handler.d.ts +9 -20
  130. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  131. package/dist/src/mcpl/push-handler.js +7 -49
  132. package/dist/src/mcpl/push-handler.js.map +1 -1
  133. package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
  134. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  135. package/dist/src/mcpl/reference-fetcher.js +4 -4
  136. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  137. package/dist/src/mcpl/server-connection.d.ts +50 -21
  138. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  139. package/dist/src/mcpl/server-connection.js +158 -74
  140. package/dist/src/mcpl/server-connection.js.map +1 -1
  141. package/dist/src/mcpl/tags.d.ts +24 -0
  142. package/dist/src/mcpl/tags.d.ts.map +1 -1
  143. package/dist/src/mcpl/tags.js +33 -0
  144. package/dist/src/mcpl/tags.js.map +1 -1
  145. package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
  146. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  147. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  148. package/dist/src/mcpl/transport.d.ts +55 -7
  149. package/dist/src/mcpl/transport.d.ts.map +1 -1
  150. package/dist/src/mcpl/transport.js +163 -34
  151. package/dist/src/mcpl/transport.js.map +1 -1
  152. package/dist/src/mcpl/types.d.ts +22 -6
  153. package/dist/src/mcpl/types.d.ts.map +1 -1
  154. package/dist/src/mcpl/types.js.map +1 -1
  155. package/dist/src/module-registry.d.ts +4 -2
  156. package/dist/src/module-registry.d.ts.map +1 -1
  157. package/dist/src/module-registry.js +100 -13
  158. package/dist/src/module-registry.js.map +1 -1
  159. package/dist/src/modules/discord/index.d.ts.map +1 -1
  160. package/dist/src/modules/discord/index.js +8 -13
  161. package/dist/src/modules/discord/index.js.map +1 -1
  162. package/dist/src/modules/history/index.d.ts.map +1 -1
  163. package/dist/src/modules/history/index.js +27 -4
  164. package/dist/src/modules/history/index.js.map +1 -1
  165. package/dist/src/modules/history/semantic.d.ts +15 -3
  166. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  167. package/dist/src/modules/history/semantic.js +27 -5
  168. package/dist/src/modules/history/semantic.js.map +1 -1
  169. package/dist/src/modules/index.d.ts +1 -1
  170. package/dist/src/modules/index.d.ts.map +1 -1
  171. package/dist/src/modules/index.js.map +1 -1
  172. package/dist/src/modules/unknown-tool.d.ts +4 -1
  173. package/dist/src/modules/unknown-tool.d.ts.map +1 -1
  174. package/dist/src/modules/unknown-tool.js +4 -1
  175. package/dist/src/modules/unknown-tool.js.map +1 -1
  176. package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
  177. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  178. package/dist/src/modules/workspace/branch-intent.js +3 -1
  179. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  180. package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
  181. package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
  182. package/dist/src/modules/workspace/edit-plan.js +794 -0
  183. package/dist/src/modules/workspace/edit-plan.js.map +1 -0
  184. package/dist/src/modules/workspace/edit-text.d.ts +117 -0
  185. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
  186. package/dist/src/modules/workspace/edit-text.js +584 -0
  187. package/dist/src/modules/workspace/edit-text.js.map +1 -0
  188. package/dist/src/modules/workspace/effects.d.ts +5 -4
  189. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/effects.js +78 -25
  191. package/dist/src/modules/workspace/effects.js.map +1 -1
  192. package/dist/src/modules/workspace/index.d.ts +493 -62
  193. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/index.js +2216 -652
  195. package/dist/src/modules/workspace/index.js.map +1 -1
  196. package/dist/src/modules/workspace/observe.d.ts +213 -38
  197. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  198. package/dist/src/modules/workspace/observe.js +416 -36
  199. package/dist/src/modules/workspace/observe.js.map +1 -1
  200. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/reconcile.js +15 -8
  202. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  203. package/dist/src/modules/workspace/text.d.ts +32 -0
  204. package/dist/src/modules/workspace/text.d.ts.map +1 -0
  205. package/dist/src/modules/workspace/text.js +67 -0
  206. package/dist/src/modules/workspace/text.js.map +1 -0
  207. package/dist/src/modules/workspace/types.d.ts +210 -6
  208. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  209. package/dist/src/modules/workspace/types.js.map +1 -1
  210. package/dist/src/modules/workspace/watcher.d.ts +3 -2
  211. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  212. package/dist/src/modules/workspace/watcher.js.map +1 -1
  213. package/dist/src/operator-log.d.ts +52 -24
  214. package/dist/src/operator-log.d.ts.map +1 -1
  215. package/dist/src/operator-log.js.map +1 -1
  216. package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
  217. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  218. package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
  219. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  220. package/dist/src/recovery/offline-branch.d.ts +26 -1
  221. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  222. package/dist/src/recovery/offline-branch.js +164 -39
  223. package/dist/src/recovery/offline-branch.js.map +1 -1
  224. package/dist/src/recovery/recover-cli.js +23 -4
  225. package/dist/src/recovery/recover-cli.js.map +1 -1
  226. package/dist/src/send-tools.d.ts +11 -0
  227. package/dist/src/send-tools.d.ts.map +1 -0
  228. package/dist/src/send-tools.js +11 -0
  229. package/dist/src/send-tools.js.map +1 -0
  230. package/dist/src/timezone.d.ts +4 -2
  231. package/dist/src/timezone.d.ts.map +1 -1
  232. package/dist/src/timezone.js +4 -2
  233. package/dist/src/timezone.js.map +1 -1
  234. package/dist/src/tool-image-ledger.d.ts +46 -7
  235. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  236. package/dist/src/tool-image-ledger.js +60 -7
  237. package/dist/src/tool-image-ledger.js.map +1 -1
  238. package/dist/src/tool-result-guard.d.ts +14 -11
  239. package/dist/src/tool-result-guard.d.ts.map +1 -1
  240. package/dist/src/tool-result-guard.js +17 -14
  241. package/dist/src/tool-result-guard.js.map +1 -1
  242. package/dist/src/tool-result-history.d.ts +3 -2
  243. package/dist/src/tool-result-history.d.ts.map +1 -1
  244. package/dist/src/tool-result-history.js +3 -2
  245. package/dist/src/tool-result-history.js.map +1 -1
  246. package/dist/src/tune-out/coordinator.d.ts +21 -0
  247. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  248. package/dist/src/tune-out/coordinator.js +142 -40
  249. package/dist/src/tune-out/coordinator.js.map +1 -1
  250. package/dist/src/types/agent.d.ts +31 -6
  251. package/dist/src/types/agent.d.ts.map +1 -1
  252. package/dist/src/types/events.d.ts +66 -9
  253. package/dist/src/types/events.d.ts.map +1 -1
  254. package/dist/src/types/framework.d.ts +69 -62
  255. package/dist/src/types/framework.d.ts.map +1 -1
  256. package/dist/src/types/module.d.ts +40 -8
  257. package/dist/src/types/module.d.ts.map +1 -1
  258. package/dist/src/types/trace.d.ts +111 -10
  259. package/dist/src/types/trace.d.ts.map +1 -1
  260. package/dist/src/usage/types.d.ts +16 -0
  261. package/dist/src/usage/types.d.ts.map +1 -1
  262. package/dist/src/usage/usage-tracker.d.ts +14 -1
  263. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  264. package/dist/src/usage/usage-tracker.js +81 -41
  265. package/dist/src/usage/usage-tracker.js.map +1 -1
  266. package/package.json +6 -8
  267. package/src/agent.ts +99 -12
  268. package/src/api/server.ts +14 -12
  269. package/src/clock-rows.ts +302 -0
  270. package/src/code-execution/engine.ts +595 -0
  271. package/src/code-execution/index.ts +5 -17
  272. package/src/code-execution/isolate/calls.ts +199 -0
  273. package/src/code-execution/isolate/child-entry.ts +5 -0
  274. package/src/code-execution/isolate/child-main.ts +132 -0
  275. package/src/code-execution/isolate/child.ts +327 -0
  276. package/src/code-execution/isolate/frames.ts +209 -0
  277. package/src/code-execution/isolate/output.ts +161 -0
  278. package/src/code-execution/isolate/prelude.ts +409 -0
  279. package/src/code-execution/isolate/protocol.ts +240 -0
  280. package/src/code-execution/isolate/render.ts +230 -0
  281. package/src/code-execution/isolate/runner.ts +911 -0
  282. package/src/code-execution/isolate/scripts.ts +479 -0
  283. package/src/code-execution/quickjs.wasm +0 -0
  284. package/src/code-execution/quickjs.wasm.LICENSES +149 -0
  285. package/src/code-execution/tool-definition.ts +236 -167
  286. package/src/deadline-timer.ts +1 -1
  287. package/src/framework.ts +4978 -2671
  288. package/src/gate/event-gate.ts +79 -17
  289. package/src/held-writes.ts +17 -3
  290. package/src/index.ts +32 -22
  291. package/src/mcpl/channel-registry.ts +1892 -471
  292. package/src/mcpl/conversation-router.ts +26 -72
  293. package/src/mcpl/convert-block.ts +9 -5
  294. package/src/mcpl/inbound-source.ts +131 -0
  295. package/src/mcpl/index.ts +4 -2
  296. package/src/mcpl/log-value.ts +9 -0
  297. package/src/mcpl/modern-connection.ts +162 -53
  298. package/src/mcpl/prose-grammar.ts +4 -5
  299. package/src/mcpl/protocol-family.ts +200 -22
  300. package/src/mcpl/push-coalescer.ts +306 -62
  301. package/src/mcpl/push-handler.ts +12 -54
  302. package/src/mcpl/reference-fetcher.ts +4 -4
  303. package/src/mcpl/server-connection.ts +180 -79
  304. package/src/mcpl/tags.ts +36 -0
  305. package/src/mcpl/tool-result-normalize.ts +2 -1
  306. package/src/mcpl/transport.ts +162 -33
  307. package/src/mcpl/types.ts +23 -6
  308. package/src/module-registry.ts +92 -13
  309. package/src/modules/discord/index.ts +8 -13
  310. package/src/modules/history/index.ts +25 -3
  311. package/src/modules/history/semantic.ts +29 -6
  312. package/src/modules/index.ts +1 -0
  313. package/src/modules/unknown-tool.ts +4 -1
  314. package/src/modules/workspace/branch-intent.ts +3 -1
  315. package/src/modules/workspace/edit-plan.ts +954 -0
  316. package/src/modules/workspace/edit-text.ts +618 -0
  317. package/src/modules/workspace/effects.ts +70 -19
  318. package/src/modules/workspace/index.ts +2293 -650
  319. package/src/modules/workspace/observe.ts +465 -56
  320. package/src/modules/workspace/reconcile.ts +14 -7
  321. package/src/modules/workspace/text.ts +68 -0
  322. package/src/modules/workspace/types.ts +211 -6
  323. package/src/modules/workspace/watcher.ts +3 -2
  324. package/src/operator-log.ts +47 -19
  325. package/src/recovery/discord-awareness-outbox.ts +1500 -356
  326. package/src/recovery/offline-branch.ts +189 -55
  327. package/src/recovery/recover-cli.ts +25 -4
  328. package/src/send-tools.ts +11 -0
  329. package/src/timezone.ts +4 -2
  330. package/src/tool-image-ledger.ts +62 -8
  331. package/src/tool-result-guard.ts +18 -15
  332. package/src/tool-result-history.ts +3 -2
  333. package/src/tune-out/coordinator.ts +166 -45
  334. package/src/types/agent.ts +32 -6
  335. package/src/types/events.ts +66 -9
  336. package/src/types/framework.ts +69 -62
  337. package/src/types/module.ts +40 -8
  338. package/src/types/trace.ts +121 -15
  339. package/src/usage/types.ts +16 -0
  340. package/src/usage/usage-tracker.ts +86 -43
  341. package/dist/src/code-execution/py-runner.d.ts +0 -191
  342. package/dist/src/code-execution/py-runner.d.ts.map +0 -1
  343. package/dist/src/code-execution/py-runner.js +0 -655
  344. package/dist/src/code-execution/py-runner.js.map +0 -1
  345. package/dist/src/code-execution/runtime-py.d.ts +0 -50
  346. package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
  347. package/dist/src/code-execution/runtime-py.js +0 -677
  348. package/dist/src/code-execution/runtime-py.js.map +0 -1
  349. package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
  350. package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
  351. package/dist/src/mcpl/prose-stream-router.js +0 -291
  352. package/dist/src/mcpl/prose-stream-router.js.map +0 -1
  353. package/src/code-execution/py-runner.ts +0 -791
  354. package/src/code-execution/runtime-py.ts +0 -677
  355. package/src/mcpl/prose-stream-router.ts +0 -268
@@ -5,12 +5,11 @@
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
7
 
8
- import { isUtf8 } from 'node:buffer';
9
8
  import { constants as fsConstants } from 'node:fs';
10
9
  import type { Stats } from 'node:fs';
11
10
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
12
11
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
13
- import type { JsStore } from '@animalabs/chronicle';
12
+ import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
14
13
  import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
15
14
  import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
16
15
  import type {
@@ -23,6 +22,7 @@ import type {
23
22
  ReadImageInput,
24
23
  WriteInput,
25
24
  EditInput,
25
+ EditManyInput,
26
26
  DeleteInput,
27
27
  LsInput,
28
28
  GlobInput,
@@ -39,9 +39,12 @@ import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
39
39
  import { MountWatcher, type FsChange } from './watcher.js';
40
40
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
41
41
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
42
- import { BranchIntents } from './branch-intent.js';
43
- import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
42
+ import { type BranchIntent, BranchIntents } from './branch-intent.js';
43
+ import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
44
44
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
45
+ import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
46
+ import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
47
+ import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
45
48
  import {
46
49
  type MountRuntime,
47
50
  type PassOptions,
@@ -73,6 +76,18 @@ const CATCH_UP_BOUND_MS = 30_000;
73
76
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
74
77
  /** The workspace's own deadline must end before the framework's hook bound. */
75
78
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
79
+ /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
80
+ const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
81
+ /**
82
+ * How many files a direct mount's grep reads, and its listings look at, at
83
+ * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
84
+ * 16 at a time on Node's default threadpool had a median time below the
85
+ * store-backed grep's over the same files at every load tried, and one
86
+ * reading them in turn took 2 to 2.4 times as long.
87
+ */
88
+ const DIRECT_READS_AT_ONCE = 16;
89
+ /** Why sync passes over a direct mount, as its result's `skipped` says. */
90
+ const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
76
91
 
77
92
  export type {
78
93
  WorkspaceConfig,
@@ -83,6 +98,7 @@ export type {
83
98
  ReadImageInput,
84
99
  WriteInput,
85
100
  EditInput,
101
+ EditManyInput,
86
102
  DeleteInput,
87
103
  LsInput,
88
104
  GlobInput,
@@ -133,15 +149,28 @@ class WorkspaceImageReadError extends Error {
133
149
  * link and, where it leads somewhere, its target, a symlink whose canonical
134
150
  * target lands outside the canonical mount root, or one among the path's
135
151
  * directories leading out of it (`escape`), a file swapped underneath the read
136
- * (`changed`), and a path in another mount's directory nested in the one
137
- * named, or reached through a symlink among the path's directories leading
138
- * there (`other_mount`), whose message names the mount that holds it and the
139
- * path to use there. A `mount_unavailable` for a root whose realpath or stat
140
- * fails otherwise carries that errno, such as EACCES.
152
+ * (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
153
+ * and a path in another mount's directory nested in the one named
154
+ * (`other_mount`), whose message names the mount that holds it and the path
155
+ * to use there. A symlink leading into such a directory is `other_mount`
156
+ * only where its refusal names that mount: a directory, or a file that
157
+ * mount's include covers. Anywhere else, on a mount that doesn't follow
158
+ * symlinks, it gets the code of a link where it stands, `symlink` at the
159
+ * path itself and `parent_symlink` on its way, and on one that does,
160
+ * `escape`, as a path that leaves the mount. On a mount with an include, a
161
+ * link's message names its target only where a tool could name it: a
162
+ * directory, or a file the include covers. A link the include doesn't cover
163
+ * is outside the mount, and its message tells nothing of where it leads, so
164
+ * on a path's way its code is `parent_symlink` wherever it leads; named
165
+ * itself, it's refused as outside the include (`outside_include`), as any
166
+ * path the include doesn't cover is.
167
+ * A `mount_unavailable` for a root whose realpath or stat fails otherwise
168
+ * carries that errno, such as EACCES.
141
169
  */
142
170
  export type WorkspaceReadErrorCode =
143
171
  | 'unknown_mount'
144
172
  | 'traversal'
173
+ | 'outside_include'
145
174
  | 'other_mount'
146
175
  | 'mount_unavailable'
147
176
  | 'not_found'
@@ -166,6 +195,14 @@ export type WorkspaceReadStage =
166
195
  | 'stat'
167
196
  | 'read';
168
197
 
198
+ /** A path the mount's `include` doesn't cover: outside the mount. */
199
+ class OutsideIncludeError extends Error {
200
+ constructor(shownPath: string, mount: MountConfig) {
201
+ super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
202
+ + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
203
+ }
204
+ }
205
+
169
206
  /**
170
207
  * A file operation's path lies in another mount's directory nested in the
171
208
  * mount it names. The message names the mount that holds it and the path to
@@ -231,6 +268,16 @@ export interface ReadFileFromDiskOptions {
231
268
  maxBytes?: number;
232
269
  }
233
270
 
271
+ /**
272
+ * Why a pass decided nothing, or null where it decided its paths. A pass gives
273
+ * up deciding, and reports no path, when the selected branch kept changing
274
+ * while it gathered, or when a sync's branch was no longer selected; it then
275
+ * carries no branchId, and the region it leaves incomplete says why.
276
+ */
277
+ function undecidedOf(pass: PassResult): string | null {
278
+ return pass.branchId === undefined ? (pass.incomplete[0]?.reason ?? 'the pass decided nothing') : null;
279
+ }
280
+
234
281
  function errnoOf(err: unknown): string | undefined {
235
282
  return typeof err === 'object' && err !== null && 'code' in err && typeof (err as { code?: unknown }).code === 'string'
236
283
  ? (err as { code: string }).code
@@ -288,27 +335,37 @@ function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean {
288
335
  * link's reason, as the observations give it, after the path; or, for a
289
336
  * directory the walk couldn't ask, a failure to read, coded `not_found` with
290
337
  * the readlink's errno, as every failed lookup is. A link's code follows
291
- * where it leads, so a caller choosing advice by code gets advice that fits:
338
+ * where its refusal says it leads (shownLeads), so a caller choosing advice
339
+ * by code gets advice that fits, and the code says no more than the message:
292
340
  * `escape` for a link leading outside the mount, the code a path resolving
293
- * outside it gets too; `other_mount` for one into a nested mount's directory,
294
- * the code that mount's own paths get; and `parent_symlink` for any other,
295
- * which `symlink`, a file that is itself a link, leaves apart.
341
+ * outside it gets too; `other_mount` for one into a nested mount's directory
342
+ * where the message names that mount (namesHolder), the code that mount's own
343
+ * paths get; and for any other, a link the mount's include doesn't cover
344
+ * among them, the code of a link the mount doesn't follow where it stands:
345
+ * `symlink` at the path itself, as any other link there gets, and
346
+ * `parent_symlink` among its directories, judged on the parsed path
347
+ * (`relativePath`), whatever spelling the caller gave. Where the link leads is
348
+ * read beneath `rootReal`, the root the check that found it resolved, so the
349
+ * link and its target are judged beneath one root, by that root's nesting
350
+ * (`nested`).
296
351
  */
297
352
  async function linkReadError(
298
- mount: { config: { name: string; path: string } },
353
+ mount: { config: { name: string; path: string; include?: readonly string[] } },
299
354
  found: LinkFound,
300
355
  mountPrefixedPath: string,
301
- nested: readonly { dir: string; name: string }[],
356
+ relativePath: string,
357
+ nested: readonly NestedMount[],
358
+ rootReal: string,
302
359
  ): Promise<WorkspaceReadError> {
303
360
  if (!('link' in found)) {
304
361
  return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
305
362
  }
306
- const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
307
- const leads = await linkLeads(mount.config.path, rootReal, found.link);
308
- const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
309
- const code = leads.kind === 'outside' ? 'escape'
310
- : leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
311
- : 'parent_symlink';
363
+ const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
364
+ const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
365
+ const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
366
+ const code = leads?.kind === 'outside' ? 'escape'
367
+ : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
368
+ : relativePath === found.link ? 'symlink' : 'parent_symlink';
312
369
  return new WorkspaceReadError(code, reason, 'readlink');
313
370
  }
314
371
 
@@ -325,6 +382,48 @@ function nestedDir(from: string, to: string): string | undefined {
325
382
  return rel.split(sep).join('/');
326
383
  }
327
384
 
385
+ /** Each directory above `path`, innermost first, to the filesystem's root. */
386
+ function ancestorsOf(path: string): string[] {
387
+ const dirs: string[] = [];
388
+ for (let dir = dirname(path); ; dir = dirname(dir)) {
389
+ dirs.push(dir);
390
+ if (dirname(dir) === dir) return dirs;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Where a mount is nested in an outer mount, as a start places it: at its
396
+ * directory as configured beneath the outer mount's configured path
397
+ * (`outerPath`), beneath each directory above its configured path that is
398
+ * the outer root on disk (`above`, each with where it lies on disk), and
399
+ * where its real root (`real`) lies beneath the outer root (`outerReal`),
400
+ * each also as disk spells it beneath `spellAt`. Its real root is claimed
401
+ * where it doesn't hold the outer root, which it would otherwise claim
402
+ * whole. Undefined where it's nested nowhere.
403
+ */
404
+ async function placementOf(
405
+ outerPath: string,
406
+ outerReal: string,
407
+ innerPath: string,
408
+ real: string,
409
+ above: ReadonlyArray<{ dir: string; real: string }>,
410
+ spellAt: string,
411
+ ): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
412
+ const configured = nestedDir(outerPath, innerPath);
413
+ const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
414
+ const fromRoots: Array<{ dir: string; root: string }> = [];
415
+ for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
416
+ if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
417
+ }
418
+ if (fromRoots.length === 0) return undefined;
419
+ const spellings = [...fromRoots];
420
+ for (const found of fromRoots) {
421
+ const dir = await diskSpelling(spellAt, found.dir);
422
+ if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
423
+ }
424
+ return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
425
+ }
426
+
328
427
  /** A mount nested in another, as the outer mount's otherMounts holds it. */
329
428
  interface NestedMountEntry {
330
429
  name: string;
@@ -340,6 +439,22 @@ interface NestedMountEntry {
340
439
  spellings: Array<{ dir: string; root: string }>;
341
440
  }
342
441
 
442
+ /**
443
+ * A path as parsePath reads it: its mount, and its mount-relative and
444
+ * mount-prefixed paths as the parse spells them; with the root the parse
445
+ * judged it beneath, a direct mount's as the parse resolved it and any
446
+ * other's as configured, and, where a direct mount's root lies elsewhere
447
+ * than the last start placed it, the mounts nested in it there
448
+ * (movedNesting), null otherwise.
449
+ */
450
+ interface ParsedPath {
451
+ mount: MountState;
452
+ relativePath: string;
453
+ path: string;
454
+ root: string;
455
+ moved: NestedMount[] | null;
456
+ }
457
+
343
458
  /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
344
459
  const LINKS_FOLLOWED = 40;
345
460
 
@@ -348,13 +463,14 @@ const LINKS_FOLLOWED = 40;
348
463
  * it, with the links on its way and in it taken out, read without holding
349
464
  * the process. Where nothing is there yet, a link leads where its target
350
465
  * would be, and anything else lies beneath its nearest existing directory's
351
- * real path, with the rest as given. A link's target is read from the
352
- * link's directory as it is on disk, and nothing is resolved lexically on
353
- * the way, so a `..` is taken from where the links before it lead, as the
466
+ * real path, with the rest as given, a `..` after a name that isn't there
467
+ * taken lexically. A link's target is read from the link's directory as it
468
+ * is on disk, so a `..` is taken from where the links before it lead, as the
354
469
  * kernel takes it. Where realpath fails for any other reason, such as a
355
- * loop or a lack of permission, the whole path as path.resolve reads it.
470
+ * loop or a lack of permission, the whole path as path.resolve reads it, and
471
+ * `unresolved`, where given, takes the first such code.
356
472
  */
357
- async function realBeneath(given: string, links = 0): Promise<string> {
473
+ async function realBeneath(given: string, links = 0, unresolved?: { code?: string }): Promise<string> {
358
474
  // A trailing separator, as a link to a directory is often written, names
359
475
  // the same place, but readlink would then follow the link it names.
360
476
  const { root } = parse(given);
@@ -365,14 +481,33 @@ async function realBeneath(given: string, links = 0): Promise<string> {
365
481
  } catch (err) {
366
482
  const code = errnoOf(err);
367
483
  const parent = dirname(absolute);
368
- if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute) return resolve(absolute);
369
- const place = await realBeneath(parent, links);
484
+ if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links > LINKS_FOLLOWED || parent === absolute) {
485
+ if (unresolved) unresolved.code ??= links > LINKS_FOLLOWED ? 'ELOOP' : code ?? 'EIO';
486
+ return resolve(absolute);
487
+ }
488
+ const place = await realBeneath(parent, links, unresolved);
370
489
  const target = await readlink(absolute).catch(() => null);
371
- if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
490
+ if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1, unresolved);
372
491
  return join(place, basename(absolute));
373
492
  }
374
493
  }
375
494
 
495
+ /**
496
+ * A configured value as a refusal shows it: a number or a bigint as
497
+ * JavaScript writes it (JSON would say null for NaN, and throws for a
498
+ * bigint), anything else as JSON, or as String gives it where JSON can't,
499
+ * as for a list holding a bigint.
500
+ */
501
+ function shownValue(value: unknown): string {
502
+ if (typeof value === 'number') return String(value);
503
+ if (typeof value === 'bigint') return `${value}n`;
504
+ try {
505
+ return JSON.stringify(value) ?? String(value);
506
+ } catch {
507
+ return String(value);
508
+ }
509
+ }
510
+
376
511
  function isContainedPath(root: string, candidate: string): boolean {
377
512
  if (candidate === root) return true;
378
513
  const normalizedRoot = root.endsWith(sep) ? root : root + sep;
@@ -826,6 +961,10 @@ export class WorkspaceModule implements Module {
826
961
  private otherMounts = new Map<string, NestedMountEntry[]>();
827
962
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
828
963
  private rootFoldsCase = new Map<string, boolean>();
964
+ /** Where the last start placed each mount's root (readNesting's `reals`). */
965
+ private placedRoots = new Map<string, string>();
966
+ /** Each directory above each mount's configured path, with where the last start found it on disk (readNesting's `above`). */
967
+ private placedAbove = new Map<string, Array<{ dir: string; real: string }>>();
829
968
  /**
830
969
  * Whether a file's mode is observed, taken in and applied (MountRuntime's
831
970
  * `modesObserved`): not on Windows, which reports no POSIX modes. A test
@@ -837,17 +976,49 @@ export class WorkspaceModule implements Module {
837
976
  * mounts, so restoration must be second-callback-safe like watcher
838
977
  * startup already is (issue #72). Cleared after application. */
839
978
  private savedState: WorkspaceModuleState | null = null;
979
+ /**
980
+ * The mounts configured direct that run as 'chronicle' this session, with
981
+ * the changes the store held there that disk lacked (chooseKinds), for
982
+ * ensureRunning to say once both halves of the lifecycle have run.
983
+ */
984
+ private notDirect = new Map<string, Array<{ path: string; what: string }>>();
840
985
 
841
986
  constructor(config: WorkspaceConfig) {
842
987
  for (const mount of config.mounts) {
843
988
  const cap: unknown = mount.walkCap;
844
989
  if (cap !== undefined && !(Number.isSafeInteger(cap) && (cap as number) > 0)) {
845
- // Numbers as JavaScript writes them (JSON would say null for NaN), anything else as JSON.
846
- const shown = typeof cap === 'number' ? String(cap) : typeof cap === 'bigint' ? `${cap}n` : JSON.stringify(cap) ?? String(cap);
847
990
  throw new Error(
848
- `[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${shown}`,
991
+ `[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${shownValue(cap)}`,
849
992
  );
850
993
  }
994
+ const backend: unknown = mount.backend;
995
+ if (backend !== undefined && backend !== 'chronicle' && backend !== 'direct') {
996
+ throw new Error(`[workspace] Mount "${mount.name}": backend must be 'chronicle' or 'direct', not ${shownValue(backend)}`);
997
+ }
998
+ const refused = backend === 'direct' ? directRefusals(mount) : [];
999
+ if (refused.length > 0) {
1000
+ throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
1001
+ }
1002
+ const include: unknown = mount.include;
1003
+ if (include !== undefined) {
1004
+ // A pattern the matcher can't read would match nothing, and take every
1005
+ // file out of the mount unsaid (includePatternReadable).
1006
+ if (!Array.isArray(include) || include.length === 0) {
1007
+ throw new Error(
1008
+ `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
1009
+ );
1010
+ }
1011
+ // By index: an entry that is itself undefined, or a hole, is unreadable too.
1012
+ const at = include.findIndex((p) => !includePatternReadable(p));
1013
+ if (at >= 0) {
1014
+ const unreadable: unknown = include[at];
1015
+ throw new Error(
1016
+ `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
1017
+ + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
1018
+ + `${shownValue(unreadable)} is none of these`,
1019
+ );
1020
+ }
1021
+ }
851
1022
  }
852
1023
  // Detect overlapping mount paths: a mount nested inside another is that
853
1024
  // mount's directory, which the outer mount passes over in every pass,
@@ -890,23 +1061,34 @@ export class WorkspaceModule implements Module {
890
1061
  // before the call, knowing no mount, and a later initStore starts clean.
891
1062
  const mounts = new Map<string, MountState>();
892
1063
  const intents = new Map<string, BranchIntents>();
1064
+ // A direct mount's files never go into the store, so its states aren't
1065
+ // made. One that ran as 'chronicle' before has them, and they're read
1066
+ // below for what the store holds there that disk lacks.
1067
+ const made = this.config.mounts.some((m) => m.backend === 'direct') ? new Set(store.listStates().map((s) => s.id)) : null;
893
1068
 
894
1069
  // Register tree states for each mount: the workspace tree, and the
895
1070
  // branch-local intent that qualifies it.
896
1071
  for (const mount of this.config.mounts) {
897
1072
  const treeStateId = `workspace/${mount.name}/tree`;
898
1073
  const intentTreeStateId = `workspace/${mount.name}/intent`;
899
- for (const id of [treeStateId, intentTreeStateId]) {
900
- ensureStateRegistered(store, {
901
- id,
902
- strategy: 'tree',
903
- deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
904
- fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
905
- });
1074
+ const direct = mount.backend === 'direct';
1075
+ // A mount configured direct that ran as 'chronicle' runs direct only
1076
+ // once chooseKinds finds nothing held there.
1077
+ const ranStoreBacked = direct && made!.has(treeStateId) && made!.has(intentTreeStateId);
1078
+ if (!direct) {
1079
+ for (const id of [treeStateId, intentTreeStateId]) {
1080
+ ensureStateRegistered(store, {
1081
+ id,
1082
+ strategy: 'tree',
1083
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
1084
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
1085
+ });
1086
+ }
906
1087
  }
907
1088
 
908
1089
  mounts.set(mount.name, {
909
1090
  config: mount,
1091
+ direct: direct && !ranStoreBacked,
910
1092
  treeStateId,
911
1093
  intentTreeStateId,
912
1094
  lastMaterializedSeq: 0,
@@ -916,7 +1098,9 @@ export class WorkspaceModule implements Module {
916
1098
  watcherReadyAt: null,
917
1099
  watcherError: null,
918
1100
  });
919
- intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
1101
+ if (!direct || ranStoreBacked) {
1102
+ intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
1103
+ }
920
1104
  }
921
1105
 
922
1106
  this.store = store;
@@ -924,14 +1108,53 @@ export class WorkspaceModule implements Module {
924
1108
  for (const [name, intent] of intents) this.intents.set(name, intent);
925
1109
 
926
1110
  // What disk last agreed with, per mount and path: global records, so a
927
- // branch switch never rewinds it.
1111
+ // branch switch never rewinds it. A mount configured direct has its
1112
+ // evidence read only where a 'chronicle' run left its states, for what
1113
+ // the store holds there that disk lacks (chooseKinds); one that never ran
1114
+ // so has none to read, and none is begun for it.
928
1115
  this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
929
- this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
1116
+ this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
930
1117
 
1118
+ this.chooseKinds();
931
1119
  this.applySavedState();
932
1120
  this.ensureRunning();
933
1121
  }
934
1122
 
1123
+ /**
1124
+ * Whether each mount configured direct runs direct: not while the store
1125
+ * holds changes there that disk lacks, on the branch selected then, so
1126
+ * they stay in view (MountConfig.backend); it runs as 'chronicle' instead,
1127
+ * and ensureRunning says so. Decided when initStore gives the store, and
1128
+ * again at each start, which knows how the mounts nest, so a start that
1129
+ * finds none held starts the mount direct. A path held more than one way
1130
+ * is named for the last way set here.
1131
+ */
1132
+ private chooseKinds(): void {
1133
+ // Every mount is decided before any is set, so a read of the store that
1134
+ // throws leaves the decisions as they were.
1135
+ const decided = new Map<string, Array<{ path: string; what: string }>>();
1136
+ for (const [name, state] of this.mounts) {
1137
+ if (state.config.backend !== 'direct') continue;
1138
+ // One with no states from a 'chronicle' run holds nothing.
1139
+ if (!this.intents.has(name)) {
1140
+ decided.set(name, []);
1141
+ continue;
1142
+ }
1143
+ const held = this.heldChanges(state);
1144
+ const what = new Map<string, string>();
1145
+ for (const path of held.owed) what.set(path, 'changed in the workspace');
1146
+ for (const path of held.unreadable) what.set(path, UNREADABLE_INTENT);
1147
+ for (const path of held.deletions) what.set(path, 'deleted in the workspace');
1148
+ for (const path of held.conflicts) what.set(path, 'in conflict');
1149
+ decided.set(name, [...what].sort(([a], [b]) => a.localeCompare(b)).map(([path, label]) => ({ path, what: label })));
1150
+ }
1151
+ this.notDirect = new Map();
1152
+ for (const [name, held] of decided) {
1153
+ this.mounts.get(name)!.direct = held.length === 0;
1154
+ if (held.length > 0) this.notDirect.set(name, held);
1155
+ }
1156
+ }
1157
+
935
1158
  // ==========================================================================
936
1159
  // Reconciliation plumbing
937
1160
  // ==========================================================================
@@ -960,7 +1183,7 @@ export class WorkspaceModule implements Module {
960
1183
  * A mount as observation reads it, with the root identity the workspace
961
1184
  * recorded for it, if any: what a pass reads beneath (runtime), with
962
1185
  * rootIdentity's, and what a disk read checks its root against (readRoot),
963
- * with rootIdentityForRead's.
1186
+ * with rootIdentityForRead's. A direct mount's has none (diskView).
964
1187
  */
965
1188
  private viewOf(mount: MountState, rootIdentity: MountView['rootIdentity']): MountView {
966
1189
  return {
@@ -969,12 +1192,42 @@ export class WorkspaceModule implements Module {
969
1192
  rootIdentity,
970
1193
  followSymlinks: mount.config.followSymlinks === true,
971
1194
  maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
972
- ignore: mount.config.ignore ?? [],
1195
+ ignore: this.ignoreOf(mount),
1196
+ ...(mount.config.include ? { include: mount.config.include } : {}),
973
1197
  others: this.othersOf(mount.config.name),
974
1198
  nested: this.nestedOf(mount.config.name),
975
1199
  };
976
1200
  }
977
1201
 
1202
+ /**
1203
+ * A mount's ignore list: its config's, or, for a mount configured direct
1204
+ * that names none, the direct kind's default (MountConfig.ignore), whichever
1205
+ * kind it runs as this session, as its watch mode goes by its config
1206
+ * (watchOf).
1207
+ */
1208
+ private ignoreOf(mount: MountState): string[] {
1209
+ return mount.config.ignore ?? (mount.config.backend === 'direct' ? [...DIRECT_MOUNT_IGNORE] : []);
1210
+ }
1211
+
1212
+ /**
1213
+ * A direct mount as its tools read it: its view, with no root identity,
1214
+ * since it promises what's at its root now and keeps no evidence to check
1215
+ * that against (MountConfig.backend), and its root as realpath resolves it,
1216
+ * or as given where that fails, as a pass's runtime takes it. The view
1217
+ * reads beneath that root, so a walk, a listing and grep's reads judge
1218
+ * and open their files beneath one root, however its configured path is
1219
+ * repointed meanwhile. A read of one file goes through openContainedFile
1220
+ * instead, which refuses it once its root is no longer the directory it
1221
+ * checked.
1222
+ */
1223
+ private async diskView(mount: MountState): Promise<{ view: MountView; rootReal: string; moved: NestedMount[] | null }> {
1224
+ const root = resolve(mount.config.path);
1225
+ const rootReal = await realpath(root).catch(() => root);
1226
+ const view = { ...this.viewOf(mount, undefined), root: rootReal };
1227
+ const moved = await this.movedNesting(mount, rootReal);
1228
+ return { view: moved === null ? view : { ...view, nested: moved, others: moved.map((n) => n.dir) }, rootReal, moved };
1229
+ }
1230
+
978
1231
  /** The view reconciliation needs of a mount, refreshed per pass. */
979
1232
  private async runtime(mount: MountState): Promise<MountRuntime> {
980
1233
  const root = resolve(mount.config.path);
@@ -994,7 +1247,7 @@ export class WorkspaceModule implements Module {
994
1247
  intents: this.intents.get(mount.config.name)!,
995
1248
  readOnly: mount.config.mode === 'read-only',
996
1249
  walkCap: mount.config.walkCap ?? WALK_CAP,
997
- catchUpScan: (mount.config.watch ?? 'always') === 'always',
1250
+ catchUpScan: watchOf(mount.config) === 'always',
998
1251
  modesObserved: this.modesObserved,
999
1252
  };
1000
1253
  }
@@ -1008,15 +1261,18 @@ export class WorkspaceModule implements Module {
1008
1261
  * under that spelling beside their own. So the start stops before a segment
1009
1262
  * spelled any other way, not there, or not a directory, and before a link,
1010
1263
  * which a walk of the whole mount doesn't go through either. It lists only
1011
- * directories it has checked lie inside the mount, and none the mount
1012
- * passes over by design, which the walk then answers for.
1264
+ * directories it has checked lie inside the mount, and, on a 'chronicle'
1265
+ * mount, none it passes over by design, which the walk then answers for.
1266
+ * A direct mount walks a path its tools name whatever its ignore list or
1267
+ * an editor's file name covers there (MountConfig.ignore), so there it
1268
+ * lists such directories too, and only another mount's it doesn't.
1013
1269
  */
1014
1270
  private async walkStart(mount: MountState, base: string, beneath: string): Promise<string> {
1015
1271
  if (beneath === '') return base;
1016
- const { view, rootReal } = await this.runtime(mount);
1272
+ const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
1017
1273
  let start = base;
1018
1274
  for (const segment of beneath.split('/')) {
1019
- if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined) break;
1275
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
1020
1276
  const here = await checkDir(view, rootReal, start);
1021
1277
  if (here.kind !== 'checked') break;
1022
1278
  const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
@@ -1048,10 +1304,23 @@ export class WorkspaceModule implements Module {
1048
1304
  /** One store→disk push; the caller holds the mount's turn. */
1049
1305
  private async pushUnlocked(mount: MountState, paths: string[], opts: PushOptions = {}): Promise<PushResult> {
1050
1306
  const watcher = this.watchers.get(mount.config.name);
1051
- return pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), paths, {
1307
+ // A path outside the mount's include never reaches disk, even when the
1308
+ // workspace's history holds it from before the include. It's skipped,
1309
+ // and a materialize counts it, with the reason.
1310
+ const inside: string[] = [];
1311
+ const outside: string[] = [];
1312
+ for (const p of paths) {
1313
+ // A key no spelling names, such as an earlier version's `a//b.txt`,
1314
+ // isn't the mount's either, and isn't the include's to report.
1315
+ if (!parsedSpelling(p)) continue;
1316
+ (insideMount(p, mount.config.include) ? inside : outside).push(p);
1317
+ }
1318
+ const pushed = await pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), inside, {
1052
1319
  ...opts,
1053
1320
  beforeEffect: (p) => watcher?.suppress(p),
1054
1321
  });
1322
+ for (const path of outside) pushed.skipped.push({ path, reason: OUTSIDE_INCLUDE });
1323
+ return pushed;
1055
1324
  }
1056
1325
 
1057
1326
  async start(ctx: ModuleContext): Promise<void> {
@@ -1062,12 +1331,14 @@ export class WorkspaceModule implements Module {
1062
1331
  // before they're known. A stop begun meanwhile stands, and this start then
1063
1332
  // sets nothing.
1064
1333
  const stops = this.stopsBegun;
1065
- const { nesting, reals } = await this.readNesting();
1334
+ const { nesting, reals, above } = await this.readNesting();
1066
1335
  const rootFoldsCase = new Map<string, boolean>();
1067
1336
  for (const mount of this.config.mounts) rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)!));
1068
1337
  if (this.stopsBegun !== stops) return;
1069
1338
  this.otherMounts = nesting;
1070
1339
  this.rootFoldsCase = rootFoldsCase;
1340
+ this.placedRoots = reals;
1341
+ this.placedAbove = above;
1071
1342
 
1072
1343
  this.ctx = ctx;
1073
1344
  this.stopped = false;
@@ -1207,16 +1478,19 @@ export class WorkspaceModule implements Module {
1207
1478
  /**
1208
1479
  * Start chokidar watchers and emit workspace:mounted events.
1209
1480
  * Idempotent: safe to call from both start() and initStore(), fires once
1210
- * per mount regardless of which runs second.
1481
+ * per mount regardless of which runs second. Once both have run, it
1482
+ * decides again which mounts configured direct run direct, now that the
1483
+ * mounts' nesting is known, and says which don't, once for this start.
1211
1484
  */
1212
1485
  private ensureRunning(): void {
1213
1486
  if (!this.ctx || this.mounts.size === 0) return;
1487
+ this.chooseKinds();
1488
+ if (this.notDirect.size > 0) this.sayNotDirect();
1214
1489
 
1215
1490
  for (const [name, mount] of this.mounts) {
1216
1491
  if (this.watchers.has(name)) continue;
1217
1492
 
1218
- const watchMode = mount.config.watch ?? 'always';
1219
- if (watchMode === 'always') {
1493
+ if (watchOf(mount.config) === 'always') {
1220
1494
  // Chokidar is started with ignoreInitial:true, so changes made while
1221
1495
  // the session was down would be invisible. A catch-up pass takes them
1222
1496
  // in under the three-way rule: only real differences produce tree
@@ -1300,6 +1574,51 @@ export class WorkspaceModule implements Module {
1300
1574
  }
1301
1575
  }
1302
1576
 
1577
+ /**
1578
+ * Say, for whoever runs the host, which mounts configured direct run as
1579
+ * 'chronicle' this session (chooseKinds), what the workspace holds there
1580
+ * that keeps them so, and the two ways to start one direct: on stderr,
1581
+ * and as one ops alert for them all (the framework's failures log, its
1582
+ * `ops:alert` trace and, where one is set, its webhook, which posts one
1583
+ * alert of a kind per cooldown and keeps only the start and end of a long
1584
+ * one). So the words of fixed length, what's happening and the ways out,
1585
+ * come first, and the mounts and what they hold after them.
1586
+ */
1587
+ private sayNotDirect(): void {
1588
+ const mounts = [...this.notDirect];
1589
+ const one = mounts.length === 1;
1590
+ // An intent the workspace can't read may hold a change or may not, so
1591
+ // it's counted as what it is.
1592
+ const counted = (paths: Array<{ what: string }>) => {
1593
+ const u = paths.filter((h) => h.what === UNREADABLE_INTENT).length;
1594
+ const c = paths.length - u;
1595
+ return [
1596
+ c > 0 ? `${c} change${c === 1 ? '' : 's'} that disk lacks` : '',
1597
+ u > 0 ? `${u} intent${u === 1 ? '' : 's'} it can't read` : '',
1598
+ ].filter((part) => part !== '').join(' and ');
1599
+ };
1600
+ const each = mounts.map(([name, paths]) => `"${name}" (${counted(paths)})`);
1601
+ const which = one
1602
+ ? `Mount "${mounts[0]![0]}" holds ${counted(mounts[0]![1])}`
1603
+ : `Mounts ${each.slice(0, -1).join(', ')} and ${each[each.length - 1]} hold`;
1604
+ const held = mounts.flatMap(([name, paths]) => paths.map((h) => `"${name}/${h.path}" (${h.what})`));
1605
+ // Materialize settles an intent the workspace can't read only where the
1606
+ // workspace holds a copy there, and says nothing of one it doesn't, so
1607
+ // the ways out say what does wherever one is held, past the ten paths
1608
+ // named too.
1609
+ const intents = mounts.some(([, paths]) => paths.some((h) => h.what === UNREADABLE_INTENT))
1610
+ ? "Sync the path of an intent it can't read. "
1611
+ : '';
1612
+ const message = (one
1613
+ ? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
1614
+ : "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
1615
+ "sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
1616
+ `follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1617
+ `${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
1618
+ console.warn(`[workspace] ${message}`);
1619
+ this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
1620
+ }
1621
+
1303
1622
  private async initialScan(mountName: string): Promise<void> {
1304
1623
  const store = this.store;
1305
1624
  const mount = this.mounts.get(mountName);
@@ -1367,12 +1686,15 @@ export class WorkspaceModule implements Module {
1367
1686
  this.ctx = null;
1368
1687
  }
1369
1688
 
1370
- /** Mount names + modes, for peer callers (framework spill/journal paths)
1371
- * that need a writable mount without reaching into private state. */
1372
- getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only' }> {
1689
+ /** Mount names + modes, and each mount's include where it sets one, for
1690
+ * peer callers (framework spill/journal paths) that need a mount for files
1691
+ * of their own, the first writable one without an include
1692
+ * (firstWritableMountName), without reaching into private state. */
1693
+ getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[] }> {
1373
1694
  return [...this.mounts.values()].map((m) => ({
1374
1695
  name: m.config.name,
1375
1696
  mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
1697
+ ...(m.config.include ? { include: [...m.config.include] } : {}),
1376
1698
  }));
1377
1699
  }
1378
1700
 
@@ -1381,6 +1703,19 @@ export class WorkspaceModule implements Module {
1381
1703
  // ==========================================================================
1382
1704
 
1383
1705
  getTools(): ToolDefinition[] {
1706
+ // Where a mount runs direct, four descriptions say what that changes.
1707
+ // A host with none keeps them as they were, and its residents' caches.
1708
+ const direct = [...this.mounts.values()].some((m) => m.direct);
1709
+ const lsDirect = direct
1710
+ ? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
1711
+ + 'its size and no state, or a note where the mount doesn\'t take it. A path its ignore list covers is listed when you name it.'
1712
+ : '';
1713
+ const grepDirect = direct
1714
+ ? ' On a direct mount, grep searches disk\'s files as they are now (version disk), and lists as skipped, with why, '
1715
+ + 'those it doesn\'t: binary, over the size limit, or not read, such as a link the mount doesn\'t follow.'
1716
+ : '';
1717
+ const globDirect = direct ? ' On a direct mount, a match carries its size, or a note where the mount doesn\'t take it, and no state.' : '';
1718
+ const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
1384
1719
  return [
1385
1720
  {
1386
1721
  name: 'read',
@@ -1426,18 +1761,62 @@ export class WorkspaceModule implements Module {
1426
1761
  },
1427
1762
  {
1428
1763
  name: 'edit',
1429
- description: 'Edit a file by replacing a substring. The oldString must be unique unless replaceAll is true.',
1764
+ description: 'Edit a file by replacing exact text. oldString must occur exactly once, or with replaceAll at least once, '
1765
+ + "and every occurrence is replaced; occurrences that overlap count, but can't each be replaced. "
1766
+ + 'A miss shows the closest region it finds, numbered, and a count that differs shows the lines that matched. '
1767
+ + 'CRLF line ends are matched with LF text and kept, as is a BOM. As second chances, straight and curly quotes match as equal, '
1768
+ + "and an oldString carrying the file's BOM at its start or a line's CR at its end, as a character page shows them, matches without them, "
1769
+ + 'its newString read the same way. '
1770
+ + "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
1771
+ + 'A change disk made to the file is taken in first, and the answer says so. '
1772
+ + 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
1773
+ + 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
1430
1774
  inputSchema: {
1431
1775
  type: 'object' as const,
1432
1776
  properties: {
1433
1777
  path: { type: 'string', description: 'File path (mount-prefixed)' },
1434
- oldString: { type: 'string', description: 'String to find' },
1435
- newString: { type: 'string', description: 'Replacement string' },
1436
- replaceAll: { type: 'boolean', description: 'Replace all occurrences (default: false)' },
1778
+ oldString: { type: 'string', description: 'Exact text to replace, including whitespace and indentation' },
1779
+ newString: { type: 'string', description: 'Replacement text' },
1780
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence (default: false)' },
1437
1781
  },
1438
1782
  required: ['path', 'oldString', 'newString'],
1439
1783
  },
1440
1784
  },
1785
+ {
1786
+ name: 'edit_many',
1787
+ description: 'Apply several exact edits at once, in one file or across files of one mount, all or none: no edit is stored unless every edit applies. '
1788
+ + "Each edit's oldString must occur in its file exactly `occurrences` times (1 when left out), and every one is replaced; "
1789
+ + "occurrences that overlap count, but can't each be replaced. "
1790
+ + "Each edit is matched in its file as this call reads it, never in another edit's result, so the list's order doesn't matter, "
1791
+ + "and two edits' matches may touch but not overlap. Matching is edit's. "
1792
+ + "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
1793
+ + 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
1794
+ + "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
1795
+ + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
1796
+ inputSchema: {
1797
+ type: 'object' as const,
1798
+ properties: {
1799
+ edits: {
1800
+ type: 'array',
1801
+ description: "The edits, each matched in its file as this call reads it, never in another edit's result",
1802
+ items: {
1803
+ type: 'object',
1804
+ properties: {
1805
+ path: { type: 'string', description: 'File path (mount-prefixed); every edit names a file of the same mount' },
1806
+ oldString: { type: 'string', description: 'Exact text to replace, as the file holds it before this call' },
1807
+ newString: { type: 'string', description: 'Replacement text' },
1808
+ occurrences: {
1809
+ type: 'integer',
1810
+ description: 'How many times oldString occurs in the file (default: 1); every one is replaced, and any other count is refused',
1811
+ },
1812
+ },
1813
+ required: ['path', 'oldString', 'newString'],
1814
+ },
1815
+ },
1816
+ },
1817
+ required: ['edits'],
1818
+ },
1819
+ },
1441
1820
  {
1442
1821
  name: 'delete',
1443
1822
  description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
@@ -1461,18 +1840,18 @@ export class WorkspaceModule implements Module {
1461
1840
  + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace, '
1462
1841
  + 'with applyDeletions too where the workspace deleted it; '
1463
1842
  + 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
1464
- + 'the listing could not see.',
1843
+ + 'the listing could not see.' + lsDirect,
1465
1844
  inputSchema: {
1466
1845
  type: 'object' as const,
1467
1846
  properties: {
1468
- path: { type: 'string', description: 'Directory path (mount-prefixed, optional)' },
1847
+ path: { type: 'string', description: 'Directory path (mount-prefixed, optional; without it, the mounts are listed, each with its include where it has one)' },
1469
1848
  recursive: { type: 'boolean', description: 'List recursively (default: false)' },
1470
1849
  },
1471
1850
  },
1472
1851
  },
1473
1852
  {
1474
1853
  name: 'glob',
1475
- description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.',
1854
+ description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.' + globDirect,
1476
1855
  inputSchema: {
1477
1856
  type: 'object' as const,
1478
1857
  properties: {
@@ -1488,7 +1867,7 @@ export class WorkspaceModule implements Module {
1488
1867
  + 'which version matched: the workspace\'s (with its state); the disk version kept with a conflict '
1489
1868
  + '(conflicting-disk); or, once disk has changed since the conflict was recorded, that recorded version '
1490
1869
  + '(recorded-disk). Disk versions it could not search are listed as skipped: disk-only files (binary or '
1491
- + 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.',
1870
+ + 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.' + grepDirect,
1492
1871
  inputSchema: {
1493
1872
  type: 'object' as const,
1494
1873
  properties: {
@@ -1514,7 +1893,9 @@ export class WorkspaceModule implements Module {
1514
1893
  {
1515
1894
  name: 'materialize',
1516
1895
  description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1517
- + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.',
1896
+ + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
1897
+ + 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
1898
+ + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
1518
1899
  inputSchema: {
1519
1900
  type: 'object' as const,
1520
1901
  properties: {
@@ -1546,15 +1927,17 @@ export class WorkspaceModule implements Module {
1546
1927
  + 'reported nowhere else is counted under `passedOver`, by mount and reason: another mount\'s '
1547
1928
  + 'directory nested in this one, entries the mount\'s ignore list covers, and entries with an '
1548
1929
  + 'editor\'s swap or backup file name (`.name.swp` and `.name.swx` as the whole name, names ending '
1549
- + 'in `~` or in `.subl…tmp`), that hold nothing the workspace tracks (such a directory counts once, '
1550
- + 'with what lies beneath it). So a '
1930
+ + 'in `~` or in `.subl…tmp`), each holding nothing the workspace tracks (such a directory counts '
1931
+ + 'once, with what lies beneath it); and files and links outside the mount\'s include, a link '
1932
+ + 'counted as "a symlink, not walked" instead where it leads to a directory on a mount that follows '
1933
+ + 'symlinks, and wherever it leads on one that doesn\'t. So a '
1551
1934
  + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
1552
1935
  + 'sync looked everywhere and nothing had changed. A path the mount ignores, or an editor\'s file, is '
1553
1936
  + 'synced when you name it, and listed under `ignored`: a sync without a path passes over it. A path '
1554
1937
  + 'in another mount\'s directory isn\'t synced even when you name it: use the mount that holds it. Naming '
1555
1938
  + 'an ignored directory sets aside the patterns that cover it or a directory above it; one that '
1556
1939
  + 'covers only something inside it still applies, and what it covers is passed over, as are editors\' '
1557
- + 'files inside it.',
1940
+ + 'files inside it.' + syncDirect,
1558
1941
  inputSchema: {
1559
1942
  type: 'object' as const,
1560
1943
  properties: {
@@ -1587,7 +1970,7 @@ export class WorkspaceModule implements Module {
1587
1970
  const mountName = slash < 0 ? path : path.slice(0, slash);
1588
1971
  const mount = this.config.mounts.find(m => m.name === mountName);
1589
1972
  if (!mount) throw new Error(`Unknown mount: "${mountName}"`);
1590
- // Against the resolved root, as parsePath judges it.
1973
+ // Against the resolved root, as parsePathAsGiven's traversal guard judges it.
1591
1974
  const root = resolve(mount.path);
1592
1975
  const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1));
1593
1976
  if (!isContainedPath(root, resolved)) throw new Error(`Path traversal detected: "${path}"`);
@@ -1645,16 +2028,18 @@ export class WorkspaceModule implements Module {
1645
2028
  const input = call.input as Record<string, unknown>;
1646
2029
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1647
2030
  if (generated) {
1648
- if (call.callerAgentName !== generated.agentName) return {success:false,isError:true,error:'Generated file belongs to another agent'};
1649
- if (call.name !== 'read') return {success:false,isError:true,error:'Generated file is read-only; edit the presentation configuration instead'};
1650
- return await this.handleRead(input as unknown as ReadInput, generated.read());
2031
+ if (call.name === 'read' && call.callerAgentName === generated.agentName) {
2032
+ return await this.handleRead(input as unknown as ReadInput, generated.read());
2033
+ }
2034
+ return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
1651
2035
  }
1652
2036
 
1653
2037
  switch (call.name) {
1654
2038
  case 'read': return await this.handleRead(input as unknown as ReadInput);
1655
2039
  case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
1656
2040
  case 'write': return await this.handleWrite(input as unknown as WriteInput);
1657
- case 'edit': return await this.handleEdit(input as unknown as EditInput);
2041
+ case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars);
2042
+ case 'edit_many': return await this.handleEditMany(input as unknown as EditManyInput, call.resultMaxChars, call.callerAgentName);
1658
2043
  case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
1659
2044
  case 'ls': return await this.handleLs(input as unknown as LsInput);
1660
2045
  case 'glob': return await this.handleGlob(input as unknown as GlobInput);
@@ -1663,7 +2048,7 @@ export class WorkspaceModule implements Module {
1663
2048
  case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
1664
2049
  case 'sync': return await this.handleSync(input as unknown as SyncInput);
1665
2050
  default:
1666
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
2051
+ return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
1667
2052
  }
1668
2053
  } catch (err) {
1669
2054
  return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
@@ -1742,8 +2127,11 @@ export class WorkspaceModule implements Module {
1742
2127
 
1743
2128
  /**
1744
2129
  * Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
1745
- * absolute filesystem path. Returns null if the mount is unknown or the
1746
- * resolved path escapes the mount root.
2130
+ * absolute filesystem path. Returns null if the mount is unknown or
2131
+ * doesn't hold the path: one that leaves the mount's directory, lies in
2132
+ * another mount's directory nested in it, or falls outside its include, or
2133
+ * one that can't name a file of it, with a separator right after the
2134
+ * mount's name or a lone UTF-16 surrogate.
1747
2135
  *
1748
2136
  * **Lexical containment only.** The guard reasons about `..` segments in the
1749
2137
  * path string; it knows nothing about what is on disk. A symlink inside the
@@ -1752,15 +2140,26 @@ export class WorkspaceModule implements Module {
1752
2140
  * path for resolution-only purposes (write targets, nonexistent paths,
1753
2141
  * display); for reading file content, use {@link readFileFromDisk}, which
1754
2142
  * enforces the mount's `followSymlinks` policy and canonical containment.
2143
+ * It judges other mounts' directories by where the last start placed them,
2144
+ * so for a direct mount whose root has moved since, it can differ from
2145
+ * `readFileFromDisk`, which takes the nesting where the root lies now
2146
+ * (movedNesting), about which paths lie in another mount's directory.
1755
2147
  *
1756
2148
  * @deprecated for direct filesystem reads — call `readFileFromDisk()`.
1757
2149
  */
1758
2150
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
1759
2151
  try {
1760
- // Synchronous, so the path as given: it reaches the same file, and
1761
- // the nested-mount check matches it as configured or as disk spells
1762
- // the nested mount's directory (readNesting).
1763
- const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
2152
+ // Synchronous, so the path as given. The nested-mount check takes it
2153
+ // in each spelling readNesting knows for the nested mount's directory,
2154
+ // or in any spelling a disk that folds names may fold to one of them,
2155
+ // erring toward that mount (holderOf's asGiven, beginsWithDir). The
2156
+ // include judges it as given, so on a disk that
2157
+ // folds names a covered name whose file disk spells otherwise returns
2158
+ // that file's path, which the tools refuse, and a covered file named in
2159
+ // another case is null, which the tools read. A path the mount doesn't
2160
+ // hold, outside its include or in another mount's directory nested in
2161
+ // it, is null, as for a path that leaves the mount's directory.
2162
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
1764
2163
  return resolve(mount.config.path, relativePath);
1765
2164
  } catch {
1766
2165
  return null;
@@ -1783,10 +2182,13 @@ export class WorkspaceModule implements Module {
1783
2182
  * (agent-framework #277).
1784
2183
  *
1785
2184
  * A write whose tree entry is set but that then fails returns success
1786
- * false with `data: { inChronicle: true }`: its copy on disk was refused, or
1787
- * something after the entry (recording its intent, the event, the push)
1788
- * threw. The workspace's tools read the file from the tree, so it is there
1789
- * for them, on the branch that was current when the entry was set.
2185
+ * false with `data: { inChronicle: true }`: its copy on disk was refused,
2186
+ * the store failed after recording the entry, which skips the push where
2187
+ * the mount pushes, or something after the entry (recording its intent,
2188
+ * the event, the push) threw. The workspace's tools read the file from the
2189
+ * tree, so it is there for them, on the branch that was current when the
2190
+ * entry was set. A set that threw and couldn't be read back throws, saying
2191
+ * that whether the entry is set isn't known.
1790
2192
  */
1791
2193
  async writeBinary(
1792
2194
  mountPrefixedPath: string,
@@ -1844,7 +2246,12 @@ export class WorkspaceModule implements Module {
1844
2246
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
1845
2247
  } catch (error) {
1846
2248
  if (!committed) throw error;
1847
- failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2249
+ // As write words it: a store that failed after recording the file
2250
+ // skipped its push, so on a mount that pushes, disk lacks it.
2251
+ failure = error instanceof StoredThenFailed
2252
+ ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
2253
+ + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2254
+ : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
1848
2255
  }
1849
2256
  if (failure !== null) {
1850
2257
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -1861,8 +2268,10 @@ export class WorkspaceModule implements Module {
1861
2268
 
1862
2269
  /**
1863
2270
  * Read a file's raw bytes from a mount (through the Chronicle tree, synced
1864
- * first like the `read` tool). Public API for the framework's synthesized
1865
- * `read_image` tool and other peer callers that need binary content.
2271
+ * first like the `read` tool; on a direct mount, from disk as
2272
+ * readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
2273
+ * API for the framework's synthesized `read_image` tool and other peer
2274
+ * callers that need binary content.
1866
2275
  */
1867
2276
  async readBinary(
1868
2277
  mountPrefixedPath: string,
@@ -1881,11 +2290,13 @@ export class WorkspaceModule implements Module {
1881
2290
  } catch (error) {
1882
2291
  return { error: error instanceof Error ? error.message : String(error) };
1883
2292
  }
2293
+ // A direct mount's bytes are disk's, as read takes them, at most its size limit.
2294
+ if (mount.direct) return this.bytesFromDisk(mount, relativePath, mountPrefixedPath, mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
1884
2295
  const store = this.getStore();
1885
2296
  return this.withMount(mount, async () => {
1886
- await this.ensureSynced(mount, relativePath);
2297
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
1887
2298
  const entry = store.treeGet(mount.treeStateId, relativePath);
1888
- if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
2299
+ if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
1889
2300
  const blob = store.getBlob(entry.blobHash);
1890
2301
  if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
1891
2302
  return { data: blob };
@@ -1926,8 +2337,17 @@ export class WorkspaceModule implements Module {
1926
2337
  } catch (error) {
1927
2338
  return { error: error instanceof Error ? error.message : String(error) };
1928
2339
  }
1929
- const absolutePath = resolve(mount.config.path, relativePath);
1930
- const maxBytes = opts.maxBytes;
2340
+ const read = await this.bytesFromDisk(mount, relativePath, mountPrefixedPath, opts.maxBytes);
2341
+ return 'error' in read ? read : { data: read.data, absolutePath: resolve(mount.config.path, relativePath) };
2342
+ }
2343
+
2344
+ /** A file's bytes as readBinaryFromDisk reads them, once its path is parsed. */
2345
+ private async bytesFromDisk(
2346
+ mount: MountState,
2347
+ relativePath: string,
2348
+ mountPrefixedPath: string,
2349
+ maxBytes: number | undefined,
2350
+ ): Promise<{ data: Buffer } | { error: string }> {
1931
2351
  let tooLarge: string | undefined;
1932
2352
  try {
1933
2353
  const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
@@ -1937,14 +2357,14 @@ export class WorkspaceModule implements Module {
1937
2357
  }
1938
2358
  });
1939
2359
  try {
1940
- if (maxBytes === undefined) return { data: await handle.readFile(), absolutePath };
2360
+ if (maxBytes === undefined) return { data: await handle.readFile() };
1941
2361
  // No more than maxBytes is loaded: a file grown past it since the
1942
2362
  // fstat above is refused here, as one already over it was there. Its
1943
2363
  // size is an fstat's after the read, and at least one more than the
1944
2364
  // bytes kept, so the refusal's size is always past maxBytes.
1945
2365
  const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
1946
2366
  if (more) return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
1947
- return { data: bytes, absolutePath };
2367
+ return { data: bytes };
1948
2368
  } finally {
1949
2369
  await handle.close();
1950
2370
  }
@@ -1987,9 +2407,11 @@ export class WorkspaceModule implements Module {
1987
2407
  * as configured still names it as configured (resolveAbsolutePath). A
1988
2408
  * directory not on disk keeps the spellings the roots give alone. Each
1989
2409
  * mount's real root comes back beside the nesting (`reals`), for what
1990
- * start reads there.
2410
+ * start reads there, and each directory above each configured path with
2411
+ * where it lies on disk (`above`), for a direct mount whose root moves
2412
+ * (movedNesting).
1991
2413
  */
1992
- private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string> }> {
2414
+ private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string>; above: Map<string, Array<{ dir: string; real: string }>> }> {
1993
2415
  const reals = new Map<string, string>();
1994
2416
  // Each directory above a mount's configured path, with where it lies on
1995
2417
  // disk, so a path that reaches another mount's root through a link, as
@@ -1999,10 +2421,7 @@ export class WorkspaceModule implements Module {
1999
2421
  const given = resolve(mount.path);
2000
2422
  reals.set(mount.name, await realBeneath(given));
2001
2423
  const dirs: Array<{ dir: string; real: string }> = [];
2002
- for (let dir = dirname(given); ; dir = dirname(dir)) {
2003
- dirs.push({ dir, real: await realBeneath(dir) });
2004
- if (dirname(dir) === dir) break;
2005
- }
2424
+ for (const dir of ancestorsOf(given)) dirs.push({ dir, real: await realBeneath(dir) });
2006
2425
  above.set(mount.name, dirs);
2007
2426
  }
2008
2427
  const nesting = new Map<string, NestedMountEntry[]>();
@@ -2012,23 +2431,9 @@ export class WorkspaceModule implements Module {
2012
2431
  for (const inner of this.config.mounts) {
2013
2432
  if (outer === inner) continue;
2014
2433
  const real = reals.get(inner.name)!;
2015
- const innerPath = resolve(inner.path);
2016
- const configured = nestedDir(outerPath, innerPath);
2017
- const fromRoots: Array<{ dir: string; root: string }> = [];
2018
- // Its directory as configured, read against this mount's root as
2019
- // configured and from each directory above it that is this mount's
2020
- // root on disk, and its real root against this mount's.
2021
- const throughRoot = above.get(inner.name)!.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
2022
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
2023
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
2024
- }
2025
- if (fromRoots.length === 0) continue;
2026
- const spellings = [...fromRoots];
2027
- for (const found of fromRoots) {
2028
- const dir = await diskSpelling(outerPath, found.dir);
2029
- if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
2030
- }
2031
- const claims = !isContainedPath(real, outerReal);
2434
+ const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!, outerPath);
2435
+ if (at === undefined) continue;
2436
+ const { configured, fromRoots, spellings, claims } = at;
2032
2437
  nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2033
2438
  const placed = fromRoots.filter((s) => s.dir !== configured);
2034
2439
  if (placed.length > 0) {
@@ -2039,12 +2444,118 @@ export class WorkspaceModule implements Module {
2039
2444
  }
2040
2445
  }
2041
2446
  }
2042
- return { nesting, reals };
2447
+ return { nesting, reals, above };
2043
2448
  }
2044
2449
 
2045
- /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
2450
+ /**
2451
+ * The mounts nested in `mountName`, by directory, name, real root where it
2452
+ * has one, and include (MountView's `nested`), in each spelling known.
2453
+ */
2046
2454
  private nestedOf(mountName: string): NestedMount[] {
2047
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
2455
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
2456
+ o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2457
+ }
2458
+
2459
+ /**
2460
+ * The mounts nested in `mount` as a call of it that resolved its root to
2461
+ * `rootReal` sees them: movedNesting where that differs from where the
2462
+ * last start placed it, the start's placement (nestedOf) otherwise.
2463
+ */
2464
+ private async nestedFor(mount: MountState, rootReal: string): Promise<NestedMount[]> {
2465
+ return (await this.movedNesting(mount, rootReal)) ?? this.nestedOf(mount.config.name);
2466
+ }
2467
+
2468
+ /**
2469
+ * The mounts nested in a direct mount whose root a check resolved to
2470
+ * `rootReal`, where that's elsewhere than the last start placed it, as when
2471
+ * a link it's configured through is repointed; null where the start's
2472
+ * placement holds, or where nothing is at `rootReal`: there the parse's
2473
+ * checks keep the start's placement, and a read says the root is gone. A
2474
+ * direct mount reads beneath whatever directory is at its root, so a check
2475
+ * judges which mount a path is by the root it resolved. A tool's parse
2476
+ * resolves it once, and its respelling and its checks of the path it's
2477
+ * given (parseFilePath's, refusesAsOutside, grep's) read disk beneath that
2478
+ * root and judge by the nesting there. A read and a walk each resolve it
2479
+ * for themselves, and judge the include and the nesting again beneath it.
2480
+ * So a root repointed during a call can change which answer or refusal a
2481
+ * path gets, but nothing a read or a walk serves is a file the include
2482
+ * leaves out, nothing a walk serves lies in another mount's directory as
2483
+ * its own nesting places it, and nothing a read serves lies in the
2484
+ * directory of a mount its own nesting claims by real root. Whoever writes
2485
+ * the disk can race a read in two ways, as a rename during a call can on
2486
+ * any mount. On a disk that folds names, a read judges the include by the
2487
+ * path as its parse spelled it, so a root repointed between the two can
2488
+ * have it serve a file the new root spells otherwise (sameLevels). And a
2489
+ * mount nested by its directories alone, since its real root holds this
2490
+ * one, is judged by the parse's nesting, so a root repointed between a
2491
+ * read's parse and its open can have it serve a path in that mount's
2492
+ * directory. Each mount is placed as a start places it (placementOf), from
2493
+ * where its real root and the directories above its configured path lie on
2494
+ * disk. For a mount configured at this mount's own path or beneath it,
2495
+ * that's where they lie now: beneath `rootReal` where they lie beneath this
2496
+ * mount's path, and the directories above this mount's path as resolving
2497
+ * that path finds them. For any other mount, it's where the last start
2498
+ * found them. Each carries its own include, as nestedOf gives it. It
2499
+ * resolves nothing but the directories on this mount's own configured path
2500
+ * and those beneath its root, so a share elsewhere that doesn't answer
2501
+ * holds only its own calls, unless such a directory is a link into it,
2502
+ * where that mount's files lie. What it leaves to the next start: a mount
2503
+ * configured elsewhere whose own root has moved since, as when a link on
2504
+ * its configured path is repointed, this mount's own reached through
2505
+ * another link among them, that mount's spellings through other links, and
2506
+ * the other mounts' own placements, so a direct root moved into another
2507
+ * mount's directory is that mount's too, as a nested mount's root moved
2508
+ * while running stays placed.
2509
+ */
2510
+ private async movedNesting(mount: MountState, rootReal: string): Promise<NestedMount[] | null> {
2511
+ // One start's placements, whatever a start in the meantime sets.
2512
+ const { placedRoots, placedAbove } = this;
2513
+ const placed = placedRoots.get(mount.config.name);
2514
+ if (!mount.direct || placed === undefined || placed === rootReal) return null;
2515
+ const there = await stat(rootReal).then(() => true, (err: unknown) => {
2516
+ const code = errnoOf(err);
2517
+ return code !== 'ENOENT' && code !== 'ENOTDIR';
2518
+ });
2519
+ if (!there) return null;
2520
+ const outerPath = resolve(mount.config.path);
2521
+ const nested: NestedMount[] = [];
2522
+ // The directories above this mount's own path, as they lie on disk now,
2523
+ // resolved as resolving the path itself does.
2524
+ let ownAbove: Array<{ dir: string; real: string }> | undefined;
2525
+ const aboveOwn = async () => ownAbove ??= await Promise.all(ancestorsOf(outerPath).map(async (dir) => ({ dir, real: await realBeneath(dir) })));
2526
+ for (const inner of this.config.mounts) {
2527
+ if (inner.name === mount.config.name) continue;
2528
+ const innerPath = resolve(inner.path);
2529
+ const configured = nestedDir(outerPath, innerPath);
2530
+ let real: string | undefined;
2531
+ let above: ReadonlyArray<{ dir: string; real: string }>;
2532
+ if (configured !== undefined || innerPath === outerPath) {
2533
+ // A mount configured on this mount's own path, or beneath it, lies
2534
+ // where that path now leads: its real root, and each directory above
2535
+ // it, resolved now, beneath this root where they lie beneath it.
2536
+ const segments = configured?.split('/') ?? [];
2537
+ real = await realBeneath(join(rootReal, ...segments));
2538
+ const beneath: Array<{ dir: string; real: string }> = [];
2539
+ for (let i = segments.length - 1; i >= 1; i--) {
2540
+ beneath.push({ dir: join(outerPath, ...segments.slice(0, i)), real: await realBeneath(join(rootReal, ...segments.slice(0, i))) });
2541
+ }
2542
+ // This mount's own path, which is the root, would add only the
2543
+ // configured directory, which placementOf adds anyway.
2544
+ above = [...beneath, ...await aboveOwn()];
2545
+ } else {
2546
+ // Any other mount lies where the last start found it and the
2547
+ // directories above its path. One leading here only since, as
2548
+ // through this mount's own link, waits for the next start.
2549
+ real = placedRoots.get(inner.name);
2550
+ if (real === undefined) continue;
2551
+ above = placedAbove.get(inner.name) ?? [];
2552
+ }
2553
+ const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2554
+ if (at === undefined) continue;
2555
+ const include = this.mounts.get(inner.name)?.config.include;
2556
+ for (const s of at.spellings) nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2557
+ }
2558
+ return nested;
2048
2559
  }
2049
2560
 
2050
2561
  /**
@@ -2052,17 +2563,32 @@ export class WorkspaceModule implements Module {
2052
2563
  * of that mount, judged lexically where the path lands (`.`, `..` and
2053
2564
  * repeated separators resolved, as for the traversal guard; a link of the
2054
2565
  * outer mount isn't followed), with the path to use there; undefined when
2055
- * no nested mount's directory holds it.
2566
+ * no nested mount's directory holds it. A path parsePath has respelled is
2567
+ * compared with the directory's spellings exactly. One judged as given
2568
+ * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2569
+ * folds names may compare it (beginsWithDir), so it can't reach that
2570
+ * mount's directory by spelling a segment otherwise. On a disk that tells
2571
+ * names apart, that takes a directory of this mount's own spelled like the
2572
+ * nested one in another way as that mount's too.
2056
2573
  */
2057
- private holderOf(mountName: string, relativePath: string): { name: string; use: string } | undefined {
2574
+ private holderOf(mountName: string, relativePath: string, asGiven = false, moved: NestedMount[] | null = null): { name: string; use: string } | undefined {
2058
2575
  const mount = this.mounts.get(mountName);
2059
2576
  if (!mount) return undefined;
2060
- const resolved = resolve(mount.config.path, relativePath);
2061
- const holder = (this.otherMounts.get(mountName) ?? [])
2062
- .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
2063
- .sort((a, b) => b.root.length - a.root.length)[0];
2577
+ const root = resolve(mount.config.path);
2578
+ const resolved = resolve(root, relativePath);
2579
+ if (!isContainedPath(root, resolved)) return undefined;
2580
+ const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2581
+ // A direct mount whose root a call found moved is judged by that call's
2582
+ // nesting (movedNesting), each directory beneath its configured path.
2583
+ const dirs = moved !== null
2584
+ ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2585
+ : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2586
+ const holder = dirs
2587
+ .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2588
+ .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2589
+ .sort((a, b) => b.depth - a.depth)[0];
2064
2590
  if (!holder) return undefined;
2065
- const rest = relative(holder.root, resolved).split(sep).join('/');
2591
+ const rest = segments.slice(holder.depth).join('/');
2066
2592
  return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2067
2593
  }
2068
2594
 
@@ -2071,8 +2597,8 @@ export class WorkspaceModule implements Module {
2071
2597
  * mount and the path to use: for a sync or a push, which of them leaves
2072
2598
  * it; for a listing, whose the path is.
2073
2599
  */
2074
- private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list'): string {
2075
- const holder = this.holderOf(mountName, relativePath);
2600
+ private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
2601
+ const holder = this.holderOf(mountName, relativePath, false, moved);
2076
2602
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2077
2603
  return holder
2078
2604
  ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
@@ -2081,76 +2607,231 @@ export class WorkspaceModule implements Module {
2081
2607
 
2082
2608
  /**
2083
2609
  * parsePath for the file operations (read, write, edit, delete, and the
2084
- * module's binary and disk reads and writes). A path in another mount's
2085
- * directory nested in this one is that mount's: this mount's passes don't
2086
- * observe it and its pushes don't write it, so its file operations refuse
2087
- * it, naming the innermost mount that holds it and the path to use there.
2610
+ * module's binary and disk reads and writes), refused where the mount
2611
+ * doesn't hold the path. A path in another mount's directory nested in
2612
+ * this one is that mount's: this mount's passes don't observe it and its
2613
+ * pushes don't write it, so the refusal names the innermost mount that
2614
+ * holds it and the path to use there. A path the mount's include doesn't
2615
+ * cover is outside the mount, whatever disk or the workspace's history has
2616
+ * there. A directory is checked the same way, since a path can be a
2617
+ * directory on one side and a file on the other.
2088
2618
  */
2089
2619
  private async parseFilePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
2090
- const parsed = this.refuseNested(await this.parsePath(path));
2091
- // A path can also reach a nested mount's directory through a symlink on
2092
- // its way. No push of this mount writes it there and no read reads it,
2093
- // so it's refused here, before anything is stored, and the checks where
2094
- // disk is acted on refuse it again.
2095
- const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
2620
+ const spelled = await this.parsePath(path);
2621
+ // Both checks judge by the nesting the parse found, and the second reads
2622
+ // disk beneath the root the parse resolved.
2623
+ const { mount, root, moved } = spelled;
2624
+ const parsed = this.refuseNotHeld(spelled, path, false, moved);
2625
+ // A path can also reach a nested mount's directory, or a file this
2626
+ // mount's own include leaves out, through a symlink on its way. No push
2627
+ // of this mount writes it there and no read reads it, so it's refused
2628
+ // here, before anything is stored, and the checks where disk is acted on
2629
+ // refuse it again.
2630
+ const refusal = await this.landingRefusal(mount, parsed.relativePath, parsed.path, moved ?? this.nestedOf(mount.config.name), root);
2096
2631
  if (refusal) throw refusal;
2097
2632
  return parsed;
2098
2633
  }
2099
2634
 
2100
2635
  /**
2101
2636
  * The refusal for a path of `mount` that reaches a nested mount's directory
2102
- * through a symlink on its way (nestedAtReal), or null where it doesn't. On
2103
- * a mount that doesn't follow the link, the refusal is the link's own
2104
- * (linkWords), as the readers and pushes give it. A mount with nothing
2105
- * nested has nothing to find, so it pays nothing.
2637
+ * through a symlink on its way (nestedAtReal), or a file this mount's own
2638
+ * include leaves out (coversLanding), or, on a mount that follows links
2639
+ * with an include, one realpath can't resolve, or null where it doesn't.
2640
+ * On a mount that doesn't follow the link, the refusal is the link's own
2641
+ * (linkWords), as the readers and pushes give it. On one that does, it
2642
+ * names the nested mount only where the place is that mount's
2643
+ * (namesNestedPlace); anywhere else the place is no mount's, so the path
2644
+ * leaves this one, in the disk readers' words, as it does where it lands
2645
+ * on a file this mount's include leaves out. A mount with nothing nested,
2646
+ * and without both an include and links followed, has nothing to find, so
2647
+ * it pays nothing. The caller gives the nesting and the root to judge by
2648
+ * (`nested`, `root`). A direct mount's file operation gives those its
2649
+ * parse found, so where the path lands, and any link on its way, is judged
2650
+ * beneath the root that parse resolved; any other caller gives the start's
2651
+ * nesting and the configured path.
2106
2652
  */
2107
- private async leadsIntoNested(mount: MountState, relativePath: string, path: string): Promise<Error | null> {
2108
- const nested = this.nestedOf(mount.config.name);
2109
- if (nested.length === 0) return null;
2110
- const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
2111
- if (!holder) return null;
2653
+ private async landingRefusal(mount: MountState, relativePath: string, path: string, nested: readonly NestedMount[], root: string): Promise<Error | null> {
2654
+ const judged = mount.config.followSymlinks === true && mount.config.include !== undefined;
2655
+ if (nested.length === 0 && !judged) return null;
2656
+ const unresolved: { code?: string } = {};
2657
+ const real = await realBeneath(resolve(root, relativePath), 0, unresolved);
2658
+ // Where realpath can't resolve the path, as for a loop or a directory
2659
+ // this process can't search, where it lands can't be judged, and a
2660
+ // promise about content fails closed: the path is refused, by every file
2661
+ // operation, in the words a disk read gives a path it can't resolve,
2662
+ // whatever the place realpath stopped short of would say.
2663
+ if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2664
+ const holder = nestedAtReal(real, nested);
2665
+ if (!holder) {
2666
+ if (!judged) return null;
2667
+ const rootReal = await realBeneath(root);
2668
+ if (!isContainedPath(rootReal, real) || await stat(real).then((info) => info.isDirectory(), () => false)) return null;
2669
+ return await coversLanding({ include: mount.config.include }, rootReal, real, relativePath) ? null : new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2670
+ }
2112
2671
  if (mount.config.followSymlinks !== true) {
2113
- const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
2114
- if (found !== null) return await linkReadError(mount, found, path, nested);
2672
+ const rootReal = await realBeneath(root);
2673
+ const found = await linkOnTheWay({ root: rootReal, followSymlinks: false }, relativePath);
2674
+ if (found !== null) return await linkReadError(mount, found, path, relativePath, nested, rootReal);
2115
2675
  }
2676
+ // On a mount with an include, the path to use there names each directory
2677
+ // on the place's way as that mount's, so a place beneath anything else
2678
+ // there, such as a file the include leaves out, isn't named; one beneath
2679
+ // a directory not there yet is.
2680
+ const onItsWay = holder.include === undefined
2681
+ || await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
2682
+ const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2683
+ if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2116
2684
  return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2117
2685
  }
2118
2686
 
2119
- /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
2120
- private refuseNested<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P): P {
2121
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
2687
+ /**
2688
+ * A parsed path, refused where the mount doesn't hold it: in a nested
2689
+ * mount's directory first, naming the mount that holds it, then outside
2690
+ * the mount's include. Each is judged on the path as the caller parsed it:
2691
+ * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2692
+ * which is synchronous and says so (`asGiven`, so the nested-mount check
2693
+ * folds where disk does). The include's refusal shows the path as given
2694
+ * (`given`), since it answers the path the caller asked about.
2695
+ */
2696
+ private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, asGiven = false, moved: NestedMount[] | null = null): P {
2697
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2122
2698
  if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2699
+ if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2700
+ throw new OutsideIncludeError(given, parsed.mount.config);
2701
+ }
2123
2702
  return parsed;
2124
2703
  }
2125
2704
 
2705
+ /**
2706
+ * Whether a mount-relative path names a file, as grep decides what a path
2707
+ * it's given names: for a path the include covers, a file in the
2708
+ * workspace's history, or anything but a directory on disk. Grep takes it
2709
+ * as a directory only when neither side has a file there, and filters what
2710
+ * it finds beneath it by the mount's include. A path the include doesn't
2711
+ * cover names no file of the mount's, whatever disk or history holds
2712
+ * there, so one refusesAsOutside lets through is a directory. A direct
2713
+ * mount has no history: what a run of it as 'chronicle' left in the store
2714
+ * isn't its own, so disk alone decides. Disk is read beneath `root`: a
2715
+ * direct mount's is the root its parse resolved, a store-backed mount's
2716
+ * its configured path.
2717
+ */
2718
+ private async namesFile(mount: MountState, relativePath: string, root: string): Promise<boolean> {
2719
+ return relativePath !== '' && insideMount(relativePath, mount.config.include) && (
2720
+ (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) ||
2721
+ await lstat(join(root, relativePath)).then((s) => !s.isDirectory(), () => false)
2722
+ );
2723
+ }
2724
+
2725
+ /**
2726
+ * Whether sync, materialize, grep, glob or ls refuses a path it's given as
2727
+ * outside the mount's include. A path the include doesn't cover is a scope
2728
+ * exactly where a listing names a directory: where disk has a directory
2729
+ * reached from the mount's root through directories, with no link among
2730
+ * them, or the workspace's history holds an entry beneath it that's the
2731
+ * mount's own to list (withinIncludes). The one exception is a directory
2732
+ * the ignore list covers, which a listing passes over and which is still a
2733
+ * scope when it's named, since a sync that names it takes in what's there
2734
+ * (#276). Anything else is refused, a file at the path included, which no
2735
+ * scope tool takes up. The walk descends no link it meets. Where covered
2736
+ * history makes a name a scope and disk has since put a link there, a
2737
+ * mount that doesn't follow symlinks refuses the link, as it refuses any
2738
+ * link on a path's way, and on a mount that does, a pass of the scope
2739
+ * follows the link, and its answer shows whether the link leads out of the
2740
+ * mount. A
2741
+ * path in another mount's directory nested in this one is that mount's,
2742
+ * whatever this mount's include says, so it isn't refused here: each tool
2743
+ * names the mount that holds it, as the file operations do. A direct mount
2744
+ * has no history, as namesFile says, and is judged beneath the root its
2745
+ * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2746
+ */
2747
+ private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
2748
+ if (this.holderOf(mount.config.name, relativePath, false, moved)) return false;
2749
+ if (insideMount(relativePath, mount.config.include)) return false;
2750
+ const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
2751
+ if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
2752
+ .some((entry) => withinIncludes(own, entry.path))) return false;
2753
+ // A directory, and each directory above it to the mount's root, as the
2754
+ // walk reaches it: through no link, since the walk descends none.
2755
+ const segments = relativePath.split('/');
2756
+ for (let i = 1; i <= segments.length; i++) {
2757
+ const entry = await lstat(join(root, ...segments.slice(0, i))).catch(() => null);
2758
+ if (!entry?.isDirectory()) return true;
2759
+ }
2760
+ return false;
2761
+ }
2762
+
2126
2763
  /**
2127
2764
  * Parse a mount-prefixed path into its mount and mount-relative path, as
2128
- * disk spells it (diskSpelling), with `path` the mount-prefixed path so
2129
- * spelled. On a disk that folds names, a path spelled otherwise reaches
2130
- * disk's file, so every tool takes it by disk's name: one name per file,
2131
- * and the names its answers give are disk's, except that a path that names
2132
- * a workspace entry exactly, or does once its directories take disk's
2133
- * spelling, keeps the entry's spelling. A path the workspace records an
2134
- * intent at, such as a deletion disk hasn't been given yet, counts as an
2135
- * entry here, so the calls that settle it reach it. The workspace's
2136
- * entries (entriesOf) stand in for disk where it has nothing yet, so a
2137
- * draft has one name too. Neither applies to a path that disk's spelling
2138
- * puts in a nested mount's directory.
2765
+ * disk spells it (diskSpelling). `path` is the mount-prefixed path in that
2766
+ * spelling where it differs from the one spelling, and otherwise the path
2767
+ * as the caller gave it. The path is read in its one spelling first
2768
+ * (parsePathAsGiven), so the respelling only ever sees a path with no
2769
+ * empty, `.` or `..` segment and no separator before its first. On a disk
2770
+ * that folds names, a path spelled otherwise reaches disk's file, so every
2771
+ * tool takes it by disk's name: one name per file, and the names its
2772
+ * answers give are disk's, except that a path that names a workspace entry
2773
+ * exactly, or does once its directories take disk's spelling, keeps the
2774
+ * entry's spelling, and what lies past a link on the way keeps the
2775
+ * caller's, since the step reads nothing past a link. A path the workspace
2776
+ * records an intent at, such as a deletion disk hasn't been given yet,
2777
+ * counts as an entry here, so the calls that settle it reach it. The
2778
+ * workspace's entries (entriesOf) stand in for disk where it has nothing
2779
+ * yet, so a draft has one name too. Neither applies to a path that disk's
2780
+ * spelling puts in a nested mount's directory. A direct mount holds no
2781
+ * entries, so its paths take disk's spelling alone. Past a nested mount's
2782
+ * directory the path stays as given, since what lies there is that mount's
2783
+ * to name. Every answer shows `path`, a name the include leaves out
2784
+ * included, since how disk spells a name the caller gave is no content of
2785
+ * that file, except the include's own refusal, which shows the path
2786
+ * exactly as given (refuseNotHeld), since it answers the path asked about.
2139
2787
  * A handler that must read something at the moment it's asked, as
2140
2788
  * handleSync reads the branch a path sync adopts on, reads it before
2141
- * calling this, since this awaits disk. The entries are read when the
2142
- * step needs them, some after those awaits, from the branch current then.
2789
+ * calling this, since this awaits disk. The entries are read when the step
2790
+ * needs them, some after those awaits, from the branch current then.
2143
2791
  */
2144
- private async parsePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
2792
+ private async parsePath(path: string): Promise<ParsedPath> {
2145
2793
  const given = this.parsePathAsGiven(path);
2146
- const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
2794
+ // A direct mount's root is resolved once here, so the respelling and the
2795
+ // checks of the path that follow read disk beneath one root and judge by
2796
+ // the nesting there (movedNesting).
2797
+ const configured = resolve(given.mount.config.path);
2798
+ const root = given.mount.direct ? await realpath(configured).catch(() => configured) : configured;
2799
+ const moved = given.mount.direct ? await this.movedNesting(given.mount, root) : null;
2800
+ const spelled = await diskSpelling(root, given.relativePath, given.mount.direct ? undefined : this.entriesOf(given.mount));
2801
+ // Past a nested mount's directory the path stays as given: what lies
2802
+ // there is that mount's, whose own include decides what a tool may name,
2803
+ // so this mount doesn't respell it.
2804
+ // The directory itself takes disk's spelling, as a listing names it.
2805
+ // The respelling keeps each segment's place, so the given path's rest
2806
+ // follows the directory's segments.
2807
+ const depth = this.nestedDepth(given.mount.config.name, spelled, moved);
2808
+ const relativePath = depth === undefined ? spelled
2809
+ : [...spelled.split('/').slice(0, depth), ...given.relativePath.split('/').slice(depth)].join('/');
2147
2810
  return {
2148
2811
  mount: given.mount,
2149
2812
  relativePath,
2150
2813
  path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
2814
+ root,
2815
+ moved,
2151
2816
  };
2152
2817
  }
2153
2818
 
2819
+ /**
2820
+ * How many segments the innermost nested mount's directory that holds
2821
+ * `relativePath` has, in a spelling it's known by (othersOf), or, for a
2822
+ * direct mount whose root has moved, in the nesting there (`moved`);
2823
+ * undefined when no nested mount's directory holds it.
2824
+ */
2825
+ private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
2826
+ let depth: number | undefined;
2827
+ for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
2828
+ if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
2829
+ const segments = dir.split('/').length;
2830
+ if (depth === undefined || segments > depth) depth = segments;
2831
+ }
2832
+ return depth;
2833
+ }
2834
+
2154
2835
  /**
2155
2836
  * A mount's entries on the current branch, and the paths it records an
2156
2837
  * intent at there, as diskSpelling reads them when it does, whether its
@@ -2168,14 +2849,34 @@ export class WorkspaceModule implements Module {
2168
2849
  // given yet or a conflict, is the workspace's own name for the file
2169
2850
  // there, as an entry is, so the calls that settle it reach it.
2170
2851
  has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
2171
- under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
2852
+ // Only an entry this mount's include covers spells a draft's path
2853
+ // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
2854
+ // from an earlier version, nor one the include leaves out, which would
2855
+ // respell a covered draft into a name the include leaves out. An entry
2856
+ // in a nested mount's directory that this mount's include covers
2857
+ // spells one whatever that mount's include says, so on a disk that
2858
+ // folds names, a draft naming the directory in another spelling takes
2859
+ // the entry's spelling and is judged as in it.
2860
+ under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
2172
2861
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
2173
2862
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
2174
2863
  };
2175
2864
  }
2176
2865
 
2177
2866
  /**
2178
- * Parse a mount-prefixed path into (mountName, relativePath), as spelled.
2867
+ * Parse a mount-prefixed path into its mount and the one mount-relative
2868
+ * path every later step reads, as given, before disk's spelling
2869
+ * (parsePath): `/` between segments (`\` read as `/` where it's the
2870
+ * platform's separator), with no empty or `.` segment and no trailing `/`,
2871
+ * and '' for the mount's root. So the traversal guard here and every later
2872
+ * step judge one spelling of the path, which parsePath then respells name
2873
+ * by name as disk lists them. A `..` segment takes back the segment before
2874
+ * it, before anything looks the path up, so a mount's include judges the
2875
+ * path every tool acts on, and one with nothing left to take back climbs
2876
+ * above the mount's root and is refused, whatever it reaches afterwards. A
2877
+ * separator straight after the mount's name would make the rest an
2878
+ * absolute path, so that is refused too, and every reader that takes a
2879
+ * path refuses it here, a disk reader included.
2179
2880
  */
2180
2881
  private parsePathAsGiven(path: string): { mount: MountState; relativePath: string } {
2181
2882
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
@@ -2185,25 +2886,37 @@ export class WorkspaceModule implements Module {
2185
2886
  }
2186
2887
  const slashIdx = path.indexOf('/');
2187
2888
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
2188
- const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2889
+ const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2189
2890
 
2190
2891
  const mount = this.mounts.get(mountName);
2191
2892
  if (!mount) {
2192
2893
  throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
2193
2894
  }
2194
-
2195
- // Path traversal guard (CWE-22): ensure resolved path stays within mount.
2196
- // Containment must compare with the PLATFORM separator (isContainedPath):
2197
- // resolve() emits backslashes on Windows, so a '/'-suffixed root never
2198
- // prefix-matches there and every in-mount path was rejected as traversal.
2199
- // Against the resolved root, so a root configured with a trailing
2200
- // separator, or relative, contains its own paths, the root included.
2895
+ const segments = pathSegments(given);
2896
+ if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
2897
+ throw new Error(`Path traversal detected: "${path}" has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root.`);
2898
+ }
2899
+ const kept: string[] = [];
2900
+ for (const segment of segments) {
2901
+ if (segment !== '..') kept.push(segment);
2902
+ else if (kept.pop() === undefined) {
2903
+ throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
2904
+ }
2905
+ }
2906
+ const relativePath = kept.join('/');
2907
+
2908
+ // Path traversal guard (CWE-22), on the path every later step reads: on
2909
+ // Windows a first segment such as `C:` names another drive. Containment
2910
+ // must compare with the PLATFORM separator (isContainedPath): resolve()
2911
+ // emits backslashes on Windows, so a '/'-suffixed root never prefix-matches
2912
+ // there and every in-mount path was rejected as traversal. Against the
2913
+ // resolved root, so a root configured with a trailing separator, or
2914
+ // relative, contains its own paths, the root included.
2201
2915
  const root = resolve(mount.config.path);
2202
2916
  const resolved = resolve(root, relativePath);
2203
2917
  if (!isContainedPath(root, resolved)) {
2204
2918
  throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
2205
2919
  }
2206
-
2207
2920
  return { mount, relativePath };
2208
2921
  }
2209
2922
 
@@ -2272,6 +2985,8 @@ export class WorkspaceModule implements Module {
2272
2985
  * which answers while the evidence awaits reopening; where even it can't
2273
2986
  * be read, as when the evidence was never read back whole, the read is
2274
2987
  * refused too, with an errno only where no directory is at the root's path.
2988
+ * A direct mount's root is checked against no identity, and its read reads
2989
+ * no evidence: it promises what's at its root now (diskView).
2275
2990
  */
2276
2991
  private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
2277
2992
  const name = mount.config.name;
@@ -2283,13 +2998,15 @@ export class WorkspaceModule implements Module {
2283
2998
  throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
2284
2999
  }
2285
3000
  let rootIdentity: MountView['rootIdentity'];
2286
- try {
2287
- rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2288
- } catch (err) {
2289
- // The evidence can't be read back, so neither can the check be made.
2290
- // Where no directory is at the root's path, its errno still says so.
2291
- throw new WorkspaceReadError('mount_unavailable',
2292
- `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
3001
+ if (!mount.direct) {
3002
+ try {
3003
+ rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
3004
+ } catch (err) {
3005
+ // The evidence can't be read back, so neither can the check be made.
3006
+ // Where no directory is at the root's path, its errno still says so.
3007
+ throw new WorkspaceReadError('mount_unavailable',
3008
+ `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
3009
+ }
2293
3010
  }
2294
3011
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2295
3012
  if ('dir' in checked) return { real, dir: checked.dir };
@@ -2305,11 +3022,13 @@ export class WorkspaceModule implements Module {
2305
3022
  * with the code and errno readRoot gives what is at its path now (its stage
2306
3023
  * and words are this check's): gone, or not a directory, with ENOENT or
2307
3024
  * ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
2308
- * another directory, with none.
3025
+ * another directory, with none. A direct mount reads whatever directory is
3026
+ * at its root (MountConfig.backend), so for it, another directory there is
3027
+ * one the next read reads.
2309
3028
  */
2310
3029
  private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
2311
3030
  const errno = await rootAbsence(resolve(mount.config.path));
2312
- const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
3031
+ const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
2313
3032
  return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2314
3033
  }
2315
3034
 
@@ -2324,7 +3043,8 @@ export class WorkspaceModule implements Module {
2324
3043
  * among the directories on its way beneath the mount's root, wherever it
2325
3044
  * leads (linkOnTheWay), before anything resolves it: as `escape` for a
2326
3045
  * link leading outside the mount, as `other_mount` for one into a nested
2327
- * mount's directory, and as `parent_symlink` for any other.
3046
+ * mount's directory where its refusal names that mount, and as
3047
+ * `parent_symlink` for any other.
2328
3048
  * 2. `lstat` the lexical path — refuse directories; refuse a final-component
2329
3049
  * symlink when the mount does not `followSymlinks`; refuse anything else
2330
3050
  * that isn't a regular file, such as a FIFO, without opening it.
@@ -2384,9 +3104,14 @@ export class WorkspaceModule implements Module {
2384
3104
  // mount that doesn't follow symlinks goes through none among the
2385
3105
  // directories on the way, wherever it leads. Asked again of the path's
2386
3106
  // resolution after the open, below.
2387
- const linkView = { root: mount.config.path, followSymlinks: follow };
3107
+ // The checks below judge by the root step 0 checked, and read the links
3108
+ // on the path's way beneath it. The lstat and the open go through the
3109
+ // configured path, and step 5 refuses a read whose root is no longer the
3110
+ // directory step 0 checked, so what's served is judged beneath one root.
3111
+ const linkView = { root: realMountRoot, followSymlinks: follow };
3112
+ const nested = await this.nestedFor(mount, realMountRoot);
2388
3113
  const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
2389
- if (onTheWay !== null) throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
3114
+ if (onTheWay !== null) throw await linkReadError(mount, onTheWay, mountPrefixedPath, relativePath, nested, realMountRoot);
2390
3115
 
2391
3116
  let fileInfo: Awaited<ReturnType<typeof lstat>>;
2392
3117
  try {
@@ -2473,14 +3198,23 @@ export class WorkspaceModule implements Module {
2473
3198
  }
2474
3199
 
2475
3200
  // A directory swapped for a link since the walk above is caught here, by
2476
- // what the path resolved to after the open.
3201
+ // what the path resolved to after the open, and the check below holds
3202
+ // the opened file to that path. A link put back after this realpath,
3203
+ // leading to the opened file itself, passes both: a race this doesn't
3204
+ // close.
2477
3205
  const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
2478
- if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
2479
-
2480
- if (!isContainedPath(realMountRoot, realFilePath)) {
3206
+ if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
3207
+
3208
+ const inside = isContainedPath(realMountRoot, realFilePath);
3209
+ const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3210
+ // A place in a nested mount's directory that its include leaves out is
3211
+ // no mount's, so the path leaves this one (namesNestedPlace), and so
3212
+ // does one reaching a file this mount's own include leaves out
3213
+ // (coversLanding).
3214
+ const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
3215
+ if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
2481
3216
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
2482
3217
  }
2483
- const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
2484
3218
  if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
2485
3219
 
2486
3220
  let pathStat: Stats;
@@ -2532,7 +3266,9 @@ export class WorkspaceModule implements Module {
2532
3266
  * - Once the workspace has recorded the mount root's identity, with the
2533
3267
  * first evidence it gathers there, a root that is another directory is
2534
3268
  * an unavailable mount: a replaced directory, a link swapped in for it,
2535
- * or an unmounted drive's mountpoint. In any mount, recorded or not, a
3269
+ * or an unmounted drive's mountpoint. A direct mount's reads check no
3270
+ * recorded identity: they read beneath whatever directory is at its
3271
+ * root (MountConfig.backend). In any mount, recorded or not, a
2536
3272
  * root that stops being the directory the read checked until the file
2537
3273
  * has been opened and checked is unavailable too: with ENOENT or
2538
3274
  * ENOTDIR where no directory is there, as before the read, and with no
@@ -2540,9 +3276,10 @@ export class WorkspaceModule implements Module {
2540
3276
  * the descriptor that was checked.
2541
3277
  *
2542
3278
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
2543
- * mount, lexical traversal, a path in another mount's directory nested in
2544
- * the one named (`other_mount`), unavailable mount, missing or non-regular
2545
- * file, directory, policy-denied symlink, outside-mount target, and
3279
+ * mount, lexical traversal, a path the mount's include doesn't cover
3280
+ * (`outside_include`), a path in another mount's directory nested in the one named
3281
+ * (`other_mount`), unavailable mount, missing or non-regular file,
3282
+ * directory, policy-denied symlink, outside-mount target, and
2546
3283
  * file-changed-during-read.
2547
3284
  */
2548
3285
  async readFileFromDisk(
@@ -2560,7 +3297,8 @@ export class WorkspaceModule implements Module {
2560
3297
  // A link's refusal (parseFilePath) carries its own code.
2561
3298
  if (err instanceof WorkspaceReadError) throw err;
2562
3299
  const message = err instanceof Error ? err.message : String(err);
2563
- const code: WorkspaceReadErrorCode = err instanceof NestedMountError ? 'other_mount'
3300
+ const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
3301
+ : err instanceof NestedMountError ? 'other_mount'
2564
3302
  : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2565
3303
  throw new WorkspaceReadError(code, message, 'parse');
2566
3304
  }
@@ -2692,9 +3430,11 @@ export class WorkspaceModule implements Module {
2692
3430
  /**
2693
3431
  * Commit a tool's write or delete to the workspace tree, with the branch
2694
3432
  * intent it implies: store origin for a path disk never agreed with; for a
2695
- * deletion, a tombstone, unless the evidence holds no agreed disk copy (P
3433
+ * deletion, a tombstone, written before the entry is removed, unless the
3434
+ * agreement's evidence for the path, P, holds no agreed disk copy (P
2696
3435
  * absent, or no P and store origin), when it clears the path's intent and
2697
- * pushes nothing. It pushes the change to disk when the mount opts in via
3436
+ * pushes nothing. It pushes the change to
3437
+ * disk when the mount opts in via
2698
3438
  * `autoMaterialize` — required for cross-agent pipelines, since another
2699
3439
  * agent's watcher on the same directory only sees real filesystem events.
2700
3440
  * The push follows materialize's rule: a disk copy that changed since it
@@ -2713,85 +3453,210 @@ export class WorkspaceModule implements Module {
2713
3453
  private async commitToolChange(
2714
3454
  mount: MountState,
2715
3455
  relativePath: string,
2716
- change: { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string },
3456
+ change: ToolChange,
2717
3457
  onCommitted?: () => void,
2718
3458
  ): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
3459
+ const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3460
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3461
+ }
3462
+
3463
+ /**
3464
+ * commitToolChange for several paths of one mount at once, as edit_many
3465
+ * commits its files: every path's precondition is enforced before any entry
3466
+ * is set, then a deletion's tombstone is written, then every entry is set
3467
+ * in one record of the store, which takes all of them or none, then each
3468
+ * other path's intent, by the rule commitToolChange's doc gives, which the
3469
+ * code below applies, with no await between, and one push takes the paths
3470
+ * to disk, but for a deletion that clears its path's intent. `onCommitted`
3471
+ * is told of each path once the entries are set, also when the store throws
3472
+ * after setting them, whose error then goes on in place of the push
3473
+ * (changesLanded).
3474
+ * Answers, by path, the reason for each that reached the workspace but not
3475
+ * disk (`refused`), and, apart from it, why one whose bytes reached disk
3476
+ * left the file's mode as disk had it (`modeKept`), and that a deletion
3477
+ * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3478
+ * commitToolChange answers them for one path.
3479
+ */
3480
+ private async commitToolChanges(
3481
+ mount: MountState,
3482
+ changes: Array<{ path: string; change: ToolChange }>,
3483
+ onCommitted?: (path: string) => void,
3484
+ ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
2719
3485
  const store = this.getStore();
3486
+ const agreement = this.agreementOrThrow();
2720
3487
  const intents = this.intents.get(mount.config.name)!;
2721
3488
  // The mutation's precondition, enforced here, synchronously with the
2722
3489
  // commit: whatever settleBeforeMutation's pass could or couldn't settle.
2723
- settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
2724
- const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
2725
- const known = evidence !== undefined;
2726
- if (change.kind === 'set') {
2727
- store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
2728
- // A later intent update or disk push can throw; the tree entry is
2729
- // already set, on the branch current now. If the active branch changes
2730
- // during the push, that branch's tree has the file and the new one may
2731
- // not.
2732
- onCommitted?.();
2733
- intents.update(relativePath, (cur) => {
2734
- const next = { ...cur };
2735
- delete next.tombstone;
2736
- // Disk has never agreed with this path: a missing disk copy is a draft
2737
- // not yet materialized, not a deletion.
2738
- if (!known) next.origin = 'store';
2739
- return next;
2740
- });
2741
- } else {
2742
- store.treeRemove(mount.treeStateId, relativePath);
2743
- onCommitted?.();
2744
- // A deletion whose evidence holds no agreed disk copy (disk last agreed
2745
- // it had nothing here, or has never agreed with this entry, which the
2746
- // workspace created) puts the store back at the agreement. Nothing on
2747
- // disk is the workspace's to unlink, so it pushes nothing, and it
2748
- // clears the path's whole intent: no tombstone, so it needs no proof of
2749
- // absence, which a path disk can't show, such as one beneath a symlink
2750
- // the mount doesn't follow, would never give; and no conflict, which
2751
- // has nothing left to be about once the workspace has given up its
2752
- // side. Whatever disk holds there is a sync's to observe, a file the
2753
- // workspace never saw included. Any other deletion keeps its
2754
- // tombstone, which guards it: a copy disk agreed with, a push of ours
2755
- // that may have landed (pending or interrupted), or an entry from
2756
- // before the evidence.
2757
- let agreed = true;
2758
- let note: string | null = null;
2759
- intents.update(relativePath, (cur) => {
2760
- agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
2761
- if (agreed) {
2762
- const next = { ...cur, tombstone: { hash: change.previousHash } };
2763
- delete next.origin;
2764
- return next;
3490
+ // Every path's comes first, so nothing that can refuse follows a set.
3491
+ for (const { path } of changes) {
3492
+ settleAdoptionBeforeMutation(store, agreement, { name: mount.config.name, treeStateId: mount.treeStateId }, path);
3493
+ }
3494
+ const entry = (change: ToolChange & { kind: 'set' }) => ({ blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
3495
+ // The changes the tree doesn't hold yet, which the read-back below checks,
3496
+ // read before any intent is written, so no read comes between a
3497
+ // tombstone's write and the put-back's guard.
3498
+ const changing = changes.filter(({ path, change }) => !holds(store.treeGet(mount.treeStateId, path), change));
3499
+ // Each path's intent, by the rule commitToolChange's doc gives. A
3500
+ // deletion's tombstone is written before its entry is removed. Removed
3501
+ // without it, the entry would read as a file this branch doesn't have,
3502
+ // which no materialize deletes and no second delete can reach
3503
+ // (anima-research/agent-framework#553). Every other intent is written
3504
+ // after the tree change: what it gives up, the tombstone a write replaces
3505
+ // or the origin and conflict a deletion clears, guards the path until the
3506
+ // change lands.
3507
+ const notes = new Map<string, string>();
3508
+ const unpushed = new Set<string>();
3509
+ const intentChanges = changes.map(({ path, change }): { path: string; first: boolean; next: (cur: BranchIntent) => BranchIntent } => {
3510
+ const evidence = agreement.get(mount.config.name, path);
3511
+ const cur = intents.get(path) ?? {};
3512
+ if (change.kind === 'remove') {
3513
+ // A deletion whose evidence holds no agreed disk copy (disk last
3514
+ // agreed it had nothing here, or has never agreed with this entry,
3515
+ // which the workspace created) puts the store back at the
3516
+ // agreement. Nothing on disk is the workspace's to unlink, so it
3517
+ // pushes nothing, and it clears the path's whole intent: no
3518
+ // tombstone, so it needs no proof of absence, which a path disk
3519
+ // can't show, such as one beneath a symlink the mount doesn't
3520
+ // follow, would never give; and no conflict, which has nothing left
3521
+ // to be about once the workspace has given up its side. Whatever
3522
+ // disk holds there is a sync's to observe, a file the workspace
3523
+ // never saw included. Any other deletion keeps its tombstone, which
3524
+ // guards it: a copy disk agreed with, a push of ours that may have
3525
+ // landed (pending or interrupted), or an entry from before the
3526
+ // evidence.
3527
+ if (!(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'))) {
3528
+ return {
3529
+ path,
3530
+ first: true,
3531
+ next: (current) => {
3532
+ const next = { ...current, tombstone: { hash: change.previousHash } };
3533
+ delete next.origin;
3534
+ return next;
3535
+ },
3536
+ };
2765
3537
  }
3538
+ unpushed.add(path);
2766
3539
  // A conflict it clears may name a file disk held there, which the
2767
- // workspace never took in: the answer says that file wasn't unlinked,
2768
- // where the deletion alone would read as having removed it. The
2769
- // deletion doesn't look at disk, so a file nothing recorded gets no
2770
- // note.
3540
+ // workspace never took in: the answer says that file wasn't
3541
+ // unlinked, where the deletion alone would read as having removed
3542
+ // it. The deletion doesn't look at disk, so a file nothing recorded
3543
+ // gets no note.
2771
3544
  if (cur.conflict?.disk) {
2772
- note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
2773
- `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
3545
+ notes.set(path, `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${path} `
3546
+ + `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`);
2774
3547
  }
2775
- return {};
2776
- });
2777
- if (!agreed) return { refused: null, modeKept: null, note };
3548
+ return { path, first: false, next: () => ({}) };
3549
+ }
3550
+ return {
3551
+ path,
3552
+ first: false,
3553
+ next: (current) => {
3554
+ const next = { ...current };
3555
+ delete next.tombstone;
3556
+ // Disk has never agreed with this path: a missing disk copy is a
3557
+ // draft not yet materialized, not a deletion.
3558
+ if (evidence === undefined) next.origin = 'store';
3559
+ return next;
3560
+ },
3561
+ };
3562
+ });
3563
+ // A tombstone written for a change that then doesn't land is put back:
3564
+ // the path's intent record as it was, a damaged one's bytes included, so
3565
+ // a refused deletion changes nothing. The put-back is best effort, since
3566
+ // the error that stopped the call is its answer. A tombstone it leaves
3567
+ // beside the entry guards nothing: decide gives no verdict from one while
3568
+ // the entry is there, status and materialize take it as no deletion, and
3569
+ // the path's next agreement clears it.
3570
+ const written: Array<{ path: string; record: JsTreeEntry | null }> = [];
3571
+ const putBack = () => {
3572
+ for (const { path, record } of written) {
3573
+ try {
3574
+ if (record) store.treeSet(intents.treeStateId, path, record);
3575
+ else if (store.treeGet(intents.treeStateId, path)) store.treeRemove(intents.treeStateId, path);
3576
+ } catch (error) {
3577
+ // Left beside the entry, where it guards nothing (above).
3578
+ console.warn(
3579
+ `[workspace] Mount "${mount.config.name}": a change to ${path} failed, and putting its branch intent back threw too `
3580
+ + `(${error instanceof Error ? error.message : String(error)}), so a tombstone may be left beside the entry. One there `
3581
+ + `deletes nothing, and the path's next agreement with disk clears it`,
3582
+ );
3583
+ }
3584
+ }
3585
+ };
3586
+ try {
3587
+ for (const { path, first, next } of intentChanges) {
3588
+ if (!first) continue;
3589
+ written.push({ path, record: store.treeGet(intents.treeStateId, path) });
3590
+ intents.update(path, next);
3591
+ }
3592
+ } catch (error) {
3593
+ putBack();
3594
+ throw error;
2778
3595
  }
2779
- if (!this.pushesOnCommit(mount)) return { refused: null, modeKept: null, note: null };
2780
-
2781
- const pushed = await this.pushUnlocked(mount, [relativePath], {
2782
- applyDeletions: change.kind === 'remove',
3596
+ // A store can fail after its record lands: Chronicle 0.4 writes a state's
3597
+ // auto-snapshot after appending its update, and that failure reaches the
3598
+ // caller with the update recorded (anima-research/chronicle#39). So a set
3599
+ // that threw is read back, and where every path it changes holds what its
3600
+ // change leaves, the record is in: the paths' intents follow, as for any
3601
+ // set, and then the error goes on in place of the push. A change the tree
3602
+ // already holds can't show whether the record is in, so a call whose every
3603
+ // change the tree holds already goes on as the store's own failure.
3604
+ let landedThenThrew: { error: unknown } | null = null;
3605
+ try {
3606
+ if (changes.length === 1) {
3607
+ const [{ path, change }] = changes as [{ path: string; change: ToolChange }];
3608
+ if (change.kind === 'set') store.treeSet(mount.treeStateId, path, entry(change));
3609
+ else store.treeRemove(mount.treeStateId, path);
3610
+ } else {
3611
+ // One record for every path, so a store that fails partway sets none.
3612
+ store.treeBatch(mount.treeStateId, changes.map(({ path, change }) => (change.kind === 'set'
3613
+ ? { op: 'set', path, entry: entry(change) }
3614
+ : { op: 'remove', path })));
3615
+ }
3616
+ } catch (error) {
3617
+ // A read-back that fails can't tell whether the change landed, so its
3618
+ // error goes on and a tombstone stays: put back where the removal did
3619
+ // land, it would leave #553's state, and beside an entry it guards
3620
+ // nothing.
3621
+ if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3622
+ putBack();
3623
+ throw error;
3624
+ }
3625
+ landedThenThrew = { error };
3626
+ }
3627
+ // A later intent update or disk push can throw; the tree entries are
3628
+ // already set, on the branch current now. If the active branch changes
3629
+ // during the push, that branch's tree has the files and the new one may
3630
+ // not.
3631
+ for (const { path } of changes) onCommitted?.(path);
3632
+ for (const { path, first, next } of intentChanges) if (!first) intents.update(path, next);
3633
+ const pushing = changes.filter(({ path }) => !unpushed.has(path));
3634
+ if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3635
+ const refused = new Map<string, string>();
3636
+ const modeKept = new Map<string, string>();
3637
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) return { refused, modeKept, notes };
3638
+
3639
+ const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3640
+ applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
2783
3641
  branchId: store.currentBranch().id,
2784
3642
  });
2785
- const refused = pushed.skipped.find((s) => s.path === relativePath);
2786
- if (!refused) return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
2787
- this.ctx?.pushEvent({
2788
- type: 'workspace:materialize-failed',
2789
- mount: mount.config.name,
2790
- path: relativePath,
2791
- op: change.kind === 'set' ? 'write' : 'delete',
2792
- error: refused.reason,
2793
- } as ProcessEvent);
2794
- return { refused: refused.reason, modeKept: null, note: null };
3643
+ for (const { path, change } of pushing) {
3644
+ const skipped = pushed.skipped.find((s) => s.path === path);
3645
+ if (!skipped) {
3646
+ const kept = pushed.modesKept.find((k) => k.path === path)?.reason;
3647
+ if (kept) modeKept.set(path, kept);
3648
+ continue;
3649
+ }
3650
+ refused.set(path, skipped.reason);
3651
+ this.ctx?.pushEvent({
3652
+ type: 'workspace:materialize-failed',
3653
+ mount: mount.config.name,
3654
+ path,
3655
+ op: change.kind === 'set' ? 'write' : 'delete',
3656
+ error: skipped.reason,
3657
+ } as ProcessEvent);
3658
+ }
3659
+ return { refused, modeKept, notes };
2795
3660
  }
2796
3661
 
2797
3662
  // ==========================================================================
@@ -2821,13 +3686,24 @@ export class WorkspaceModule implements Module {
2821
3686
  * path, as a file tool names the one it acts on: a path the mount passes
2822
3687
  * over by design is observed, as a sync naming it would observe it (#276),
2823
3688
  * while a path in another mount's directory is refused before this, and
2824
- * passed over by any pass. The caller holds the mount's turn.
3689
+ * passed over by any pass. Answers the pass's report on the path, and why
3690
+ * the pass decided nothing where it did (undecidedOf), which leaves disk
3691
+ * unchecked. The caller holds the mount's turn.
2825
3692
  */
2826
- private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
3693
+ private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
2827
3694
  const store = this.getStore();
2828
- if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
3695
+ if (store.treeGet(mount.treeStateId, relativePath)) return { report: undefined, undecided: null };
2829
3696
  const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2830
- return pass.reports.get(relativePath);
3697
+ return { report: pass.reports.get(relativePath), undecided: undecidedOf(pass) };
3698
+ }
3699
+
3700
+ /**
3701
+ * Why the workspace holds no entry at a path, after a pass over it that
3702
+ * decided nothing: disk wasn't checked, so it may have a copy, and the same
3703
+ * call made again checks it.
3704
+ */
3705
+ private notCheckedYet(path: string, reason: string): string {
3706
+ return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
2831
3707
  }
2832
3708
 
2833
3709
  /** Why the workspace holds no entry at a path, when disk explains it. */
@@ -2850,6 +3726,63 @@ export class WorkspaceModule implements Module {
2850
3726
  }
2851
3727
  }
2852
3728
 
3729
+ /**
3730
+ * A file's bytes for read, from where the mount keeps what it reads: the
3731
+ * workspace's copy, taken in from disk first where it holds none
3732
+ * (ensureSynced); or, on a direct mount, disk's file as it is now
3733
+ * (bytesOnDisk). An error says why there are none to read.
3734
+ */
3735
+ private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3736
+ if (mount.direct) return this.bytesOnDisk(mount, relativePath, path);
3737
+ const store = this.getStore();
3738
+ return this.withMount(mount, async () => {
3739
+ const { report } = await this.ensureSynced(mount, relativePath);
3740
+ const entry = store.treeGet(mount.treeStateId, relativePath);
3741
+ if (!entry) return { error: this.notInWorkspace(path, report) };
3742
+ const blob = store.getBlob(entry.blobHash);
3743
+ if (!blob) return { error: `Blob not found for: ${path}` };
3744
+ return { bytes: blob };
3745
+ });
3746
+ }
3747
+
3748
+ /**
3749
+ * A direct mount's file for read: its bytes on disk as they are now,
3750
+ * through the mount's boundary as readFileFromDisk reaches them
3751
+ * (openContainedFile), whose refusals are the answer. What a 'chronicle'
3752
+ * mount never stores, it doesn't show either: a file over the mount's
3753
+ * size limit, and a binary one, with a NUL in its first 8 KB, which
3754
+ * read_image reads where it's an image.
3755
+ */
3756
+ private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3757
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3758
+ const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3759
+ let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3760
+ try {
3761
+ opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3762
+ if (fileStat.size > maxSize) throw new Error(over);
3763
+ });
3764
+ } catch (err) {
3765
+ // A refusal that names only the path says why with its errno, as a
3766
+ // failed read below does.
3767
+ const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
3768
+ return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
3769
+ }
3770
+ try {
3771
+ // No more than the limit is loaded, as for a file over it at its fstat.
3772
+ const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
3773
+ if (more) return { error: over };
3774
+ if (looksBinary(bytes)) {
3775
+ return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3776
+ }
3777
+ return { bytes };
3778
+ } catch (err) {
3779
+ const errno = errnoOf(err);
3780
+ return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3781
+ } finally {
3782
+ await opened.handle.close();
3783
+ }
3784
+ }
3785
+
2853
3786
  // ==========================================================================
2854
3787
  // Tool Handlers
2855
3788
  // ==========================================================================
@@ -2867,17 +3800,9 @@ export class WorkspaceModule implements Module {
2867
3800
  if (content === undefined) {
2868
3801
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
2869
3802
  input = { ...input, path };
2870
- const store = this.getStore();
2871
- const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
2872
- const report = await this.ensureSynced(mount, relativePath);
2873
- const entry = store.treeGet(mount.treeStateId, relativePath);
2874
- if (!entry) return { error: this.notInWorkspace(input.path, report) };
2875
- const blob = store.getBlob(entry.blobHash);
2876
- if (!blob) return { error: `Blob not found for: ${input.path}` };
2877
- return { text: blob.toString('utf-8') };
2878
- });
3803
+ const found = await this.bytesOf(mount, relativePath, path);
2879
3804
  if ('error' in found) return { success: false, error: found.error, isError: true };
2880
- content = found.text;
3805
+ content = found.bytes.toString('utf-8');
2881
3806
  }
2882
3807
  if (characterPaging) {
2883
3808
  const start = Math.min(offsetChars, content.length);
@@ -2971,48 +3896,23 @@ export class WorkspaceModule implements Module {
2971
3896
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2972
3897
  let treeError: WorkspaceImageReadError | null = null;
2973
3898
 
2974
- try {
2975
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
2976
- if (image) {
2977
- return {
2978
- success: true,
2979
- data: [
2980
- {
2981
- type: 'text',
2982
- text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
2983
- },
2984
- {
2985
- type: 'image',
2986
- data: image.bytes.toString('base64'),
2987
- mimeType: image.mimeType,
2988
- },
2989
- ],
2990
- };
2991
- }
2992
- } catch (err) {
2993
- if (err instanceof WorkspaceImageReadError) {
2994
- treeError = err;
2995
- } else {
2996
- throw err;
3899
+ // The workspace's copy first, where it holds one, then disk's. A direct
3900
+ // mount holds none, so its image is disk's.
3901
+ if (!mount.direct) {
3902
+ try {
3903
+ const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
3904
+ if (image) return imageResult(input.path, image);
3905
+ } catch (err) {
3906
+ if (err instanceof WorkspaceImageReadError) {
3907
+ treeError = err;
3908
+ } else {
3909
+ throw err;
3910
+ }
2997
3911
  }
2998
3912
  }
2999
3913
 
3000
3914
  try {
3001
- const image = await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize);
3002
- return {
3003
- success: true,
3004
- data: [
3005
- {
3006
- type: 'text',
3007
- text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
3008
- },
3009
- {
3010
- type: 'image',
3011
- data: image.bytes.toString('base64'),
3012
- mimeType: image.mimeType,
3013
- },
3014
- ],
3015
- };
3915
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
3016
3916
  } catch (err) {
3017
3917
  if (err instanceof WorkspaceImageReadError) {
3018
3918
  if (err.code === 'not_found' && treeError) {
@@ -3056,168 +3956,454 @@ export class WorkspaceModule implements Module {
3056
3956
  }));
3057
3957
  } catch (error) {
3058
3958
  if (!committed) throw error;
3059
- return storedButUnfinished('write', input.path, { threw: error instanceof Error ? error.message : String(error) });
3959
+ return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
3960
+ ? { storeFailed: error.message, pushes: error.pushes }
3961
+ : { threw: error instanceof Error ? error.message : String(error) });
3060
3962
  }
3061
3963
  if (refused !== null) return storedButUnfinished('write', input.path, { refused });
3062
3964
 
3063
3965
  return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
3064
3966
  }
3065
3967
 
3066
- private async handleEdit(input: EditInput): Promise<ToolResult> {
3067
- // Refused before anything is read: an empty oldString matches between
3068
- // every character, and UTF-8 can't store a lone surrogate.
3069
- if (input.oldString === '') {
3070
- return { success: false, isError: true, error: 'The edit did nothing: "oldString" is empty, and an empty string matches between every character. Give the text to replace.' };
3968
+ private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS): Promise<ToolResult> {
3969
+ return this.applyEdits('edit', [{
3970
+ path: input.path,
3971
+ oldString: input.oldString,
3972
+ newString: input.newString,
3973
+ expected: input.replaceAll ? 'all' : 1,
3974
+ }], maxChars);
3975
+ }
3976
+
3977
+ private async handleEditMany(
3978
+ input: EditManyInput,
3979
+ maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
3980
+ callerAgentName?: string,
3981
+ ): Promise<ToolResult> {
3982
+ if (input.edits.length === 0) {
3983
+ return { success: false, isError: true, error: 'The edit_many call did nothing: "edits" is empty. Give at least one edit.' };
3071
3984
  }
3072
- for (const field of ['oldString', 'newString'] as const) {
3073
- if (LONE_SURROGATE.test(input[field])) {
3074
- return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
3985
+ return this.applyEdits('edit_many', input.edits.map((edit) => ({
3986
+ path: edit.path,
3987
+ oldString: edit.oldString,
3988
+ newString: edit.newString,
3989
+ expected: edit.occurrences ?? 1,
3990
+ })), maxChars, callerAgentName);
3991
+ }
3992
+
3993
+ /**
3994
+ * A call's edits, all or none, applied to the stored copies of their files:
3995
+ * edit's one, or edit_many's in the files of one mount. Each edit is matched
3996
+ * in its file's text as this call reads it, never in another edit's result
3997
+ * (edit-plan.ts), and any problem refuses the call with none of its edits
3998
+ * stored: edit names its first problem, and edit_many every problem found.
3999
+ *
4000
+ * Each edit's own input is checked first, and each path's mount. A problem
4001
+ * there refuses edit at once, before any file's text is read, and edit_many
4002
+ * when its edits name more than one mount, which no one pass settles.
4003
+ * Otherwise edit_many goes on to settle and plan the rest, so its one
4004
+ * refusal names the problems it finds; the settling is what a read or a
4005
+ * listing of its files would take in from disk, and the refusal says what
4006
+ * it took in. A failure before the commit sets the entries stores none of
4007
+ * them, and edit_many's answer to it says so. A store that throws as it
4008
+ * sets them is read back (commitToolChanges), so the answer says they're
4009
+ * stored where its record landed, and that it can't tell where they
4010
+ * couldn't be read back.
4011
+ * Under the mount's turn, one pass settles every path with disk, by the
4012
+ * rule a listing uses, so the edits apply to what disk now holds. That pass
4013
+ * is the only step that awaits before the commit: every file is then read,
4014
+ * every edit planned, and every entry set (commitToolChanges) with no turn
4015
+ * of the event loop between, so no pass, branch change or other call moves
4016
+ * the tree between a file's read and its set. Disk then takes what it can
4017
+ * of the stored change.
4018
+ *
4019
+ * The answer is held to `maxChars`, the length the call's result may have
4020
+ * (ToolCall.resultMaxChars), as edit-text.ts describes.
4021
+ */
4022
+ private async applyEdits(
4023
+ tool: EditTool,
4024
+ given: Array<{ path: string; oldString: string; newString: string; expected: number | 'all' }>,
4025
+ maxChars: number,
4026
+ callerAgentName?: string,
4027
+ ): Promise<ToolResult> {
4028
+ const refuse = (problems: EditProblem[], notes: string[] = []): ToolResult =>
4029
+ ({ success: false, isError: true, error: refusalText(tool, problems, notes, maxChars) });
4030
+
4031
+ // Each edit's own input, before anything is read: an empty old text
4032
+ // matches between every character, and UTF-8 can't store a lone surrogate.
4033
+ const problems: EditProblem[] = [];
4034
+ const specs = new Map<number, EditSpec>();
4035
+ for (const [index, edit] of given.entries()) {
4036
+ const spec: EditSpec = {
4037
+ index,
4038
+ oldText: edit.oldString.replaceAll('\r\n', '\n'),
4039
+ newText: edit.newString.replaceAll('\r\n', '\n'),
4040
+ expected: edit.expected,
4041
+ };
4042
+ const found = problems.length;
4043
+ if (spec.oldText === '') {
4044
+ problems.push({ kind: 'input', edit: index, text: '"oldString" is empty, and an empty string matches between every character. Give the text to replace.' });
4045
+ } else if (spec.oldText === spec.newText) {
4046
+ // An edit's new text takes the line ends of the text it replaces, so
4047
+ // one whose only change is its line ends changes nothing.
4048
+ problems.push({
4049
+ kind: 'input',
4050
+ edit: index,
4051
+ text: edit.oldString !== edit.newString
4052
+ ? "oldString and newString are the same once their CRLF line ends are read as LF, so there's nothing to change: an edit keeps the file's line ends, and write replaces the whole file, line ends included."
4053
+ : "oldString and newString are the same, so there's nothing to change.",
4054
+ });
3075
4055
  }
4056
+ for (const field of ['oldString', 'newString'] as const) {
4057
+ if (LONE_SURROGATE.test(edit[field])) {
4058
+ problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
4059
+ }
4060
+ }
4061
+ if (problems.length === found) specs.set(index, spec);
3076
4062
  }
3077
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
3078
- input = { ...input, path };
3079
- if (mount.config.mode === 'read-only') {
3080
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
4063
+ // edit names its first problem, so one in its input refuses it before
4064
+ // its path is looked up on disk.
4065
+ if (tool === 'edit' && problems.length) return refuse(problems);
4066
+
4067
+ // Each path in its mount, as disk spells it, before any file's text is
4068
+ // read. Edits of one stored path are one file, planned once from one
4069
+ // text, whatever path named it, and named as disk spells it.
4070
+ const parsed = new Map<string, { mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>>();
4071
+ const targets = new Map<string, EditTarget>();
4072
+ const mounts = new Map<string, MountState>();
4073
+ for (const [index, { path }] of given.entries()) {
4074
+ if (!parsed.has(path)) parsed.set(path, await this.editablePath(tool, path, callerAgentName));
4075
+ const where = parsed.get(path)!;
4076
+ if ('kind' in where) {
4077
+ where.edits.push(index);
4078
+ continue;
4079
+ }
4080
+ mounts.set(where.mount.config.name, where.mount);
4081
+ const key = `${where.mount.config.name}/${where.relativePath}`;
4082
+ let target = targets.get(key);
4083
+ if (!target) targets.set(key, (target = { relativePath: where.relativePath, path: where.path, edits: [], specs: [] }));
4084
+ target.edits.push(index);
4085
+ const spec = specs.get(index);
4086
+ if (spec) target.specs.push(spec);
4087
+ }
4088
+ for (const where of parsed.values()) if ('kind' in where) problems.push(where);
4089
+ if (mounts.size > 1) {
4090
+ problems.push({ kind: 'mounts', edits: [...targets.values()].flatMap((target) => target.edits), mounts: [...mounts.keys()] });
3081
4091
  }
4092
+ const [mount] = mounts.values();
4093
+ if (tool === 'edit' && problems.length) return refuse(problems);
4094
+ if (!mount || mounts.size > 1) return refuse(problems);
3082
4095
 
3083
4096
  const store = this.getStore();
4097
+ // Whether the commit set the call's entries, all of them at once.
3084
4098
  let committed = false;
3085
- let appliedNote: string | null = null;
4099
+ // What each file's settling took in, for a receipt.
4100
+ const applied = new Map<EditTarget, string | null>();
4101
+ // The conflict each file's settling found, on a mount that doesn't push:
4102
+ // the success answer names it, and so does the answer to a store that
4103
+ // failed after recording the edits, which stand as on a success.
4104
+ const conflicts = new Map<EditTarget, string>();
3086
4105
  try {
3087
4106
  return await this.withMount(mount, async (): Promise<ToolResult> => {
3088
- // The edit applies to the path as disk holds it now: a change on disk
3089
- // to a file the workspace holds is taken in first, by the rule a
3090
- // listing uses. The pass settles a pending intent on the path too, as
3091
- // settleBeforeMutation's would, and the commit enforces the rest
3092
- // (commitToolChange). A write passes over its path only to settle a
3093
- // pending intent, since it replaces the whole file. As every file
3094
- // tool's pass does, it names the path it acts on, so a file the
3095
- // mount passes over by design is observed (see PassOptions.named).
3096
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3097
- const report = pass.reports.get(relativePath);
3098
- // A mode taken in alone isn't a change to the text: it brings nothing
3099
- // new to read, as it makes no event (emitFsEvents), and the edit then
3100
- // writes at the mode disk has, so it leaves nothing to say.
3101
- const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
3102
- // Which copy the edit used, when it isn't the one the workspace last
3103
- // agreed on with disk: disk's newer text, or the workspace's copy
3104
- // because disk's couldn't be read or disk has none. A file the
3105
- // workspace didn't hold has no older copy, so taking it in needs no
3106
- // word.
3107
- const whichCopy = (verb: string): string | null => took('modified')
3108
- ? `Disk's copy had changed since the workspace last agreed with it, so it was taken in first, and the edit ${verb} that text.`
3109
- : report?.state === 'unverified'
3110
- ? `The path couldn't be checked against disk (${report.note ?? 'not observed'}), so the edit ${verb} the workspace's copy.`
3111
- : report?.state === 'disk-missing-provenance-unknown'
3112
- ? `Disk has no copy of this file, and nothing records whether it was deleted there, so the edit ${verb} the workspace's copy.`
3113
- : null;
3114
- // A conflict the pass found is named when the edit is refused for the
3115
- // text it found, on either kind of mount, and on a success where the
3116
- // mount doesn't push, since such a mount keeps the conflict until
3117
- // materialize or sync settles it. A pushing mount's success met none
3118
- // (below). A stored edit that couldn't finish leaves it to the push's
3119
- // reason, which names the conflict, or to ls, which its answer points to.
3120
- const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
3121
- // Where disk has no copy now, whatever the conflict's kind, a sync of
3122
- // the path takes disk's deletion and a forced materialize writes the
3123
- // workspace's copy back. Where it has one, a sync keeps it and gives
3124
- // up the workspace's, whether or not the workspace can hold disk's
3125
- // copy: one it can't hold stays on disk alone.
3126
- const conflictNote = (where: string): string | null => conflict === null ? null
3127
- : `The path is in conflict with disk (${conflict}): the edit ${where} the workspace's copy. ` + (report?.diskNow === 'absent'
3128
- ? "Sync the path to take disk's deletion, which removes the workspace's copy, or materialize it with force to write the workspace's copy back to disk."
3129
- : "Sync the path to keep disk's copy and give up the workspace's, or materialize it with force to write the workspace's over disk's.");
3130
- const suffix = [whichCopy('looked in'), conflictNote('looked in')].filter((n) => n !== null).map((n) => ` ${n}`).join('');
3131
- appliedNote = whichCopy('applied to');
3132
-
3133
- const entry = store.treeGet(mount.treeStateId, relativePath);
3134
- if (!entry) {
3135
- // A pass also drops a copy disk made binary or too large, which
3136
- // the disk-only answer describes; any other drop is disk's deletion.
3137
- const error = took('deleted') && report?.state !== 'disk-only'
3138
- ? `File not found: ${input.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3139
- : this.notInWorkspace(input.path, report);
3140
- return { success: false, error, isError: true };
3141
- }
3142
-
3143
- const blob = store.getBlob(entry.blobHash);
3144
- if (!blob) {
3145
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
3146
- }
3147
- // Decoding would turn each invalid sequence into U+FFFD, and writing
3148
- // the text back would change bytes the edit never named.
3149
- if (!isUtf8(blob)) {
3150
- return {
3151
- success: false,
3152
- isError: true,
3153
- error: `The edit did nothing: ${input.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
3154
- + `Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.${suffix}`,
3155
- };
3156
- }
3157
-
3158
- let content = blob.toString('utf-8');
3159
-
3160
- // An edit that would replace nothing is refused, replaceAll or not;
3161
- // without replaceAll, the text must occur once, and the refusal says
3162
- // how often it does. With replaceAll, whether it occurs is enough.
3163
- const count = input.replaceAll
3164
- ? Number(content.includes(input.oldString))
3165
- : content.split(input.oldString).length - 1;
3166
- if (count === 0) {
3167
- return { success: false, error: `String not found in ${input.path}.${suffix}`, isError: true };
3168
- }
3169
- if (count > 1 && !input.replaceAll) {
3170
- return {
3171
- success: false,
3172
- error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.${suffix}`,
3173
- isError: true,
3174
- };
4107
+ // As every file tool's pass does, it names the paths it acts on, so a
4108
+ // file the mount passes over by design is observed (see PassOptions.named).
4109
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4110
+ // A pass that decided nothing checked no file's disk copy.
4111
+ const undecided = undecidedOf(pass);
4112
+ // Nothing below awaits until the commit's push.
4113
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4114
+ const hints = hintBudget();
4115
+ // As much as the largest file the call names holds, at least, so the
4116
+ // budget is the same whatever order its files are planned in.
4117
+ let largest = maxSize;
4118
+ for (const target of targets.values()) largest = Math.max(largest, store.treeGet(mount.treeStateId, target.relativePath)?.size ?? 0);
4119
+ const counts = countBudget(largest);
4120
+ const notes: string[] = [];
4121
+ const pushes = this.pushesOnCommit(mount);
4122
+ const plans: Array<{ target: EditTarget; plan: FilePlan; mode: number; backOnDisk: string | null; conflictNote: string | null }> = [];
4123
+ for (const target of targets.values()) {
4124
+ const report = pass.reports.get(target.relativePath);
4125
+ // A mode taken in alone isn't a change to the text: it brings nothing
4126
+ // new to read, as it makes no event (emitFsEvents), and the edits
4127
+ // then write at the mode disk has, so it leaves nothing to say.
4128
+ const took = (op: TreeOp) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4129
+ // Which copy the edits used, when it isn't the one the workspace
4130
+ // last agreed on with disk: disk's newer text, or the workspace's
4131
+ // copy because disk's couldn't be read or disk has none. A file the
4132
+ // workspace didn't hold has no older copy, so taking it in needs no
4133
+ // word.
4134
+ const subject = tool === 'edit' ? 'the edit' : editList(target.edits);
4135
+ // A pass that decided nothing checked no copy on disk, held or not,
4136
+ // and took nothing in, so the edits would apply to text disk may
4137
+ // have moved past, on whichever branch is selected once it gave up.
4138
+ // They're refused, as a call made again can check.
4139
+ if (undecided !== null) {
4140
+ problems.push({
4141
+ kind: 'file',
4142
+ edits: target.edits,
4143
+ path: target.path,
4144
+ text: `${target.path} couldn't be checked against disk (${undecided}), so ${subject} changed nothing. Make the same call again, which checks disk first.`,
4145
+ whole: true,
4146
+ });
4147
+ continue;
4148
+ }
4149
+ const whichCopy = (verb: string): string | null => took('modified')
4150
+ ? `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} had changed since the workspace last agreed with it, so it was taken in first, and ${subject} ${verb} that text.`
4151
+ : report?.state === 'unverified'
4152
+ ? `${tool === 'edit' ? 'The path' : target.path} couldn't be checked against disk (${report.note ?? 'not observed'}), so ${subject} ${verb} the workspace's copy.`
4153
+ : report?.state === 'disk-missing-provenance-unknown'
4154
+ ? `Disk has no copy of ${tool === 'edit' ? 'this file' : target.path}, and nothing records whether it was deleted there, so ${subject} ${verb} the workspace's copy.`
4155
+ : null;
4156
+ // A conflict the pass found is named when the edits are refused for
4157
+ // the text they found, on either kind of mount, and on a success
4158
+ // where the mount doesn't push, since such a mount keeps the conflict
4159
+ // until materialize or sync settles it. A pushing mount's push that
4160
+ // completed met none: unforced, it stops at a conflict, so the edits
4161
+ // made the two copies agree, which settles it, or there was none.
4162
+ // Edits stored that couldn't finish leave it to the push's reason,
4163
+ // which names the conflict, or to ls, which that answer points to
4164
+ // when the rest of the call failed; where the store failed after
4165
+ // recording them, the answer names it as the success does.
4166
+ // What a sync does follows what disk holds now, whatever kind the
4167
+ // conflict was recorded as: where disk has no copy, it takes the
4168
+ // deletion; where disk has one, it keeps disk's copy and gives up
4169
+ // the workspace's, though a copy the workspace can't hold, binary
4170
+ // or past maxFileSize, isn't taken in.
4171
+ const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
4172
+ const conflictNote = (where: string): string | null => conflict === null ? null
4173
+ : `${tool === 'edit' ? 'The path' : target.path} is in conflict with disk (${conflict}): ${subject} ${where} the workspace's copy. `
4174
+ + (report?.diskNow === 'absent'
4175
+ ? "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."
4176
+ : "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.");
4177
+ applied.set(target, whichCopy('applied to'));
4178
+ // The edits make the path the workspace's own change, so a file disk
4179
+ // lacked goes back with this call's push, or with the next
4180
+ // materialize where the mount doesn't push. A push that didn't
4181
+ // complete answers with its reason instead.
4182
+ const backOnDisk = report?.state !== 'disk-missing-provenance-unknown' ? null
4183
+ : tool === 'edit'
4184
+ ? `${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.`
4185
+ : `${pushes ? `This call's push put ${target.path} back on disk` : `A materialize of ${target.path} puts it back on disk`}; if it was deleted there on purpose, delete it here too.`;
4186
+ const lookedIn = [whichCopy('looked in'), conflictNote('looked in')].filter((note): note is string => note !== null);
4187
+
4188
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4189
+ if (!entry) {
4190
+ // A pass also drops a copy disk made binary or too large, which
4191
+ // the disk-only answer describes; any other drop is disk's deletion.
4192
+ const text = took('deleted') && report?.state !== 'disk-only'
4193
+ ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
4194
+ : this.notInWorkspace(target.path, report);
4195
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
4196
+ continue;
4197
+ }
4198
+ notes.push(...lookedIn);
4199
+ const blob = store.getBlob(entry.blobHash);
4200
+ if (!blob) {
4201
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4202
+ continue;
4203
+ }
4204
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
4205
+ // the text back would change bytes the edits never named.
4206
+ const file = decodeForEdit(blob);
4207
+ if (!file) {
4208
+ problems.push({
4209
+ kind: 'file',
4210
+ edits: target.edits,
4211
+ path: target.path,
4212
+ text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
4213
+ + '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.',
4214
+ whole: false,
4215
+ });
4216
+ continue;
4217
+ }
4218
+ if (!target.specs.length) continue;
4219
+ const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
4220
+ if ('problems' in planned) {
4221
+ // One at a time: a call of many edits can have more problems
4222
+ // than a spread passes as arguments.
4223
+ for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
4224
+ continue;
4225
+ }
4226
+ // The planner refuses a file its counts take past the limit before
4227
+ // building it, from the least its bytes can be; one it built past
4228
+ // the limit, as its CRs or multi-byte characters can take it, is
4229
+ // refused here.
4230
+ if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
4231
+ const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
4232
+ problems.push({
4233
+ kind: 'file',
4234
+ edits: target.edits,
4235
+ path: target.path,
4236
+ text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
4237
+ + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
4238
+ + "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
4239
+ whole: false,
4240
+ });
4241
+ continue;
4242
+ }
4243
+ plans.push({
4244
+ target,
4245
+ plan: planned.plan,
4246
+ mode: entry.mode,
4247
+ backOnDisk,
4248
+ conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
4249
+ });
4250
+ const conflicted = plans.at(-1)!.conflictNote;
4251
+ if (conflicted !== null) conflicts.set(target, conflicted);
3175
4252
  }
4253
+ if (problems.length) return refuse(problems, notes);
3176
4254
 
3177
- // A replacer function, so that the new text goes in as given: in a
3178
- // replacement string, $&, $$, $' and $` are patterns.
3179
- content = input.replaceAll
3180
- ? content.replaceAll(input.oldString, () => input.newString)
3181
- : content.replace(input.oldString, () => input.newString);
3182
-
3183
- const newBuffer = Buffer.from(content, 'utf-8');
3184
- const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
3185
- const { refused, modeKept } = await this.commitToolChange(mount, relativePath, {
3186
- kind: 'set',
3187
- blobHash: newBlobHash,
3188
- size: newBuffer.byteLength,
3189
- mode: entry.mode,
3190
- }, () => { committed = true; });
3191
- if (refused !== null) return storedButUnfinished('edit', input.path, { refused }, appliedNote);
3192
-
3193
- // The edit makes the path the workspace's own change (commitToolChange),
3194
- // so a file disk lacked goes back with this edit's push, or with the
3195
- // next materialize where the mount doesn't push. A push that didn't
3196
- // complete answers above, with its reason. One that completed met no
3197
- // conflict, since this push, unforced, stops at one: the edit made the
3198
- // two copies agree, which settles it, or there was none. So only a
3199
- // mount that doesn't push still holds the conflict the pass found.
3200
- const pushes = this.pushesOnCommit(mount);
3201
- const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
3202
- ? `${pushes ? 'Its push put the file back on disk' : 'A materialize of the path puts it back on disk'}; if it was deleted there on purpose, delete it here too.`
3203
- : null;
3204
- const notes = [appliedNote, backOnDisk, pushes ? null : conflictNote('is in')].filter((n): n is string => n !== null);
4255
+ const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4256
+ path: target.relativePath,
4257
+ change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4258
+ })), () => { committed = true; });
4259
+ if (refused.size > 0) return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
3205
4260
  return {
3206
4261
  success: true,
3207
- data: {
3208
- path: input.path,
3209
- size: newBuffer.byteLength,
3210
- ...(notes.length > 0 ? { note: notes.join(' ') } : {}),
3211
- ...(modeKept ? { modeKept } : {}),
3212
- },
4262
+ data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4263
+ path: target.path,
4264
+ plan,
4265
+ notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4266
+ modeKept: modeKept.get(target.relativePath) ?? null,
4267
+ })), given.length, maxChars),
3213
4268
  };
3214
4269
  });
3215
4270
  } catch (error) {
3216
- if (!committed) throw error;
3217
- return storedButUnfinished('edit', input.path, { threw: error instanceof Error ? error.message : String(error) }, appliedNote);
4271
+ const message = error instanceof Error ? error.message : String(error);
4272
+ if (committed) {
4273
+ return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
4274
+ ? { storeFailed: message, pushes: error.pushes }
4275
+ : { threw: message }, maxChars);
4276
+ }
4277
+ // A set that threw and couldn't be read back may have stored the edits,
4278
+ // and a second call could then apply them twice.
4279
+ if (error instanceof CommitUnknownError) {
4280
+ const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4281
+ return {
4282
+ success: false,
4283
+ isError: true,
4284
+ error: tool === 'edit_many'
4285
+ ? `The edit_many call can't tell whether it stored its edits: the store failed as it set them (${set}), and reading them back failed too (${read}). `
4286
+ + 'Read its files before sending it again: a second call can apply its edits twice.'
4287
+ : `Whether the edit of "${[...targets.values()][0]!.path}" is stored isn't known: the store failed as it set it (${set}), and reading it back failed too (${read}). `
4288
+ + 'Read the file before sending the edit again: a second edit can apply the change twice.',
4289
+ };
4290
+ }
4291
+ // The commit sets every entry in one record or none, and a set that
4292
+ // threw was read back, so nothing is stored; or every change was one
4293
+ // the tree already held, which leaves the tree as it was either way.
4294
+ if (tool === 'edit_many') return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
4295
+ throw error;
4296
+ }
4297
+ }
4298
+
4299
+ /**
4300
+ * Where an edit's path is in its mount, or why the call can't edit it: a
4301
+ * path no mount holds, one outside its mount or holding a lone surrogate
4302
+ * (parsePath), one a nested mount holds or that leads into one through a
4303
+ * link (parseFilePath), a path that names its mount's root, a read-only
4304
+ * mount, or for edit_many a generated file. edit's
4305
+ * generated file is refused before it gets here, by the same rule
4306
+ * (handleToolCall checks a call's `path`, which edit_many's input lacks).
4307
+ */
4308
+ private async editablePath(
4309
+ tool: EditTool,
4310
+ path: string,
4311
+ callerAgentName: string | undefined,
4312
+ ): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
4313
+ const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: true });
4314
+ try {
4315
+ // In the one spelling every file tool reads, as disk spells it, so two
4316
+ // spellings of a file are one entry and, in one call, one plan
4317
+ // (parsePathAsGiven); and a path in a mount nested inside this one is
4318
+ // that mount's, which every file tool refuses here (parseFilePath).
4319
+ const where = await this.parseFilePath(path);
4320
+ // One that names the mount's root, its bare name included, names no
4321
+ // file.
4322
+ if (where.relativePath === '') return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
4323
+ if (where.mount.config.mode === 'read-only') return unusable(`Mount "${where.mount.config.name}" is read-only`);
4324
+ const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4325
+ if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4326
+ return where;
4327
+ } catch (error) {
4328
+ return unusable(error instanceof Error ? error.message : String(error));
3218
4329
  }
3219
4330
  }
3220
4331
 
4332
+ /**
4333
+ * Why a call can't change a generated file: another agent's is that agent's,
4334
+ * and the agent's own is read-only, since it shows the agent's tool
4335
+ * presentation, which set_tool_visibility and set_tool_description change.
4336
+ */
4337
+ private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
4338
+ return callerAgentName !== generated.agentName
4339
+ ? 'Generated file belongs to another agent'
4340
+ : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4341
+ }
4342
+
4343
+ /**
4344
+ * The answer to edits stored but not finished: the commit set every file's
4345
+ * entry, and then disk refused some of their pushes, or the store failed
4346
+ * after recording them, so their push didn't run, or the rest of the call
4347
+ * failed. It stays an error, since part of the call failed, and leads with
4348
+ * what is stored, which must not be sent again: a second call can apply
4349
+ * those edits twice. edit's is the answer write and delete give, one path's;
4350
+ * edit_many's lists the files disk refused, each with its push's reason,
4351
+ * notes each file disk took with its mode left as disk had it, as the
4352
+ * success answer's `modeKept` does, within `maxChars`, and never says to
4353
+ * send the call again. Where the store failed after recording the edits,
4354
+ * which stand as on a success, it names each conflict the success answer
4355
+ * would (`conflicts`, found only where the mount doesn't push).
4356
+ */
4357
+ private editsStoredButUnfinished(
4358
+ tool: EditTool,
4359
+ targets: EditTarget[],
4360
+ applied: Map<EditTarget, string | null>,
4361
+ conflicts: Map<EditTarget, string>,
4362
+ outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
4363
+ maxChars: number,
4364
+ ): ToolResult {
4365
+ const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4366
+ if (tool === 'edit') {
4367
+ const [target] = targets;
4368
+ const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
4369
+ return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
4370
+ }
4371
+ const stored = "Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.";
4372
+ // The head is never cut, so what stands for the whole call goes in it,
4373
+ // and the parts are the files disk refused, each with its push's reason.
4374
+ let head: string;
4375
+ const parts: string[] = [];
4376
+ if ('refused' in outcome) {
4377
+ const files = targets.length;
4378
+ const refused = outcome.refused.size;
4379
+ head = `${stored}\n\n${files === 1
4380
+ ? "Disk didn't take the call's one file. Its push didn't complete:"
4381
+ : `Disk took ${files - refused} of the call's ${files} files. ${refused === 1
4382
+ ? "This file's push didn't complete:"
4383
+ : "These files' pushes didn't complete, each with its reason:"}`}`;
4384
+ for (const target of targets) {
4385
+ const reason = outcome.refused.get(target.relativePath);
4386
+ if (reason !== undefined) parts.push(`${target.path}: ${reason}`);
4387
+ }
4388
+ } else if ('storeFailed' in outcome) {
4389
+ head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4390
+ + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4391
+ } else {
4392
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4393
+ }
4394
+ const kept = 'modeKept' in outcome
4395
+ ? targets.flatMap((target) => {
4396
+ const reason = outcome.modeKept.get(target.relativePath);
4397
+ return reason === undefined ? [] : [`Disk took ${target.path}: ${reason}.`];
4398
+ })
4399
+ : [];
4400
+ const notes = [
4401
+ ...kept,
4402
+ ...targets.flatMap((target) => [applied.get(target) ?? null, conflictOf(target)]).filter((note): note is string => !!note),
4403
+ ];
4404
+ return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
4405
+ }
4406
+
3221
4407
  private async handleDelete(input: DeleteInput): Promise<ToolResult> {
3222
4408
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3223
4409
  input = { ...input, path };
@@ -3229,11 +4415,12 @@ export class WorkspaceModule implements Module {
3229
4415
  let committed = false;
3230
4416
  try {
3231
4417
  return await this.withMount(mount, async (): Promise<ToolResult> => {
3232
- const report = await this.ensureSynced(mount, relativePath);
4418
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
3233
4419
  await this.settleBeforeMutation(mount, relativePath);
3234
4420
  const entry = store.treeGet(mount.treeStateId, relativePath);
3235
4421
  if (!entry) {
3236
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
4422
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4423
+ return { success: false, error, isError: true };
3237
4424
  }
3238
4425
 
3239
4426
  const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
@@ -3250,68 +4437,162 @@ export class WorkspaceModule implements Module {
3250
4437
  });
3251
4438
  } catch (error) {
3252
4439
  if (!committed) throw error;
4440
+ if (error instanceof StoredThenFailed) {
4441
+ // Its note, as the success answer's: a deletion that cleared a
4442
+ // conflict naming a disk file unlinked nothing.
4443
+ const note = error.notes.get(relativePath);
4444
+ return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
4445
+ note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
4446
+ }
3253
4447
  return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
3254
4448
  }
3255
4449
  }
3256
4450
 
3257
4451
  private async handleLs(input: LsInput): Promise<ToolResult> {
3258
- const store = this.getStore();
3259
-
3260
4452
  if (!input.path) {
3261
- // List all mounts
4453
+ // List all mounts, with the kind a mount configured direct runs as
4454
+ // this session and the ignore list it walks by, whose default no
4455
+ // config shows, and each with its include where it has one, so the
4456
+ // files a mount holds show before a path outside them is refused.
3262
4457
  const mounts = [...this.mounts.entries()].map(([name, m]) => ({
3263
4458
  name,
3264
4459
  path: m.config.path,
3265
4460
  mode: m.config.mode,
4461
+ ...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
4462
+ ...(m.config.include ? { include: [...m.config.include] } : {}),
3266
4463
  }));
3267
4464
  return { success: true, data: { mounts } };
3268
4465
  }
3269
4466
 
3270
- const { mount, relativePath, path } = await this.parsePath(input.path);
4467
+ const { mount, relativePath, path, moved, root } = await this.parsePath(input.path);
4468
+ // A path outside the mount's include that's no scope (refusesAsOutside)
4469
+ // is refused, as glob refuses it, and shown as given.
4470
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4471
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4472
+ }
3271
4473
  input = { ...input, path };
3272
4474
  const recursive = input.recursive === true;
3273
4475
 
3274
4476
  // Every listing checks the paths it shows against disk first, under the
3275
- // three-way rule, whatever the mount's watch mode.
3276
- return this.withMount(mount, async (): Promise<ToolResult> => {
3277
- const pass = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive });
3278
- const reports = [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path));
3279
- const incomplete = pass.incomplete.length > 0 ? { incomplete: pass.incomplete } : {};
3280
-
3281
- if (recursive) {
3282
- return {
3283
- success: true,
3284
- data: {
3285
- path: input.path,
3286
- entries: reports.map((r) => ({ path: r.path, ...stateView(r) })),
3287
- count: reports.length,
3288
- ...incomplete,
3289
- },
3290
- };
3291
- }
3292
-
3293
- // Non-recursive: the scope's files, and the directories beneath it that
3294
- // disk or the workspace has.
3295
- const prefix = relativePath ? relativePath + '/' : '';
3296
- const dirNames = new Set(pass.dirs.map((d) => d.slice(prefix.length)));
3297
- for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
3298
- const slash = entry.path.indexOf('/', prefix.length);
3299
- if (slash >= 0) dirNames.add(entry.path.slice(prefix.length, slash));
3300
- }
3301
- const children: Array<Record<string, unknown>> = [
3302
- ...[...dirNames].sort().map((name) => ({ name, type: 'directory' })),
3303
- ...reports.map((r) => ({ name: r.path.slice(prefix.length), type: 'file', ...stateView(r) })),
3304
- ];
4477
+ // three-way rule, whatever the mount's watch mode; a direct mount's
4478
+ // shows disk's.
4479
+ const listing = await this.listingOf(mount, relativePath, recursive);
4480
+ const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
4481
+ if (recursive) {
3305
4482
  return {
3306
4483
  success: true,
3307
4484
  data: {
3308
4485
  path: input.path,
3309
- entries: children,
3310
- count: children.length,
4486
+ entries: listing.files,
4487
+ count: listing.files.length,
3311
4488
  ...incomplete,
3312
4489
  },
3313
4490
  };
4491
+ }
4492
+
4493
+ // Non-recursive: the scope's files, and the directories beneath it.
4494
+ const prefix = relativePath ? relativePath + '/' : '';
4495
+ const children: Array<Record<string, unknown>> = [
4496
+ ...listing.dirs.map((d) => d.slice(prefix.length)).sort().map((name) => ({ name, type: 'directory' })),
4497
+ ...listing.files.map(({ path: file, ...view }) => ({ name: file.slice(prefix.length), type: 'file', ...view })),
4498
+ ];
4499
+ return {
4500
+ success: true,
4501
+ data: {
4502
+ path: input.path,
4503
+ entries: children,
4504
+ count: children.length,
4505
+ ...incomplete,
4506
+ },
4507
+ };
4508
+ }
4509
+
4510
+ /**
4511
+ * What a listing shows of a directory scope, from where the mount keeps
4512
+ * what it reads: the store-backed kind's pass (listingFromStore), or, on a
4513
+ * direct mount, disk (listingFromDisk). `keep` chooses the files shown.
4514
+ */
4515
+ private listingOf(mount: MountState, dir: string, recursive: boolean, keep?: (path: string) => boolean): Promise<Listing> {
4516
+ return mount.direct ? this.listingFromDisk(mount, dir, recursive, keep) : this.listingFromStore(mount, dir, recursive, keep);
4517
+ }
4518
+
4519
+ /**
4520
+ * A 'chronicle' mount's listing: a pass over the scope under the three-way
4521
+ * rule, each path it decided with its state, and the directories beneath
4522
+ * the scope that disk or the workspace has.
4523
+ */
4524
+ private listingFromStore(mount: MountState, dir: string, recursive: boolean, keep?: (path: string) => boolean): Promise<Listing> {
4525
+ const store = this.getStore();
4526
+ return this.withMount(mount, async (): Promise<Listing> => {
4527
+ const pass = await this.passUnlocked(mount, { kind: 'dir', dir, recursive });
4528
+ const files = [...pass.reports.values()]
4529
+ .sort((a, b) => a.path.localeCompare(b.path))
4530
+ .filter((report) => keep === undefined || keep(report.path))
4531
+ .map((report) => ({ path: report.path, ...stateView(report) }));
4532
+ if (recursive) return { files, dirs: [], incomplete: pass.incomplete };
4533
+ // Read with the pass's reports, on the branch it decided on, before
4534
+ // anything else takes the mount's turn.
4535
+ const dirs = new Set(pass.dirs);
4536
+ const prefix = dir ? dir + '/' : '';
4537
+ // History implies a directory only where it's the mount's own to name
4538
+ // (withinIncludes): not history in a nested mount's directory that
4539
+ // that mount's include leaves out.
4540
+ const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
4541
+ for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
4542
+ if (!withinIncludes(own, entry.path)) continue;
4543
+ const slash = entry.path.indexOf('/', prefix.length);
4544
+ if (slash >= 0) dirs.add(entry.path.slice(0, slash));
4545
+ }
4546
+ return { files, dirs: [...dirs], incomplete: pass.incomplete };
4547
+ });
4548
+ }
4549
+
4550
+ /**
4551
+ * A direct mount's listing: its walk of the scope (diskWalk), with each
4552
+ * entry it took as disk has it now, observed as a pass observes it: a
4553
+ * file's size, or why the mount doesn't take it (a link it doesn't follow,
4554
+ * one that leaves the mount, what isn't a regular file). A file gone since
4555
+ * the walk listed it is no longer there to show.
4556
+ */
4557
+ private async listingFromDisk(mount: MountState, dir: string, recursive: boolean, keep?: (path: string) => boolean): Promise<Listing> {
4558
+ const { view, rootReal, taken, dirs, incomplete } = await this.diskWalk(mount, dir, recursive);
4559
+ const files: Listing['files'] = [];
4560
+ await inOrder(taken.filter((path) => keep === undefined || keep(path)), DIRECT_READS_AT_ONCE, (path) => observePath(view, rootReal, path), (seen, path) => {
4561
+ if (seen.kind === 'file') files.push({ path, size: seen.size });
4562
+ else if (seen.kind === 'unobserved') files.push({ path, note: seen.reason });
3314
4563
  });
4564
+ return { files, dirs, incomplete };
4565
+ }
4566
+
4567
+ /**
4568
+ * A direct mount's walk of a scope, from its view (diskView): the entries
4569
+ * it took that aren't directories (files, links and the like), in path
4570
+ * order; the directories directly beneath a scope it doesn't descend; and
4571
+ * the regions it couldn't see that a pass reports beside no tracked file:
4572
+ * what it couldn't list, what the cap left, and a scope in another mount's
4573
+ * directory, said as a listing says it. A path the walk names is walked
4574
+ * whatever the mount's ignore list or an editor's file name covers there
4575
+ * (MountConfig.ignore), as a path sync's walk is.
4576
+ */
4577
+ private async diskWalk(mount: MountState, dir: string, recursive: boolean): Promise<{
4578
+ view: MountView; rootReal: string; taken: string[]; dirs: string[]; incomplete: Array<{ path: string; reason: string }>;
4579
+ }> {
4580
+ const { view, rootReal, moved } = await this.diskView(mount);
4581
+ const walk = await walkScope(view, rootReal, dir, recursive, mount.config.walkCap ?? WALK_CAP, { named: true });
4582
+ const incomplete = walk.incomplete
4583
+ .filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
4584
+ .map((region) => ({
4585
+ path: region.path,
4586
+ reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved) : region.reason,
4587
+ }));
4588
+ return {
4589
+ view,
4590
+ rootReal,
4591
+ taken: [...walk.files, ...walk.othersTaken].sort((a, b) => a.localeCompare(b)),
4592
+ // A walk that doesn't descend sees the scope's own subdirectories alone.
4593
+ dirs: recursive ? [] : [...walk.dirs],
4594
+ incomplete,
4595
+ };
3315
4596
  }
3316
4597
 
3317
4598
  private async handleGlob(input: GlobInput): Promise<ToolResult> {
@@ -3322,7 +4603,16 @@ export class WorkspaceModule implements Module {
3322
4603
  // Search across mounts
3323
4604
  const mountsToSearch = input.path
3324
4605
  ? [await this.parsePath(input.path)]
3325
- : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
4606
+ : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '', moved: null, root: resolve(m.config.path) }));
4607
+ // The path is a directory to search in. One outside the mount's include
4608
+ // that's no scope (refusesAsOutside) is refused, as grep refuses it,
4609
+ // rather than searched and reported as matching nothing.
4610
+ if (input.path) {
4611
+ const { mount, relativePath, moved, root } = mountsToSearch[0]!;
4612
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4613
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4614
+ }
4615
+ }
3326
4616
 
3327
4617
  // Every match lies beneath the pattern's literal directory, so the walk
3328
4618
  // starts there: past the walk's cap, a walk of the whole scope can stop
@@ -3330,15 +4620,10 @@ export class WorkspaceModule implements Module {
3330
4620
  const patternDir = literalDirOf(input.pattern);
3331
4621
  for (const { mount, relativePath } of mountsToSearch) {
3332
4622
  const start = await this.walkStart(mount, relativePath, patternDir);
3333
- const pass = await this.withMount(mount, () =>
3334
- this.passUnlocked(mount, { kind: 'dir', dir: start, recursive: true }));
3335
- for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
3336
- const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
3337
- if (regex.test(testPath)) {
3338
- matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) });
3339
- }
3340
- }
3341
- for (const region of pass.incomplete) {
4623
+ const matched = (path: string) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
4624
+ const listing = await this.listingOf(mount, start, true, matched);
4625
+ for (const { path, ...view } of listing.files) matches.push({ path: `${mount.config.name}/${path}`, ...view });
4626
+ for (const region of listing.incomplete) {
3342
4627
  incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
3343
4628
  }
3344
4629
  }
@@ -3355,7 +4640,6 @@ export class WorkspaceModule implements Module {
3355
4640
  }
3356
4641
 
3357
4642
  private async handleGrep(input: GrepInput): Promise<ToolResult> {
3358
- const store = this.getStore();
3359
4643
  let regex: RegExp;
3360
4644
  try {
3361
4645
  regex = new RegExp(input.pattern);
@@ -3374,11 +4658,19 @@ export class WorkspaceModule implements Module {
3374
4658
 
3375
4659
  const mountsToSearch = input.path
3376
4660
  ? [await this.parsePath(input.path)]
3377
- : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
4661
+ : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '', moved: null, root: resolve(m.config.path) }));
4662
+ // A path outside the mount's include that's no scope is refused, as read
4663
+ // refuses it, rather than searched and reported as holding no match.
4664
+ if (input.path) {
4665
+ const { mount, relativePath, moved, root } = mountsToSearch[0]!;
4666
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4667
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4668
+ }
4669
+ }
3378
4670
 
3379
4671
  type GrepMatch = { line: number; text: string; context?: string[] };
3380
- const results: Array<{ file: string; state: string; version: 'workspace' | 'conflicting-disk' | 'recorded-disk'; matches: GrepMatch[] }> = [];
3381
- const skipped: Array<{ file: string; reason: string; size?: number; mimeType?: string }> = [];
4672
+ const results: Array<{ file: string; state?: string; version: GrepText['version']; matches: GrepMatch[] }> = [];
4673
+ const skipped: GrepSkip[] = [];
3382
4674
  const incomplete: Array<{ path: string; reason: string }> = [];
3383
4675
 
3384
4676
  const search = (content: string): GrepMatch[] => {
@@ -3398,12 +4690,13 @@ export class WorkspaceModule implements Module {
3398
4690
  return found;
3399
4691
  };
3400
4692
 
3401
- for (const { mount, relativePath } of mountsToSearch) {
4693
+ for (const { mount, relativePath, moved, root } of mountsToSearch) {
3402
4694
  // A path named in another mount's directory, as holderOf resolves it,
3403
4695
  // is that mount's: grep searches nothing of this mount's there, and
3404
- // says whose it is.
3405
- if (this.holderOf(mount.config.name, relativePath)) {
3406
- incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list') });
4696
+ // says whose it is. A direct mount's is judged by the nesting its parse
4697
+ // found where its root lies now (movedNesting).
4698
+ if (this.holderOf(mount.config.name, relativePath, false, moved)) {
4699
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
3407
4700
  continue;
3408
4701
  }
3409
4702
  // A `path` and a filter whose directories don't overlap leave no file
@@ -3411,110 +4704,16 @@ export class WorkspaceModule implements Module {
3411
4704
  const deeper = deeperOf(relativePath, filterDir);
3412
4705
  if (deeper === null) continue;
3413
4706
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
3414
- await this.withMount(mount, async () => {
3415
- // A `path` naming a single file greps just that file; otherwise it is a
3416
- // directory prefix. (A file path once produced the prefix "notes.md/"
3417
- // and silently matched nothing.)
3418
- const fileScope = relativePath !== '' && (
3419
- store.treeGet(mount.treeStateId, relativePath) !== null ||
3420
- await lstat(join(mount.config.path, relativePath)).then((s) => !s.isDirectory(), () => false)
3421
- );
3422
- // What follows reads the tree and branch intent to go with the pass's
3423
- // reports, synchronously. They must be the branch the pass decided
3424
- // on: one selected in between would label its content with the other
3425
- // branch's states. So a pass whose branch is no longer selected runs
3426
- // again, and a branch that keeps changing leaves the scope unsearched.
3427
- //
3428
- // A copy held at a path that now leads into a nested mount's directory
3429
- // through a link is that mount's file too: read refuses it
3430
- // (parseFilePath), so grep searches none of it and says why. The pass
3431
- // can't check such a path's disk, so only an unverified one can be
3432
- // one. Asking awaits disk, so it's done before the branch is
3433
- // compared, and a branch selected meanwhile runs the pass again.
3434
- let pass: PassResult | null = null;
3435
- let refused = new Map<string, string>();
3436
- for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
3437
- const next = await this.passUnlocked(mount, fileScope
3438
- ? { kind: 'paths', paths: [relativePath] }
3439
- : { kind: 'dir', dir: start, recursive: true });
3440
- const asked = new Map<string, string>();
3441
- for (const report of next.reports.values()) {
3442
- if (report.state !== 'unverified') continue;
3443
- const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
3444
- if (refusal) asked.set(report.path, refusal.message);
3445
- }
3446
- if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
3447
- pass = next;
3448
- refused = asked;
3449
- }
3450
- }
3451
- if (pass === null) {
3452
- incomplete.push({
3453
- path: `${mount.config.name}/${relativePath}`,
3454
- reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
3455
- });
3456
- return;
3457
- }
3458
- for (const region of pass.incomplete) {
3459
- incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
3460
- }
3461
-
3462
- const intents = this.intents.get(mount.config.name)!;
3463
- for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
3464
- if (fileGlob && !fileGlob.test(report.path)) continue;
3465
- const file = `${mount.config.name}/${report.path}`;
3466
- if (report.state === 'disk-only') {
3467
- skipped.push({
3468
- file,
3469
- reason: 'disk-only: binary or over the size limit, never stored',
3470
- ...(report.size !== undefined ? { size: report.size } : {}),
3471
- ...(report.mimeType ? { mimeType: report.mimeType } : {}),
3472
- });
3473
- continue;
3474
- }
3475
- // A path in another mount's directory is that mount's: grep gives
3476
- // none of this mount's copy of it, as read refuses it. Beneath a
3477
- // scope it's passed over by design, like the directory.
3478
- if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
3479
- const refusal = refused.get(report.path);
3480
- if (refusal !== undefined) {
3481
- skipped.push({ file, reason: refusal });
3482
- continue;
3483
- }
3484
- // The workspace's version, labelled with the path's state.
3485
- const entry = store.treeGet(mount.treeStateId, report.path);
3486
- const blob = entry ? store.getBlob(entry.blobHash) : null;
3487
- if (blob) {
3488
- const found = search(blob.toString('utf-8'));
3489
- if (found.length > 0) results.push({ file, state: report.state, version: 'workspace', matches: found });
3490
- } else if (entry) {
3491
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
3492
- }
3493
- if (report.state !== 'conflict') continue;
3494
- // A conflict's disk side. The version recorded with the conflict is
3495
- // disk's only while disk still holds it; once disk has changed
3496
- // since, it is labelled as recorded, and a file disk holds now
3497
- // (never stored) is listed as not searched. A binary or oversize
3498
- // disk version was never stored either.
3499
- const disk = intents.get(report.path)?.conflict?.disk;
3500
- const changed = report.conflict?.diskChangedSinceRecorded === true;
3501
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
3502
- if (diskBlob) {
3503
- const found = search(diskBlob.toString('utf-8'));
3504
- if (found.length > 0) results.push({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', matches: found });
3505
- }
3506
- if (disk?.stored && !diskBlob) {
3507
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
3508
- }
3509
- if (changed) {
3510
- if (report.diskNow === 'file') {
3511
- skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
3512
- }
3513
- } else if (disk && !disk.stored) {
3514
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
3515
- }
3516
- }
3517
- });
4707
+ const searched = (text: GrepText): void => {
4708
+ const matches = search(text.text);
4709
+ if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
4710
+ };
4711
+ const kept = (path: string): boolean => fileGlob === null || fileGlob.test(path);
4712
+ const found = mount.direct
4713
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
4714
+ : await this.textsFromStore(mount, relativePath, start, kept, searched);
4715
+ skipped.push(...found.skipped);
4716
+ incomplete.push(...found.incomplete);
3518
4717
  }
3519
4718
 
3520
4719
  return {
@@ -3529,6 +4728,169 @@ export class WorkspaceModule implements Module {
3529
4728
  };
3530
4729
  }
3531
4730
 
4731
+ /**
4732
+ * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
4733
+ * single file `relativePath` names, those `keep` lets through, each handed
4734
+ * to `each` in path order: a pass over the scope, then each path's
4735
+ * workspace copy, labelled with its state, and a conflict's disk side.
4736
+ * What it doesn't search is in `skipped`, and what it couldn't see in
4737
+ * `incomplete`, each mount-prefixed.
4738
+ */
4739
+ private async textsFromStore(
4740
+ mount: MountState,
4741
+ relativePath: string,
4742
+ start: string,
4743
+ keep: (path: string) => boolean,
4744
+ each: (text: GrepText) => void,
4745
+ ): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
4746
+ const store = this.getStore();
4747
+ const skipped: GrepSkip[] = [];
4748
+ const incomplete: Array<{ path: string; reason: string }> = [];
4749
+ await this.withMount(mount, async () => {
4750
+ // A `path` naming a single file greps just that file; otherwise it is a
4751
+ // directory prefix. (A file path once produced the prefix "notes.md/"
4752
+ // and silently matched nothing.)
4753
+ const fileScope = await this.namesFile(mount, relativePath, resolve(mount.config.path));
4754
+ // What follows reads the tree and branch intent to go with the pass's
4755
+ // reports, synchronously. They must be the branch the pass decided
4756
+ // on: one selected in between would label its content with the other
4757
+ // branch's states. So a pass whose branch is no longer selected runs
4758
+ // again, and a branch that keeps changing leaves the scope unsearched.
4759
+ //
4760
+ // A copy held at a path that now leads into a nested mount's directory
4761
+ // through a link is that mount's file too, and one that now lands on a
4762
+ // file this mount's include leaves out is no file of this mount's:
4763
+ // read refuses either (parseFilePath), so grep searches none of it and
4764
+ // says why. The pass
4765
+ // can't check such a path's disk, so only an unverified one can be
4766
+ // one. Asking awaits disk, so it's done before the branch is
4767
+ // compared, and a branch selected meanwhile runs the pass again.
4768
+ let pass: PassResult | null = null;
4769
+ let refused = new Map<string, string>();
4770
+ for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
4771
+ const next = await this.passUnlocked(mount, fileScope
4772
+ ? { kind: 'paths', paths: [relativePath] }
4773
+ : { kind: 'dir', dir: start, recursive: true });
4774
+ const asked = new Map<string, string>();
4775
+ for (const report of next.reports.values()) {
4776
+ if (report.state !== 'unverified') continue;
4777
+ const refusal = await this.landingRefusal(mount, report.path, `${mount.config.name}/${report.path}`, this.nestedOf(mount.config.name), resolve(mount.config.path));
4778
+ if (refusal) asked.set(report.path, refusal.message);
4779
+ }
4780
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
4781
+ pass = next;
4782
+ refused = asked;
4783
+ }
4784
+ }
4785
+ if (pass === null) {
4786
+ incomplete.push({
4787
+ path: `${mount.config.name}/${relativePath}`,
4788
+ reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
4789
+ });
4790
+ return;
4791
+ }
4792
+ for (const region of pass.incomplete) {
4793
+ incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
4794
+ }
4795
+
4796
+ const intents = this.intents.get(mount.config.name)!;
4797
+ for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
4798
+ if (!keep(report.path)) continue;
4799
+ const file = `${mount.config.name}/${report.path}`;
4800
+ if (report.state === 'disk-only') {
4801
+ skipped.push({
4802
+ file,
4803
+ reason: 'disk-only: binary or over the size limit, never stored',
4804
+ ...(report.size !== undefined ? { size: report.size } : {}),
4805
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
4806
+ });
4807
+ continue;
4808
+ }
4809
+ // A path in another mount's directory is that mount's: grep gives
4810
+ // none of this mount's copy of it, as read refuses it. Beneath a
4811
+ // scope it's passed over by design, like the directory.
4812
+ if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
4813
+ const refusal = refused.get(report.path);
4814
+ if (refusal !== undefined) {
4815
+ skipped.push({ file, reason: refusal });
4816
+ continue;
4817
+ }
4818
+ // The workspace's version, labelled with the path's state.
4819
+ const entry = store.treeGet(mount.treeStateId, report.path);
4820
+ const blob = entry ? store.getBlob(entry.blobHash) : null;
4821
+ if (blob) {
4822
+ each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
4823
+ } else if (entry) {
4824
+ skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
4825
+ }
4826
+ if (report.state !== 'conflict') continue;
4827
+ // A conflict's disk side. The version recorded with the conflict is
4828
+ // disk's only while disk still holds it; once disk has changed
4829
+ // since, it is labelled as recorded, and a file disk holds now
4830
+ // (never stored) is listed as not searched. A binary or oversize
4831
+ // disk version was never stored either.
4832
+ const disk = intents.get(report.path)?.conflict?.disk;
4833
+ const changed = report.conflict?.diskChangedSinceRecorded === true;
4834
+ const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
4835
+ if (diskBlob) {
4836
+ each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
4837
+ }
4838
+ if (disk?.stored && !diskBlob) {
4839
+ skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
4840
+ }
4841
+ if (changed) {
4842
+ if (report.diskNow === 'file') {
4843
+ skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
4844
+ }
4845
+ } else if (disk && !disk.stored) {
4846
+ skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
4847
+ }
4848
+ }
4849
+ });
4850
+ return { skipped, incomplete };
4851
+ }
4852
+
4853
+ /**
4854
+ * The texts a direct mount's grep searches beneath `start`, or in the
4855
+ * single file `relativePath` names, as namesFile judges it beneath `root`,
4856
+ * the root grep's parse resolved, those `keep` lets through, each handed
4857
+ * to `each` in path order: each file its walk took (diskWalk), read from
4858
+ * disk as it is now, DIRECT_READS_AT_ONCE at a time. A file over the size
4859
+ * limit, a binary one and one the mount doesn't take are in `skipped`,
4860
+ * each with why, as a 'chronicle' mount's grep lists what it never stored;
4861
+ * one gone since the walk listed it isn't there to search.
4862
+ */
4863
+ private async textsFromDisk(
4864
+ mount: MountState,
4865
+ relativePath: string,
4866
+ root: string,
4867
+ start: string,
4868
+ keep: (path: string) => boolean,
4869
+ each: (text: GrepText) => void,
4870
+ ): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
4871
+ const name = mount.config.name;
4872
+ const fileScope = await this.namesFile(mount, relativePath, root);
4873
+ const { view, rootReal, taken, incomplete } = fileScope
4874
+ ? { ...(await this.diskView(mount)), taken: [relativePath], incomplete: [] }
4875
+ : await this.diskWalk(mount, start, true);
4876
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4877
+ const skipped: GrepSkip[] = [];
4878
+ await inOrder(taken.filter(keep), DIRECT_READS_AT_ONCE, (path) => readBytes(view, rootReal, path, maxSize), (read, path) => {
4879
+ const file = `${name}/${path}`;
4880
+ if (read.kind === 'absent') return;
4881
+ if (read.kind === 'unobserved') {
4882
+ skipped.push({ file, reason: read.reason });
4883
+ return;
4884
+ }
4885
+ const mimeType = sniffImageMime(read.head);
4886
+ const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
4887
+ if (read.bytes === null) skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
4888
+ else if (looksBinary(read.head)) skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
4889
+ else each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
4890
+ });
4891
+ return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
4892
+ }
4893
+
3532
4894
  private async handleStatus(input: StatusInput): Promise<ToolResult> {
3533
4895
  const store = this.getStore();
3534
4896
  const status: Record<string, unknown> = {};
@@ -3538,25 +4900,30 @@ export class WorkspaceModule implements Module {
3538
4900
 
3539
4901
  for (const [name, mount] of this.mounts) {
3540
4902
  if (input.mount !== undefined && name !== input.mount) continue;
3541
- const entries = store.treeList(mount.treeStateId);
4903
+ if (mount.direct) {
4904
+ // A direct mount keeps nothing to count: its kind, and the ignore
4905
+ // list its walks go by, whose default no config shows.
4906
+ status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
4907
+ continue;
4908
+ }
4909
+ // What the workspace's history holds outside the mount's include isn't the mount's,
4910
+ // and nor is what it holds in a nested mount's directory that mount's include leaves
4911
+ // out (withinIncludes), which no listing names either.
4912
+ const listable = { include: mount.config.include, nested: this.nestedOf(name) };
4913
+ const entries = store.treeList(mount.treeStateId).filter((e) => withinIncludes(listable, e.path));
3542
4914
  const currentSeq = store.currentSequence();
3543
- const own = this.ownPaths(mount);
3544
- const intents = this.intents.get(name)!.list().filter(([p]) => own(p));
3545
- const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
3546
- // A workspace deletion is pending until a push confirms it on disk:
3547
- // left there without applyDeletions, or unlinked but unconfirmed.
3548
- const deletionPaths = intents.filter(([, bi]) => bi.tombstone).map(([p]) => p);
3549
- // Pending is what disk owes by the evidence: every entry it doesn't
3550
- // show on disk (a refused or failed push stays owed), conflicts and
3551
- // deletions. An entry from before the evidence isn't counted until a
3552
- // listing or a materialize checks it: unchecked isn't unpushed.
3553
- const pending = new Set([...conflictPaths, ...deletionPaths, ...this.entriesByEvidence(mount).owed]);
4915
+ const { conflicts: conflictPaths, deletions: deletionPaths, owed } = this.heldChanges(mount);
4916
+ const pending = new Set([...conflictPaths, ...deletionPaths, ...owed]);
3554
4917
 
3555
4918
  const currentBranch = store.currentBranch();
3556
4919
  status[name] = {
3557
4920
  path: mount.config.path,
3558
4921
  mode: mount.config.mode,
3559
- watch: mount.config.watch ?? 'always',
4922
+ // A mount configured direct runs as 'chronicle' this session
4923
+ // (chooseKinds), by the direct kind's ignore list where its config
4924
+ // names none.
4925
+ ...(mount.config.backend === 'direct' ? { backend: 'chronicle', ignore: this.ignoreOf(mount) } : {}),
4926
+ watch: watchOf(mount.config),
3560
4927
  fileCount: entries.length,
3561
4928
  lastMaterializedSeq: mount.lastMaterializedSeq,
3562
4929
  currentSeq,
@@ -3574,6 +4941,37 @@ export class WorkspaceModule implements Module {
3574
4941
  return { success: true, data: status };
3575
4942
  }
3576
4943
 
4944
+ /**
4945
+ * What the store holds of a mount that disk lacks, on the selected branch,
4946
+ * among the mount's own paths. Status counts the first three as pending:
4947
+ * recorded conflicts; workspace deletions, pending until a push confirms
4948
+ * them on disk (left there without applyDeletions, or unlinked but
4949
+ * unconfirmed); and what disk owes by the evidence (entriesByEvidence's
4950
+ * `owed`: every entry it doesn't show on disk, a refused or failed push's
4951
+ * included). An entry from before the evidence isn't among them until a
4952
+ * listing or a materialize checks it: unchecked isn't unpushed. A
4953
+ * tombstone beside an entry that's still there, left by a deletion or a
4954
+ * write that stopped partway (commitToolChanges), is no deletion: it holds
4955
+ * no change disk lacks, and its entry is judged by the evidence as any
4956
+ * other is. The fourth, intents that can't be read, may hold a deletion
4957
+ * or a conflict, so a mount configured direct counts them too (initStore).
4958
+ * Each lies where the mount's include covers, as status counts them: what
4959
+ * the workspace's history holds outside it is no change of the mount's,
4960
+ * and no push writes it to disk (pushUnlocked).
4961
+ */
4962
+ private heldChanges(mount: MountState): { conflicts: string[]; deletions: string[]; owed: string[]; unreadable: string[] } {
4963
+ const store = this.getStore();
4964
+ const own = this.ownPaths(mount);
4965
+ // What the workspace's history holds outside the mount's include isn't the mount's.
4966
+ const intents = this.intents.get(mount.config.name)!.list().filter(([p]) => insideMount(p, mount.config.include) && own(p));
4967
+ return {
4968
+ conflicts: intents.filter(([, bi]) => bi.conflict).map(([p]) => p),
4969
+ deletions: intents.filter(([p, bi]) => bi.tombstone && !store.treeGet(mount.treeStateId, p)).map(([p]) => p),
4970
+ owed: this.entriesByEvidence(mount).owed,
4971
+ unreadable: intents.filter(([, bi]) => bi.unreadable !== undefined).map(([p]) => p),
4972
+ };
4973
+ }
4974
+
3577
4975
  /**
3578
4976
  * What a materialize of `mount` should consider: a named file, or every
3579
4977
  * tracked path beneath a named directory; otherwise what the evidence says
@@ -3600,17 +4998,26 @@ export class WorkspaceModule implements Module {
3600
4998
 
3601
4999
  const { owed, unchecked, agreed } = this.entriesByEvidence(mount);
3602
5000
  for (const path of [...owed, ...unchecked]) selected.add(path);
3603
- for (const [p, bi] of intents) if (bi.conflict || bi.tombstone) selected.add(p);
5001
+ // A tombstone is a deletion only where its entry is gone: one beside an
5002
+ // entry that's still there, left by a change that stopped partway
5003
+ // (commitToolChanges), would select an agreed entry, and a forced push
5004
+ // would write it over a newer disk copy. A deletion or conflict held
5005
+ // outside the mount's include isn't the mount's, as status counts it:
5006
+ // not taken up, so not reported skipped.
5007
+ for (const [p, bi] of intents) {
5008
+ if ((bi.conflict || (bi.tombstone && !store.treeGet(mount.treeStateId, p))) && insideMount(p, mount.config.include)) selected.add(p);
5009
+ }
3604
5010
  return { paths: [...selected], inPlace: agreed > 0 };
3605
5011
  }
3606
5012
 
3607
5013
  /**
3608
5014
  * Whether a path of the mount is its own to push and to count as owed:
3609
5015
  * not in another mount's directory nested in it. Copies the mount's tree
3610
- * holds there (stored before the nesting) stay in its history and its
3611
- * listings (and in status's fileCount, which counts the tree), passed over
3612
- * by its syncs, and left out of what it pushes and of status's pending
3613
- * changes, conflicts and workspace deletions.
5016
+ * holds there (stored before the nesting) stay in its history, and in its
5017
+ * listings and status's fileCount where that mount's include covers them
5018
+ * (withinIncludes). Its syncs pass over them, and
5019
+ * they're left out of what it pushes and of status's pending changes,
5020
+ * conflicts and workspace deletions.
3614
5021
  */
3615
5022
  private ownPaths(mount: MountState): (path: string) => boolean {
3616
5023
  const others = this.othersOf(mount.config.name);
@@ -3639,7 +5046,9 @@ export class WorkspaceModule implements Module {
3639
5046
  let agreed = 0;
3640
5047
  const own = this.ownPaths(mount);
3641
5048
  for (const e of store.treeList(mount.treeStateId)) {
3642
- if (!own(e.path)) continue;
5049
+ // Outside the mount's include, or in another mount's directory nested
5050
+ // in it: neither owed to disk nor counted as on it.
5051
+ if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
3643
5052
  const p = agreement.get(name, e.path);
3644
5053
  if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
3645
5054
  else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
@@ -3660,7 +5069,16 @@ export class WorkspaceModule implements Module {
3660
5069
  const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
3661
5070
 
3662
5071
  let explicit: { mount: MountState; relativePath: string } | null = null;
3663
- if (input.path) explicit = await this.parsePath(input.path);
5072
+ if (input.path) {
5073
+ const parsed = await this.parsePath(input.path);
5074
+ explicit = parsed;
5075
+ // A path outside the mount's include that's no scope (refusesAsOutside)
5076
+ // is refused; beneath a directory, the push leaves out what the include
5077
+ // doesn't cover.
5078
+ if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
5079
+ return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
5080
+ }
5081
+ }
3664
5082
 
3665
5083
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
3666
5084
  if (explicit) {
@@ -3686,6 +5104,10 @@ export class WorkspaceModule implements Module {
3686
5104
  // whatever branch is current when its turn comes, unguarded. The push
3687
5105
  // then writes nothing if the branch changes after that.
3688
5106
  const blocked: Array<{ mount: string; reason: string }> = [];
5107
+ // What the push left because the mount's include doesn't cover it is
5108
+ // counted, as a sync's passedOver counts it, and not named: no tool names
5109
+ // a file outside the include.
5110
+ const passedOver: Array<{ mount: string; reason: string; count: number }> = [];
3689
5111
  const guarded: string[] = [];
3690
5112
  let proceeded = 0;
3691
5113
 
@@ -3731,10 +5153,13 @@ export class WorkspaceModule implements Module {
3731
5153
  for (const p of pushed.pendingDeletions) pendingDeletions.push({ mount: name, path: p });
3732
5154
  // Refusals ride the same skipped list as branch blocks: divergence must
3733
5155
  // be visible, not resolved silently either way.
5156
+ let outside = 0;
3734
5157
  for (const s of pushed.skipped) {
5158
+ if (s.reason === OUTSIDE_INCLUDE) { outside++; continue; }
3735
5159
  const reason = s.reason === NOT_PUSHED_IN_OTHER_MOUNT ? this.otherMountNote(name, s.path, 'push') : s.reason;
3736
5160
  blocked.push({ mount: name, reason: `${s.path}: ${reason}` });
3737
5161
  }
5162
+ if (outside > 0) passedOver.push({ mount: name, reason: OUTSIDE_INCLUDE, count: outside });
3738
5163
  // Written paths whose mode stayed as disk had it: no refusal, so not
3739
5164
  // among what was skipped.
3740
5165
  for (const k of pushed.modesKept) modesKept.push({ mount: name, ...k });
@@ -3780,6 +5205,7 @@ export class WorkspaceModule implements Module {
3780
5205
  }
3781
5206
  : {}),
3782
5207
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
5208
+ ...(passedOver.length > 0 ? { passedOver } : {}),
3783
5209
  ...(modesKept.length > 0 ? { modesKept } : {}),
3784
5210
  },
3785
5211
  // What disk lost, then what it was written: kept in the stub if the
@@ -3846,7 +5272,13 @@ export class WorkspaceModule implements Module {
3846
5272
  // A path names its own mount; otherwise the given mount, or every mount.
3847
5273
  let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
3848
5274
  if (input.path) {
3849
- const { mount, relativePath } = await this.parsePath(input.path);
5275
+ const { mount, relativePath, moved, root } = await this.parsePath(input.path);
5276
+ // A path outside the mount's include that's no scope (refusesAsOutside)
5277
+ // is refused; beneath a directory, the walk passes over what the include
5278
+ // doesn't cover.
5279
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5280
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5281
+ }
3850
5282
  targets = [{ name: mount.config.name, mount, relativePath }];
3851
5283
  } else if (input.mount) {
3852
5284
  const m = this.mounts.get(input.mount);
@@ -3859,6 +5291,12 @@ export class WorkspaceModule implements Module {
3859
5291
  }
3860
5292
 
3861
5293
  for (const { name, mount, relativePath } of targets) {
5294
+ // A direct mount's tools read disk as it is, so a sync has nothing to
5295
+ // take in there, and says it didn't look rather than that nothing changed.
5296
+ if (mount.direct) {
5297
+ allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
5298
+ continue;
5299
+ }
3862
5300
  const pass = await this.withMount(mount, async () => {
3863
5301
  if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
3864
5302
  if (!relativePath) {
@@ -3931,7 +5369,7 @@ export class WorkspaceModule implements Module {
3931
5369
  // without a path passes over it, so nothing else keeps it current
3932
5370
  // (agent-framework #276). A path in another mount's directory isn't
3933
5371
  // synced at all, and the note says so.
3934
- const passedOver = passedOverAt(relativePath, mount.config.ignore ?? [], this.othersOf(name));
5372
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
3935
5373
  if (passedOver !== undefined) {
3936
5374
  allIgnored.push({
3937
5375
  mount: name,
@@ -3995,11 +5433,14 @@ export class WorkspaceModule implements Module {
3995
5433
  * The watcher's ops only choose which paths to look at: each path is
3996
5434
  * observed again and decided under the three-way rule, so an event that
3997
5435
  * arrives late or out of order can't delete or revert anything by itself.
5436
+ * A path outside the mount (insideMount), such as one its include doesn't
5437
+ * cover, is no pass's to decide (gather), so a batch of nothing else takes
5438
+ * no turn of the mount.
3998
5439
  */
3999
5440
  private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
4000
5441
  const mount = this.mounts.get(mountName);
4001
5442
  if (!this.store || !this.running() || !mount) return;
4002
- const paths = [...new Set(changes.map((c) => c.path))];
5443
+ const paths = [...new Set(changes.map((c) => c.path))].filter((p) => insideMount(p, mount.config.include));
4003
5444
  if (paths.length === 0) return;
4004
5445
  await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'paths', paths }));
4005
5446
  }
@@ -4020,7 +5461,7 @@ export class WorkspaceModule implements Module {
4020
5461
  async onToolBatchComplete(_agentName?: string): Promise<void> {
4021
5462
  // Every agent's workspace is the store's current branch, so the agent
4022
5463
  // that ran the batch doesn't choose what is scanned.
4023
- const mounts = [...this.mounts.values()].filter((m) => m.config.watch === 'on-agent-action');
5464
+ const mounts = [...this.mounts.values()].filter((m) => watchOf(m.config) === 'on-agent-action');
4024
5465
  if (mounts.length === 0 || !this.store || !this.running()) return;
4025
5466
  const deadlineMs = this.agentActionScanDeadlineMs();
4026
5467
  await Promise.all(mounts.map(async (mount) => {
@@ -4169,6 +5610,72 @@ export class WorkspaceModule implements Module {
4169
5610
 
4170
5611
  }
4171
5612
 
5613
+ /**
5614
+ * A text grep searches (textsFromStore, textsFromDisk): its mount-prefixed
5615
+ * file, which copy it is, and on a 'chronicle' mount, the path's state.
5616
+ */
5617
+ interface GrepText {
5618
+ file: string;
5619
+ state?: string;
5620
+ version: 'workspace' | 'conflicting-disk' | 'recorded-disk' | 'disk';
5621
+ text: string;
5622
+ }
5623
+
5624
+ /** A file, or a copy of one, grep doesn't search, with why. */
5625
+ type GrepSkip = { file: string; reason: string; size?: number; mimeType?: string };
5626
+
5627
+ /** What a listing shows of a directory scope (listingOf). */
5628
+ interface Listing {
5629
+ /** Each file it shows, by mount-relative path, in order, with what a listing says of it beyond its name. */
5630
+ files: Array<{ path: string } & Record<string, unknown>>;
5631
+ /** For a listing that doesn't descend, the directories directly beneath the scope, mount-relative; none for one that does. */
5632
+ dirs: string[];
5633
+ /** The regions it couldn't see, mount-relative, each with why. */
5634
+ incomplete: Array<{ path: string; reason: string }>;
5635
+ }
5636
+
5637
+ /**
5638
+ * Run `work` on each item, at most `width` at once, and hand each result to
5639
+ * `each` in the items' order, as soon as it and those before it are done.
5640
+ * A result handed on is let go, so what's held at once is at most `width`
5641
+ * results, as a direct grep's file contents are. A `work` that rejects
5642
+ * rejects this when its turn comes; any started after it finish unheard.
5643
+ * Exported for its tests.
5644
+ */
5645
+ export async function inOrder<T, R>(items: readonly T[], width: number, work: (item: T) => Promise<R>, each: (result: R, item: T) => void): Promise<void> {
5646
+ const waiting: Array<Promise<R>> = [];
5647
+ let started = 0;
5648
+ for (let i = 0; i < items.length; i++) {
5649
+ while (started < items.length && started < i + width) {
5650
+ const running = work(items[started]!);
5651
+ // Handled from the start, so a rejection that comes before its turn
5652
+ // isn't an unhandled one; awaiting it below still throws it.
5653
+ running.catch(() => {});
5654
+ waiting.push(running);
5655
+ started++;
5656
+ }
5657
+ each(await waiting.shift()!, items[i]!);
5658
+ }
5659
+ }
5660
+
5661
+ /** read_image's answer for an image, wherever it was read from. */
5662
+ function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
5663
+ return {
5664
+ success: true,
5665
+ data: [
5666
+ {
5667
+ type: 'text',
5668
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
5669
+ },
5670
+ {
5671
+ type: 'image',
5672
+ data: image.bytes.toString('base64'),
5673
+ mimeType: image.mimeType,
5674
+ },
5675
+ ],
5676
+ };
5677
+ }
5678
+
4172
5679
  /** What a listing shows of a path beyond its name. */
4173
5680
  function stateView(report: PathReport): Record<string, unknown> {
4174
5681
  return {
@@ -4187,6 +5694,122 @@ function stateView(report: PathReport): Record<string, unknown> {
4187
5694
  /** A lone UTF-16 surrogate, which UTF-8 can't store: in `u` mode, a pair is one code point. */
4188
5695
  const LONE_SURROGATE = /\p{Cs}/u;
4189
5696
 
5697
+ /** Why a direct mount's read stopped when it found another directory put at its root as it opened the file. */
5698
+ const DIRECT_ROOT_REPLACED = 'the mount root was replaced as the file was opened: reading it again reads the directory there now';
5699
+
5700
+ /**
5701
+ * How the alert that a mount configured direct runs as 'chronicle' names an
5702
+ * intent the workspace can't read. A sync of its path settles it, with
5703
+ * disk's copy, or with none where disk has none, and where disk's file
5704
+ * can't be read it skips the path and says why. Materialize settles one
5705
+ * only where the workspace holds a copy there: a plain one skips it and says
5706
+ * to sync, a forced one writes the copy, and where the workspace holds none
5707
+ * it neither selects nor names it.
5708
+ */
5709
+ const UNREADABLE_INTENT = "its workspace intent can't be read; a sync of the path settles it or says why not";
5710
+
5711
+ /**
5712
+ * A mount's watch mode as it runs. A mount configured direct watches
5713
+ * nothing, whichever kind it runs as this session: its config takes no
5714
+ * other mode (directRefusals), and leaving it out means 'never' there.
5715
+ */
5716
+ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
5717
+ return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
5718
+ }
5719
+
5720
+ /**
5721
+ * What a direct mount's config sets that would never act there, each with
5722
+ * the way out (MountConfig.backend): a direct mount is read-only, and has no
5723
+ * watcher to tune, no change event to wake on and no push to make.
5724
+ */
5725
+ function directRefusals(mount: MountConfig): string[] {
5726
+ const refused: string[] = [];
5727
+ if (mount.mode !== 'read-only') {
5728
+ refused.push(mount.mode === undefined ? "mode must be 'read-only' (left out, it's 'read-write')" : `mode must be 'read-only', not ${shownValue(mount.mode)}`);
5729
+ }
5730
+ if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
5731
+ for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
5732
+ if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);
5733
+ }
5734
+ const wake = mount.wakeOnChange;
5735
+ if (wake === true || (Array.isArray(wake) && wake.length > 0)) refused.push('wakeOnChange would never wake (leave it out)');
5736
+ if (mount.autoMaterialize === true) refused.push('autoMaterialize would never push (leave it out)');
5737
+ return refused;
5738
+ }
5739
+
5740
+ /** A change commitToolChanges makes to one path's tree entry. */
5741
+ type ToolChange = { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string };
5742
+
5743
+ /** A file an edit or edit_many call edits: one stored path, and every edit naming it. */
5744
+ interface EditTarget {
5745
+ /** Its path in its mount, as disk spells it: the store's key for it. */
5746
+ relativePath: string;
5747
+ /** Its mount-prefixed path as disk spells it, which its answer uses. */
5748
+ path: string;
5749
+ /** Every edit naming it, by index, and the specs of those fit to be matched. */
5750
+ edits: number[];
5751
+ specs: EditSpec[];
5752
+ }
5753
+
5754
+ /**
5755
+ * Whether a tree entry is what a change leaves: for a set, an entry with its
5756
+ * blob, size and mode; for a removal, none.
5757
+ */
5758
+ function holds(entry: ReturnType<JsStore['treeGet']>, change: ToolChange): boolean {
5759
+ return change.kind === 'set'
5760
+ ? entry !== null && entry.blobHash === change.blobHash && entry.size === change.size && entry.mode === (change.mode ?? 0o644)
5761
+ : entry === null;
5762
+ }
5763
+
5764
+ /**
5765
+ * Whether a store's set that threw recorded the changes anyway: every path
5766
+ * holds what its change leaves. The changes given are those the tree didn't
5767
+ * hold before the set, and one record takes all of them or none, so their
5768
+ * holding shows the record is in. Reading them back can fail too, and then
5769
+ * whether they're stored isn't known (CommitUnknownError).
5770
+ */
5771
+ function changesLanded(
5772
+ store: JsStore,
5773
+ treeStateId: string,
5774
+ changes: Array<{ path: string; change: ToolChange }>,
5775
+ setError: unknown,
5776
+ ): boolean {
5777
+ try {
5778
+ return changes.every(({ path, change }) => holds(store.treeGet(treeStateId, path), change));
5779
+ } catch (readError) {
5780
+ throw new CommitUnknownError(setError, readError);
5781
+ }
5782
+ }
5783
+
5784
+ /**
5785
+ * A store's set threw, and reading its entries back threw too, so whether the
5786
+ * change is stored isn't known. Its message says so after both errors.
5787
+ */
5788
+ class CommitUnknownError extends Error {
5789
+ readonly setError: string;
5790
+ readonly readError: string;
5791
+ constructor(setError: unknown, readError: unknown) {
5792
+ const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
5793
+ super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
5794
+ this.name = 'CommitUnknownError';
5795
+ this.setError = text(setError);
5796
+ this.readError = text(readError);
5797
+ }
5798
+ }
5799
+
5800
+ /**
5801
+ * A store's set that threw after recording the change (changesLanded): the
5802
+ * entries are set and their intents follow, and the store's error goes on in
5803
+ * place of the push. `pushes` says whether the mount would have pushed the
5804
+ * change to disk, and `notes` are commitToolChanges' own, by path.
5805
+ */
5806
+ class StoredThenFailed extends Error {
5807
+ constructor(storeError: unknown, readonly pushes: boolean, readonly notes: Map<string, string>) {
5808
+ super(storeError instanceof Error ? storeError.message : String(storeError));
5809
+ this.name = 'StoredThenFailed';
5810
+ }
5811
+ }
5812
+
4190
5813
  /**
4191
5814
  * The answer to a write, edit or delete that the workspace stored but
4192
5815
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -4195,12 +5818,13 @@ const LONE_SURROGATE = /\p{Cs}/u;
4195
5818
  * and sending it again could apply it twice. The push's reason says what disk
4196
5819
  * holds and the way out, as materialize takes it. The text carries all of it,
4197
5820
  * since an error's data reaches neither the model nor a code_execution
4198
- * script. An edit's note on which copy it applied to comes last.
5821
+ * script. A note comes last: an edit's on which copy it applied to, or a
5822
+ * deletion's that it unlinked nothing.
4199
5823
  */
4200
5824
  function storedButUnfinished(
4201
5825
  tool: 'write' | 'edit' | 'delete',
4202
5826
  path: string,
4203
- outcome: { refused: string } | { threw: string },
5827
+ outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
4204
5828
  note: string | null = null,
4205
5829
  ): ToolResult {
4206
5830
  const change = tool === 'delete' ? 'deletion' : tool;
@@ -4208,7 +5832,12 @@ function storedButUnfinished(
4208
5832
  + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
4209
5833
  const rest = 'refused' in outcome
4210
5834
  ? ` Its push to disk didn't complete: ${outcome.refused}.`
4211
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
5835
+ : 'storeFailed' in outcome
5836
+ ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
5837
+ : tool === 'delete'
5838
+ ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
5839
+ : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
5840
+ : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
4212
5841
  return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
4213
5842
  }
4214
5843
 
@@ -4230,16 +5859,19 @@ function linesOf(content: string): string[] {
4230
5859
  const INPUT_MINIMUMS: Record<string, Record<string, number>> = {
4231
5860
  read: { offset: 1, limit: 1, offsetChars: 0, limitChars: 1 },
4232
5861
  grep: { contextBefore: 0, contextAfter: 0 },
5862
+ edit_many: { occurrences: 1 },
4233
5863
  };
4234
5864
 
4235
5865
  /**
4236
5866
  * Why a tool call's input doesn't fit its tool's schema, or null when it does:
4237
5867
  * an object, each required field present, and each field its schema calls a
4238
5868
  * string, a boolean or an integer of that type, an integer at least its
4239
- * minimum. The workspace's schemas use no other types. Without it, an edit
4240
- * missing `newString` wrote the word "undefined", and an offset of 1.5
4241
- * numbered lines 1.5, 2.5 and on. A field the schema doesn't describe is left
4242
- * to its handler.
5869
+ * minimum. A field it calls an array of objects is an array, and each of its
5870
+ * items is checked the same way against the items' schema, with the same
5871
+ * minimums (edit_many's edits). The workspace's schemas use no other types.
5872
+ * Without it, an edit missing `newString` wrote the word "undefined", and an
5873
+ * offset of 1.5 numbered lines 1.5, 2.5 and on. A field the schema doesn't
5874
+ * describe is left to its handler.
4243
5875
  */
4244
5876
  function inputProblem(
4245
5877
  schema: { properties?: Record<string, unknown>; required?: string[] },
@@ -4254,17 +5886,28 @@ function inputProblem(
4254
5886
  if (fields[name] === undefined) return `"${name}" is required`;
4255
5887
  }
4256
5888
  for (const [name, value] of Object.entries(fields)) {
4257
- const type = (schema.properties?.[name] as { type?: string } | undefined)?.type;
5889
+ const property = schema.properties?.[name] as { type?: string; items?: { type?: string } } | undefined;
5890
+ const type = property?.type;
4258
5891
  if (value === undefined || type === undefined) continue;
4259
5892
  const minimum = minimums[name];
4260
5893
  const fits = type === 'string' ? typeof value === 'string'
4261
5894
  : type === 'boolean' ? typeof value === 'boolean'
4262
5895
  : type === 'integer'
4263
5896
  ? Number.isSafeInteger(value) && (minimum === undefined || (value as number) >= minimum)
5897
+ : type === 'array' ? Array.isArray(value)
4264
5898
  : true;
5899
+ if (fits && type === 'array' && property?.items?.type === 'object') {
5900
+ for (const [i, item] of (value as unknown[]).entries()) {
5901
+ const problem = item !== null && typeof item === 'object' && !Array.isArray(item)
5902
+ ? inputProblem(property.items as Parameters<typeof inputProblem>[0], item, minimums)
5903
+ : 'it must be an object of the fields its schema lists';
5904
+ if (problem !== null) return `"${name}" item ${i + 1}: ${problem}`;
5905
+ }
5906
+ }
4265
5907
  if (!fits) {
4266
5908
  const wanted = type === 'string' ? 'a string'
4267
5909
  : type === 'boolean' ? 'true or false'
5910
+ : type === 'array' ? 'an array'
4268
5911
  : minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
4269
5912
  const got = value === null ? 'null'
4270
5913
  : typeof value === 'number' ? String(value)