@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
@@ -4,7 +4,6 @@
4
4
  * Provides a unified workspace with mount-based filesystem access,
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
- import { isUtf8 } from 'node:buffer';
8
7
  import { constants as fsConstants } from 'node:fs';
9
8
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
10
9
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
@@ -13,8 +12,11 @@ import { MountWatcher } from './watcher.js';
13
12
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
14
13
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
15
14
  import { BranchIntents } from './branch-intent.js';
16
- import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix } from './observe.js';
15
+ 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 } from './observe.js';
17
16
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
17
+ import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
18
+ import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
19
+ import { cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
18
20
  import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
19
21
  /** Default for how long the after-batch scan may hold the agent's next inference. */
20
22
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
@@ -30,6 +32,18 @@ const CATCH_UP_BOUND_MS = 30_000;
30
32
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
31
33
  /** The workspace's own deadline must end before the framework's hook bound. */
32
34
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
35
+ /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
36
+ const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
37
+ /**
38
+ * How many files a direct mount's grep reads, and its listings look at, at
39
+ * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
40
+ * 16 at a time on Node's default threadpool had a median time below the
41
+ * store-backed grep's over the same files at every load tried, and one
42
+ * reading them in turn took 2 to 2.4 times as long.
43
+ */
44
+ const DIRECT_READS_AT_ONCE = 16;
45
+ /** Why sync passes over a direct mount, as its result's `skipped` says. */
46
+ const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
33
47
  class WorkspaceImageReadError extends Error {
34
48
  code;
35
49
  constructor(code, message) {
@@ -38,6 +52,13 @@ class WorkspaceImageReadError extends Error {
38
52
  this.name = 'WorkspaceImageReadError';
39
53
  }
40
54
  }
55
+ /** A path the mount's `include` doesn't cover: outside the mount. */
56
+ class OutsideIncludeError extends Error {
57
+ constructor(shownPath, mount) {
58
+ super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
59
+ + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
60
+ }
61
+ }
41
62
  /**
42
63
  * A file operation's path lies in another mount's directory nested in the
43
64
  * mount it names. The message names the mount that holds it and the path to
@@ -67,6 +88,15 @@ export class WorkspaceReadError extends Error {
67
88
  this.name = 'WorkspaceReadError';
68
89
  }
69
90
  }
91
+ /**
92
+ * Why a pass decided nothing, or null where it decided its paths. A pass gives
93
+ * up deciding, and reports no path, when the selected branch kept changing
94
+ * while it gathered, or when a sync's branch was no longer selected; it then
95
+ * carries no branchId, and the region it leaves incomplete says why.
96
+ */
97
+ function undecidedOf(pass) {
98
+ return pass.branchId === undefined ? (pass.incomplete[0]?.reason ?? 'the pass decided nothing') : null;
99
+ }
70
100
  function errnoOf(err) {
71
101
  return typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string'
72
102
  ? err.code
@@ -118,22 +148,30 @@ function matchesPartialPrefix(buffer, prefix) {
118
148
  * link's reason, as the observations give it, after the path; or, for a
119
149
  * directory the walk couldn't ask, a failure to read, coded `not_found` with
120
150
  * the readlink's errno, as every failed lookup is. A link's code follows
121
- * where it leads, so a caller choosing advice by code gets advice that fits:
151
+ * where its refusal says it leads (shownLeads), so a caller choosing advice
152
+ * by code gets advice that fits, and the code says no more than the message:
122
153
  * `escape` for a link leading outside the mount, the code a path resolving
123
- * outside it gets too; `other_mount` for one into a nested mount's directory,
124
- * the code that mount's own paths get; and `parent_symlink` for any other,
125
- * which `symlink`, a file that is itself a link, leaves apart.
154
+ * outside it gets too; `other_mount` for one into a nested mount's directory
155
+ * where the message names that mount (namesHolder), the code that mount's own
156
+ * paths get; and for any other, a link the mount's include doesn't cover
157
+ * among them, the code of a link the mount doesn't follow where it stands:
158
+ * `symlink` at the path itself, as any other link there gets, and
159
+ * `parent_symlink` among its directories, judged on the parsed path
160
+ * (`relativePath`), whatever spelling the caller gave. Where the link leads is
161
+ * read beneath `rootReal`, the root the check that found it resolved, so the
162
+ * link and its target are judged beneath one root, by that root's nesting
163
+ * (`nested`).
126
164
  */
127
- async function linkReadError(mount, found, mountPrefixedPath, nested) {
165
+ async function linkReadError(mount, found, mountPrefixedPath, relativePath, nested, rootReal) {
128
166
  if (!('link' in found)) {
129
167
  return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
130
168
  }
131
- const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
132
- const leads = await linkLeads(mount.config.path, rootReal, found.link);
133
- const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
134
- const code = leads.kind === 'outside' ? 'escape'
135
- : leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
136
- : 'parent_symlink';
169
+ const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
170
+ const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
171
+ const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
172
+ const code = leads?.kind === 'outside' ? 'escape'
173
+ : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
174
+ : relativePath === found.link ? 'symlink' : 'parent_symlink';
137
175
  return new WorkspaceReadError(code, reason, 'readlink');
138
176
  }
139
177
  /**
@@ -149,6 +187,43 @@ function nestedDir(from, to) {
149
187
  return undefined;
150
188
  return rel.split(sep).join('/');
151
189
  }
190
+ /** Each directory above `path`, innermost first, to the filesystem's root. */
191
+ function ancestorsOf(path) {
192
+ const dirs = [];
193
+ for (let dir = dirname(path);; dir = dirname(dir)) {
194
+ dirs.push(dir);
195
+ if (dirname(dir) === dir)
196
+ return dirs;
197
+ }
198
+ }
199
+ /**
200
+ * Where a mount is nested in an outer mount, as a start places it: at its
201
+ * directory as configured beneath the outer mount's configured path
202
+ * (`outerPath`), beneath each directory above its configured path that is
203
+ * the outer root on disk (`above`, each with where it lies on disk), and
204
+ * where its real root (`real`) lies beneath the outer root (`outerReal`),
205
+ * each also as disk spells it beneath `spellAt`. Its real root is claimed
206
+ * where it doesn't hold the outer root, which it would otherwise claim
207
+ * whole. Undefined where it's nested nowhere.
208
+ */
209
+ async function placementOf(outerPath, outerReal, innerPath, real, above, spellAt) {
210
+ const configured = nestedDir(outerPath, innerPath);
211
+ const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
212
+ const fromRoots = [];
213
+ for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
214
+ if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
215
+ fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
216
+ }
217
+ if (fromRoots.length === 0)
218
+ return undefined;
219
+ const spellings = [...fromRoots];
220
+ for (const found of fromRoots) {
221
+ const dir = await diskSpelling(spellAt, found.dir);
222
+ if (!spellings.some((s) => s.dir === dir))
223
+ spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
224
+ }
225
+ return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
226
+ }
152
227
  /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
153
228
  const LINKS_FOLLOWED = 40;
154
229
  /**
@@ -156,13 +231,14 @@ const LINKS_FOLLOWED = 40;
156
231
  * it, with the links on its way and in it taken out, read without holding
157
232
  * the process. Where nothing is there yet, a link leads where its target
158
233
  * would be, and anything else lies beneath its nearest existing directory's
159
- * real path, with the rest as given. A link's target is read from the
160
- * link's directory as it is on disk, and nothing is resolved lexically on
161
- * the way, so a `..` is taken from where the links before it lead, as the
234
+ * real path, with the rest as given, a `..` after a name that isn't there
235
+ * taken lexically. A link's target is read from the link's directory as it
236
+ * is on disk, so a `..` is taken from where the links before it lead, as the
162
237
  * kernel takes it. Where realpath fails for any other reason, such as a
163
- * loop or a lack of permission, the whole path as path.resolve reads it.
238
+ * loop or a lack of permission, the whole path as path.resolve reads it, and
239
+ * `unresolved`, where given, takes the first such code.
164
240
  */
165
- async function realBeneath(given, links = 0) {
241
+ async function realBeneath(given, links = 0, unresolved) {
166
242
  // A trailing separator, as a link to a directory is often written, names
167
243
  // the same place, but readlink would then follow the link it names.
168
244
  const { root } = parse(given);
@@ -175,15 +251,36 @@ async function realBeneath(given, links = 0) {
175
251
  catch (err) {
176
252
  const code = errnoOf(err);
177
253
  const parent = dirname(absolute);
178
- if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute)
254
+ if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links > LINKS_FOLLOWED || parent === absolute) {
255
+ if (unresolved)
256
+ unresolved.code ??= links > LINKS_FOLLOWED ? 'ELOOP' : code ?? 'EIO';
179
257
  return resolve(absolute);
180
- const place = await realBeneath(parent, links);
258
+ }
259
+ const place = await realBeneath(parent, links, unresolved);
181
260
  const target = await readlink(absolute).catch(() => null);
182
261
  if (target !== null)
183
- return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
262
+ return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1, unresolved);
184
263
  return join(place, basename(absolute));
185
264
  }
186
265
  }
266
+ /**
267
+ * A configured value as a refusal shows it: a number or a bigint as
268
+ * JavaScript writes it (JSON would say null for NaN, and throws for a
269
+ * bigint), anything else as JSON, or as String gives it where JSON can't,
270
+ * as for a list holding a bigint.
271
+ */
272
+ function shownValue(value) {
273
+ if (typeof value === 'number')
274
+ return String(value);
275
+ if (typeof value === 'bigint')
276
+ return `${value}n`;
277
+ try {
278
+ return JSON.stringify(value) ?? String(value);
279
+ }
280
+ catch {
281
+ return String(value);
282
+ }
283
+ }
187
284
  function isContainedPath(root, candidate) {
188
285
  if (candidate === root)
189
286
  return true;
@@ -589,6 +686,10 @@ export class WorkspaceModule {
589
686
  otherMounts = new Map();
590
687
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
591
688
  rootFoldsCase = new Map();
689
+ /** Where the last start placed each mount's root (readNesting's `reals`). */
690
+ placedRoots = new Map();
691
+ /** Each directory above each mount's configured path, with where the last start found it on disk (readNesting's `above`). */
692
+ placedAbove = new Map();
592
693
  /**
593
694
  * Whether a file's mode is observed, taken in and applied (MountRuntime's
594
695
  * `modesObserved`): not on Windows, which reports no POSIX modes. A test
@@ -600,13 +701,41 @@ export class WorkspaceModule {
600
701
  * mounts, so restoration must be second-callback-safe like watcher
601
702
  * startup already is (issue #72). Cleared after application. */
602
703
  savedState = null;
704
+ /**
705
+ * The mounts configured direct that run as 'chronicle' this session, with
706
+ * the changes the store held there that disk lacked (chooseKinds), for
707
+ * ensureRunning to say once both halves of the lifecycle have run.
708
+ */
709
+ notDirect = new Map();
603
710
  constructor(config) {
604
711
  for (const mount of config.mounts) {
605
712
  const cap = mount.walkCap;
606
713
  if (cap !== undefined && !(Number.isSafeInteger(cap) && cap > 0)) {
607
- // Numbers as JavaScript writes them (JSON would say null for NaN), anything else as JSON.
608
- const shown = typeof cap === 'number' ? String(cap) : typeof cap === 'bigint' ? `${cap}n` : JSON.stringify(cap) ?? String(cap);
609
- throw new Error(`[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${shown}`);
714
+ throw new Error(`[workspace] Mount "${mount.name}": walkCap must be a positive integer no larger than ${Number.MAX_SAFE_INTEGER}, not ${shownValue(cap)}`);
715
+ }
716
+ const backend = mount.backend;
717
+ if (backend !== undefined && backend !== 'chronicle' && backend !== 'direct') {
718
+ throw new Error(`[workspace] Mount "${mount.name}": backend must be 'chronicle' or 'direct', not ${shownValue(backend)}`);
719
+ }
720
+ const refused = backend === 'direct' ? directRefusals(mount) : [];
721
+ if (refused.length > 0) {
722
+ throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
723
+ }
724
+ const include = mount.include;
725
+ if (include !== undefined) {
726
+ // A pattern the matcher can't read would match nothing, and take every
727
+ // file out of the mount unsaid (includePatternReadable).
728
+ if (!Array.isArray(include) || include.length === 0) {
729
+ throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`);
730
+ }
731
+ // By index: an entry that is itself undefined, or a hole, is unreadable too.
732
+ const at = include.findIndex((p) => !includePatternReadable(p));
733
+ if (at >= 0) {
734
+ const unreadable = include[at];
735
+ throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
736
+ + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
737
+ + `${shownValue(unreadable)} is none of these`);
738
+ }
610
739
  }
611
740
  }
612
741
  // Detect overlapping mount paths: a mount nested inside another is that
@@ -649,21 +778,32 @@ export class WorkspaceModule {
649
778
  // before the call, knowing no mount, and a later initStore starts clean.
650
779
  const mounts = new Map();
651
780
  const intents = new Map();
781
+ // A direct mount's files never go into the store, so its states aren't
782
+ // made. One that ran as 'chronicle' before has them, and they're read
783
+ // below for what the store holds there that disk lacks.
784
+ const made = this.config.mounts.some((m) => m.backend === 'direct') ? new Set(store.listStates().map((s) => s.id)) : null;
652
785
  // Register tree states for each mount: the workspace tree, and the
653
786
  // branch-local intent that qualifies it.
654
787
  for (const mount of this.config.mounts) {
655
788
  const treeStateId = `workspace/${mount.name}/tree`;
656
789
  const intentTreeStateId = `workspace/${mount.name}/intent`;
657
- for (const id of [treeStateId, intentTreeStateId]) {
658
- ensureStateRegistered(store, {
659
- id,
660
- strategy: 'tree',
661
- deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
662
- fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
663
- });
790
+ const direct = mount.backend === 'direct';
791
+ // A mount configured direct that ran as 'chronicle' runs direct only
792
+ // once chooseKinds finds nothing held there.
793
+ const ranStoreBacked = direct && made.has(treeStateId) && made.has(intentTreeStateId);
794
+ if (!direct) {
795
+ for (const id of [treeStateId, intentTreeStateId]) {
796
+ ensureStateRegistered(store, {
797
+ id,
798
+ strategy: 'tree',
799
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
800
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
801
+ });
802
+ }
664
803
  }
665
804
  mounts.set(mount.name, {
666
805
  config: mount,
806
+ direct: direct && !ranStoreBacked,
667
807
  treeStateId,
668
808
  intentTreeStateId,
669
809
  lastMaterializedSeq: 0,
@@ -673,7 +813,9 @@ export class WorkspaceModule {
673
813
  watcherReadyAt: null,
674
814
  watcherError: null,
675
815
  });
676
- intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
816
+ if (!direct || ranStoreBacked) {
817
+ intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
818
+ }
677
819
  }
678
820
  this.store = store;
679
821
  for (const [name, state] of mounts)
@@ -681,12 +823,56 @@ export class WorkspaceModule {
681
823
  for (const [name, intent] of intents)
682
824
  this.intents.set(name, intent);
683
825
  // What disk last agreed with, per mount and path: global records, so a
684
- // branch switch never rewinds it.
826
+ // branch switch never rewinds it. A mount configured direct has its
827
+ // evidence read only where a 'chronicle' run left its states, for what
828
+ // the store holds there that disk lacks (chooseKinds); one that never ran
829
+ // so has none to read, and none is begun for it.
685
830
  this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
686
- this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
831
+ this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
832
+ this.chooseKinds();
687
833
  this.applySavedState();
688
834
  this.ensureRunning();
689
835
  }
836
+ /**
837
+ * Whether each mount configured direct runs direct: not while the store
838
+ * holds changes there that disk lacks, on the branch selected then, so
839
+ * they stay in view (MountConfig.backend); it runs as 'chronicle' instead,
840
+ * and ensureRunning says so. Decided when initStore gives the store, and
841
+ * again at each start, which knows how the mounts nest, so a start that
842
+ * finds none held starts the mount direct. A path held more than one way
843
+ * is named for the last way set here.
844
+ */
845
+ chooseKinds() {
846
+ // Every mount is decided before any is set, so a read of the store that
847
+ // throws leaves the decisions as they were.
848
+ const decided = new Map();
849
+ for (const [name, state] of this.mounts) {
850
+ if (state.config.backend !== 'direct')
851
+ continue;
852
+ // One with no states from a 'chronicle' run holds nothing.
853
+ if (!this.intents.has(name)) {
854
+ decided.set(name, []);
855
+ continue;
856
+ }
857
+ const held = this.heldChanges(state);
858
+ const what = new Map();
859
+ for (const path of held.owed)
860
+ what.set(path, 'changed in the workspace');
861
+ for (const path of held.unreadable)
862
+ what.set(path, UNREADABLE_INTENT);
863
+ for (const path of held.deletions)
864
+ what.set(path, 'deleted in the workspace');
865
+ for (const path of held.conflicts)
866
+ what.set(path, 'in conflict');
867
+ decided.set(name, [...what].sort(([a], [b]) => a.localeCompare(b)).map(([path, label]) => ({ path, what: label })));
868
+ }
869
+ this.notDirect = new Map();
870
+ for (const [name, held] of decided) {
871
+ this.mounts.get(name).direct = held.length === 0;
872
+ if (held.length > 0)
873
+ this.notDirect.set(name, held);
874
+ }
875
+ }
690
876
  // ==========================================================================
691
877
  // Reconciliation plumbing
692
878
  // ==========================================================================
@@ -713,7 +899,7 @@ export class WorkspaceModule {
713
899
  * A mount as observation reads it, with the root identity the workspace
714
900
  * recorded for it, if any: what a pass reads beneath (runtime), with
715
901
  * rootIdentity's, and what a disk read checks its root against (readRoot),
716
- * with rootIdentityForRead's.
902
+ * with rootIdentityForRead's. A direct mount's has none (diskView).
717
903
  */
718
904
  viewOf(mount, rootIdentity) {
719
905
  return {
@@ -722,11 +908,39 @@ export class WorkspaceModule {
722
908
  rootIdentity,
723
909
  followSymlinks: mount.config.followSymlinks === true,
724
910
  maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
725
- ignore: mount.config.ignore ?? [],
911
+ ignore: this.ignoreOf(mount),
912
+ ...(mount.config.include ? { include: mount.config.include } : {}),
726
913
  others: this.othersOf(mount.config.name),
727
914
  nested: this.nestedOf(mount.config.name),
728
915
  };
729
916
  }
917
+ /**
918
+ * A mount's ignore list: its config's, or, for a mount configured direct
919
+ * that names none, the direct kind's default (MountConfig.ignore), whichever
920
+ * kind it runs as this session, as its watch mode goes by its config
921
+ * (watchOf).
922
+ */
923
+ ignoreOf(mount) {
924
+ return mount.config.ignore ?? (mount.config.backend === 'direct' ? [...DIRECT_MOUNT_IGNORE] : []);
925
+ }
926
+ /**
927
+ * A direct mount as its tools read it: its view, with no root identity,
928
+ * since it promises what's at its root now and keeps no evidence to check
929
+ * that against (MountConfig.backend), and its root as realpath resolves it,
930
+ * or as given where that fails, as a pass's runtime takes it. The view
931
+ * reads beneath that root, so a walk, a listing and grep's reads judge
932
+ * and open their files beneath one root, however its configured path is
933
+ * repointed meanwhile. A read of one file goes through openContainedFile
934
+ * instead, which refuses it once its root is no longer the directory it
935
+ * checked.
936
+ */
937
+ async diskView(mount) {
938
+ const root = resolve(mount.config.path);
939
+ const rootReal = await realpath(root).catch(() => root);
940
+ const view = { ...this.viewOf(mount, undefined), root: rootReal };
941
+ const moved = await this.movedNesting(mount, rootReal);
942
+ return { view: moved === null ? view : { ...view, nested: moved, others: moved.map((n) => n.dir) }, rootReal, moved };
943
+ }
730
944
  /** The view reconciliation needs of a mount, refreshed per pass. */
731
945
  async runtime(mount) {
732
946
  const root = resolve(mount.config.path);
@@ -746,7 +960,7 @@ export class WorkspaceModule {
746
960
  intents: this.intents.get(mount.config.name),
747
961
  readOnly: mount.config.mode === 'read-only',
748
962
  walkCap: mount.config.walkCap ?? WALK_CAP,
749
- catchUpScan: (mount.config.watch ?? 'always') === 'always',
963
+ catchUpScan: watchOf(mount.config) === 'always',
750
964
  modesObserved: this.modesObserved,
751
965
  };
752
966
  }
@@ -759,16 +973,19 @@ export class WorkspaceModule {
759
973
  * under that spelling beside their own. So the start stops before a segment
760
974
  * spelled any other way, not there, or not a directory, and before a link,
761
975
  * which a walk of the whole mount doesn't go through either. It lists only
762
- * directories it has checked lie inside the mount, and none the mount
763
- * passes over by design, which the walk then answers for.
976
+ * directories it has checked lie inside the mount, and, on a 'chronicle'
977
+ * mount, none it passes over by design, which the walk then answers for.
978
+ * A direct mount walks a path its tools name whatever its ignore list or
979
+ * an editor's file name covers there (MountConfig.ignore), so there it
980
+ * lists such directories too, and only another mount's it doesn't.
764
981
  */
765
982
  async walkStart(mount, base, beneath) {
766
983
  if (beneath === '')
767
984
  return base;
768
- const { view, rootReal } = await this.runtime(mount);
985
+ const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
769
986
  let start = base;
770
987
  for (const segment of beneath.split('/')) {
771
- if (start !== '' && passedOverAt(start, view.ignore, view.others) !== undefined)
988
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
772
989
  break;
773
990
  const here = await checkDir(view, rootReal, start);
774
991
  if (here.kind !== 'checked')
@@ -804,10 +1021,25 @@ export class WorkspaceModule {
804
1021
  /** One store→disk push; the caller holds the mount's turn. */
805
1022
  async pushUnlocked(mount, paths, opts = {}) {
806
1023
  const watcher = this.watchers.get(mount.config.name);
807
- return pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), paths, {
1024
+ // A path outside the mount's include never reaches disk, even when the
1025
+ // workspace's history holds it from before the include. It's skipped,
1026
+ // and a materialize counts it, with the reason.
1027
+ const inside = [];
1028
+ const outside = [];
1029
+ for (const p of paths) {
1030
+ // A key no spelling names, such as an earlier version's `a//b.txt`,
1031
+ // isn't the mount's either, and isn't the include's to report.
1032
+ if (!parsedSpelling(p))
1033
+ continue;
1034
+ (insideMount(p, mount.config.include) ? inside : outside).push(p);
1035
+ }
1036
+ const pushed = await pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), inside, {
808
1037
  ...opts,
809
1038
  beforeEffect: (p) => watcher?.suppress(p),
810
1039
  });
1040
+ for (const path of outside)
1041
+ pushed.skipped.push({ path, reason: OUTSIDE_INCLUDE });
1042
+ return pushed;
811
1043
  }
812
1044
  async start(ctx) {
813
1045
  // Which mounts are nested where, with the roots placed where disk has
@@ -817,7 +1049,7 @@ export class WorkspaceModule {
817
1049
  // before they're known. A stop begun meanwhile stands, and this start then
818
1050
  // sets nothing.
819
1051
  const stops = this.stopsBegun;
820
- const { nesting, reals } = await this.readNesting();
1052
+ const { nesting, reals, above } = await this.readNesting();
821
1053
  const rootFoldsCase = new Map();
822
1054
  for (const mount of this.config.mounts)
823
1055
  rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)));
@@ -825,6 +1057,8 @@ export class WorkspaceModule {
825
1057
  return;
826
1058
  this.otherMounts = nesting;
827
1059
  this.rootFoldsCase = rootFoldsCase;
1060
+ this.placedRoots = reals;
1061
+ this.placedAbove = above;
828
1062
  this.ctx = ctx;
829
1063
  this.stopped = false;
830
1064
  // Decode persisted state if restarting; application waits until mounts
@@ -953,16 +1187,20 @@ export class WorkspaceModule {
953
1187
  /**
954
1188
  * Start chokidar watchers and emit workspace:mounted events.
955
1189
  * Idempotent: safe to call from both start() and initStore(), fires once
956
- * per mount regardless of which runs second.
1190
+ * per mount regardless of which runs second. Once both have run, it
1191
+ * decides again which mounts configured direct run direct, now that the
1192
+ * mounts' nesting is known, and says which don't, once for this start.
957
1193
  */
958
1194
  ensureRunning() {
959
1195
  if (!this.ctx || this.mounts.size === 0)
960
1196
  return;
1197
+ this.chooseKinds();
1198
+ if (this.notDirect.size > 0)
1199
+ this.sayNotDirect();
961
1200
  for (const [name, mount] of this.mounts) {
962
1201
  if (this.watchers.has(name))
963
1202
  continue;
964
- const watchMode = mount.config.watch ?? 'always';
965
- if (watchMode === 'always') {
1203
+ if (watchOf(mount.config) === 'always') {
966
1204
  // Chokidar is started with ignoreInitial:true, so changes made while
967
1205
  // the session was down would be invisible. A catch-up pass takes them
968
1206
  // in under the three-way rule: only real differences produce tree
@@ -1042,6 +1280,50 @@ export class WorkspaceModule {
1042
1280
  });
1043
1281
  }
1044
1282
  }
1283
+ /**
1284
+ * Say, for whoever runs the host, which mounts configured direct run as
1285
+ * 'chronicle' this session (chooseKinds), what the workspace holds there
1286
+ * that keeps them so, and the two ways to start one direct: on stderr,
1287
+ * and as one ops alert for them all (the framework's failures log, its
1288
+ * `ops:alert` trace and, where one is set, its webhook, which posts one
1289
+ * alert of a kind per cooldown and keeps only the start and end of a long
1290
+ * one). So the words of fixed length, what's happening and the ways out,
1291
+ * come first, and the mounts and what they hold after them.
1292
+ */
1293
+ sayNotDirect() {
1294
+ const mounts = [...this.notDirect];
1295
+ const one = mounts.length === 1;
1296
+ // An intent the workspace can't read may hold a change or may not, so
1297
+ // it's counted as what it is.
1298
+ const counted = (paths) => {
1299
+ const u = paths.filter((h) => h.what === UNREADABLE_INTENT).length;
1300
+ const c = paths.length - u;
1301
+ return [
1302
+ c > 0 ? `${c} change${c === 1 ? '' : 's'} that disk lacks` : '',
1303
+ u > 0 ? `${u} intent${u === 1 ? '' : 's'} it can't read` : '',
1304
+ ].filter((part) => part !== '').join(' and ');
1305
+ };
1306
+ const each = mounts.map(([name, paths]) => `"${name}" (${counted(paths)})`);
1307
+ const which = one
1308
+ ? `Mount "${mounts[0][0]}" holds ${counted(mounts[0][1])}`
1309
+ : `Mounts ${each.slice(0, -1).join(', ')} and ${each[each.length - 1]} hold`;
1310
+ const held = mounts.flatMap(([name, paths]) => paths.map((h) => `"${name}/${h.path}" (${h.what})`));
1311
+ // Materialize settles an intent the workspace can't read only where the
1312
+ // workspace holds a copy there, and says nothing of one it doesn't, so
1313
+ // the ways out say what does wherever one is held, past the ten paths
1314
+ // named too.
1315
+ const intents = mounts.some(([, paths]) => paths.some((h) => h.what === UNREADABLE_INTENT))
1316
+ ? "Sync the path of an intent it can't read. "
1317
+ : '';
1318
+ const message = (one
1319
+ ? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
1320
+ : "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
1321
+ "sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
1322
+ `follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1323
+ `${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
1324
+ console.warn(`[workspace] ${message}`);
1325
+ this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
1326
+ }
1045
1327
  async initialScan(mountName) {
1046
1328
  const store = this.store;
1047
1329
  const mount = this.mounts.get(mountName);
@@ -1108,18 +1390,34 @@ export class WorkspaceModule {
1108
1390
  }
1109
1391
  this.ctx = null;
1110
1392
  }
1111
- /** Mount names + modes, for peer callers (framework spill/journal paths)
1112
- * that need a writable mount without reaching into private state. */
1393
+ /** Mount names + modes, and each mount's include where it sets one, for
1394
+ * peer callers (framework spill/journal paths) that need a mount for files
1395
+ * of their own, the first writable one without an include
1396
+ * (firstWritableMountName), without reaching into private state. */
1113
1397
  getMounts() {
1114
1398
  return [...this.mounts.values()].map((m) => ({
1115
1399
  name: m.config.name,
1116
1400
  mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
1401
+ ...(m.config.include ? { include: [...m.config.include] } : {}),
1117
1402
  }));
1118
1403
  }
1119
1404
  // ==========================================================================
1120
1405
  // Tool Definitions
1121
1406
  // ==========================================================================
1122
1407
  getTools() {
1408
+ // Where a mount runs direct, four descriptions say what that changes.
1409
+ // A host with none keeps them as they were, and its residents' caches.
1410
+ const direct = [...this.mounts.values()].some((m) => m.direct);
1411
+ const lsDirect = direct
1412
+ ? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
1413
+ + '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.'
1414
+ : '';
1415
+ const grepDirect = direct
1416
+ ? ' On a direct mount, grep searches disk\'s files as they are now (version disk), and lists as skipped, with why, '
1417
+ + 'those it doesn\'t: binary, over the size limit, or not read, such as a link the mount doesn\'t follow.'
1418
+ : '';
1419
+ 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.' : '';
1420
+ const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
1123
1421
  return [
1124
1422
  {
1125
1423
  name: 'read',
@@ -1165,18 +1463,62 @@ export class WorkspaceModule {
1165
1463
  },
1166
1464
  {
1167
1465
  name: 'edit',
1168
- description: 'Edit a file by replacing a substring. The oldString must be unique unless replaceAll is true.',
1466
+ description: 'Edit a file by replacing exact text. oldString must occur exactly once, or with replaceAll at least once, '
1467
+ + "and every occurrence is replaced; occurrences that overlap count, but can't each be replaced. "
1468
+ + 'A miss shows the closest region it finds, numbered, and a count that differs shows the lines that matched. '
1469
+ + 'CRLF line ends are matched with LF text and kept, as is a BOM. As second chances, straight and curly quotes match as equal, '
1470
+ + "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, "
1471
+ + 'its newString read the same way. '
1472
+ + "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
1473
+ + 'A change disk made to the file is taken in first, and the answer says so. '
1474
+ + 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
1475
+ + 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
1169
1476
  inputSchema: {
1170
1477
  type: 'object',
1171
1478
  properties: {
1172
1479
  path: { type: 'string', description: 'File path (mount-prefixed)' },
1173
- oldString: { type: 'string', description: 'String to find' },
1174
- newString: { type: 'string', description: 'Replacement string' },
1175
- replaceAll: { type: 'boolean', description: 'Replace all occurrences (default: false)' },
1480
+ oldString: { type: 'string', description: 'Exact text to replace, including whitespace and indentation' },
1481
+ newString: { type: 'string', description: 'Replacement text' },
1482
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence (default: false)' },
1176
1483
  },
1177
1484
  required: ['path', 'oldString', 'newString'],
1178
1485
  },
1179
1486
  },
1487
+ {
1488
+ name: 'edit_many',
1489
+ 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. '
1490
+ + "Each edit's oldString must occur in its file exactly `occurrences` times (1 when left out), and every one is replaced; "
1491
+ + "occurrences that overlap count, but can't each be replaced. "
1492
+ + "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, "
1493
+ + "and two edits' matches may touch but not overlap. Matching is edit's. "
1494
+ + "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
1495
+ + 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
1496
+ + "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
1497
+ + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
1498
+ inputSchema: {
1499
+ type: 'object',
1500
+ properties: {
1501
+ edits: {
1502
+ type: 'array',
1503
+ description: "The edits, each matched in its file as this call reads it, never in another edit's result",
1504
+ items: {
1505
+ type: 'object',
1506
+ properties: {
1507
+ path: { type: 'string', description: 'File path (mount-prefixed); every edit names a file of the same mount' },
1508
+ oldString: { type: 'string', description: 'Exact text to replace, as the file holds it before this call' },
1509
+ newString: { type: 'string', description: 'Replacement text' },
1510
+ occurrences: {
1511
+ type: 'integer',
1512
+ description: 'How many times oldString occurs in the file (default: 1); every one is replaced, and any other count is refused',
1513
+ },
1514
+ },
1515
+ required: ['path', 'oldString', 'newString'],
1516
+ },
1517
+ },
1518
+ },
1519
+ required: ['edits'],
1520
+ },
1521
+ },
1180
1522
  {
1181
1523
  name: 'delete',
1182
1524
  description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
@@ -1200,18 +1542,18 @@ export class WorkspaceModule {
1200
1542
  + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace, '
1201
1543
  + 'with applyDeletions too where the workspace deleted it; '
1202
1544
  + 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
1203
- + 'the listing could not see.',
1545
+ + 'the listing could not see.' + lsDirect,
1204
1546
  inputSchema: {
1205
1547
  type: 'object',
1206
1548
  properties: {
1207
- path: { type: 'string', description: 'Directory path (mount-prefixed, optional)' },
1549
+ path: { type: 'string', description: 'Directory path (mount-prefixed, optional; without it, the mounts are listed, each with its include where it has one)' },
1208
1550
  recursive: { type: 'boolean', description: 'List recursively (default: false)' },
1209
1551
  },
1210
1552
  },
1211
1553
  },
1212
1554
  {
1213
1555
  name: 'glob',
1214
- description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.',
1556
+ description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.' + globDirect,
1215
1557
  inputSchema: {
1216
1558
  type: 'object',
1217
1559
  properties: {
@@ -1227,7 +1569,7 @@ export class WorkspaceModule {
1227
1569
  + 'which version matched: the workspace\'s (with its state); the disk version kept with a conflict '
1228
1570
  + '(conflicting-disk); or, once disk has changed since the conflict was recorded, that recorded version '
1229
1571
  + '(recorded-disk). Disk versions it could not search are listed as skipped: disk-only files (binary or '
1230
- + 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.',
1572
+ + 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.' + grepDirect,
1231
1573
  inputSchema: {
1232
1574
  type: 'object',
1233
1575
  properties: {
@@ -1253,7 +1595,9 @@ export class WorkspaceModule {
1253
1595
  {
1254
1596
  name: 'materialize',
1255
1597
  description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1256
- + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.',
1598
+ + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
1599
+ + 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
1600
+ + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
1257
1601
  inputSchema: {
1258
1602
  type: 'object',
1259
1603
  properties: {
@@ -1285,15 +1629,17 @@ export class WorkspaceModule {
1285
1629
  + 'reported nowhere else is counted under `passedOver`, by mount and reason: another mount\'s '
1286
1630
  + 'directory nested in this one, entries the mount\'s ignore list covers, and entries with an '
1287
1631
  + 'editor\'s swap or backup file name (`.name.swp` and `.name.swx` as the whole name, names ending '
1288
- + 'in `~` or in `.subl…tmp`), that hold nothing the workspace tracks (such a directory counts once, '
1289
- + 'with what lies beneath it). So a '
1632
+ + 'in `~` or in `.subl…tmp`), each holding nothing the workspace tracks (such a directory counts '
1633
+ + 'once, with what lies beneath it); and files and links outside the mount\'s include, a link '
1634
+ + 'counted as "a symlink, not walked" instead where it leads to a directory on a mount that follows '
1635
+ + 'symlinks, and wherever it leads on one that doesn\'t. So a '
1290
1636
  + 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
1291
1637
  + 'sync looked everywhere and nothing had changed. A path the mount ignores, or an editor\'s file, is '
1292
1638
  + 'synced when you name it, and listed under `ignored`: a sync without a path passes over it. A path '
1293
1639
  + 'in another mount\'s directory isn\'t synced even when you name it: use the mount that holds it. Naming '
1294
1640
  + 'an ignored directory sets aside the patterns that cover it or a directory above it; one that '
1295
1641
  + 'covers only something inside it still applies, and what it covers is passed over, as are editors\' '
1296
- + 'files inside it.',
1642
+ + 'files inside it.' + syncDirect,
1297
1643
  inputSchema: {
1298
1644
  type: 'object',
1299
1645
  properties: {
@@ -1324,7 +1670,7 @@ export class WorkspaceModule {
1324
1670
  const mount = this.config.mounts.find(m => m.name === mountName);
1325
1671
  if (!mount)
1326
1672
  throw new Error(`Unknown mount: "${mountName}"`);
1327
- // Against the resolved root, as parsePath judges it.
1673
+ // Against the resolved root, as parsePathAsGiven's traversal guard judges it.
1328
1674
  const root = resolve(mount.path);
1329
1675
  const resolved = resolve(root, slash < 0 ? '' : path.slice(slash + 1));
1330
1676
  if (!isContainedPath(root, resolved))
@@ -1381,17 +1727,17 @@ export class WorkspaceModule {
1381
1727
  const input = call.input;
1382
1728
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1383
1729
  if (generated) {
1384
- if (call.callerAgentName !== generated.agentName)
1385
- return { success: false, isError: true, error: 'Generated file belongs to another agent' };
1386
- if (call.name !== 'read')
1387
- return { success: false, isError: true, error: 'Generated file is read-only; edit the presentation configuration instead' };
1388
- return await this.handleRead(input, generated.read());
1730
+ if (call.name === 'read' && call.callerAgentName === generated.agentName) {
1731
+ return await this.handleRead(input, generated.read());
1732
+ }
1733
+ return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
1389
1734
  }
1390
1735
  switch (call.name) {
1391
1736
  case 'read': return await this.handleRead(input);
1392
1737
  case 'read_image': return await this.handleReadImage(input);
1393
1738
  case 'write': return await this.handleWrite(input);
1394
- case 'edit': return await this.handleEdit(input);
1739
+ case 'edit': return await this.handleEdit(input, call.resultMaxChars);
1740
+ case 'edit_many': return await this.handleEditMany(input, call.resultMaxChars, call.callerAgentName);
1395
1741
  case 'delete': return await this.handleDelete(input);
1396
1742
  case 'ls': return await this.handleLs(input);
1397
1743
  case 'glob': return await this.handleGlob(input);
@@ -1400,7 +1746,7 @@ export class WorkspaceModule {
1400
1746
  case 'materialize': return await this.handleMaterialize(input);
1401
1747
  case 'sync': return await this.handleSync(input);
1402
1748
  default:
1403
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
1749
+ return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
1404
1750
  }
1405
1751
  }
1406
1752
  catch (err) {
@@ -1478,8 +1824,11 @@ export class WorkspaceModule {
1478
1824
  // ==========================================================================
1479
1825
  /**
1480
1826
  * Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
1481
- * absolute filesystem path. Returns null if the mount is unknown or the
1482
- * resolved path escapes the mount root.
1827
+ * absolute filesystem path. Returns null if the mount is unknown or
1828
+ * doesn't hold the path: one that leaves the mount's directory, lies in
1829
+ * another mount's directory nested in it, or falls outside its include, or
1830
+ * one that can't name a file of it, with a separator right after the
1831
+ * mount's name or a lone UTF-16 surrogate.
1483
1832
  *
1484
1833
  * **Lexical containment only.** The guard reasons about `..` segments in the
1485
1834
  * path string; it knows nothing about what is on disk. A symlink inside the
@@ -1488,15 +1837,26 @@ export class WorkspaceModule {
1488
1837
  * path for resolution-only purposes (write targets, nonexistent paths,
1489
1838
  * display); for reading file content, use {@link readFileFromDisk}, which
1490
1839
  * enforces the mount's `followSymlinks` policy and canonical containment.
1840
+ * It judges other mounts' directories by where the last start placed them,
1841
+ * so for a direct mount whose root has moved since, it can differ from
1842
+ * `readFileFromDisk`, which takes the nesting where the root lies now
1843
+ * (movedNesting), about which paths lie in another mount's directory.
1491
1844
  *
1492
1845
  * @deprecated for direct filesystem reads — call `readFileFromDisk()`.
1493
1846
  */
1494
1847
  resolveAbsolutePath(mountPrefixedPath) {
1495
1848
  try {
1496
- // Synchronous, so the path as given: it reaches the same file, and
1497
- // the nested-mount check matches it as configured or as disk spells
1498
- // the nested mount's directory (readNesting).
1499
- const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
1849
+ // Synchronous, so the path as given. The nested-mount check takes it
1850
+ // in each spelling readNesting knows for the nested mount's directory,
1851
+ // or in any spelling a disk that folds names may fold to one of them,
1852
+ // erring toward that mount (holderOf's asGiven, beginsWithDir). The
1853
+ // include judges it as given, so on a disk that
1854
+ // folds names a covered name whose file disk spells otherwise returns
1855
+ // that file's path, which the tools refuse, and a covered file named in
1856
+ // another case is null, which the tools read. A path the mount doesn't
1857
+ // hold, outside its include or in another mount's directory nested in
1858
+ // it, is null, as for a path that leaves the mount's directory.
1859
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
1500
1860
  return resolve(mount.config.path, relativePath);
1501
1861
  }
1502
1862
  catch {
@@ -1519,10 +1879,13 @@ export class WorkspaceModule {
1519
1879
  * (agent-framework #277).
1520
1880
  *
1521
1881
  * A write whose tree entry is set but that then fails returns success
1522
- * false with `data: { inChronicle: true }`: its copy on disk was refused, or
1523
- * something after the entry (recording its intent, the event, the push)
1524
- * threw. The workspace's tools read the file from the tree, so it is there
1525
- * for them, on the branch that was current when the entry was set.
1882
+ * false with `data: { inChronicle: true }`: its copy on disk was refused,
1883
+ * the store failed after recording the entry, which skips the push where
1884
+ * the mount pushes, or something after the entry (recording its intent,
1885
+ * the event, the push) threw. The workspace's tools read the file from the
1886
+ * tree, so it is there for them, on the branch that was current when the
1887
+ * entry was set. A set that threw and couldn't be read back throws, saying
1888
+ * that whether the entry is set isn't known.
1526
1889
  */
1527
1890
  async writeBinary(mountPrefixedPath, data, mimeType, options = {}) {
1528
1891
  if (!this.running())
@@ -1572,7 +1935,12 @@ export class WorkspaceModule {
1572
1935
  catch (error) {
1573
1936
  if (!committed)
1574
1937
  throw error;
1575
- failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
1938
+ // As write words it: a store that failed after recording the file
1939
+ // skipped its push, so on a mount that pushes, disk lacks it.
1940
+ failure = error instanceof StoredThenFailed
1941
+ ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
1942
+ + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
1943
+ : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
1576
1944
  }
1577
1945
  if (failure !== null) {
1578
1946
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -1588,8 +1956,10 @@ export class WorkspaceModule {
1588
1956
  }
1589
1957
  /**
1590
1958
  * Read a file's raw bytes from a mount (through the Chronicle tree, synced
1591
- * first like the `read` tool). Public API for the framework's synthesized
1592
- * `read_image` tool and other peer callers that need binary content.
1959
+ * first like the `read` tool; on a direct mount, from disk as
1960
+ * readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
1961
+ * API for the framework's synthesized `read_image` tool and other peer
1962
+ * callers that need binary content.
1593
1963
  */
1594
1964
  async readBinary(mountPrefixedPath) {
1595
1965
  if (!this.running())
@@ -1605,12 +1975,15 @@ export class WorkspaceModule {
1605
1975
  catch (error) {
1606
1976
  return { error: error instanceof Error ? error.message : String(error) };
1607
1977
  }
1978
+ // A direct mount's bytes are disk's, as read takes them, at most its size limit.
1979
+ if (mount.direct)
1980
+ return this.bytesFromDisk(mount, relativePath, mountPrefixedPath, mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
1608
1981
  const store = this.getStore();
1609
1982
  return this.withMount(mount, async () => {
1610
- await this.ensureSynced(mount, relativePath);
1983
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
1611
1984
  const entry = store.treeGet(mount.treeStateId, relativePath);
1612
1985
  if (!entry)
1613
- return { error: `File not found: ${mountPrefixedPath}` };
1986
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
1614
1987
  const blob = store.getBlob(entry.blobHash);
1615
1988
  if (!blob)
1616
1989
  return { error: `Blob not found for: ${mountPrefixedPath}` };
@@ -1649,8 +2022,11 @@ export class WorkspaceModule {
1649
2022
  catch (error) {
1650
2023
  return { error: error instanceof Error ? error.message : String(error) };
1651
2024
  }
1652
- const absolutePath = resolve(mount.config.path, relativePath);
1653
- const maxBytes = opts.maxBytes;
2025
+ const read = await this.bytesFromDisk(mount, relativePath, mountPrefixedPath, opts.maxBytes);
2026
+ return 'error' in read ? read : { data: read.data, absolutePath: resolve(mount.config.path, relativePath) };
2027
+ }
2028
+ /** A file's bytes as readBinaryFromDisk reads them, once its path is parsed. */
2029
+ async bytesFromDisk(mount, relativePath, mountPrefixedPath, maxBytes) {
1654
2030
  let tooLarge;
1655
2031
  try {
1656
2032
  const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
@@ -1661,7 +2037,7 @@ export class WorkspaceModule {
1661
2037
  });
1662
2038
  try {
1663
2039
  if (maxBytes === undefined)
1664
- return { data: await handle.readFile(), absolutePath };
2040
+ return { data: await handle.readFile() };
1665
2041
  // No more than maxBytes is loaded: a file grown past it since the
1666
2042
  // fstat above is refused here, as one already over it was there. Its
1667
2043
  // size is an fstat's after the read, and at least one more than the
@@ -1669,7 +2045,7 @@ export class WorkspaceModule {
1669
2045
  const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
1670
2046
  if (more)
1671
2047
  return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
1672
- return { data: bytes, absolutePath };
2048
+ return { data: bytes };
1673
2049
  }
1674
2050
  finally {
1675
2051
  await handle.close();
@@ -1713,7 +2089,9 @@ export class WorkspaceModule {
1713
2089
  * as configured still names it as configured (resolveAbsolutePath). A
1714
2090
  * directory not on disk keeps the spellings the roots give alone. Each
1715
2091
  * mount's real root comes back beside the nesting (`reals`), for what
1716
- * start reads there.
2092
+ * start reads there, and each directory above each configured path with
2093
+ * where it lies on disk (`above`), for a direct mount whose root moves
2094
+ * (movedNesting).
1717
2095
  */
1718
2096
  async readNesting() {
1719
2097
  const reals = new Map();
@@ -1725,11 +2103,8 @@ export class WorkspaceModule {
1725
2103
  const given = resolve(mount.path);
1726
2104
  reals.set(mount.name, await realBeneath(given));
1727
2105
  const dirs = [];
1728
- for (let dir = dirname(given);; dir = dirname(dir)) {
2106
+ for (const dir of ancestorsOf(given))
1729
2107
  dirs.push({ dir, real: await realBeneath(dir) });
1730
- if (dirname(dir) === dir)
1731
- break;
1732
- }
1733
2108
  above.set(mount.name, dirs);
1734
2109
  }
1735
2110
  const nesting = new Map();
@@ -1740,26 +2115,10 @@ export class WorkspaceModule {
1740
2115
  if (outer === inner)
1741
2116
  continue;
1742
2117
  const real = reals.get(inner.name);
1743
- const innerPath = resolve(inner.path);
1744
- const configured = nestedDir(outerPath, innerPath);
1745
- const fromRoots = [];
1746
- // Its directory as configured, read against this mount's root as
1747
- // configured and from each directory above it that is this mount's
1748
- // root on disk, and its real root against this mount's.
1749
- const throughRoot = above.get(inner.name).filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
1750
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
1751
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
1752
- fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
1753
- }
1754
- if (fromRoots.length === 0)
2118
+ const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name), outerPath);
2119
+ if (at === undefined)
1755
2120
  continue;
1756
- const spellings = [...fromRoots];
1757
- for (const found of fromRoots) {
1758
- const dir = await diskSpelling(outerPath, found.dir);
1759
- if (!spellings.some((s) => s.dir === dir))
1760
- spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
1761
- }
1762
- const claims = !isContainedPath(real, outerReal);
2121
+ const { configured, fromRoots, spellings, claims } = at;
1763
2122
  nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
1764
2123
  const placed = fromRoots.filter((s) => s.dir !== configured);
1765
2124
  if (placed.length > 0) {
@@ -1768,30 +2127,156 @@ export class WorkspaceModule {
1768
2127
  }
1769
2128
  }
1770
2129
  }
1771
- return { nesting, reals };
2130
+ return { nesting, reals, above };
1772
2131
  }
1773
- /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
2132
+ /**
2133
+ * The mounts nested in `mountName`, by directory, name, real root where it
2134
+ * has one, and include (MountView's `nested`), in each spelling known.
2135
+ */
1774
2136
  nestedOf(mountName) {
1775
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
2137
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2138
+ }
2139
+ /**
2140
+ * The mounts nested in `mount` as a call of it that resolved its root to
2141
+ * `rootReal` sees them: movedNesting where that differs from where the
2142
+ * last start placed it, the start's placement (nestedOf) otherwise.
2143
+ */
2144
+ async nestedFor(mount, rootReal) {
2145
+ return (await this.movedNesting(mount, rootReal)) ?? this.nestedOf(mount.config.name);
2146
+ }
2147
+ /**
2148
+ * The mounts nested in a direct mount whose root a check resolved to
2149
+ * `rootReal`, where that's elsewhere than the last start placed it, as when
2150
+ * a link it's configured through is repointed; null where the start's
2151
+ * placement holds, or where nothing is at `rootReal`: there the parse's
2152
+ * checks keep the start's placement, and a read says the root is gone. A
2153
+ * direct mount reads beneath whatever directory is at its root, so a check
2154
+ * judges which mount a path is by the root it resolved. A tool's parse
2155
+ * resolves it once, and its respelling and its checks of the path it's
2156
+ * given (parseFilePath's, refusesAsOutside, grep's) read disk beneath that
2157
+ * root and judge by the nesting there. A read and a walk each resolve it
2158
+ * for themselves, and judge the include and the nesting again beneath it.
2159
+ * So a root repointed during a call can change which answer or refusal a
2160
+ * path gets, but nothing a read or a walk serves is a file the include
2161
+ * leaves out, nothing a walk serves lies in another mount's directory as
2162
+ * its own nesting places it, and nothing a read serves lies in the
2163
+ * directory of a mount its own nesting claims by real root. Whoever writes
2164
+ * the disk can race a read in two ways, as a rename during a call can on
2165
+ * any mount. On a disk that folds names, a read judges the include by the
2166
+ * path as its parse spelled it, so a root repointed between the two can
2167
+ * have it serve a file the new root spells otherwise (sameLevels). And a
2168
+ * mount nested by its directories alone, since its real root holds this
2169
+ * one, is judged by the parse's nesting, so a root repointed between a
2170
+ * read's parse and its open can have it serve a path in that mount's
2171
+ * directory. Each mount is placed as a start places it (placementOf), from
2172
+ * where its real root and the directories above its configured path lie on
2173
+ * disk. For a mount configured at this mount's own path or beneath it,
2174
+ * that's where they lie now: beneath `rootReal` where they lie beneath this
2175
+ * mount's path, and the directories above this mount's path as resolving
2176
+ * that path finds them. For any other mount, it's where the last start
2177
+ * found them. Each carries its own include, as nestedOf gives it. It
2178
+ * resolves nothing but the directories on this mount's own configured path
2179
+ * and those beneath its root, so a share elsewhere that doesn't answer
2180
+ * holds only its own calls, unless such a directory is a link into it,
2181
+ * where that mount's files lie. What it leaves to the next start: a mount
2182
+ * configured elsewhere whose own root has moved since, as when a link on
2183
+ * its configured path is repointed, this mount's own reached through
2184
+ * another link among them, that mount's spellings through other links, and
2185
+ * the other mounts' own placements, so a direct root moved into another
2186
+ * mount's directory is that mount's too, as a nested mount's root moved
2187
+ * while running stays placed.
2188
+ */
2189
+ async movedNesting(mount, rootReal) {
2190
+ // One start's placements, whatever a start in the meantime sets.
2191
+ const { placedRoots, placedAbove } = this;
2192
+ const placed = placedRoots.get(mount.config.name);
2193
+ if (!mount.direct || placed === undefined || placed === rootReal)
2194
+ return null;
2195
+ const there = await stat(rootReal).then(() => true, (err) => {
2196
+ const code = errnoOf(err);
2197
+ return code !== 'ENOENT' && code !== 'ENOTDIR';
2198
+ });
2199
+ if (!there)
2200
+ return null;
2201
+ const outerPath = resolve(mount.config.path);
2202
+ const nested = [];
2203
+ // The directories above this mount's own path, as they lie on disk now,
2204
+ // resolved as resolving the path itself does.
2205
+ let ownAbove;
2206
+ const aboveOwn = async () => ownAbove ??= await Promise.all(ancestorsOf(outerPath).map(async (dir) => ({ dir, real: await realBeneath(dir) })));
2207
+ for (const inner of this.config.mounts) {
2208
+ if (inner.name === mount.config.name)
2209
+ continue;
2210
+ const innerPath = resolve(inner.path);
2211
+ const configured = nestedDir(outerPath, innerPath);
2212
+ let real;
2213
+ let above;
2214
+ if (configured !== undefined || innerPath === outerPath) {
2215
+ // A mount configured on this mount's own path, or beneath it, lies
2216
+ // where that path now leads: its real root, and each directory above
2217
+ // it, resolved now, beneath this root where they lie beneath it.
2218
+ const segments = configured?.split('/') ?? [];
2219
+ real = await realBeneath(join(rootReal, ...segments));
2220
+ const beneath = [];
2221
+ for (let i = segments.length - 1; i >= 1; i--) {
2222
+ beneath.push({ dir: join(outerPath, ...segments.slice(0, i)), real: await realBeneath(join(rootReal, ...segments.slice(0, i))) });
2223
+ }
2224
+ // This mount's own path, which is the root, would add only the
2225
+ // configured directory, which placementOf adds anyway.
2226
+ above = [...beneath, ...await aboveOwn()];
2227
+ }
2228
+ else {
2229
+ // Any other mount lies where the last start found it and the
2230
+ // directories above its path. One leading here only since, as
2231
+ // through this mount's own link, waits for the next start.
2232
+ real = placedRoots.get(inner.name);
2233
+ if (real === undefined)
2234
+ continue;
2235
+ above = placedAbove.get(inner.name) ?? [];
2236
+ }
2237
+ const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2238
+ if (at === undefined)
2239
+ continue;
2240
+ const include = this.mounts.get(inner.name)?.config.include;
2241
+ for (const s of at.spellings)
2242
+ nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2243
+ }
2244
+ return nested;
1776
2245
  }
1777
2246
  /**
1778
2247
  * The innermost mount nested in `mountName` whose directory holds a path
1779
2248
  * of that mount, judged lexically where the path lands (`.`, `..` and
1780
2249
  * repeated separators resolved, as for the traversal guard; a link of the
1781
2250
  * outer mount isn't followed), with the path to use there; undefined when
1782
- * no nested mount's directory holds it.
2251
+ * no nested mount's directory holds it. A path parsePath has respelled is
2252
+ * compared with the directory's spellings exactly. One judged as given
2253
+ * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2254
+ * folds names may compare it (beginsWithDir), so it can't reach that
2255
+ * mount's directory by spelling a segment otherwise. On a disk that tells
2256
+ * names apart, that takes a directory of this mount's own spelled like the
2257
+ * nested one in another way as that mount's too.
1783
2258
  */
1784
- holderOf(mountName, relativePath) {
2259
+ holderOf(mountName, relativePath, asGiven = false, moved = null) {
1785
2260
  const mount = this.mounts.get(mountName);
1786
2261
  if (!mount)
1787
2262
  return undefined;
1788
- const resolved = resolve(mount.config.path, relativePath);
1789
- const holder = (this.otherMounts.get(mountName) ?? [])
1790
- .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
1791
- .sort((a, b) => b.root.length - a.root.length)[0];
2263
+ const root = resolve(mount.config.path);
2264
+ const resolved = resolve(root, relativePath);
2265
+ if (!isContainedPath(root, resolved))
2266
+ return undefined;
2267
+ const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2268
+ // A direct mount whose root a call found moved is judged by that call's
2269
+ // nesting (movedNesting), each directory beneath its configured path.
2270
+ const dirs = moved !== null
2271
+ ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2272
+ : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2273
+ const holder = dirs
2274
+ .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2275
+ .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2276
+ .sort((a, b) => b.depth - a.depth)[0];
1792
2277
  if (!holder)
1793
2278
  return undefined;
1794
- const rest = relative(holder.root, resolved).split(sep).join('/');
2279
+ const rest = segments.slice(holder.depth).join('/');
1795
2280
  return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
1796
2281
  }
1797
2282
  /**
@@ -1799,8 +2284,8 @@ export class WorkspaceModule {
1799
2284
  * mount and the path to use: for a sync or a push, which of them leaves
1800
2285
  * it; for a listing, whose the path is.
1801
2286
  */
1802
- otherMountNote(mountName, relativePath, by) {
1803
- const holder = this.holderOf(mountName, relativePath);
2287
+ otherMountNote(mountName, relativePath, by, moved = null) {
2288
+ const holder = this.holderOf(mountName, relativePath, false, moved);
1804
2289
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
1805
2290
  return holder
1806
2291
  ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
@@ -1808,77 +2293,236 @@ export class WorkspaceModule {
1808
2293
  }
1809
2294
  /**
1810
2295
  * parsePath for the file operations (read, write, edit, delete, and the
1811
- * module's binary and disk reads and writes). A path in another mount's
1812
- * directory nested in this one is that mount's: this mount's passes don't
1813
- * observe it and its pushes don't write it, so its file operations refuse
1814
- * it, naming the innermost mount that holds it and the path to use there.
2296
+ * module's binary and disk reads and writes), refused where the mount
2297
+ * doesn't hold the path. A path in another mount's directory nested in
2298
+ * this one is that mount's: this mount's passes don't observe it and its
2299
+ * pushes don't write it, so the refusal names the innermost mount that
2300
+ * holds it and the path to use there. A path the mount's include doesn't
2301
+ * cover is outside the mount, whatever disk or the workspace's history has
2302
+ * there. A directory is checked the same way, since a path can be a
2303
+ * directory on one side and a file on the other.
1815
2304
  */
1816
2305
  async parseFilePath(path) {
1817
- const parsed = this.refuseNested(await this.parsePath(path));
1818
- // A path can also reach a nested mount's directory through a symlink on
1819
- // its way. No push of this mount writes it there and no read reads it,
1820
- // so it's refused here, before anything is stored, and the checks where
1821
- // disk is acted on refuse it again.
1822
- const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
2306
+ const spelled = await this.parsePath(path);
2307
+ // Both checks judge by the nesting the parse found, and the second reads
2308
+ // disk beneath the root the parse resolved.
2309
+ const { mount, root, moved } = spelled;
2310
+ const parsed = this.refuseNotHeld(spelled, path, false, moved);
2311
+ // A path can also reach a nested mount's directory, or a file this
2312
+ // mount's own include leaves out, through a symlink on its way. No push
2313
+ // of this mount writes it there and no read reads it, so it's refused
2314
+ // here, before anything is stored, and the checks where disk is acted on
2315
+ // refuse it again.
2316
+ const refusal = await this.landingRefusal(mount, parsed.relativePath, parsed.path, moved ?? this.nestedOf(mount.config.name), root);
1823
2317
  if (refusal)
1824
2318
  throw refusal;
1825
2319
  return parsed;
1826
2320
  }
1827
2321
  /**
1828
2322
  * The refusal for a path of `mount` that reaches a nested mount's directory
1829
- * through a symlink on its way (nestedAtReal), or null where it doesn't. On
1830
- * a mount that doesn't follow the link, the refusal is the link's own
1831
- * (linkWords), as the readers and pushes give it. A mount with nothing
1832
- * nested has nothing to find, so it pays nothing.
1833
- */
1834
- async leadsIntoNested(mount, relativePath, path) {
1835
- const nested = this.nestedOf(mount.config.name);
1836
- if (nested.length === 0)
1837
- return null;
1838
- const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
1839
- if (!holder)
2323
+ * through a symlink on its way (nestedAtReal), or a file this mount's own
2324
+ * include leaves out (coversLanding), or, on a mount that follows links
2325
+ * with an include, one realpath can't resolve, or null where it doesn't.
2326
+ * On a mount that doesn't follow the link, the refusal is the link's own
2327
+ * (linkWords), as the readers and pushes give it. On one that does, it
2328
+ * names the nested mount only where the place is that mount's
2329
+ * (namesNestedPlace); anywhere else the place is no mount's, so the path
2330
+ * leaves this one, in the disk readers' words, as it does where it lands
2331
+ * on a file this mount's include leaves out. A mount with nothing nested,
2332
+ * and without both an include and links followed, has nothing to find, so
2333
+ * it pays nothing. The caller gives the nesting and the root to judge by
2334
+ * (`nested`, `root`). A direct mount's file operation gives those its
2335
+ * parse found, so where the path lands, and any link on its way, is judged
2336
+ * beneath the root that parse resolved; any other caller gives the start's
2337
+ * nesting and the configured path.
2338
+ */
2339
+ async landingRefusal(mount, relativePath, path, nested, root) {
2340
+ const judged = mount.config.followSymlinks === true && mount.config.include !== undefined;
2341
+ if (nested.length === 0 && !judged)
1840
2342
  return null;
2343
+ const unresolved = {};
2344
+ const real = await realBeneath(resolve(root, relativePath), 0, unresolved);
2345
+ // Where realpath can't resolve the path, as for a loop or a directory
2346
+ // this process can't search, where it lands can't be judged, and a
2347
+ // promise about content fails closed: the path is refused, by every file
2348
+ // operation, in the words a disk read gives a path it can't resolve,
2349
+ // whatever the place realpath stopped short of would say.
2350
+ if (judged && unresolved.code !== undefined)
2351
+ return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2352
+ const holder = nestedAtReal(real, nested);
2353
+ if (!holder) {
2354
+ if (!judged)
2355
+ return null;
2356
+ const rootReal = await realBeneath(root);
2357
+ if (!isContainedPath(rootReal, real) || await stat(real).then((info) => info.isDirectory(), () => false))
2358
+ return null;
2359
+ return await coversLanding({ include: mount.config.include }, rootReal, real, relativePath) ? null : new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2360
+ }
1841
2361
  if (mount.config.followSymlinks !== true) {
1842
- const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
2362
+ const rootReal = await realBeneath(root);
2363
+ const found = await linkOnTheWay({ root: rootReal, followSymlinks: false }, relativePath);
1843
2364
  if (found !== null)
1844
- return await linkReadError(mount, found, path, nested);
1845
- }
2365
+ return await linkReadError(mount, found, path, relativePath, nested, rootReal);
2366
+ }
2367
+ // On a mount with an include, the path to use there names each directory
2368
+ // on the place's way as that mount's, so a place beneath anything else
2369
+ // there, such as a file the include leaves out, isn't named; one beneath
2370
+ // a directory not there yet is.
2371
+ const onItsWay = holder.include === undefined
2372
+ || await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
2373
+ const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2374
+ if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory))
2375
+ return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
1846
2376
  return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
1847
2377
  }
1848
- /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
1849
- refuseNested(parsed) {
1850
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
2378
+ /**
2379
+ * A parsed path, refused where the mount doesn't hold it: in a nested
2380
+ * mount's directory first, naming the mount that holds it, then outside
2381
+ * the mount's include. Each is judged on the path as the caller parsed it:
2382
+ * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2383
+ * which is synchronous and says so (`asGiven`, so the nested-mount check
2384
+ * folds where disk does). The include's refusal shows the path as given
2385
+ * (`given`), since it answers the path the caller asked about.
2386
+ */
2387
+ refuseNotHeld(parsed, given, asGiven = false, moved = null) {
2388
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
1851
2389
  if (holder)
1852
2390
  throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2391
+ if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2392
+ throw new OutsideIncludeError(given, parsed.mount.config);
2393
+ }
1853
2394
  return parsed;
1854
2395
  }
2396
+ /**
2397
+ * Whether a mount-relative path names a file, as grep decides what a path
2398
+ * it's given names: for a path the include covers, a file in the
2399
+ * workspace's history, or anything but a directory on disk. Grep takes it
2400
+ * as a directory only when neither side has a file there, and filters what
2401
+ * it finds beneath it by the mount's include. A path the include doesn't
2402
+ * cover names no file of the mount's, whatever disk or history holds
2403
+ * there, so one refusesAsOutside lets through is a directory. A direct
2404
+ * mount has no history: what a run of it as 'chronicle' left in the store
2405
+ * isn't its own, so disk alone decides. Disk is read beneath `root`: a
2406
+ * direct mount's is the root its parse resolved, a store-backed mount's
2407
+ * its configured path.
2408
+ */
2409
+ async namesFile(mount, relativePath, root) {
2410
+ return relativePath !== '' && insideMount(relativePath, mount.config.include) && ((!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) ||
2411
+ await lstat(join(root, relativePath)).then((s) => !s.isDirectory(), () => false));
2412
+ }
2413
+ /**
2414
+ * Whether sync, materialize, grep, glob or ls refuses a path it's given as
2415
+ * outside the mount's include. A path the include doesn't cover is a scope
2416
+ * exactly where a listing names a directory: where disk has a directory
2417
+ * reached from the mount's root through directories, with no link among
2418
+ * them, or the workspace's history holds an entry beneath it that's the
2419
+ * mount's own to list (withinIncludes). The one exception is a directory
2420
+ * the ignore list covers, which a listing passes over and which is still a
2421
+ * scope when it's named, since a sync that names it takes in what's there
2422
+ * (#276). Anything else is refused, a file at the path included, which no
2423
+ * scope tool takes up. The walk descends no link it meets. Where covered
2424
+ * history makes a name a scope and disk has since put a link there, a
2425
+ * mount that doesn't follow symlinks refuses the link, as it refuses any
2426
+ * link on a path's way, and on a mount that does, a pass of the scope
2427
+ * follows the link, and its answer shows whether the link leads out of the
2428
+ * mount. A
2429
+ * path in another mount's directory nested in this one is that mount's,
2430
+ * whatever this mount's include says, so it isn't refused here: each tool
2431
+ * names the mount that holds it, as the file operations do. A direct mount
2432
+ * has no history, as namesFile says, and is judged beneath the root its
2433
+ * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2434
+ */
2435
+ async refusesAsOutside(mount, relativePath, moved, root) {
2436
+ if (this.holderOf(mount.config.name, relativePath, false, moved))
2437
+ return false;
2438
+ if (insideMount(relativePath, mount.config.include))
2439
+ return false;
2440
+ const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
2441
+ if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
2442
+ .some((entry) => withinIncludes(own, entry.path)))
2443
+ return false;
2444
+ // A directory, and each directory above it to the mount's root, as the
2445
+ // walk reaches it: through no link, since the walk descends none.
2446
+ const segments = relativePath.split('/');
2447
+ for (let i = 1; i <= segments.length; i++) {
2448
+ const entry = await lstat(join(root, ...segments.slice(0, i))).catch(() => null);
2449
+ if (!entry?.isDirectory())
2450
+ return true;
2451
+ }
2452
+ return false;
2453
+ }
1855
2454
  /**
1856
2455
  * Parse a mount-prefixed path into its mount and mount-relative path, as
1857
- * disk spells it (diskSpelling), with `path` the mount-prefixed path so
1858
- * spelled. On a disk that folds names, a path spelled otherwise reaches
1859
- * disk's file, so every tool takes it by disk's name: one name per file,
1860
- * and the names its answers give are disk's, except that a path that names
1861
- * a workspace entry exactly, or does once its directories take disk's
1862
- * spelling, keeps the entry's spelling. A path the workspace records an
1863
- * intent at, such as a deletion disk hasn't been given yet, counts as an
1864
- * entry here, so the calls that settle it reach it. The workspace's
1865
- * entries (entriesOf) stand in for disk where it has nothing yet, so a
1866
- * draft has one name too. Neither applies to a path that disk's spelling
1867
- * puts in a nested mount's directory.
2456
+ * disk spells it (diskSpelling). `path` is the mount-prefixed path in that
2457
+ * spelling where it differs from the one spelling, and otherwise the path
2458
+ * as the caller gave it. The path is read in its one spelling first
2459
+ * (parsePathAsGiven), so the respelling only ever sees a path with no
2460
+ * empty, `.` or `..` segment and no separator before its first. On a disk
2461
+ * that folds names, a path spelled otherwise reaches disk's file, so every
2462
+ * tool takes it by disk's name: one name per file, and the names its
2463
+ * answers give are disk's, except that a path that names a workspace entry
2464
+ * exactly, or does once its directories take disk's spelling, keeps the
2465
+ * entry's spelling, and what lies past a link on the way keeps the
2466
+ * caller's, since the step reads nothing past a link. A path the workspace
2467
+ * records an intent at, such as a deletion disk hasn't been given yet,
2468
+ * counts as an entry here, so the calls that settle it reach it. The
2469
+ * workspace's entries (entriesOf) stand in for disk where it has nothing
2470
+ * yet, so a draft has one name too. Neither applies to a path that disk's
2471
+ * spelling puts in a nested mount's directory. A direct mount holds no
2472
+ * entries, so its paths take disk's spelling alone. Past a nested mount's
2473
+ * directory the path stays as given, since what lies there is that mount's
2474
+ * to name. Every answer shows `path`, a name the include leaves out
2475
+ * included, since how disk spells a name the caller gave is no content of
2476
+ * that file, except the include's own refusal, which shows the path
2477
+ * exactly as given (refuseNotHeld), since it answers the path asked about.
1868
2478
  * A handler that must read something at the moment it's asked, as
1869
2479
  * handleSync reads the branch a path sync adopts on, reads it before
1870
- * calling this, since this awaits disk. The entries are read when the
1871
- * step needs them, some after those awaits, from the branch current then.
2480
+ * calling this, since this awaits disk. The entries are read when the step
2481
+ * needs them, some after those awaits, from the branch current then.
1872
2482
  */
1873
2483
  async parsePath(path) {
1874
2484
  const given = this.parsePathAsGiven(path);
1875
- const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
2485
+ // A direct mount's root is resolved once here, so the respelling and the
2486
+ // checks of the path that follow read disk beneath one root and judge by
2487
+ // the nesting there (movedNesting).
2488
+ const configured = resolve(given.mount.config.path);
2489
+ const root = given.mount.direct ? await realpath(configured).catch(() => configured) : configured;
2490
+ const moved = given.mount.direct ? await this.movedNesting(given.mount, root) : null;
2491
+ const spelled = await diskSpelling(root, given.relativePath, given.mount.direct ? undefined : this.entriesOf(given.mount));
2492
+ // Past a nested mount's directory the path stays as given: what lies
2493
+ // there is that mount's, whose own include decides what a tool may name,
2494
+ // so this mount doesn't respell it.
2495
+ // The directory itself takes disk's spelling, as a listing names it.
2496
+ // The respelling keeps each segment's place, so the given path's rest
2497
+ // follows the directory's segments.
2498
+ const depth = this.nestedDepth(given.mount.config.name, spelled, moved);
2499
+ const relativePath = depth === undefined ? spelled
2500
+ : [...spelled.split('/').slice(0, depth), ...given.relativePath.split('/').slice(depth)].join('/');
1876
2501
  return {
1877
2502
  mount: given.mount,
1878
2503
  relativePath,
1879
2504
  path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
2505
+ root,
2506
+ moved,
1880
2507
  };
1881
2508
  }
2509
+ /**
2510
+ * How many segments the innermost nested mount's directory that holds
2511
+ * `relativePath` has, in a spelling it's known by (othersOf), or, for a
2512
+ * direct mount whose root has moved, in the nesting there (`moved`);
2513
+ * undefined when no nested mount's directory holds it.
2514
+ */
2515
+ nestedDepth(mountName, relativePath, moved = null) {
2516
+ let depth;
2517
+ for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
2518
+ if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
2519
+ continue;
2520
+ const segments = dir.split('/').length;
2521
+ if (depth === undefined || segments > depth)
2522
+ depth = segments;
2523
+ }
2524
+ return depth;
2525
+ }
1882
2526
  /**
1883
2527
  * A mount's entries on the current branch, and the paths it records an
1884
2528
  * intent at there, as diskSpelling reads them when it does, whether its
@@ -1896,13 +2540,33 @@ export class WorkspaceModule {
1896
2540
  // given yet or a conflict, is the workspace's own name for the file
1897
2541
  // there, as an entry is, so the calls that settle it reach it.
1898
2542
  has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
1899
- under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
2543
+ // Only an entry this mount's include covers spells a draft's path
2544
+ // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
2545
+ // from an earlier version, nor one the include leaves out, which would
2546
+ // respell a covered draft into a name the include leaves out. An entry
2547
+ // in a nested mount's directory that this mount's include covers
2548
+ // spells one whatever that mount's include says, so on a disk that
2549
+ // folds names, a draft naming the directory in another spelling takes
2550
+ // the entry's spelling and is judged as in it.
2551
+ under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
1900
2552
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
1901
2553
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
1902
2554
  };
1903
2555
  }
1904
2556
  /**
1905
- * Parse a mount-prefixed path into (mountName, relativePath), as spelled.
2557
+ * Parse a mount-prefixed path into its mount and the one mount-relative
2558
+ * path every later step reads, as given, before disk's spelling
2559
+ * (parsePath): `/` between segments (`\` read as `/` where it's the
2560
+ * platform's separator), with no empty or `.` segment and no trailing `/`,
2561
+ * and '' for the mount's root. So the traversal guard here and every later
2562
+ * step judge one spelling of the path, which parsePath then respells name
2563
+ * by name as disk lists them. A `..` segment takes back the segment before
2564
+ * it, before anything looks the path up, so a mount's include judges the
2565
+ * path every tool acts on, and one with nothing left to take back climbs
2566
+ * above the mount's root and is refused, whatever it reaches afterwards. A
2567
+ * separator straight after the mount's name would make the rest an
2568
+ * absolute path, so that is refused too, and every reader that takes a
2569
+ * path refuses it here, a disk reader included.
1906
2570
  */
1907
2571
  parsePathAsGiven(path) {
1908
2572
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
@@ -1912,17 +2576,31 @@ export class WorkspaceModule {
1912
2576
  }
1913
2577
  const slashIdx = path.indexOf('/');
1914
2578
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
1915
- const relativePath = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2579
+ const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
1916
2580
  const mount = this.mounts.get(mountName);
1917
2581
  if (!mount) {
1918
2582
  throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
1919
2583
  }
1920
- // Path traversal guard (CWE-22): ensure resolved path stays within mount.
1921
- // Containment must compare with the PLATFORM separator (isContainedPath):
1922
- // resolve() emits backslashes on Windows, so a '/'-suffixed root never
1923
- // prefix-matches there and every in-mount path was rejected as traversal.
1924
- // Against the resolved root, so a root configured with a trailing
1925
- // separator, or relative, contains its own paths, the root included.
2584
+ const segments = pathSegments(given);
2585
+ if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
2586
+ 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.`);
2587
+ }
2588
+ const kept = [];
2589
+ for (const segment of segments) {
2590
+ if (segment !== '..')
2591
+ kept.push(segment);
2592
+ else if (kept.pop() === undefined) {
2593
+ throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
2594
+ }
2595
+ }
2596
+ const relativePath = kept.join('/');
2597
+ // Path traversal guard (CWE-22), on the path every later step reads: on
2598
+ // Windows a first segment such as `C:` names another drive. Containment
2599
+ // must compare with the PLATFORM separator (isContainedPath): resolve()
2600
+ // emits backslashes on Windows, so a '/'-suffixed root never prefix-matches
2601
+ // there and every in-mount path was rejected as traversal. Against the
2602
+ // resolved root, so a root configured with a trailing separator, or
2603
+ // relative, contains its own paths, the root included.
1926
2604
  const root = resolve(mount.config.path);
1927
2605
  const resolved = resolve(root, relativePath);
1928
2606
  if (!isContainedPath(root, resolved)) {
@@ -1983,6 +2661,8 @@ export class WorkspaceModule {
1983
2661
  * which answers while the evidence awaits reopening; where even it can't
1984
2662
  * be read, as when the evidence was never read back whole, the read is
1985
2663
  * refused too, with an errno only where no directory is at the root's path.
2664
+ * A direct mount's root is checked against no identity, and its read reads
2665
+ * no evidence: it promises what's at its root now (diskView).
1986
2666
  */
1987
2667
  async readRoot(mount) {
1988
2668
  const name = mount.config.name;
@@ -1995,13 +2675,15 @@ export class WorkspaceModule {
1995
2675
  throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
1996
2676
  }
1997
2677
  let rootIdentity;
1998
- try {
1999
- rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2000
- }
2001
- catch (err) {
2002
- // The evidence can't be read back, so neither can the check be made.
2003
- // Where no directory is at the root's path, its errno still says so.
2004
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2678
+ if (!mount.direct) {
2679
+ try {
2680
+ rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2681
+ }
2682
+ catch (err) {
2683
+ // The evidence can't be read back, so neither can the check be made.
2684
+ // Where no directory is at the root's path, its errno still says so.
2685
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2686
+ }
2005
2687
  }
2006
2688
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2007
2689
  if ('dir' in checked)
@@ -2017,11 +2699,13 @@ export class WorkspaceModule {
2017
2699
  * with the code and errno readRoot gives what is at its path now (its stage
2018
2700
  * and words are this check's): gone, or not a directory, with ENOENT or
2019
2701
  * ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
2020
- * another directory, with none.
2702
+ * another directory, with none. A direct mount reads whatever directory is
2703
+ * at its root (MountConfig.backend), so for it, another directory there is
2704
+ * one the next read reads.
2021
2705
  */
2022
2706
  async rootMoved(mount) {
2023
2707
  const errno = await rootAbsence(resolve(mount.config.path));
2024
- const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
2708
+ const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
2025
2709
  return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2026
2710
  }
2027
2711
  /**
@@ -2035,7 +2719,8 @@ export class WorkspaceModule {
2035
2719
  * among the directories on its way beneath the mount's root, wherever it
2036
2720
  * leads (linkOnTheWay), before anything resolves it: as `escape` for a
2037
2721
  * link leading outside the mount, as `other_mount` for one into a nested
2038
- * mount's directory, and as `parent_symlink` for any other.
2722
+ * mount's directory where its refusal names that mount, and as
2723
+ * `parent_symlink` for any other.
2039
2724
  * 2. `lstat` the lexical path — refuse directories; refuse a final-component
2040
2725
  * symlink when the mount does not `followSymlinks`; refuse anything else
2041
2726
  * that isn't a regular file, such as a FIFO, without opening it.
@@ -2083,10 +2768,15 @@ export class WorkspaceModule {
2083
2768
  // mount that doesn't follow symlinks goes through none among the
2084
2769
  // directories on the way, wherever it leads. Asked again of the path's
2085
2770
  // resolution after the open, below.
2086
- const linkView = { root: mount.config.path, followSymlinks: follow };
2771
+ // The checks below judge by the root step 0 checked, and read the links
2772
+ // on the path's way beneath it. The lstat and the open go through the
2773
+ // configured path, and step 5 refuses a read whose root is no longer the
2774
+ // directory step 0 checked, so what's served is judged beneath one root.
2775
+ const linkView = { root: realMountRoot, followSymlinks: follow };
2776
+ const nested = await this.nestedFor(mount, realMountRoot);
2087
2777
  const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
2088
2778
  if (onTheWay !== null)
2089
- throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
2779
+ throw await linkReadError(mount, onTheWay, mountPrefixedPath, relativePath, nested, realMountRoot);
2090
2780
  let fileInfo;
2091
2781
  try {
2092
2782
  fileInfo = await lstat(lexicalPath);
@@ -2167,14 +2857,23 @@ export class WorkspaceModule {
2167
2857
  throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
2168
2858
  }
2169
2859
  // A directory swapped for a link since the walk above is caught here, by
2170
- // what the path resolved to after the open.
2860
+ // what the path resolved to after the open, and the check below holds
2861
+ // the opened file to that path. A link put back after this realpath,
2862
+ // leading to the opened file itself, passes both: a race this doesn't
2863
+ // close.
2171
2864
  const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
2172
2865
  if (resolvedThrough !== null)
2173
- throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
2174
- if (!isContainedPath(realMountRoot, realFilePath)) {
2866
+ throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
2867
+ const inside = isContainedPath(realMountRoot, realFilePath);
2868
+ const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
2869
+ // A place in a nested mount's directory that its include leaves out is
2870
+ // no mount's, so the path leaves this one (namesNestedPlace), and so
2871
+ // does one reaching a file this mount's own include leaves out
2872
+ // (coversLanding).
2873
+ const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
2874
+ if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
2175
2875
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
2176
2876
  }
2177
- const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
2178
2877
  if (holder)
2179
2878
  throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
2180
2879
  let pathStat;
@@ -2227,7 +2926,9 @@ export class WorkspaceModule {
2227
2926
  * - Once the workspace has recorded the mount root's identity, with the
2228
2927
  * first evidence it gathers there, a root that is another directory is
2229
2928
  * an unavailable mount: a replaced directory, a link swapped in for it,
2230
- * or an unmounted drive's mountpoint. In any mount, recorded or not, a
2929
+ * or an unmounted drive's mountpoint. A direct mount's reads check no
2930
+ * recorded identity: they read beneath whatever directory is at its
2931
+ * root (MountConfig.backend). In any mount, recorded or not, a
2231
2932
  * root that stops being the directory the read checked until the file
2232
2933
  * has been opened and checked is unavailable too: with ENOENT or
2233
2934
  * ENOTDIR where no directory is there, as before the read, and with no
@@ -2235,9 +2936,10 @@ export class WorkspaceModule {
2235
2936
  * the descriptor that was checked.
2236
2937
  *
2237
2938
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
2238
- * mount, lexical traversal, a path in another mount's directory nested in
2239
- * the one named (`other_mount`), unavailable mount, missing or non-regular
2240
- * file, directory, policy-denied symlink, outside-mount target, and
2939
+ * mount, lexical traversal, a path the mount's include doesn't cover
2940
+ * (`outside_include`), a path in another mount's directory nested in the one named
2941
+ * (`other_mount`), unavailable mount, missing or non-regular file,
2942
+ * directory, policy-denied symlink, outside-mount target, and
2241
2943
  * file-changed-during-read.
2242
2944
  */
2243
2945
  async readFileFromDisk(mountPrefixedPath, options = {}) {
@@ -2254,8 +2956,9 @@ export class WorkspaceModule {
2254
2956
  if (err instanceof WorkspaceReadError)
2255
2957
  throw err;
2256
2958
  const message = err instanceof Error ? err.message : String(err);
2257
- const code = err instanceof NestedMountError ? 'other_mount'
2258
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2959
+ const code = err instanceof OutsideIncludeError ? 'outside_include'
2960
+ : err instanceof NestedMountError ? 'other_mount'
2961
+ : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2259
2962
  throw new WorkspaceReadError(code, message, 'parse');
2260
2963
  }
2261
2964
  if (!relativePath) {
@@ -2388,9 +3091,11 @@ export class WorkspaceModule {
2388
3091
  /**
2389
3092
  * Commit a tool's write or delete to the workspace tree, with the branch
2390
3093
  * intent it implies: store origin for a path disk never agreed with; for a
2391
- * deletion, a tombstone, unless the evidence holds no agreed disk copy (P
3094
+ * deletion, a tombstone, written before the entry is removed, unless the
3095
+ * agreement's evidence for the path, P, holds no agreed disk copy (P
2392
3096
  * absent, or no P and store origin), when it clears the path's intent and
2393
- * pushes nothing. It pushes the change to disk when the mount opts in via
3097
+ * pushes nothing. It pushes the change to
3098
+ * disk when the mount opts in via
2394
3099
  * `autoMaterialize` — required for cross-agent pipelines, since another
2395
3100
  * agent's watcher on the same directory only sees real filesystem events.
2396
3101
  * The push follows materialize's rule: a disk copy that changed since it
@@ -2407,86 +3112,215 @@ export class WorkspaceModule {
2407
3112
  * the path, as an edit makes).
2408
3113
  */
2409
3114
  async commitToolChange(mount, relativePath, change, onCommitted) {
3115
+ const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3116
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3117
+ }
3118
+ /**
3119
+ * commitToolChange for several paths of one mount at once, as edit_many
3120
+ * commits its files: every path's precondition is enforced before any entry
3121
+ * is set, then a deletion's tombstone is written, then every entry is set
3122
+ * in one record of the store, which takes all of them or none, then each
3123
+ * other path's intent, by the rule commitToolChange's doc gives, which the
3124
+ * code below applies, with no await between, and one push takes the paths
3125
+ * to disk, but for a deletion that clears its path's intent. `onCommitted`
3126
+ * is told of each path once the entries are set, also when the store throws
3127
+ * after setting them, whose error then goes on in place of the push
3128
+ * (changesLanded).
3129
+ * Answers, by path, the reason for each that reached the workspace but not
3130
+ * disk (`refused`), and, apart from it, why one whose bytes reached disk
3131
+ * left the file's mode as disk had it (`modeKept`), and that a deletion
3132
+ * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3133
+ * commitToolChange answers them for one path.
3134
+ */
3135
+ async commitToolChanges(mount, changes, onCommitted) {
2410
3136
  const store = this.getStore();
3137
+ const agreement = this.agreementOrThrow();
2411
3138
  const intents = this.intents.get(mount.config.name);
2412
3139
  // The mutation's precondition, enforced here, synchronously with the
2413
3140
  // commit: whatever settleBeforeMutation's pass could or couldn't settle.
2414
- settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
2415
- const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
2416
- const known = evidence !== undefined;
2417
- if (change.kind === 'set') {
2418
- store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
2419
- // A later intent update or disk push can throw; the tree entry is
2420
- // already set, on the branch current now. If the active branch changes
2421
- // during the push, that branch's tree has the file and the new one may
2422
- // not.
2423
- onCommitted?.();
2424
- intents.update(relativePath, (cur) => {
2425
- const next = { ...cur };
2426
- delete next.tombstone;
2427
- // Disk has never agreed with this path: a missing disk copy is a draft
2428
- // not yet materialized, not a deletion.
2429
- if (!known)
2430
- next.origin = 'store';
2431
- return next;
2432
- });
2433
- }
2434
- else {
2435
- store.treeRemove(mount.treeStateId, relativePath);
2436
- onCommitted?.();
2437
- // A deletion whose evidence holds no agreed disk copy (disk last agreed
2438
- // it had nothing here, or has never agreed with this entry, which the
2439
- // workspace created) puts the store back at the agreement. Nothing on
2440
- // disk is the workspace's to unlink, so it pushes nothing, and it
2441
- // clears the path's whole intent: no tombstone, so it needs no proof of
2442
- // absence, which a path disk can't show, such as one beneath a symlink
2443
- // the mount doesn't follow, would never give; and no conflict, which
2444
- // has nothing left to be about once the workspace has given up its
2445
- // side. Whatever disk holds there is a sync's to observe, a file the
2446
- // workspace never saw included. Any other deletion keeps its
2447
- // tombstone, which guards it: a copy disk agreed with, a push of ours
2448
- // that may have landed (pending or interrupted), or an entry from
2449
- // before the evidence.
2450
- let agreed = true;
2451
- let note = null;
2452
- intents.update(relativePath, (cur) => {
2453
- agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
2454
- if (agreed) {
2455
- const next = { ...cur, tombstone: { hash: change.previousHash } };
2456
- delete next.origin;
2457
- return next;
3141
+ // Every path's comes first, so nothing that can refuse follows a set.
3142
+ for (const { path } of changes) {
3143
+ settleAdoptionBeforeMutation(store, agreement, { name: mount.config.name, treeStateId: mount.treeStateId }, path);
3144
+ }
3145
+ const entry = (change) => ({ blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
3146
+ // The changes the tree doesn't hold yet, which the read-back below checks,
3147
+ // read before any intent is written, so no read comes between a
3148
+ // tombstone's write and the put-back's guard.
3149
+ const changing = changes.filter(({ path, change }) => !holds(store.treeGet(mount.treeStateId, path), change));
3150
+ // Each path's intent, by the rule commitToolChange's doc gives. A
3151
+ // deletion's tombstone is written before its entry is removed. Removed
3152
+ // without it, the entry would read as a file this branch doesn't have,
3153
+ // which no materialize deletes and no second delete can reach
3154
+ // (anima-research/agent-framework#553). Every other intent is written
3155
+ // after the tree change: what it gives up, the tombstone a write replaces
3156
+ // or the origin and conflict a deletion clears, guards the path until the
3157
+ // change lands.
3158
+ const notes = new Map();
3159
+ const unpushed = new Set();
3160
+ const intentChanges = changes.map(({ path, change }) => {
3161
+ const evidence = agreement.get(mount.config.name, path);
3162
+ const cur = intents.get(path) ?? {};
3163
+ if (change.kind === 'remove') {
3164
+ // A deletion whose evidence holds no agreed disk copy (disk last
3165
+ // agreed it had nothing here, or has never agreed with this entry,
3166
+ // which the workspace created) puts the store back at the
3167
+ // agreement. Nothing on disk is the workspace's to unlink, so it
3168
+ // pushes nothing, and it clears the path's whole intent: no
3169
+ // tombstone, so it needs no proof of absence, which a path disk
3170
+ // can't show, such as one beneath a symlink the mount doesn't
3171
+ // follow, would never give; and no conflict, which has nothing left
3172
+ // to be about once the workspace has given up its side. Whatever
3173
+ // disk holds there is a sync's to observe, a file the workspace
3174
+ // never saw included. Any other deletion keeps its tombstone, which
3175
+ // guards it: a copy disk agreed with, a push of ours that may have
3176
+ // landed (pending or interrupted), or an entry from before the
3177
+ // evidence.
3178
+ if (!(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'))) {
3179
+ return {
3180
+ path,
3181
+ first: true,
3182
+ next: (current) => {
3183
+ const next = { ...current, tombstone: { hash: change.previousHash } };
3184
+ delete next.origin;
3185
+ return next;
3186
+ },
3187
+ };
2458
3188
  }
3189
+ unpushed.add(path);
2459
3190
  // A conflict it clears may name a file disk held there, which the
2460
- // workspace never took in: the answer says that file wasn't unlinked,
2461
- // where the deletion alone would read as having removed it. The
2462
- // deletion doesn't look at disk, so a file nothing recorded gets no
2463
- // note.
3191
+ // workspace never took in: the answer says that file wasn't
3192
+ // unlinked, where the deletion alone would read as having removed
3193
+ // it. The deletion doesn't look at disk, so a file nothing recorded
3194
+ // gets no note.
2464
3195
  if (cur.conflict?.disk) {
2465
- note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
2466
- `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
3196
+ notes.set(path, `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${path} `
3197
+ + `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`);
2467
3198
  }
2468
- return {};
2469
- });
2470
- if (!agreed)
2471
- return { refused: null, modeKept: null, note };
3199
+ return { path, first: false, next: () => ({}) };
3200
+ }
3201
+ return {
3202
+ path,
3203
+ first: false,
3204
+ next: (current) => {
3205
+ const next = { ...current };
3206
+ delete next.tombstone;
3207
+ // Disk has never agreed with this path: a missing disk copy is a
3208
+ // draft not yet materialized, not a deletion.
3209
+ if (evidence === undefined)
3210
+ next.origin = 'store';
3211
+ return next;
3212
+ },
3213
+ };
3214
+ });
3215
+ // A tombstone written for a change that then doesn't land is put back:
3216
+ // the path's intent record as it was, a damaged one's bytes included, so
3217
+ // a refused deletion changes nothing. The put-back is best effort, since
3218
+ // the error that stopped the call is its answer. A tombstone it leaves
3219
+ // beside the entry guards nothing: decide gives no verdict from one while
3220
+ // the entry is there, status and materialize take it as no deletion, and
3221
+ // the path's next agreement clears it.
3222
+ const written = [];
3223
+ const putBack = () => {
3224
+ for (const { path, record } of written) {
3225
+ try {
3226
+ if (record)
3227
+ store.treeSet(intents.treeStateId, path, record);
3228
+ else if (store.treeGet(intents.treeStateId, path))
3229
+ store.treeRemove(intents.treeStateId, path);
3230
+ }
3231
+ catch (error) {
3232
+ // Left beside the entry, where it guards nothing (above).
3233
+ console.warn(`[workspace] Mount "${mount.config.name}": a change to ${path} failed, and putting its branch intent back threw too `
3234
+ + `(${error instanceof Error ? error.message : String(error)}), so a tombstone may be left beside the entry. One there `
3235
+ + `deletes nothing, and the path's next agreement with disk clears it`);
3236
+ }
3237
+ }
3238
+ };
3239
+ try {
3240
+ for (const { path, first, next } of intentChanges) {
3241
+ if (!first)
3242
+ continue;
3243
+ written.push({ path, record: store.treeGet(intents.treeStateId, path) });
3244
+ intents.update(path, next);
3245
+ }
2472
3246
  }
2473
- if (!this.pushesOnCommit(mount))
2474
- return { refused: null, modeKept: null, note: null };
2475
- const pushed = await this.pushUnlocked(mount, [relativePath], {
2476
- applyDeletions: change.kind === 'remove',
3247
+ catch (error) {
3248
+ putBack();
3249
+ throw error;
3250
+ }
3251
+ // A store can fail after its record lands: Chronicle 0.4 writes a state's
3252
+ // auto-snapshot after appending its update, and that failure reaches the
3253
+ // caller with the update recorded (anima-research/chronicle#39). So a set
3254
+ // that threw is read back, and where every path it changes holds what its
3255
+ // change leaves, the record is in: the paths' intents follow, as for any
3256
+ // set, and then the error goes on in place of the push. A change the tree
3257
+ // already holds can't show whether the record is in, so a call whose every
3258
+ // change the tree holds already goes on as the store's own failure.
3259
+ let landedThenThrew = null;
3260
+ try {
3261
+ if (changes.length === 1) {
3262
+ const [{ path, change }] = changes;
3263
+ if (change.kind === 'set')
3264
+ store.treeSet(mount.treeStateId, path, entry(change));
3265
+ else
3266
+ store.treeRemove(mount.treeStateId, path);
3267
+ }
3268
+ else {
3269
+ // One record for every path, so a store that fails partway sets none.
3270
+ store.treeBatch(mount.treeStateId, changes.map(({ path, change }) => (change.kind === 'set'
3271
+ ? { op: 'set', path, entry: entry(change) }
3272
+ : { op: 'remove', path })));
3273
+ }
3274
+ }
3275
+ catch (error) {
3276
+ // A read-back that fails can't tell whether the change landed, so its
3277
+ // error goes on and a tombstone stays: put back where the removal did
3278
+ // land, it would leave #553's state, and beside an entry it guards
3279
+ // nothing.
3280
+ if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3281
+ putBack();
3282
+ throw error;
3283
+ }
3284
+ landedThenThrew = { error };
3285
+ }
3286
+ // A later intent update or disk push can throw; the tree entries are
3287
+ // already set, on the branch current now. If the active branch changes
3288
+ // during the push, that branch's tree has the files and the new one may
3289
+ // not.
3290
+ for (const { path } of changes)
3291
+ onCommitted?.(path);
3292
+ for (const { path, first, next } of intentChanges)
3293
+ if (!first)
3294
+ intents.update(path, next);
3295
+ const pushing = changes.filter(({ path }) => !unpushed.has(path));
3296
+ if (landedThenThrew)
3297
+ throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3298
+ const refused = new Map();
3299
+ const modeKept = new Map();
3300
+ if (!this.pushesOnCommit(mount) || pushing.length === 0)
3301
+ return { refused, modeKept, notes };
3302
+ const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3303
+ applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
2477
3304
  branchId: store.currentBranch().id,
2478
3305
  });
2479
- const refused = pushed.skipped.find((s) => s.path === relativePath);
2480
- if (!refused)
2481
- return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
2482
- this.ctx?.pushEvent({
2483
- type: 'workspace:materialize-failed',
2484
- mount: mount.config.name,
2485
- path: relativePath,
2486
- op: change.kind === 'set' ? 'write' : 'delete',
2487
- error: refused.reason,
2488
- });
2489
- return { refused: refused.reason, modeKept: null, note: null };
3306
+ for (const { path, change } of pushing) {
3307
+ const skipped = pushed.skipped.find((s) => s.path === path);
3308
+ if (!skipped) {
3309
+ const kept = pushed.modesKept.find((k) => k.path === path)?.reason;
3310
+ if (kept)
3311
+ modeKept.set(path, kept);
3312
+ continue;
3313
+ }
3314
+ refused.set(path, skipped.reason);
3315
+ this.ctx?.pushEvent({
3316
+ type: 'workspace:materialize-failed',
3317
+ mount: mount.config.name,
3318
+ path,
3319
+ op: change.kind === 'set' ? 'write' : 'delete',
3320
+ error: skipped.reason,
3321
+ });
3322
+ }
3323
+ return { refused, modeKept, notes };
2490
3324
  }
2491
3325
  // ==========================================================================
2492
3326
  // Lazy Sync
@@ -2514,14 +3348,24 @@ export class WorkspaceModule {
2514
3348
  * path, as a file tool names the one it acts on: a path the mount passes
2515
3349
  * over by design is observed, as a sync naming it would observe it (#276),
2516
3350
  * while a path in another mount's directory is refused before this, and
2517
- * passed over by any pass. The caller holds the mount's turn.
3351
+ * passed over by any pass. Answers the pass's report on the path, and why
3352
+ * the pass decided nothing where it did (undecidedOf), which leaves disk
3353
+ * unchecked. The caller holds the mount's turn.
2518
3354
  */
2519
3355
  async ensureSynced(mount, relativePath) {
2520
3356
  const store = this.getStore();
2521
3357
  if (store.treeGet(mount.treeStateId, relativePath))
2522
- return undefined;
3358
+ return { report: undefined, undecided: null };
2523
3359
  const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2524
- return pass.reports.get(relativePath);
3360
+ return { report: pass.reports.get(relativePath), undecided: undecidedOf(pass) };
3361
+ }
3362
+ /**
3363
+ * Why the workspace holds no entry at a path, after a pass over it that
3364
+ * decided nothing: disk wasn't checked, so it may have a copy, and the same
3365
+ * call made again checks it.
3366
+ */
3367
+ notCheckedYet(path, reason) {
3368
+ return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
2525
3369
  }
2526
3370
  /** Why the workspace holds no entry at a path, when disk explains it. */
2527
3371
  notInWorkspace(path, report) {
@@ -2542,6 +3386,69 @@ export class WorkspaceModule {
2542
3386
  return `File not found: ${path}`;
2543
3387
  }
2544
3388
  }
3389
+ /**
3390
+ * A file's bytes for read, from where the mount keeps what it reads: the
3391
+ * workspace's copy, taken in from disk first where it holds none
3392
+ * (ensureSynced); or, on a direct mount, disk's file as it is now
3393
+ * (bytesOnDisk). An error says why there are none to read.
3394
+ */
3395
+ async bytesOf(mount, relativePath, path) {
3396
+ if (mount.direct)
3397
+ return this.bytesOnDisk(mount, relativePath, path);
3398
+ const store = this.getStore();
3399
+ return this.withMount(mount, async () => {
3400
+ const { report } = await this.ensureSynced(mount, relativePath);
3401
+ const entry = store.treeGet(mount.treeStateId, relativePath);
3402
+ if (!entry)
3403
+ return { error: this.notInWorkspace(path, report) };
3404
+ const blob = store.getBlob(entry.blobHash);
3405
+ if (!blob)
3406
+ return { error: `Blob not found for: ${path}` };
3407
+ return { bytes: blob };
3408
+ });
3409
+ }
3410
+ /**
3411
+ * A direct mount's file for read: its bytes on disk as they are now,
3412
+ * through the mount's boundary as readFileFromDisk reaches them
3413
+ * (openContainedFile), whose refusals are the answer. What a 'chronicle'
3414
+ * mount never stores, it doesn't show either: a file over the mount's
3415
+ * size limit, and a binary one, with a NUL in its first 8 KB, which
3416
+ * read_image reads where it's an image.
3417
+ */
3418
+ async bytesOnDisk(mount, relativePath, path) {
3419
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3420
+ const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3421
+ let opened;
3422
+ try {
3423
+ opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3424
+ if (fileStat.size > maxSize)
3425
+ throw new Error(over);
3426
+ });
3427
+ }
3428
+ catch (err) {
3429
+ // A refusal that names only the path says why with its errno, as a
3430
+ // failed read below does.
3431
+ const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
3432
+ return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
3433
+ }
3434
+ try {
3435
+ // No more than the limit is loaded, as for a file over it at its fstat.
3436
+ const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
3437
+ if (more)
3438
+ return { error: over };
3439
+ if (looksBinary(bytes)) {
3440
+ return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3441
+ }
3442
+ return { bytes };
3443
+ }
3444
+ catch (err) {
3445
+ const errno = errnoOf(err);
3446
+ return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3447
+ }
3448
+ finally {
3449
+ await opened.handle.close();
3450
+ }
3451
+ }
2545
3452
  // ==========================================================================
2546
3453
  // Tool Handlers
2547
3454
  // ==========================================================================
@@ -2558,20 +3465,10 @@ export class WorkspaceModule {
2558
3465
  if (content === undefined) {
2559
3466
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
2560
3467
  input = { ...input, path };
2561
- const store = this.getStore();
2562
- const found = await this.withMount(mount, async () => {
2563
- const report = await this.ensureSynced(mount, relativePath);
2564
- const entry = store.treeGet(mount.treeStateId, relativePath);
2565
- if (!entry)
2566
- return { error: this.notInWorkspace(input.path, report) };
2567
- const blob = store.getBlob(entry.blobHash);
2568
- if (!blob)
2569
- return { error: `Blob not found for: ${input.path}` };
2570
- return { text: blob.toString('utf-8') };
2571
- });
3468
+ const found = await this.bytesOf(mount, relativePath, path);
2572
3469
  if ('error' in found)
2573
3470
  return { success: false, error: found.error, isError: true };
2574
- content = found.text;
3471
+ content = found.bytes.toString('utf-8');
2575
3472
  }
2576
3473
  if (characterPaging) {
2577
3474
  const start = Math.min(offsetChars, content.length);
@@ -2660,49 +3557,25 @@ export class WorkspaceModule {
2660
3557
  }
2661
3558
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2662
3559
  let treeError = null;
2663
- try {
2664
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
2665
- if (image) {
2666
- return {
2667
- success: true,
2668
- data: [
2669
- {
2670
- type: 'text',
2671
- text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
2672
- },
2673
- {
2674
- type: 'image',
2675
- data: image.bytes.toString('base64'),
2676
- mimeType: image.mimeType,
2677
- },
2678
- ],
2679
- };
2680
- }
2681
- }
2682
- catch (err) {
2683
- if (err instanceof WorkspaceImageReadError) {
2684
- treeError = err;
3560
+ // The workspace's copy first, where it holds one, then disk's. A direct
3561
+ // mount holds none, so its image is disk's.
3562
+ if (!mount.direct) {
3563
+ try {
3564
+ const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
3565
+ if (image)
3566
+ return imageResult(input.path, image);
2685
3567
  }
2686
- else {
2687
- throw err;
3568
+ catch (err) {
3569
+ if (err instanceof WorkspaceImageReadError) {
3570
+ treeError = err;
3571
+ }
3572
+ else {
3573
+ throw err;
3574
+ }
2688
3575
  }
2689
3576
  }
2690
3577
  try {
2691
- const image = await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize);
2692
- return {
2693
- success: true,
2694
- data: [
2695
- {
2696
- type: 'text',
2697
- text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
2698
- },
2699
- {
2700
- type: 'image',
2701
- data: image.bytes.toString('base64'),
2702
- mimeType: image.mimeType,
2703
- },
2704
- ],
2705
- };
3578
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
2706
3579
  }
2707
3580
  catch (err) {
2708
3581
  if (err instanceof WorkspaceImageReadError) {
@@ -2745,161 +3618,446 @@ export class WorkspaceModule {
2745
3618
  catch (error) {
2746
3619
  if (!committed)
2747
3620
  throw error;
2748
- return storedButUnfinished('write', input.path, { threw: error instanceof Error ? error.message : String(error) });
3621
+ return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
3622
+ ? { storeFailed: error.message, pushes: error.pushes }
3623
+ : { threw: error instanceof Error ? error.message : String(error) });
2749
3624
  }
2750
3625
  if (refused !== null)
2751
3626
  return storedButUnfinished('write', input.path, { refused });
2752
3627
  return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
2753
3628
  }
2754
- async handleEdit(input) {
2755
- // Refused before anything is read: an empty oldString matches between
2756
- // every character, and UTF-8 can't store a lone surrogate.
2757
- if (input.oldString === '') {
2758
- return { success: false, isError: true, error: 'The edit did nothing: "oldString" is empty, and an empty string matches between every character. Give the text to replace.' };
2759
- }
2760
- for (const field of ['oldString', 'newString']) {
2761
- if (LONE_SURROGATE.test(input[field])) {
2762
- return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
3629
+ async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS) {
3630
+ return this.applyEdits('edit', [{
3631
+ path: input.path,
3632
+ oldString: input.oldString,
3633
+ newString: input.newString,
3634
+ expected: input.replaceAll ? 'all' : 1,
3635
+ }], maxChars);
3636
+ }
3637
+ async handleEditMany(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3638
+ if (input.edits.length === 0) {
3639
+ return { success: false, isError: true, error: 'The edit_many call did nothing: "edits" is empty. Give at least one edit.' };
3640
+ }
3641
+ return this.applyEdits('edit_many', input.edits.map((edit) => ({
3642
+ path: edit.path,
3643
+ oldString: edit.oldString,
3644
+ newString: edit.newString,
3645
+ expected: edit.occurrences ?? 1,
3646
+ })), maxChars, callerAgentName);
3647
+ }
3648
+ /**
3649
+ * A call's edits, all or none, applied to the stored copies of their files:
3650
+ * edit's one, or edit_many's in the files of one mount. Each edit is matched
3651
+ * in its file's text as this call reads it, never in another edit's result
3652
+ * (edit-plan.ts), and any problem refuses the call with none of its edits
3653
+ * stored: edit names its first problem, and edit_many every problem found.
3654
+ *
3655
+ * Each edit's own input is checked first, and each path's mount. A problem
3656
+ * there refuses edit at once, before any file's text is read, and edit_many
3657
+ * when its edits name more than one mount, which no one pass settles.
3658
+ * Otherwise edit_many goes on to settle and plan the rest, so its one
3659
+ * refusal names the problems it finds; the settling is what a read or a
3660
+ * listing of its files would take in from disk, and the refusal says what
3661
+ * it took in. A failure before the commit sets the entries stores none of
3662
+ * them, and edit_many's answer to it says so. A store that throws as it
3663
+ * sets them is read back (commitToolChanges), so the answer says they're
3664
+ * stored where its record landed, and that it can't tell where they
3665
+ * couldn't be read back.
3666
+ * Under the mount's turn, one pass settles every path with disk, by the
3667
+ * rule a listing uses, so the edits apply to what disk now holds. That pass
3668
+ * is the only step that awaits before the commit: every file is then read,
3669
+ * every edit planned, and every entry set (commitToolChanges) with no turn
3670
+ * of the event loop between, so no pass, branch change or other call moves
3671
+ * the tree between a file's read and its set. Disk then takes what it can
3672
+ * of the stored change.
3673
+ *
3674
+ * The answer is held to `maxChars`, the length the call's result may have
3675
+ * (ToolCall.resultMaxChars), as edit-text.ts describes.
3676
+ */
3677
+ async applyEdits(tool, given, maxChars, callerAgentName) {
3678
+ const refuse = (problems, notes = []) => ({ success: false, isError: true, error: refusalText(tool, problems, notes, maxChars) });
3679
+ // Each edit's own input, before anything is read: an empty old text
3680
+ // matches between every character, and UTF-8 can't store a lone surrogate.
3681
+ const problems = [];
3682
+ const specs = new Map();
3683
+ for (const [index, edit] of given.entries()) {
3684
+ const spec = {
3685
+ index,
3686
+ oldText: edit.oldString.replaceAll('\r\n', '\n'),
3687
+ newText: edit.newString.replaceAll('\r\n', '\n'),
3688
+ expected: edit.expected,
3689
+ };
3690
+ const found = problems.length;
3691
+ if (spec.oldText === '') {
3692
+ problems.push({ kind: 'input', edit: index, text: '"oldString" is empty, and an empty string matches between every character. Give the text to replace.' });
2763
3693
  }
2764
- }
2765
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
2766
- input = { ...input, path };
2767
- if (mount.config.mode === 'read-only') {
2768
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2769
- }
3694
+ else if (spec.oldText === spec.newText) {
3695
+ // An edit's new text takes the line ends of the text it replaces, so
3696
+ // one whose only change is its line ends changes nothing.
3697
+ problems.push({
3698
+ kind: 'input',
3699
+ edit: index,
3700
+ text: edit.oldString !== edit.newString
3701
+ ? "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."
3702
+ : "oldString and newString are the same, so there's nothing to change.",
3703
+ });
3704
+ }
3705
+ for (const field of ['oldString', 'newString']) {
3706
+ if (LONE_SURROGATE.test(edit[field])) {
3707
+ problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
3708
+ }
3709
+ }
3710
+ if (problems.length === found)
3711
+ specs.set(index, spec);
3712
+ }
3713
+ // edit names its first problem, so one in its input refuses it before
3714
+ // its path is looked up on disk.
3715
+ if (tool === 'edit' && problems.length)
3716
+ return refuse(problems);
3717
+ // Each path in its mount, as disk spells it, before any file's text is
3718
+ // read. Edits of one stored path are one file, planned once from one
3719
+ // text, whatever path named it, and named as disk spells it.
3720
+ const parsed = new Map();
3721
+ const targets = new Map();
3722
+ const mounts = new Map();
3723
+ for (const [index, { path }] of given.entries()) {
3724
+ if (!parsed.has(path))
3725
+ parsed.set(path, await this.editablePath(tool, path, callerAgentName));
3726
+ const where = parsed.get(path);
3727
+ if ('kind' in where) {
3728
+ where.edits.push(index);
3729
+ continue;
3730
+ }
3731
+ mounts.set(where.mount.config.name, where.mount);
3732
+ const key = `${where.mount.config.name}/${where.relativePath}`;
3733
+ let target = targets.get(key);
3734
+ if (!target)
3735
+ targets.set(key, (target = { relativePath: where.relativePath, path: where.path, edits: [], specs: [] }));
3736
+ target.edits.push(index);
3737
+ const spec = specs.get(index);
3738
+ if (spec)
3739
+ target.specs.push(spec);
3740
+ }
3741
+ for (const where of parsed.values())
3742
+ if ('kind' in where)
3743
+ problems.push(where);
3744
+ if (mounts.size > 1) {
3745
+ problems.push({ kind: 'mounts', edits: [...targets.values()].flatMap((target) => target.edits), mounts: [...mounts.keys()] });
3746
+ }
3747
+ const [mount] = mounts.values();
3748
+ if (tool === 'edit' && problems.length)
3749
+ return refuse(problems);
3750
+ if (!mount || mounts.size > 1)
3751
+ return refuse(problems);
2770
3752
  const store = this.getStore();
3753
+ // Whether the commit set the call's entries, all of them at once.
2771
3754
  let committed = false;
2772
- let appliedNote = null;
3755
+ // What each file's settling took in, for a receipt.
3756
+ const applied = new Map();
3757
+ // The conflict each file's settling found, on a mount that doesn't push:
3758
+ // the success answer names it, and so does the answer to a store that
3759
+ // failed after recording the edits, which stand as on a success.
3760
+ const conflicts = new Map();
2773
3761
  try {
2774
3762
  return await this.withMount(mount, async () => {
2775
- // The edit applies to the path as disk holds it now: a change on disk
2776
- // to a file the workspace holds is taken in first, by the rule a
2777
- // listing uses. The pass settles a pending intent on the path too, as
2778
- // settleBeforeMutation's would, and the commit enforces the rest
2779
- // (commitToolChange). A write passes over its path only to settle a
2780
- // pending intent, since it replaces the whole file. As every file
2781
- // tool's pass does, it names the path it acts on, so a file the
2782
- // mount passes over by design is observed (see PassOptions.named).
2783
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
2784
- const report = pass.reports.get(relativePath);
2785
- // A mode taken in alone isn't a change to the text: it brings nothing
2786
- // new to read, as it makes no event (emitFsEvents), and the edit then
2787
- // writes at the mode disk has, so it leaves nothing to say.
2788
- const took = (op) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
2789
- // Which copy the edit used, when it isn't the one the workspace last
2790
- // agreed on with disk: disk's newer text, or the workspace's copy
2791
- // because disk's couldn't be read or disk has none. A file the
2792
- // workspace didn't hold has no older copy, so taking it in needs no
2793
- // word.
2794
- const whichCopy = (verb) => took('modified')
2795
- ? `Disk's copy had changed since the workspace last agreed with it, so it was taken in first, and the edit ${verb} that text.`
2796
- : report?.state === 'unverified'
2797
- ? `The path couldn't be checked against disk (${report.note ?? 'not observed'}), so the edit ${verb} the workspace's copy.`
2798
- : report?.state === 'disk-missing-provenance-unknown'
2799
- ? `Disk has no copy of this file, and nothing records whether it was deleted there, so the edit ${verb} the workspace's copy.`
2800
- : null;
2801
- // A conflict the pass found is named when the edit is refused for the
2802
- // text it found, on either kind of mount, and on a success where the
2803
- // mount doesn't push, since such a mount keeps the conflict until
2804
- // materialize or sync settles it. A pushing mount's success met none
2805
- // (below). A stored edit that couldn't finish leaves it to the push's
2806
- // reason, which names the conflict, or to ls, which its answer points to.
2807
- const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
2808
- // Where disk has no copy now, whatever the conflict's kind, a sync of
2809
- // the path takes disk's deletion and a forced materialize writes the
2810
- // workspace's copy back. Where it has one, a sync keeps it and gives
2811
- // up the workspace's, whether or not the workspace can hold disk's
2812
- // copy: one it can't hold stays on disk alone.
2813
- const conflictNote = (where) => conflict === null ? null
2814
- : `The path is in conflict with disk (${conflict}): the edit ${where} the workspace's copy. ` + (report?.diskNow === 'absent'
2815
- ? "Sync the path to take disk's deletion, which removes the workspace's copy, or materialize it with force to write the workspace's copy back to disk."
2816
- : "Sync the path to keep disk's copy and give up the workspace's, or materialize it with force to write the workspace's over disk's.");
2817
- const suffix = [whichCopy('looked in'), conflictNote('looked in')].filter((n) => n !== null).map((n) => ` ${n}`).join('');
2818
- appliedNote = whichCopy('applied to');
2819
- const entry = store.treeGet(mount.treeStateId, relativePath);
2820
- if (!entry) {
2821
- // A pass also drops a copy disk made binary or too large, which
2822
- // the disk-only answer describes; any other drop is disk's deletion.
2823
- const error = took('deleted') && report?.state !== 'disk-only'
2824
- ? `File not found: ${input.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
2825
- : this.notInWorkspace(input.path, report);
2826
- return { success: false, error, isError: true };
2827
- }
2828
- const blob = store.getBlob(entry.blobHash);
2829
- if (!blob) {
2830
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2831
- }
2832
- // Decoding would turn each invalid sequence into U+FFFD, and writing
2833
- // the text back would change bytes the edit never named.
2834
- if (!isUtf8(blob)) {
2835
- return {
2836
- success: false,
2837
- isError: true,
2838
- error: `The edit did nothing: ${input.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
2839
- + `Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.${suffix}`,
2840
- };
2841
- }
2842
- let content = blob.toString('utf-8');
2843
- // An edit that would replace nothing is refused, replaceAll or not;
2844
- // without replaceAll, the text must occur once, and the refusal says
2845
- // how often it does. With replaceAll, whether it occurs is enough.
2846
- const count = input.replaceAll
2847
- ? Number(content.includes(input.oldString))
2848
- : content.split(input.oldString).length - 1;
2849
- if (count === 0) {
2850
- return { success: false, error: `String not found in ${input.path}.${suffix}`, isError: true };
2851
- }
2852
- if (count > 1 && !input.replaceAll) {
2853
- return {
2854
- success: false,
2855
- error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.${suffix}`,
2856
- isError: true,
2857
- };
2858
- }
2859
- // A replacer function, so that the new text goes in as given: in a
2860
- // replacement string, $&, $$, $' and $` are patterns.
2861
- content = input.replaceAll
2862
- ? content.replaceAll(input.oldString, () => input.newString)
2863
- : content.replace(input.oldString, () => input.newString);
2864
- const newBuffer = Buffer.from(content, 'utf-8');
2865
- const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2866
- const { refused, modeKept } = await this.commitToolChange(mount, relativePath, {
2867
- kind: 'set',
2868
- blobHash: newBlobHash,
2869
- size: newBuffer.byteLength,
2870
- mode: entry.mode,
2871
- }, () => { committed = true; });
2872
- if (refused !== null)
2873
- return storedButUnfinished('edit', input.path, { refused }, appliedNote);
2874
- // The edit makes the path the workspace's own change (commitToolChange),
2875
- // so a file disk lacked goes back with this edit's push, or with the
2876
- // next materialize where the mount doesn't push. A push that didn't
2877
- // complete answers above, with its reason. One that completed met no
2878
- // conflict, since this push, unforced, stops at one: the edit made the
2879
- // two copies agree, which settles it, or there was none. So only a
2880
- // mount that doesn't push still holds the conflict the pass found.
3763
+ // As every file tool's pass does, it names the paths it acts on, so a
3764
+ // file the mount passes over by design is observed (see PassOptions.named).
3765
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
3766
+ // A pass that decided nothing checked no file's disk copy.
3767
+ const undecided = undecidedOf(pass);
3768
+ // Nothing below awaits until the commit's push.
3769
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3770
+ const hints = hintBudget();
3771
+ // As much as the largest file the call names holds, at least, so the
3772
+ // budget is the same whatever order its files are planned in.
3773
+ let largest = maxSize;
3774
+ for (const target of targets.values())
3775
+ largest = Math.max(largest, store.treeGet(mount.treeStateId, target.relativePath)?.size ?? 0);
3776
+ const counts = countBudget(largest);
3777
+ const notes = [];
2881
3778
  const pushes = this.pushesOnCommit(mount);
2882
- const backOnDisk = report?.state === 'disk-missing-provenance-unknown'
2883
- ? `${pushes ? 'Its push put the file back on disk' : 'A materialize of the path puts it back on disk'}; if it was deleted there on purpose, delete it here too.`
2884
- : null;
2885
- const notes = [appliedNote, backOnDisk, pushes ? null : conflictNote('is in')].filter((n) => n !== null);
3779
+ const plans = [];
3780
+ for (const target of targets.values()) {
3781
+ const report = pass.reports.get(target.relativePath);
3782
+ // A mode taken in alone isn't a change to the text: it brings nothing
3783
+ // new to read, as it makes no event (emitFsEvents), and the edits
3784
+ // then write at the mode disk has, so it leaves nothing to say.
3785
+ const took = (op) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
3786
+ // Which copy the edits used, when it isn't the one the workspace
3787
+ // last agreed on with disk: disk's newer text, or the workspace's
3788
+ // copy because disk's couldn't be read or disk has none. A file the
3789
+ // workspace didn't hold has no older copy, so taking it in needs no
3790
+ // word.
3791
+ const subject = tool === 'edit' ? 'the edit' : editList(target.edits);
3792
+ // A pass that decided nothing checked no copy on disk, held or not,
3793
+ // and took nothing in, so the edits would apply to text disk may
3794
+ // have moved past, on whichever branch is selected once it gave up.
3795
+ // They're refused, as a call made again can check.
3796
+ if (undecided !== null) {
3797
+ problems.push({
3798
+ kind: 'file',
3799
+ edits: target.edits,
3800
+ path: target.path,
3801
+ text: `${target.path} couldn't be checked against disk (${undecided}), so ${subject} changed nothing. Make the same call again, which checks disk first.`,
3802
+ whole: true,
3803
+ });
3804
+ continue;
3805
+ }
3806
+ const whichCopy = (verb) => took('modified')
3807
+ ? `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.`
3808
+ : report?.state === 'unverified'
3809
+ ? `${tool === 'edit' ? 'The path' : target.path} couldn't be checked against disk (${report.note ?? 'not observed'}), so ${subject} ${verb} the workspace's copy.`
3810
+ : report?.state === 'disk-missing-provenance-unknown'
3811
+ ? `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.`
3812
+ : null;
3813
+ // A conflict the pass found is named when the edits are refused for
3814
+ // the text they found, on either kind of mount, and on a success
3815
+ // where the mount doesn't push, since such a mount keeps the conflict
3816
+ // until materialize or sync settles it. A pushing mount's push that
3817
+ // completed met none: unforced, it stops at a conflict, so the edits
3818
+ // made the two copies agree, which settles it, or there was none.
3819
+ // Edits stored that couldn't finish leave it to the push's reason,
3820
+ // which names the conflict, or to ls, which that answer points to
3821
+ // when the rest of the call failed; where the store failed after
3822
+ // recording them, the answer names it as the success does.
3823
+ // What a sync does follows what disk holds now, whatever kind the
3824
+ // conflict was recorded as: where disk has no copy, it takes the
3825
+ // deletion; where disk has one, it keeps disk's copy and gives up
3826
+ // the workspace's, though a copy the workspace can't hold, binary
3827
+ // or past maxFileSize, isn't taken in.
3828
+ const conflict = report?.state === 'conflict' ? report.conflict?.kind ?? 'both-changed' : null;
3829
+ const conflictNote = (where) => conflict === null ? null
3830
+ : `${tool === 'edit' ? 'The path' : target.path} is in conflict with disk (${conflict}): ${subject} ${where} the workspace's copy. `
3831
+ + (report?.diskNow === 'absent'
3832
+ ? "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."
3833
+ : "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.");
3834
+ applied.set(target, whichCopy('applied to'));
3835
+ // The edits make the path the workspace's own change, so a file disk
3836
+ // lacked goes back with this call's push, or with the next
3837
+ // materialize where the mount doesn't push. A push that didn't
3838
+ // complete answers with its reason instead.
3839
+ const backOnDisk = report?.state !== 'disk-missing-provenance-unknown' ? null
3840
+ : tool === 'edit'
3841
+ ? `${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.`
3842
+ : `${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.`;
3843
+ const lookedIn = [whichCopy('looked in'), conflictNote('looked in')].filter((note) => note !== null);
3844
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
3845
+ if (!entry) {
3846
+ // A pass also drops a copy disk made binary or too large, which
3847
+ // the disk-only answer describes; any other drop is disk's deletion.
3848
+ const text = took('deleted') && report?.state !== 'disk-only'
3849
+ ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3850
+ : this.notInWorkspace(target.path, report);
3851
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
3852
+ continue;
3853
+ }
3854
+ notes.push(...lookedIn);
3855
+ const blob = store.getBlob(entry.blobHash);
3856
+ if (!blob) {
3857
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
3858
+ continue;
3859
+ }
3860
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
3861
+ // the text back would change bytes the edits never named.
3862
+ const file = decodeForEdit(blob);
3863
+ if (!file) {
3864
+ problems.push({
3865
+ kind: 'file',
3866
+ edits: target.edits,
3867
+ path: target.path,
3868
+ text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
3869
+ + '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.',
3870
+ whole: false,
3871
+ });
3872
+ continue;
3873
+ }
3874
+ if (!target.specs.length)
3875
+ continue;
3876
+ const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
3877
+ if ('problems' in planned) {
3878
+ // One at a time: a call of many edits can have more problems
3879
+ // than a spread passes as arguments.
3880
+ for (const problem of planned.problems)
3881
+ problems.push({ ...problem, path: target.path });
3882
+ continue;
3883
+ }
3884
+ // The planner refuses a file its counts take past the limit before
3885
+ // building it, from the least its bytes can be; one it built past
3886
+ // the limit, as its CRs or multi-byte characters can take it, is
3887
+ // refused here.
3888
+ if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
3889
+ const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
3890
+ problems.push({
3891
+ kind: 'file',
3892
+ edits: target.edits,
3893
+ path: target.path,
3894
+ text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
3895
+ + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
3896
+ + "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.",
3897
+ whole: false,
3898
+ });
3899
+ continue;
3900
+ }
3901
+ plans.push({
3902
+ target,
3903
+ plan: planned.plan,
3904
+ mode: entry.mode,
3905
+ backOnDisk,
3906
+ conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
3907
+ });
3908
+ const conflicted = plans.at(-1).conflictNote;
3909
+ if (conflicted !== null)
3910
+ conflicts.set(target, conflicted);
3911
+ }
3912
+ if (problems.length)
3913
+ return refuse(problems, notes);
3914
+ const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
3915
+ path: target.relativePath,
3916
+ change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
3917
+ })), () => { committed = true; });
3918
+ if (refused.size > 0)
3919
+ return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
2886
3920
  return {
2887
3921
  success: true,
2888
- data: {
2889
- path: input.path,
2890
- size: newBuffer.byteLength,
2891
- ...(notes.length > 0 ? { note: notes.join(' ') } : {}),
2892
- ...(modeKept ? { modeKept } : {}),
2893
- },
3922
+ data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
3923
+ path: target.path,
3924
+ plan,
3925
+ notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
3926
+ modeKept: modeKept.get(target.relativePath) ?? null,
3927
+ })), given.length, maxChars),
2894
3928
  };
2895
3929
  });
2896
3930
  }
2897
3931
  catch (error) {
2898
- if (!committed)
2899
- throw error;
2900
- return storedButUnfinished('edit', input.path, { threw: error instanceof Error ? error.message : String(error) }, appliedNote);
3932
+ const message = error instanceof Error ? error.message : String(error);
3933
+ if (committed) {
3934
+ return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
3935
+ ? { storeFailed: message, pushes: error.pushes }
3936
+ : { threw: message }, maxChars);
3937
+ }
3938
+ // A set that threw and couldn't be read back may have stored the edits,
3939
+ // and a second call could then apply them twice.
3940
+ if (error instanceof CommitUnknownError) {
3941
+ const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
3942
+ return {
3943
+ success: false,
3944
+ isError: true,
3945
+ error: tool === 'edit_many'
3946
+ ? `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}). `
3947
+ + 'Read its files before sending it again: a second call can apply its edits twice.'
3948
+ : `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}). `
3949
+ + 'Read the file before sending the edit again: a second edit can apply the change twice.',
3950
+ };
3951
+ }
3952
+ // The commit sets every entry in one record or none, and a set that
3953
+ // threw was read back, so nothing is stored; or every change was one
3954
+ // the tree already held, which leaves the tree as it was either way.
3955
+ if (tool === 'edit_many')
3956
+ return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
3957
+ throw error;
3958
+ }
3959
+ }
3960
+ /**
3961
+ * Where an edit's path is in its mount, or why the call can't edit it: a
3962
+ * path no mount holds, one outside its mount or holding a lone surrogate
3963
+ * (parsePath), one a nested mount holds or that leads into one through a
3964
+ * link (parseFilePath), a path that names its mount's root, a read-only
3965
+ * mount, or for edit_many a generated file. edit's
3966
+ * generated file is refused before it gets here, by the same rule
3967
+ * (handleToolCall checks a call's `path`, which edit_many's input lacks).
3968
+ */
3969
+ async editablePath(tool, path, callerAgentName) {
3970
+ const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: true });
3971
+ try {
3972
+ // In the one spelling every file tool reads, as disk spells it, so two
3973
+ // spellings of a file are one entry and, in one call, one plan
3974
+ // (parsePathAsGiven); and a path in a mount nested inside this one is
3975
+ // that mount's, which every file tool refuses here (parseFilePath).
3976
+ const where = await this.parseFilePath(path);
3977
+ // One that names the mount's root, its bare name included, names no
3978
+ // file.
3979
+ if (where.relativePath === '')
3980
+ 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.`);
3981
+ if (where.mount.config.mode === 'read-only')
3982
+ return unusable(`Mount "${where.mount.config.name}" is read-only`);
3983
+ const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
3984
+ if (generated)
3985
+ return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
3986
+ return where;
3987
+ }
3988
+ catch (error) {
3989
+ return unusable(error instanceof Error ? error.message : String(error));
2901
3990
  }
2902
3991
  }
3992
+ /**
3993
+ * Why a call can't change a generated file: another agent's is that agent's,
3994
+ * and the agent's own is read-only, since it shows the agent's tool
3995
+ * presentation, which set_tool_visibility and set_tool_description change.
3996
+ */
3997
+ generatedFileRefusal(generated, callerAgentName) {
3998
+ return callerAgentName !== generated.agentName
3999
+ ? 'Generated file belongs to another agent'
4000
+ : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4001
+ }
4002
+ /**
4003
+ * The answer to edits stored but not finished: the commit set every file's
4004
+ * entry, and then disk refused some of their pushes, or the store failed
4005
+ * after recording them, so their push didn't run, or the rest of the call
4006
+ * failed. It stays an error, since part of the call failed, and leads with
4007
+ * what is stored, which must not be sent again: a second call can apply
4008
+ * those edits twice. edit's is the answer write and delete give, one path's;
4009
+ * edit_many's lists the files disk refused, each with its push's reason,
4010
+ * notes each file disk took with its mode left as disk had it, as the
4011
+ * success answer's `modeKept` does, within `maxChars`, and never says to
4012
+ * send the call again. Where the store failed after recording the edits,
4013
+ * which stand as on a success, it names each conflict the success answer
4014
+ * would (`conflicts`, found only where the mount doesn't push).
4015
+ */
4016
+ editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
4017
+ const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4018
+ if (tool === 'edit') {
4019
+ const [target] = targets;
4020
+ const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
4021
+ return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
4022
+ }
4023
+ 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.";
4024
+ // The head is never cut, so what stands for the whole call goes in it,
4025
+ // and the parts are the files disk refused, each with its push's reason.
4026
+ let head;
4027
+ const parts = [];
4028
+ if ('refused' in outcome) {
4029
+ const files = targets.length;
4030
+ const refused = outcome.refused.size;
4031
+ head = `${stored}\n\n${files === 1
4032
+ ? "Disk didn't take the call's one file. Its push didn't complete:"
4033
+ : `Disk took ${files - refused} of the call's ${files} files. ${refused === 1
4034
+ ? "This file's push didn't complete:"
4035
+ : "These files' pushes didn't complete, each with its reason:"}`}`;
4036
+ for (const target of targets) {
4037
+ const reason = outcome.refused.get(target.relativePath);
4038
+ if (reason !== undefined)
4039
+ parts.push(`${target.path}: ${reason}`);
4040
+ }
4041
+ }
4042
+ else if ('storeFailed' in outcome) {
4043
+ head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4044
+ + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4045
+ }
4046
+ else {
4047
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4048
+ }
4049
+ const kept = 'modeKept' in outcome
4050
+ ? targets.flatMap((target) => {
4051
+ const reason = outcome.modeKept.get(target.relativePath);
4052
+ return reason === undefined ? [] : [`Disk took ${target.path}: ${reason}.`];
4053
+ })
4054
+ : [];
4055
+ const notes = [
4056
+ ...kept,
4057
+ ...targets.flatMap((target) => [applied.get(target) ?? null, conflictOf(target)]).filter((note) => !!note),
4058
+ ];
4059
+ return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
4060
+ }
2903
4061
  async handleDelete(input) {
2904
4062
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
2905
4063
  input = { ...input, path };
@@ -2910,11 +4068,12 @@ export class WorkspaceModule {
2910
4068
  let committed = false;
2911
4069
  try {
2912
4070
  return await this.withMount(mount, async () => {
2913
- const report = await this.ensureSynced(mount, relativePath);
4071
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
2914
4072
  await this.settleBeforeMutation(mount, relativePath);
2915
4073
  const entry = store.treeGet(mount.treeStateId, relativePath);
2916
4074
  if (!entry) {
2917
- return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
4075
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4076
+ return { success: false, error, isError: true };
2918
4077
  }
2919
4078
  const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
2920
4079
  if (refused !== null)
@@ -2931,63 +4090,156 @@ export class WorkspaceModule {
2931
4090
  catch (error) {
2932
4091
  if (!committed)
2933
4092
  throw error;
4093
+ if (error instanceof StoredThenFailed) {
4094
+ // Its note, as the success answer's: a deletion that cleared a
4095
+ // conflict naming a disk file unlinked nothing.
4096
+ const note = error.notes.get(relativePath);
4097
+ return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
4098
+ }
2934
4099
  return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
2935
4100
  }
2936
4101
  }
2937
4102
  async handleLs(input) {
2938
- const store = this.getStore();
2939
4103
  if (!input.path) {
2940
- // List all mounts
4104
+ // List all mounts, with the kind a mount configured direct runs as
4105
+ // this session and the ignore list it walks by, whose default no
4106
+ // config shows, and each with its include where it has one, so the
4107
+ // files a mount holds show before a path outside them is refused.
2941
4108
  const mounts = [...this.mounts.entries()].map(([name, m]) => ({
2942
4109
  name,
2943
4110
  path: m.config.path,
2944
4111
  mode: m.config.mode,
4112
+ ...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
4113
+ ...(m.config.include ? { include: [...m.config.include] } : {}),
2945
4114
  }));
2946
4115
  return { success: true, data: { mounts } };
2947
4116
  }
2948
- const { mount, relativePath, path } = await this.parsePath(input.path);
4117
+ const { mount, relativePath, path, moved, root } = await this.parsePath(input.path);
4118
+ // A path outside the mount's include that's no scope (refusesAsOutside)
4119
+ // is refused, as glob refuses it, and shown as given.
4120
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4121
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4122
+ }
2949
4123
  input = { ...input, path };
2950
4124
  const recursive = input.recursive === true;
2951
4125
  // Every listing checks the paths it shows against disk first, under the
2952
- // three-way rule, whatever the mount's watch mode.
2953
- return this.withMount(mount, async () => {
2954
- const pass = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive });
2955
- const reports = [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path));
2956
- const incomplete = pass.incomplete.length > 0 ? { incomplete: pass.incomplete } : {};
2957
- if (recursive) {
2958
- return {
2959
- success: true,
2960
- data: {
2961
- path: input.path,
2962
- entries: reports.map((r) => ({ path: r.path, ...stateView(r) })),
2963
- count: reports.length,
2964
- ...incomplete,
2965
- },
2966
- };
2967
- }
2968
- // Non-recursive: the scope's files, and the directories beneath it that
2969
- // disk or the workspace has.
2970
- const prefix = relativePath ? relativePath + '/' : '';
2971
- const dirNames = new Set(pass.dirs.map((d) => d.slice(prefix.length)));
2972
- for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
2973
- const slash = entry.path.indexOf('/', prefix.length);
2974
- if (slash >= 0)
2975
- dirNames.add(entry.path.slice(prefix.length, slash));
2976
- }
2977
- const children = [
2978
- ...[...dirNames].sort().map((name) => ({ name, type: 'directory' })),
2979
- ...reports.map((r) => ({ name: r.path.slice(prefix.length), type: 'file', ...stateView(r) })),
2980
- ];
4126
+ // three-way rule, whatever the mount's watch mode; a direct mount's
4127
+ // shows disk's.
4128
+ const listing = await this.listingOf(mount, relativePath, recursive);
4129
+ const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
4130
+ if (recursive) {
2981
4131
  return {
2982
4132
  success: true,
2983
4133
  data: {
2984
4134
  path: input.path,
2985
- entries: children,
2986
- count: children.length,
4135
+ entries: listing.files,
4136
+ count: listing.files.length,
2987
4137
  ...incomplete,
2988
4138
  },
2989
4139
  };
4140
+ }
4141
+ // Non-recursive: the scope's files, and the directories beneath it.
4142
+ const prefix = relativePath ? relativePath + '/' : '';
4143
+ const children = [
4144
+ ...listing.dirs.map((d) => d.slice(prefix.length)).sort().map((name) => ({ name, type: 'directory' })),
4145
+ ...listing.files.map(({ path: file, ...view }) => ({ name: file.slice(prefix.length), type: 'file', ...view })),
4146
+ ];
4147
+ return {
4148
+ success: true,
4149
+ data: {
4150
+ path: input.path,
4151
+ entries: children,
4152
+ count: children.length,
4153
+ ...incomplete,
4154
+ },
4155
+ };
4156
+ }
4157
+ /**
4158
+ * What a listing shows of a directory scope, from where the mount keeps
4159
+ * what it reads: the store-backed kind's pass (listingFromStore), or, on a
4160
+ * direct mount, disk (listingFromDisk). `keep` chooses the files shown.
4161
+ */
4162
+ listingOf(mount, dir, recursive, keep) {
4163
+ return mount.direct ? this.listingFromDisk(mount, dir, recursive, keep) : this.listingFromStore(mount, dir, recursive, keep);
4164
+ }
4165
+ /**
4166
+ * A 'chronicle' mount's listing: a pass over the scope under the three-way
4167
+ * rule, each path it decided with its state, and the directories beneath
4168
+ * the scope that disk or the workspace has.
4169
+ */
4170
+ listingFromStore(mount, dir, recursive, keep) {
4171
+ const store = this.getStore();
4172
+ return this.withMount(mount, async () => {
4173
+ const pass = await this.passUnlocked(mount, { kind: 'dir', dir, recursive });
4174
+ const files = [...pass.reports.values()]
4175
+ .sort((a, b) => a.path.localeCompare(b.path))
4176
+ .filter((report) => keep === undefined || keep(report.path))
4177
+ .map((report) => ({ path: report.path, ...stateView(report) }));
4178
+ if (recursive)
4179
+ return { files, dirs: [], incomplete: pass.incomplete };
4180
+ // Read with the pass's reports, on the branch it decided on, before
4181
+ // anything else takes the mount's turn.
4182
+ const dirs = new Set(pass.dirs);
4183
+ const prefix = dir ? dir + '/' : '';
4184
+ // History implies a directory only where it's the mount's own to name
4185
+ // (withinIncludes): not history in a nested mount's directory that
4186
+ // that mount's include leaves out.
4187
+ const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
4188
+ for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
4189
+ if (!withinIncludes(own, entry.path))
4190
+ continue;
4191
+ const slash = entry.path.indexOf('/', prefix.length);
4192
+ if (slash >= 0)
4193
+ dirs.add(entry.path.slice(0, slash));
4194
+ }
4195
+ return { files, dirs: [...dirs], incomplete: pass.incomplete };
4196
+ });
4197
+ }
4198
+ /**
4199
+ * A direct mount's listing: its walk of the scope (diskWalk), with each
4200
+ * entry it took as disk has it now, observed as a pass observes it: a
4201
+ * file's size, or why the mount doesn't take it (a link it doesn't follow,
4202
+ * one that leaves the mount, what isn't a regular file). A file gone since
4203
+ * the walk listed it is no longer there to show.
4204
+ */
4205
+ async listingFromDisk(mount, dir, recursive, keep) {
4206
+ const { view, rootReal, taken, dirs, incomplete } = await this.diskWalk(mount, dir, recursive);
4207
+ const files = [];
4208
+ await inOrder(taken.filter((path) => keep === undefined || keep(path)), DIRECT_READS_AT_ONCE, (path) => observePath(view, rootReal, path), (seen, path) => {
4209
+ if (seen.kind === 'file')
4210
+ files.push({ path, size: seen.size });
4211
+ else if (seen.kind === 'unobserved')
4212
+ files.push({ path, note: seen.reason });
2990
4213
  });
4214
+ return { files, dirs, incomplete };
4215
+ }
4216
+ /**
4217
+ * A direct mount's walk of a scope, from its view (diskView): the entries
4218
+ * it took that aren't directories (files, links and the like), in path
4219
+ * order; the directories directly beneath a scope it doesn't descend; and
4220
+ * the regions it couldn't see that a pass reports beside no tracked file:
4221
+ * what it couldn't list, what the cap left, and a scope in another mount's
4222
+ * directory, said as a listing says it. A path the walk names is walked
4223
+ * whatever the mount's ignore list or an editor's file name covers there
4224
+ * (MountConfig.ignore), as a path sync's walk is.
4225
+ */
4226
+ async diskWalk(mount, dir, recursive) {
4227
+ const { view, rootReal, moved } = await this.diskView(mount);
4228
+ const walk = await walkScope(view, rootReal, dir, recursive, mount.config.walkCap ?? WALK_CAP, { named: true });
4229
+ const incomplete = walk.incomplete
4230
+ .filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
4231
+ .map((region) => ({
4232
+ path: region.path,
4233
+ reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved) : region.reason,
4234
+ }));
4235
+ return {
4236
+ view,
4237
+ rootReal,
4238
+ taken: [...walk.files, ...walk.othersTaken].sort((a, b) => a.localeCompare(b)),
4239
+ // A walk that doesn't descend sees the scope's own subdirectories alone.
4240
+ dirs: recursive ? [] : [...walk.dirs],
4241
+ incomplete,
4242
+ };
2991
4243
  }
2992
4244
  async handleGlob(input) {
2993
4245
  const regex = globToRegex(input.pattern);
@@ -2996,21 +4248,27 @@ export class WorkspaceModule {
2996
4248
  // Search across mounts
2997
4249
  const mountsToSearch = input.path
2998
4250
  ? [await this.parsePath(input.path)]
2999
- : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
4251
+ : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '', moved: null, root: resolve(m.config.path) }));
4252
+ // The path is a directory to search in. One outside the mount's include
4253
+ // that's no scope (refusesAsOutside) is refused, as grep refuses it,
4254
+ // rather than searched and reported as matching nothing.
4255
+ if (input.path) {
4256
+ const { mount, relativePath, moved, root } = mountsToSearch[0];
4257
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4258
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4259
+ }
4260
+ }
3000
4261
  // Every match lies beneath the pattern's literal directory, so the walk
3001
4262
  // starts there: past the walk's cap, a walk of the whole scope can stop
3002
4263
  // before it gets there.
3003
4264
  const patternDir = literalDirOf(input.pattern);
3004
4265
  for (const { mount, relativePath } of mountsToSearch) {
3005
4266
  const start = await this.walkStart(mount, relativePath, patternDir);
3006
- const pass = await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: start, recursive: true }));
3007
- for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
3008
- const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
3009
- if (regex.test(testPath)) {
3010
- matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) });
3011
- }
3012
- }
3013
- for (const region of pass.incomplete) {
4267
+ const matched = (path) => regex.test(relativePath ? path.slice(relativePath.length + 1) : path);
4268
+ const listing = await this.listingOf(mount, start, true, matched);
4269
+ for (const { path, ...view } of listing.files)
4270
+ matches.push({ path: `${mount.config.name}/${path}`, ...view });
4271
+ for (const region of listing.incomplete) {
3014
4272
  incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
3015
4273
  }
3016
4274
  }
@@ -3025,7 +4283,6 @@ export class WorkspaceModule {
3025
4283
  };
3026
4284
  }
3027
4285
  async handleGrep(input) {
3028
- const store = this.getStore();
3029
4286
  let regex;
3030
4287
  try {
3031
4288
  regex = new RegExp(input.pattern);
@@ -3043,7 +4300,15 @@ export class WorkspaceModule {
3043
4300
  const contextAfter = input.contextAfter ?? 0;
3044
4301
  const mountsToSearch = input.path
3045
4302
  ? [await this.parsePath(input.path)]
3046
- : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
4303
+ : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '', moved: null, root: resolve(m.config.path) }));
4304
+ // A path outside the mount's include that's no scope is refused, as read
4305
+ // refuses it, rather than searched and reported as holding no match.
4306
+ if (input.path) {
4307
+ const { mount, relativePath, moved, root } = mountsToSearch[0];
4308
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4309
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4310
+ }
4311
+ }
3047
4312
  const results = [];
3048
4313
  const skipped = [];
3049
4314
  const incomplete = [];
@@ -3063,12 +4328,13 @@ export class WorkspaceModule {
3063
4328
  }
3064
4329
  return found;
3065
4330
  };
3066
- for (const { mount, relativePath } of mountsToSearch) {
4331
+ for (const { mount, relativePath, moved, root } of mountsToSearch) {
3067
4332
  // A path named in another mount's directory, as holderOf resolves it,
3068
4333
  // is that mount's: grep searches nothing of this mount's there, and
3069
- // says whose it is.
3070
- if (this.holderOf(mount.config.name, relativePath)) {
3071
- incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list') });
4334
+ // says whose it is. A direct mount's is judged by the nesting its parse
4335
+ // found where its root lies now (movedNesting).
4336
+ if (this.holderOf(mount.config.name, relativePath, false, moved)) {
4337
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
3072
4338
  continue;
3073
4339
  }
3074
4340
  // A `path` and a filter whose directories don't overlap leave no file
@@ -3077,116 +4343,17 @@ export class WorkspaceModule {
3077
4343
  if (deeper === null)
3078
4344
  continue;
3079
4345
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
3080
- await this.withMount(mount, async () => {
3081
- // A `path` naming a single file greps just that file; otherwise it is a
3082
- // directory prefix. (A file path once produced the prefix "notes.md/"
3083
- // and silently matched nothing.)
3084
- const fileScope = relativePath !== '' && (store.treeGet(mount.treeStateId, relativePath) !== null ||
3085
- await lstat(join(mount.config.path, relativePath)).then((s) => !s.isDirectory(), () => false));
3086
- // What follows reads the tree and branch intent to go with the pass's
3087
- // reports, synchronously. They must be the branch the pass decided
3088
- // on: one selected in between would label its content with the other
3089
- // branch's states. So a pass whose branch is no longer selected runs
3090
- // again, and a branch that keeps changing leaves the scope unsearched.
3091
- //
3092
- // A copy held at a path that now leads into a nested mount's directory
3093
- // through a link is that mount's file too: read refuses it
3094
- // (parseFilePath), so grep searches none of it and says why. The pass
3095
- // can't check such a path's disk, so only an unverified one can be
3096
- // one. Asking awaits disk, so it's done before the branch is
3097
- // compared, and a branch selected meanwhile runs the pass again.
3098
- let pass = null;
3099
- let refused = new Map();
3100
- for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
3101
- const next = await this.passUnlocked(mount, fileScope
3102
- ? { kind: 'paths', paths: [relativePath] }
3103
- : { kind: 'dir', dir: start, recursive: true });
3104
- const asked = new Map();
3105
- for (const report of next.reports.values()) {
3106
- if (report.state !== 'unverified')
3107
- continue;
3108
- const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
3109
- if (refusal)
3110
- asked.set(report.path, refusal.message);
3111
- }
3112
- if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
3113
- pass = next;
3114
- refused = asked;
3115
- }
3116
- }
3117
- if (pass === null) {
3118
- incomplete.push({
3119
- path: `${mount.config.name}/${relativePath}`,
3120
- reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
3121
- });
3122
- return;
3123
- }
3124
- for (const region of pass.incomplete) {
3125
- incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
3126
- }
3127
- const intents = this.intents.get(mount.config.name);
3128
- for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
3129
- if (fileGlob && !fileGlob.test(report.path))
3130
- continue;
3131
- const file = `${mount.config.name}/${report.path}`;
3132
- if (report.state === 'disk-only') {
3133
- skipped.push({
3134
- file,
3135
- reason: 'disk-only: binary or over the size limit, never stored',
3136
- ...(report.size !== undefined ? { size: report.size } : {}),
3137
- ...(report.mimeType ? { mimeType: report.mimeType } : {}),
3138
- });
3139
- continue;
3140
- }
3141
- // A path in another mount's directory is that mount's: grep gives
3142
- // none of this mount's copy of it, as read refuses it. Beneath a
3143
- // scope it's passed over by design, like the directory.
3144
- if (inOtherMount(report.path, this.othersOf(mount.config.name)))
3145
- continue;
3146
- const refusal = refused.get(report.path);
3147
- if (refusal !== undefined) {
3148
- skipped.push({ file, reason: refusal });
3149
- continue;
3150
- }
3151
- // The workspace's version, labelled with the path's state.
3152
- const entry = store.treeGet(mount.treeStateId, report.path);
3153
- const blob = entry ? store.getBlob(entry.blobHash) : null;
3154
- if (blob) {
3155
- const found = search(blob.toString('utf-8'));
3156
- if (found.length > 0)
3157
- results.push({ file, state: report.state, version: 'workspace', matches: found });
3158
- }
3159
- else if (entry) {
3160
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
3161
- }
3162
- if (report.state !== 'conflict')
3163
- continue;
3164
- // A conflict's disk side. The version recorded with the conflict is
3165
- // disk's only while disk still holds it; once disk has changed
3166
- // since, it is labelled as recorded, and a file disk holds now
3167
- // (never stored) is listed as not searched. A binary or oversize
3168
- // disk version was never stored either.
3169
- const disk = intents.get(report.path)?.conflict?.disk;
3170
- const changed = report.conflict?.diskChangedSinceRecorded === true;
3171
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
3172
- if (diskBlob) {
3173
- const found = search(diskBlob.toString('utf-8'));
3174
- if (found.length > 0)
3175
- results.push({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', matches: found });
3176
- }
3177
- if (disk?.stored && !diskBlob) {
3178
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
3179
- }
3180
- if (changed) {
3181
- if (report.diskNow === 'file') {
3182
- 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' });
3183
- }
3184
- }
3185
- else if (disk && !disk.stored) {
3186
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
3187
- }
3188
- }
3189
- });
4346
+ const searched = (text) => {
4347
+ const matches = search(text.text);
4348
+ if (matches.length > 0)
4349
+ results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
4350
+ };
4351
+ const kept = (path) => fileGlob === null || fileGlob.test(path);
4352
+ const found = mount.direct
4353
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
4354
+ : await this.textsFromStore(mount, relativePath, start, kept, searched);
4355
+ skipped.push(...found.skipped);
4356
+ incomplete.push(...found.incomplete);
3190
4357
  }
3191
4358
  return {
3192
4359
  success: true,
@@ -3199,6 +4366,164 @@ export class WorkspaceModule {
3199
4366
  },
3200
4367
  };
3201
4368
  }
4369
+ /**
4370
+ * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
4371
+ * single file `relativePath` names, those `keep` lets through, each handed
4372
+ * to `each` in path order: a pass over the scope, then each path's
4373
+ * workspace copy, labelled with its state, and a conflict's disk side.
4374
+ * What it doesn't search is in `skipped`, and what it couldn't see in
4375
+ * `incomplete`, each mount-prefixed.
4376
+ */
4377
+ async textsFromStore(mount, relativePath, start, keep, each) {
4378
+ const store = this.getStore();
4379
+ const skipped = [];
4380
+ const incomplete = [];
4381
+ await this.withMount(mount, async () => {
4382
+ // A `path` naming a single file greps just that file; otherwise it is a
4383
+ // directory prefix. (A file path once produced the prefix "notes.md/"
4384
+ // and silently matched nothing.)
4385
+ const fileScope = await this.namesFile(mount, relativePath, resolve(mount.config.path));
4386
+ // What follows reads the tree and branch intent to go with the pass's
4387
+ // reports, synchronously. They must be the branch the pass decided
4388
+ // on: one selected in between would label its content with the other
4389
+ // branch's states. So a pass whose branch is no longer selected runs
4390
+ // again, and a branch that keeps changing leaves the scope unsearched.
4391
+ //
4392
+ // A copy held at a path that now leads into a nested mount's directory
4393
+ // through a link is that mount's file too, and one that now lands on a
4394
+ // file this mount's include leaves out is no file of this mount's:
4395
+ // read refuses either (parseFilePath), so grep searches none of it and
4396
+ // says why. The pass
4397
+ // can't check such a path's disk, so only an unverified one can be
4398
+ // one. Asking awaits disk, so it's done before the branch is
4399
+ // compared, and a branch selected meanwhile runs the pass again.
4400
+ let pass = null;
4401
+ let refused = new Map();
4402
+ for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
4403
+ const next = await this.passUnlocked(mount, fileScope
4404
+ ? { kind: 'paths', paths: [relativePath] }
4405
+ : { kind: 'dir', dir: start, recursive: true });
4406
+ const asked = new Map();
4407
+ for (const report of next.reports.values()) {
4408
+ if (report.state !== 'unverified')
4409
+ continue;
4410
+ const refusal = await this.landingRefusal(mount, report.path, `${mount.config.name}/${report.path}`, this.nestedOf(mount.config.name), resolve(mount.config.path));
4411
+ if (refusal)
4412
+ asked.set(report.path, refusal.message);
4413
+ }
4414
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
4415
+ pass = next;
4416
+ refused = asked;
4417
+ }
4418
+ }
4419
+ if (pass === null) {
4420
+ incomplete.push({
4421
+ path: `${mount.config.name}/${relativePath}`,
4422
+ reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
4423
+ });
4424
+ return;
4425
+ }
4426
+ for (const region of pass.incomplete) {
4427
+ incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
4428
+ }
4429
+ const intents = this.intents.get(mount.config.name);
4430
+ for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
4431
+ if (!keep(report.path))
4432
+ continue;
4433
+ const file = `${mount.config.name}/${report.path}`;
4434
+ if (report.state === 'disk-only') {
4435
+ skipped.push({
4436
+ file,
4437
+ reason: 'disk-only: binary or over the size limit, never stored',
4438
+ ...(report.size !== undefined ? { size: report.size } : {}),
4439
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
4440
+ });
4441
+ continue;
4442
+ }
4443
+ // A path in another mount's directory is that mount's: grep gives
4444
+ // none of this mount's copy of it, as read refuses it. Beneath a
4445
+ // scope it's passed over by design, like the directory.
4446
+ if (inOtherMount(report.path, this.othersOf(mount.config.name)))
4447
+ continue;
4448
+ const refusal = refused.get(report.path);
4449
+ if (refusal !== undefined) {
4450
+ skipped.push({ file, reason: refusal });
4451
+ continue;
4452
+ }
4453
+ // The workspace's version, labelled with the path's state.
4454
+ const entry = store.treeGet(mount.treeStateId, report.path);
4455
+ const blob = entry ? store.getBlob(entry.blobHash) : null;
4456
+ if (blob) {
4457
+ each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
4458
+ }
4459
+ else if (entry) {
4460
+ skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
4461
+ }
4462
+ if (report.state !== 'conflict')
4463
+ continue;
4464
+ // A conflict's disk side. The version recorded with the conflict is
4465
+ // disk's only while disk still holds it; once disk has changed
4466
+ // since, it is labelled as recorded, and a file disk holds now
4467
+ // (never stored) is listed as not searched. A binary or oversize
4468
+ // disk version was never stored either.
4469
+ const disk = intents.get(report.path)?.conflict?.disk;
4470
+ const changed = report.conflict?.diskChangedSinceRecorded === true;
4471
+ const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
4472
+ if (diskBlob) {
4473
+ each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
4474
+ }
4475
+ if (disk?.stored && !diskBlob) {
4476
+ skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
4477
+ }
4478
+ if (changed) {
4479
+ if (report.diskNow === 'file') {
4480
+ 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' });
4481
+ }
4482
+ }
4483
+ else if (disk && !disk.stored) {
4484
+ skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
4485
+ }
4486
+ }
4487
+ });
4488
+ return { skipped, incomplete };
4489
+ }
4490
+ /**
4491
+ * The texts a direct mount's grep searches beneath `start`, or in the
4492
+ * single file `relativePath` names, as namesFile judges it beneath `root`,
4493
+ * the root grep's parse resolved, those `keep` lets through, each handed
4494
+ * to `each` in path order: each file its walk took (diskWalk), read from
4495
+ * disk as it is now, DIRECT_READS_AT_ONCE at a time. A file over the size
4496
+ * limit, a binary one and one the mount doesn't take are in `skipped`,
4497
+ * each with why, as a 'chronicle' mount's grep lists what it never stored;
4498
+ * one gone since the walk listed it isn't there to search.
4499
+ */
4500
+ async textsFromDisk(mount, relativePath, root, start, keep, each) {
4501
+ const name = mount.config.name;
4502
+ const fileScope = await this.namesFile(mount, relativePath, root);
4503
+ const { view, rootReal, taken, incomplete } = fileScope
4504
+ ? { ...(await this.diskView(mount)), taken: [relativePath], incomplete: [] }
4505
+ : await this.diskWalk(mount, start, true);
4506
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4507
+ const skipped = [];
4508
+ await inOrder(taken.filter(keep), DIRECT_READS_AT_ONCE, (path) => readBytes(view, rootReal, path, maxSize), (read, path) => {
4509
+ const file = `${name}/${path}`;
4510
+ if (read.kind === 'absent')
4511
+ return;
4512
+ if (read.kind === 'unobserved') {
4513
+ skipped.push({ file, reason: read.reason });
4514
+ return;
4515
+ }
4516
+ const mimeType = sniffImageMime(read.head);
4517
+ const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
4518
+ if (read.bytes === null)
4519
+ skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
4520
+ else if (looksBinary(read.head))
4521
+ skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
4522
+ else
4523
+ each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
4524
+ });
4525
+ return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
4526
+ }
3202
4527
  async handleStatus(input) {
3203
4528
  const store = this.getStore();
3204
4529
  const status = {};
@@ -3208,24 +4533,29 @@ export class WorkspaceModule {
3208
4533
  for (const [name, mount] of this.mounts) {
3209
4534
  if (input.mount !== undefined && name !== input.mount)
3210
4535
  continue;
3211
- const entries = store.treeList(mount.treeStateId);
4536
+ if (mount.direct) {
4537
+ // A direct mount keeps nothing to count: its kind, and the ignore
4538
+ // list its walks go by, whose default no config shows.
4539
+ status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
4540
+ continue;
4541
+ }
4542
+ // What the workspace's history holds outside the mount's include isn't the mount's,
4543
+ // and nor is what it holds in a nested mount's directory that mount's include leaves
4544
+ // out (withinIncludes), which no listing names either.
4545
+ const listable = { include: mount.config.include, nested: this.nestedOf(name) };
4546
+ const entries = store.treeList(mount.treeStateId).filter((e) => withinIncludes(listable, e.path));
3212
4547
  const currentSeq = store.currentSequence();
3213
- const own = this.ownPaths(mount);
3214
- const intents = this.intents.get(name).list().filter(([p]) => own(p));
3215
- const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
3216
- // A workspace deletion is pending until a push confirms it on disk:
3217
- // left there without applyDeletions, or unlinked but unconfirmed.
3218
- const deletionPaths = intents.filter(([, bi]) => bi.tombstone).map(([p]) => p);
3219
- // Pending is what disk owes by the evidence: every entry it doesn't
3220
- // show on disk (a refused or failed push stays owed), conflicts and
3221
- // deletions. An entry from before the evidence isn't counted until a
3222
- // listing or a materialize checks it: unchecked isn't unpushed.
3223
- const pending = new Set([...conflictPaths, ...deletionPaths, ...this.entriesByEvidence(mount).owed]);
4548
+ const { conflicts: conflictPaths, deletions: deletionPaths, owed } = this.heldChanges(mount);
4549
+ const pending = new Set([...conflictPaths, ...deletionPaths, ...owed]);
3224
4550
  const currentBranch = store.currentBranch();
3225
4551
  status[name] = {
3226
4552
  path: mount.config.path,
3227
4553
  mode: mount.config.mode,
3228
- watch: mount.config.watch ?? 'always',
4554
+ // A mount configured direct runs as 'chronicle' this session
4555
+ // (chooseKinds), by the direct kind's ignore list where its config
4556
+ // names none.
4557
+ ...(mount.config.backend === 'direct' ? { backend: 'chronicle', ignore: this.ignoreOf(mount) } : {}),
4558
+ watch: watchOf(mount.config),
3229
4559
  fileCount: entries.length,
3230
4560
  lastMaterializedSeq: mount.lastMaterializedSeq,
3231
4561
  currentSeq,
@@ -3241,6 +4571,36 @@ export class WorkspaceModule {
3241
4571
  }
3242
4572
  return { success: true, data: status };
3243
4573
  }
4574
+ /**
4575
+ * What the store holds of a mount that disk lacks, on the selected branch,
4576
+ * among the mount's own paths. Status counts the first three as pending:
4577
+ * recorded conflicts; workspace deletions, pending until a push confirms
4578
+ * them on disk (left there without applyDeletions, or unlinked but
4579
+ * unconfirmed); and what disk owes by the evidence (entriesByEvidence's
4580
+ * `owed`: every entry it doesn't show on disk, a refused or failed push's
4581
+ * included). An entry from before the evidence isn't among them until a
4582
+ * listing or a materialize checks it: unchecked isn't unpushed. A
4583
+ * tombstone beside an entry that's still there, left by a deletion or a
4584
+ * write that stopped partway (commitToolChanges), is no deletion: it holds
4585
+ * no change disk lacks, and its entry is judged by the evidence as any
4586
+ * other is. The fourth, intents that can't be read, may hold a deletion
4587
+ * or a conflict, so a mount configured direct counts them too (initStore).
4588
+ * Each lies where the mount's include covers, as status counts them: what
4589
+ * the workspace's history holds outside it is no change of the mount's,
4590
+ * and no push writes it to disk (pushUnlocked).
4591
+ */
4592
+ heldChanges(mount) {
4593
+ const store = this.getStore();
4594
+ const own = this.ownPaths(mount);
4595
+ // What the workspace's history holds outside the mount's include isn't the mount's.
4596
+ const intents = this.intents.get(mount.config.name).list().filter(([p]) => insideMount(p, mount.config.include) && own(p));
4597
+ return {
4598
+ conflicts: intents.filter(([, bi]) => bi.conflict).map(([p]) => p),
4599
+ deletions: intents.filter(([p, bi]) => bi.tombstone && !store.treeGet(mount.treeStateId, p)).map(([p]) => p),
4600
+ owed: this.entriesByEvidence(mount).owed,
4601
+ unreadable: intents.filter(([, bi]) => bi.unreadable !== undefined).map(([p]) => p),
4602
+ };
4603
+ }
3244
4604
  /**
3245
4605
  * What a materialize of `mount` should consider: a named file, or every
3246
4606
  * tracked path beneath a named directory; otherwise what the evidence says
@@ -3271,18 +4631,26 @@ export class WorkspaceModule {
3271
4631
  const { owed, unchecked, agreed } = this.entriesByEvidence(mount);
3272
4632
  for (const path of [...owed, ...unchecked])
3273
4633
  selected.add(path);
3274
- for (const [p, bi] of intents)
3275
- if (bi.conflict || bi.tombstone)
4634
+ // A tombstone is a deletion only where its entry is gone: one beside an
4635
+ // entry that's still there, left by a change that stopped partway
4636
+ // (commitToolChanges), would select an agreed entry, and a forced push
4637
+ // would write it over a newer disk copy. A deletion or conflict held
4638
+ // outside the mount's include isn't the mount's, as status counts it:
4639
+ // not taken up, so not reported skipped.
4640
+ for (const [p, bi] of intents) {
4641
+ if ((bi.conflict || (bi.tombstone && !store.treeGet(mount.treeStateId, p))) && insideMount(p, mount.config.include))
3276
4642
  selected.add(p);
4643
+ }
3277
4644
  return { paths: [...selected], inPlace: agreed > 0 };
3278
4645
  }
3279
4646
  /**
3280
4647
  * Whether a path of the mount is its own to push and to count as owed:
3281
4648
  * not in another mount's directory nested in it. Copies the mount's tree
3282
- * holds there (stored before the nesting) stay in its history and its
3283
- * listings (and in status's fileCount, which counts the tree), passed over
3284
- * by its syncs, and left out of what it pushes and of status's pending
3285
- * changes, conflicts and workspace deletions.
4649
+ * holds there (stored before the nesting) stay in its history, and in its
4650
+ * listings and status's fileCount where that mount's include covers them
4651
+ * (withinIncludes). Its syncs pass over them, and
4652
+ * they're left out of what it pushes and of status's pending changes,
4653
+ * conflicts and workspace deletions.
3286
4654
  */
3287
4655
  ownPaths(mount) {
3288
4656
  const others = this.othersOf(mount.config.name);
@@ -3310,7 +4678,9 @@ export class WorkspaceModule {
3310
4678
  let agreed = 0;
3311
4679
  const own = this.ownPaths(mount);
3312
4680
  for (const e of store.treeList(mount.treeStateId)) {
3313
- if (!own(e.path))
4681
+ // Outside the mount's include, or in another mount's directory nested
4682
+ // in it: neither owed to disk nor counted as on it.
4683
+ if (!insideMount(e.path, mount.config.include) || !own(e.path))
3314
4684
  continue;
3315
4685
  const p = agreement.get(name, e.path);
3316
4686
  if (p === undefined)
@@ -3333,8 +4703,16 @@ export class WorkspaceModule {
3333
4703
  const pendingDeletions = [];
3334
4704
  const modesKept = [];
3335
4705
  let explicit = null;
3336
- if (input.path)
3337
- explicit = await this.parsePath(input.path);
4706
+ if (input.path) {
4707
+ const parsed = await this.parsePath(input.path);
4708
+ explicit = parsed;
4709
+ // A path outside the mount's include that's no scope (refusesAsOutside)
4710
+ // is refused; beneath a directory, the push leaves out what the include
4711
+ // doesn't cover.
4712
+ if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
4713
+ return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
4714
+ }
4715
+ }
3338
4716
  let mountsToMaterialize;
3339
4717
  if (explicit) {
3340
4718
  mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
@@ -3360,6 +4738,10 @@ export class WorkspaceModule {
3360
4738
  // whatever branch is current when its turn comes, unguarded. The push
3361
4739
  // then writes nothing if the branch changes after that.
3362
4740
  const blocked = [];
4741
+ // What the push left because the mount's include doesn't cover it is
4742
+ // counted, as a sync's passedOver counts it, and not named: no tool names
4743
+ // a file outside the include.
4744
+ const passedOver = [];
3363
4745
  const guarded = [];
3364
4746
  let proceeded = 0;
3365
4747
  for (const { name, mount } of mountsToMaterialize) {
@@ -3407,10 +4789,17 @@ export class WorkspaceModule {
3407
4789
  pendingDeletions.push({ mount: name, path: p });
3408
4790
  // Refusals ride the same skipped list as branch blocks: divergence must
3409
4791
  // be visible, not resolved silently either way.
4792
+ let outside = 0;
3410
4793
  for (const s of pushed.skipped) {
4794
+ if (s.reason === OUTSIDE_INCLUDE) {
4795
+ outside++;
4796
+ continue;
4797
+ }
3411
4798
  const reason = s.reason === NOT_PUSHED_IN_OTHER_MOUNT ? this.otherMountNote(name, s.path, 'push') : s.reason;
3412
4799
  blocked.push({ mount: name, reason: `${s.path}: ${reason}` });
3413
4800
  }
4801
+ if (outside > 0)
4802
+ passedOver.push({ mount: name, reason: OUTSIDE_INCLUDE, count: outside });
3414
4803
  // Written paths whose mode stayed as disk had it: no refusal, so not
3415
4804
  // among what was skipped.
3416
4805
  for (const k of pushed.modesKept)
@@ -3455,6 +4844,7 @@ export class WorkspaceModule {
3455
4844
  }
3456
4845
  : {}),
3457
4846
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
4847
+ ...(passedOver.length > 0 ? { passedOver } : {}),
3458
4848
  ...(modesKept.length > 0 ? { modesKept } : {}),
3459
4849
  },
3460
4850
  // What disk lost, then what it was written: kept in the stub if the
@@ -3511,7 +4901,13 @@ export class WorkspaceModule {
3511
4901
  // A path names its own mount; otherwise the given mount, or every mount.
3512
4902
  let targets;
3513
4903
  if (input.path) {
3514
- const { mount, relativePath } = await this.parsePath(input.path);
4904
+ const { mount, relativePath, moved, root } = await this.parsePath(input.path);
4905
+ // A path outside the mount's include that's no scope (refusesAsOutside)
4906
+ // is refused; beneath a directory, the walk passes over what the include
4907
+ // doesn't cover.
4908
+ if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4909
+ return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4910
+ }
3515
4911
  targets = [{ name: mount.config.name, mount, relativePath }];
3516
4912
  }
3517
4913
  else if (input.mount) {
@@ -3525,6 +4921,12 @@ export class WorkspaceModule {
3525
4921
  targets = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount, relativePath: '' }));
3526
4922
  }
3527
4923
  for (const { name, mount, relativePath } of targets) {
4924
+ // A direct mount's tools read disk as it is, so a sync has nothing to
4925
+ // take in there, and says it didn't look rather than that nothing changed.
4926
+ if (mount.direct) {
4927
+ allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
4928
+ continue;
4929
+ }
3528
4930
  const pass = await this.withMount(mount, async () => {
3529
4931
  if (input.acceptRoot && await this.acceptRootUnlocked(mount))
3530
4932
  rootsAccepted.push(name);
@@ -3602,7 +5004,7 @@ export class WorkspaceModule {
3602
5004
  // without a path passes over it, so nothing else keeps it current
3603
5005
  // (agent-framework #276). A path in another mount's directory isn't
3604
5006
  // synced at all, and the note says so.
3605
- const passedOver = passedOverAt(relativePath, mount.config.ignore ?? [], this.othersOf(name));
5007
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
3606
5008
  if (passedOver !== undefined) {
3607
5009
  allIgnored.push({
3608
5010
  mount: name,
@@ -3666,12 +5068,15 @@ export class WorkspaceModule {
3666
5068
  * The watcher's ops only choose which paths to look at: each path is
3667
5069
  * observed again and decided under the three-way rule, so an event that
3668
5070
  * arrives late or out of order can't delete or revert anything by itself.
5071
+ * A path outside the mount (insideMount), such as one its include doesn't
5072
+ * cover, is no pass's to decide (gather), so a batch of nothing else takes
5073
+ * no turn of the mount.
3669
5074
  */
3670
5075
  async handleFsChanges(mountName, changes) {
3671
5076
  const mount = this.mounts.get(mountName);
3672
5077
  if (!this.store || !this.running() || !mount)
3673
5078
  return;
3674
- const paths = [...new Set(changes.map((c) => c.path))];
5079
+ const paths = [...new Set(changes.map((c) => c.path))].filter((p) => insideMount(p, mount.config.include));
3675
5080
  if (paths.length === 0)
3676
5081
  return;
3677
5082
  await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'paths', paths }));
@@ -3692,7 +5097,7 @@ export class WorkspaceModule {
3692
5097
  async onToolBatchComplete(_agentName) {
3693
5098
  // Every agent's workspace is the store's current branch, so the agent
3694
5099
  // that ran the batch doesn't choose what is scanned.
3695
- const mounts = [...this.mounts.values()].filter((m) => m.config.watch === 'on-agent-action');
5100
+ const mounts = [...this.mounts.values()].filter((m) => watchOf(m.config) === 'on-agent-action');
3696
5101
  if (mounts.length === 0 || !this.store || !this.running())
3697
5102
  return;
3698
5103
  const deadlineMs = this.agentActionScanDeadlineMs();
@@ -3837,6 +5242,46 @@ export class WorkspaceModule {
3837
5242
  }
3838
5243
  }
3839
5244
  }
5245
+ /**
5246
+ * Run `work` on each item, at most `width` at once, and hand each result to
5247
+ * `each` in the items' order, as soon as it and those before it are done.
5248
+ * A result handed on is let go, so what's held at once is at most `width`
5249
+ * results, as a direct grep's file contents are. A `work` that rejects
5250
+ * rejects this when its turn comes; any started after it finish unheard.
5251
+ * Exported for its tests.
5252
+ */
5253
+ export async function inOrder(items, width, work, each) {
5254
+ const waiting = [];
5255
+ let started = 0;
5256
+ for (let i = 0; i < items.length; i++) {
5257
+ while (started < items.length && started < i + width) {
5258
+ const running = work(items[started]);
5259
+ // Handled from the start, so a rejection that comes before its turn
5260
+ // isn't an unhandled one; awaiting it below still throws it.
5261
+ running.catch(() => { });
5262
+ waiting.push(running);
5263
+ started++;
5264
+ }
5265
+ each(await waiting.shift(), items[i]);
5266
+ }
5267
+ }
5268
+ /** read_image's answer for an image, wherever it was read from. */
5269
+ function imageResult(path, image) {
5270
+ return {
5271
+ success: true,
5272
+ data: [
5273
+ {
5274
+ type: 'text',
5275
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
5276
+ },
5277
+ {
5278
+ type: 'image',
5279
+ data: image.bytes.toString('base64'),
5280
+ mimeType: image.mimeType,
5281
+ },
5282
+ ],
5283
+ };
5284
+ }
3840
5285
  /** What a listing shows of a path beyond its name. */
3841
5286
  function stateView(report) {
3842
5287
  return {
@@ -3852,6 +5297,104 @@ function stateView(report) {
3852
5297
  // ==========================================================================
3853
5298
  /** A lone UTF-16 surrogate, which UTF-8 can't store: in `u` mode, a pair is one code point. */
3854
5299
  const LONE_SURROGATE = /\p{Cs}/u;
5300
+ /** Why a direct mount's read stopped when it found another directory put at its root as it opened the file. */
5301
+ const DIRECT_ROOT_REPLACED = 'the mount root was replaced as the file was opened: reading it again reads the directory there now';
5302
+ /**
5303
+ * How the alert that a mount configured direct runs as 'chronicle' names an
5304
+ * intent the workspace can't read. A sync of its path settles it, with
5305
+ * disk's copy, or with none where disk has none, and where disk's file
5306
+ * can't be read it skips the path and says why. Materialize settles one
5307
+ * only where the workspace holds a copy there: a plain one skips it and says
5308
+ * to sync, a forced one writes the copy, and where the workspace holds none
5309
+ * it neither selects nor names it.
5310
+ */
5311
+ const UNREADABLE_INTENT = "its workspace intent can't be read; a sync of the path settles it or says why not";
5312
+ /**
5313
+ * A mount's watch mode as it runs. A mount configured direct watches
5314
+ * nothing, whichever kind it runs as this session: its config takes no
5315
+ * other mode (directRefusals), and leaving it out means 'never' there.
5316
+ */
5317
+ function watchOf(config) {
5318
+ return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
5319
+ }
5320
+ /**
5321
+ * What a direct mount's config sets that would never act there, each with
5322
+ * the way out (MountConfig.backend): a direct mount is read-only, and has no
5323
+ * watcher to tune, no change event to wake on and no push to make.
5324
+ */
5325
+ function directRefusals(mount) {
5326
+ const refused = [];
5327
+ if (mount.mode !== 'read-only') {
5328
+ 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)}`);
5329
+ }
5330
+ if (mount.watch !== undefined && mount.watch !== 'never')
5331
+ refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
5332
+ for (const key of ['watchDebounceMs', 'watchRootPollMs']) {
5333
+ if (mount[key] !== undefined)
5334
+ refused.push(`${key} would tune no watcher (leave it out)`);
5335
+ }
5336
+ const wake = mount.wakeOnChange;
5337
+ if (wake === true || (Array.isArray(wake) && wake.length > 0))
5338
+ refused.push('wakeOnChange would never wake (leave it out)');
5339
+ if (mount.autoMaterialize === true)
5340
+ refused.push('autoMaterialize would never push (leave it out)');
5341
+ return refused;
5342
+ }
5343
+ /**
5344
+ * Whether a tree entry is what a change leaves: for a set, an entry with its
5345
+ * blob, size and mode; for a removal, none.
5346
+ */
5347
+ function holds(entry, change) {
5348
+ return change.kind === 'set'
5349
+ ? entry !== null && entry.blobHash === change.blobHash && entry.size === change.size && entry.mode === (change.mode ?? 0o644)
5350
+ : entry === null;
5351
+ }
5352
+ /**
5353
+ * Whether a store's set that threw recorded the changes anyway: every path
5354
+ * holds what its change leaves. The changes given are those the tree didn't
5355
+ * hold before the set, and one record takes all of them or none, so their
5356
+ * holding shows the record is in. Reading them back can fail too, and then
5357
+ * whether they're stored isn't known (CommitUnknownError).
5358
+ */
5359
+ function changesLanded(store, treeStateId, changes, setError) {
5360
+ try {
5361
+ return changes.every(({ path, change }) => holds(store.treeGet(treeStateId, path), change));
5362
+ }
5363
+ catch (readError) {
5364
+ throw new CommitUnknownError(setError, readError);
5365
+ }
5366
+ }
5367
+ /**
5368
+ * A store's set threw, and reading its entries back threw too, so whether the
5369
+ * change is stored isn't known. Its message says so after both errors.
5370
+ */
5371
+ class CommitUnknownError extends Error {
5372
+ setError;
5373
+ readError;
5374
+ constructor(setError, readError) {
5375
+ const text = (error) => (error instanceof Error ? error.message : String(error));
5376
+ super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
5377
+ this.name = 'CommitUnknownError';
5378
+ this.setError = text(setError);
5379
+ this.readError = text(readError);
5380
+ }
5381
+ }
5382
+ /**
5383
+ * A store's set that threw after recording the change (changesLanded): the
5384
+ * entries are set and their intents follow, and the store's error goes on in
5385
+ * place of the push. `pushes` says whether the mount would have pushed the
5386
+ * change to disk, and `notes` are commitToolChanges' own, by path.
5387
+ */
5388
+ class StoredThenFailed extends Error {
5389
+ pushes;
5390
+ notes;
5391
+ constructor(storeError, pushes, notes) {
5392
+ super(storeError instanceof Error ? storeError.message : String(storeError));
5393
+ this.pushes = pushes;
5394
+ this.notes = notes;
5395
+ this.name = 'StoredThenFailed';
5396
+ }
5397
+ }
3855
5398
  /**
3856
5399
  * The answer to a write, edit or delete that the workspace stored but
3857
5400
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -3860,7 +5403,8 @@ const LONE_SURROGATE = /\p{Cs}/u;
3860
5403
  * and sending it again could apply it twice. The push's reason says what disk
3861
5404
  * holds and the way out, as materialize takes it. The text carries all of it,
3862
5405
  * since an error's data reaches neither the model nor a code_execution
3863
- * script. An edit's note on which copy it applied to comes last.
5406
+ * script. A note comes last: an edit's on which copy it applied to, or a
5407
+ * deletion's that it unlinked nothing.
3864
5408
  */
3865
5409
  function storedButUnfinished(tool, path, outcome, note = null) {
3866
5410
  const change = tool === 'delete' ? 'deletion' : tool;
@@ -3868,7 +5412,12 @@ function storedButUnfinished(tool, path, outcome, note = null) {
3868
5412
  + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
3869
5413
  const rest = 'refused' in outcome
3870
5414
  ? ` Its push to disk didn't complete: ${outcome.refused}.`
3871
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
5415
+ : 'storeFailed' in outcome
5416
+ ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
5417
+ : tool === 'delete'
5418
+ ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
5419
+ : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
5420
+ : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
3872
5421
  return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
3873
5422
  }
3874
5423
  /**
@@ -3889,15 +5438,18 @@ function linesOf(content) {
3889
5438
  const INPUT_MINIMUMS = {
3890
5439
  read: { offset: 1, limit: 1, offsetChars: 0, limitChars: 1 },
3891
5440
  grep: { contextBefore: 0, contextAfter: 0 },
5441
+ edit_many: { occurrences: 1 },
3892
5442
  };
3893
5443
  /**
3894
5444
  * Why a tool call's input doesn't fit its tool's schema, or null when it does:
3895
5445
  * an object, each required field present, and each field its schema calls a
3896
5446
  * string, a boolean or an integer of that type, an integer at least its
3897
- * minimum. The workspace's schemas use no other types. Without it, an edit
3898
- * missing `newString` wrote the word "undefined", and an offset of 1.5
3899
- * numbered lines 1.5, 2.5 and on. A field the schema doesn't describe is left
3900
- * to its handler.
5447
+ * minimum. A field it calls an array of objects is an array, and each of its
5448
+ * items is checked the same way against the items' schema, with the same
5449
+ * minimums (edit_many's edits). The workspace's schemas use no other types.
5450
+ * Without it, an edit missing `newString` wrote the word "undefined", and an
5451
+ * offset of 1.5 numbered lines 1.5, 2.5 and on. A field the schema doesn't
5452
+ * describe is left to its handler.
3901
5453
  */
3902
5454
  function inputProblem(schema, input, minimums = {}) {
3903
5455
  if (input === null || typeof input !== 'object' || Array.isArray(input)) {
@@ -3909,7 +5461,8 @@ function inputProblem(schema, input, minimums = {}) {
3909
5461
  return `"${name}" is required`;
3910
5462
  }
3911
5463
  for (const [name, value] of Object.entries(fields)) {
3912
- const type = schema.properties?.[name]?.type;
5464
+ const property = schema.properties?.[name];
5465
+ const type = property?.type;
3913
5466
  if (value === undefined || type === undefined)
3914
5467
  continue;
3915
5468
  const minimum = minimums[name];
@@ -3917,11 +5470,22 @@ function inputProblem(schema, input, minimums = {}) {
3917
5470
  : type === 'boolean' ? typeof value === 'boolean'
3918
5471
  : type === 'integer'
3919
5472
  ? Number.isSafeInteger(value) && (minimum === undefined || value >= minimum)
3920
- : true;
5473
+ : type === 'array' ? Array.isArray(value)
5474
+ : true;
5475
+ if (fits && type === 'array' && property?.items?.type === 'object') {
5476
+ for (const [i, item] of value.entries()) {
5477
+ const problem = item !== null && typeof item === 'object' && !Array.isArray(item)
5478
+ ? inputProblem(property.items, item, minimums)
5479
+ : 'it must be an object of the fields its schema lists';
5480
+ if (problem !== null)
5481
+ return `"${name}" item ${i + 1}: ${problem}`;
5482
+ }
5483
+ }
3921
5484
  if (!fits) {
3922
5485
  const wanted = type === 'string' ? 'a string'
3923
5486
  : type === 'boolean' ? 'true or false'
3924
- : minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
5487
+ : type === 'array' ? 'an array'
5488
+ : minimum === undefined ? 'a whole number' : `a whole number of at least ${minimum}`;
3925
5489
  const got = value === null ? 'null'
3926
5490
  : typeof value === 'number' ? String(value)
3927
5491
  : Array.isArray(value) ? 'an array'