@animalabs/agent-framework 0.26.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/README.md +99 -8
  2. package/dist/src/agent.d.ts +291 -23
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +596 -61
  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 +5 -4
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +34 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +36 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/cut-off-round.d.ts +24 -0
  18. package/dist/src/cut-off-round.d.ts.map +1 -0
  19. package/dist/src/cut-off-round.js +87 -0
  20. package/dist/src/cut-off-round.js.map +1 -0
  21. package/dist/src/framework.d.ts +1038 -193
  22. package/dist/src/framework.d.ts.map +1 -1
  23. package/dist/src/framework.js +5967 -1514
  24. package/dist/src/framework.js.map +1 -1
  25. package/dist/src/gate/event-gate.d.ts +405 -46
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/event-gate.js +2646 -399
  28. package/dist/src/gate/event-gate.js.map +1 -1
  29. package/dist/src/gate/index.d.ts +3 -2
  30. package/dist/src/gate/index.d.ts.map +1 -1
  31. package/dist/src/gate/index.js +2 -1
  32. package/dist/src/gate/index.js.map +1 -1
  33. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  34. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  35. package/dist/src/gate/recipe-reconcile.js +325 -0
  36. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  37. package/dist/src/gate/reply-judge.d.ts +116 -0
  38. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  39. package/dist/src/gate/reply-judge.js +467 -0
  40. package/dist/src/gate/reply-judge.js.map +1 -0
  41. package/dist/src/gate/types.d.ts +229 -8
  42. package/dist/src/gate/types.d.ts.map +1 -1
  43. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  44. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  45. package/dist/src/heartbeat/heartbeat.js +752 -0
  46. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  47. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  48. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.js +107 -0
  50. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  51. package/dist/src/index.d.ts +10 -4
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +7 -2
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  56. package/dist/src/mcpl/capability-mask.js +3 -2
  57. package/dist/src/mcpl/capability-mask.js.map +1 -1
  58. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  59. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  60. package/dist/src/mcpl/channel-registry.js +1154 -435
  61. package/dist/src/mcpl/channel-registry.js.map +1 -1
  62. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  63. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  64. package/dist/src/mcpl/conversation-router.js +29 -15
  65. package/dist/src/mcpl/conversation-router.js.map +1 -1
  66. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  67. package/dist/src/mcpl/convert-block.js +14 -2
  68. package/dist/src/mcpl/convert-block.js.map +1 -1
  69. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  70. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  71. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  72. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  73. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  74. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  75. package/dist/src/mcpl/inbound-source.js +40 -69
  76. package/dist/src/mcpl/inbound-source.js.map +1 -1
  77. package/dist/src/mcpl/index.d.ts +5 -1
  78. package/dist/src/mcpl/index.d.ts.map +1 -1
  79. package/dist/src/mcpl/index.js +4 -0
  80. package/dist/src/mcpl/index.js.map +1 -1
  81. package/dist/src/mcpl/inference-router.d.ts +14 -2
  82. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  83. package/dist/src/mcpl/inference-router.js +73 -28
  84. package/dist/src/mcpl/inference-router.js.map +1 -1
  85. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  86. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  87. package/dist/src/mcpl/prose-grammar.js +6 -0
  88. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  89. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  90. package/dist/src/mcpl/protocol-family.js +3 -0
  91. package/dist/src/mcpl/protocol-family.js.map +1 -1
  92. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  93. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  94. package/dist/src/mcpl/push-coalescer.js +33 -11
  95. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  96. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  97. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  98. package/dist/src/mcpl/reference-fetcher.js +177 -145
  99. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  100. package/dist/src/mcpl/references.d.ts +380 -46
  101. package/dist/src/mcpl/references.d.ts.map +1 -1
  102. package/dist/src/mcpl/references.js +736 -77
  103. package/dist/src/mcpl/references.js.map +1 -1
  104. package/dist/src/mcpl/server-connection.d.ts +93 -4
  105. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  106. package/dist/src/mcpl/server-connection.js +176 -11
  107. package/dist/src/mcpl/server-connection.js.map +1 -1
  108. package/dist/src/mcpl/tags.d.ts +9 -2
  109. package/dist/src/mcpl/tags.d.ts.map +1 -1
  110. package/dist/src/mcpl/tags.js +12 -2
  111. package/dist/src/mcpl/tags.js.map +1 -1
  112. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  113. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  114. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  115. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  116. package/dist/src/mcpl/tool-presence.d.ts +311 -0
  117. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  118. package/dist/src/mcpl/tool-presence.js +697 -0
  119. package/dist/src/mcpl/tool-presence.js.map +1 -0
  120. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  121. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  122. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  123. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  124. package/dist/src/mcpl/tool-signature.d.ts +46 -0
  125. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  126. package/dist/src/mcpl/tool-signature.js +321 -0
  127. package/dist/src/mcpl/tool-signature.js.map +1 -0
  128. package/dist/src/mcpl/toolset-host.d.ts +251 -0
  129. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  130. package/dist/src/mcpl/toolset-host.js +720 -0
  131. package/dist/src/mcpl/toolset-host.js.map +1 -0
  132. package/dist/src/mcpl/toolsets-module.d.ts +83 -0
  133. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  134. package/dist/src/mcpl/toolsets-module.js +523 -0
  135. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  136. package/dist/src/mcpl/transport.d.ts +2 -2
  137. package/dist/src/mcpl/transport.d.ts.map +1 -1
  138. package/dist/src/mcpl/transport.js +3 -2
  139. package/dist/src/mcpl/transport.js.map +1 -1
  140. package/dist/src/mcpl/types.d.ts +24 -2
  141. package/dist/src/mcpl/types.d.ts.map +1 -1
  142. package/dist/src/mcpl/types.js.map +1 -1
  143. package/dist/src/mcpl/visible-content.d.ts +7 -9
  144. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  145. package/dist/src/mcpl/visible-content.js +21 -9
  146. package/dist/src/mcpl/visible-content.js.map +1 -1
  147. package/dist/src/module-registry.d.ts +27 -9
  148. package/dist/src/module-registry.d.ts.map +1 -1
  149. package/dist/src/module-registry.js +77 -17
  150. package/dist/src/module-registry.js.map +1 -1
  151. package/dist/src/modules/discord/index.js +1 -1
  152. package/dist/src/modules/discord/index.js.map +1 -1
  153. package/dist/src/modules/history/index.d.ts +2 -1
  154. package/dist/src/modules/history/index.d.ts.map +1 -1
  155. package/dist/src/modules/history/index.js +73 -28
  156. package/dist/src/modules/history/index.js.map +1 -1
  157. package/dist/src/modules/history/semantic.d.ts +108 -6
  158. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  159. package/dist/src/modules/history/semantic.js +273 -57
  160. package/dist/src/modules/history/semantic.js.map +1 -1
  161. package/dist/src/modules/workspace/answers.d.ts +22 -0
  162. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  163. package/dist/src/modules/workspace/answers.js +123 -0
  164. package/dist/src/modules/workspace/answers.js.map +1 -0
  165. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  166. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/branch-intent.js +6 -2
  168. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  169. package/dist/src/modules/workspace/direct-write.d.ts +184 -0
  170. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  171. package/dist/src/modules/workspace/direct-write.js +417 -0
  172. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  173. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  174. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  176. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  177. package/dist/src/modules/workspace/edit-text.d.ts +27 -12
  178. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/edit-text.js +61 -29
  180. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  181. package/dist/src/modules/workspace/effects.d.ts +10 -0
  182. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/effects.js +21 -14
  184. package/dist/src/modules/workspace/effects.js.map +1 -1
  185. package/dist/src/modules/workspace/index.d.ts +308 -78
  186. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  187. package/dist/src/modules/workspace/index.js +2103 -720
  188. package/dist/src/modules/workspace/index.js.map +1 -1
  189. package/dist/src/modules/workspace/observe.d.ts +75 -63
  190. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  191. package/dist/src/modules/workspace/observe.js +366 -180
  192. package/dist/src/modules/workspace/observe.js.map +1 -1
  193. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  194. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  195. package/dist/src/modules/workspace/read-page.js +219 -0
  196. package/dist/src/modules/workspace/read-page.js.map +1 -0
  197. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  198. package/dist/src/modules/workspace/reconcile.js +26 -16
  199. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  200. package/dist/src/modules/workspace/result-text.d.ts +68 -0
  201. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  202. package/dist/src/modules/workspace/result-text.js +94 -0
  203. package/dist/src/modules/workspace/result-text.js.map +1 -0
  204. package/dist/src/modules/workspace/sync.d.ts +9 -0
  205. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  206. package/dist/src/modules/workspace/sync.js +14 -0
  207. package/dist/src/modules/workspace/sync.js.map +1 -1
  208. package/dist/src/modules/workspace/text.d.ts +29 -12
  209. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  210. package/dist/src/modules/workspace/text.js +35 -33
  211. package/dist/src/modules/workspace/text.js.map +1 -1
  212. package/dist/src/modules/workspace/types.d.ts +137 -60
  213. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  214. package/dist/src/modules/workspace/types.js.map +1 -1
  215. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  216. package/dist/src/modules/workspace/watcher.js +8 -3
  217. package/dist/src/modules/workspace/watcher.js.map +1 -1
  218. package/dist/src/prose-drafts.d.ts +4 -3
  219. package/dist/src/prose-drafts.d.ts.map +1 -1
  220. package/dist/src/prose-drafts.js +3 -2
  221. package/dist/src/prose-drafts.js.map +1 -1
  222. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  223. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  224. package/dist/src/reasoning-loss-notice.js +50 -0
  225. package/dist/src/reasoning-loss-notice.js.map +1 -0
  226. package/dist/src/reasoning-loss.d.ts +24 -0
  227. package/dist/src/reasoning-loss.d.ts.map +1 -0
  228. package/dist/src/reasoning-loss.js +102 -0
  229. package/dist/src/reasoning-loss.js.map +1 -0
  230. package/dist/src/send-tools.d.ts +3 -2
  231. package/dist/src/send-tools.d.ts.map +1 -1
  232. package/dist/src/send-tools.js +3 -2
  233. package/dist/src/send-tools.js.map +1 -1
  234. package/dist/src/sent-rows.d.ts +59 -0
  235. package/dist/src/sent-rows.d.ts.map +1 -0
  236. package/dist/src/sent-rows.js +145 -0
  237. package/dist/src/sent-rows.js.map +1 -0
  238. package/dist/src/silent-heartbeat.d.ts +17 -10
  239. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  240. package/dist/src/silent-heartbeat.js.map +1 -1
  241. package/dist/src/stable-hash.d.ts +3 -0
  242. package/dist/src/stable-hash.d.ts.map +1 -0
  243. package/dist/src/stable-hash.js +15 -0
  244. package/dist/src/stable-hash.js.map +1 -0
  245. package/dist/src/stored-injections.d.ts +99 -0
  246. package/dist/src/stored-injections.d.ts.map +1 -0
  247. package/dist/src/stored-injections.js +106 -0
  248. package/dist/src/stored-injections.js.map +1 -0
  249. package/dist/src/text-as-stored.d.ts +4 -3
  250. package/dist/src/text-as-stored.d.ts.map +1 -1
  251. package/dist/src/text-as-stored.js +4 -3
  252. package/dist/src/text-as-stored.js.map +1 -1
  253. package/dist/src/thinking-drops.d.ts +74 -0
  254. package/dist/src/thinking-drops.d.ts.map +1 -0
  255. package/dist/src/thinking-drops.js +96 -0
  256. package/dist/src/thinking-drops.js.map +1 -0
  257. package/dist/src/tool-args.d.ts +12 -0
  258. package/dist/src/tool-args.d.ts.map +1 -1
  259. package/dist/src/tool-args.js +35 -1
  260. package/dist/src/tool-args.js.map +1 -1
  261. package/dist/src/tool-image-ledger.d.ts +9 -8
  262. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  263. package/dist/src/tool-image-ledger.js +9 -8
  264. package/dist/src/tool-image-ledger.js.map +1 -1
  265. package/dist/src/tool-presentation.d.ts.map +1 -1
  266. package/dist/src/tool-presentation.js +3 -1
  267. package/dist/src/tool-presentation.js.map +1 -1
  268. package/dist/src/tool-result-guard.d.ts +12 -6
  269. package/dist/src/tool-result-guard.d.ts.map +1 -1
  270. package/dist/src/tool-result-guard.js +44 -17
  271. package/dist/src/tool-result-guard.js.map +1 -1
  272. package/dist/src/tool-result-history.d.ts +176 -24
  273. package/dist/src/tool-result-history.d.ts.map +1 -1
  274. package/dist/src/tool-result-history.js +411 -91
  275. package/dist/src/tool-result-history.js.map +1 -1
  276. package/dist/src/tune-out/coordinator.d.ts +17 -9
  277. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  278. package/dist/src/tune-out/coordinator.js +84 -37
  279. package/dist/src/tune-out/coordinator.js.map +1 -1
  280. package/dist/src/tune-out/tools.d.ts +4 -1
  281. package/dist/src/tune-out/tools.d.ts.map +1 -1
  282. package/dist/src/types/agent.d.ts +217 -7
  283. package/dist/src/types/agent.d.ts.map +1 -1
  284. package/dist/src/types/events.d.ts +33 -9
  285. package/dist/src/types/events.d.ts.map +1 -1
  286. package/dist/src/types/framework.d.ts +39 -3
  287. package/dist/src/types/framework.d.ts.map +1 -1
  288. package/dist/src/types/module.d.ts +95 -9
  289. package/dist/src/types/module.d.ts.map +1 -1
  290. package/dist/src/types/trace.d.ts +91 -3
  291. package/dist/src/types/trace.d.ts.map +1 -1
  292. package/package.json +5 -4
  293. package/src/agent.ts +661 -67
  294. package/src/api/server.ts +5 -4
  295. package/src/auto-continue.ts +43 -0
  296. package/src/code-execution/isolate/scripts.ts +11 -3
  297. package/src/cut-off-round.ts +98 -0
  298. package/src/framework.ts +6168 -1540
  299. package/src/gate/event-gate.ts +2895 -405
  300. package/src/gate/index.ts +10 -1
  301. package/src/gate/recipe-reconcile.ts +355 -0
  302. package/src/gate/reply-judge.ts +512 -0
  303. package/src/gate/types.ts +234 -9
  304. package/src/heartbeat/heartbeat.ts +804 -0
  305. package/src/heartbeat/reminder-time.ts +107 -0
  306. package/src/index.ts +11 -4
  307. package/src/mcpl/capability-mask.ts +3 -2
  308. package/src/mcpl/channel-registry.ts +1222 -468
  309. package/src/mcpl/conversation-router.ts +34 -16
  310. package/src/mcpl/convert-block.ts +14 -1
  311. package/src/mcpl/hook-orchestrator.ts +33 -6
  312. package/src/mcpl/inbound-source.ts +42 -69
  313. package/src/mcpl/index.ts +6 -1
  314. package/src/mcpl/inference-router.ts +79 -34
  315. package/src/mcpl/prose-grammar.ts +7 -0
  316. package/src/mcpl/protocol-family.ts +2 -0
  317. package/src/mcpl/push-coalescer.ts +51 -13
  318. package/src/mcpl/reference-fetcher.ts +201 -162
  319. package/src/mcpl/references.ts +851 -77
  320. package/src/mcpl/server-connection.ts +189 -11
  321. package/src/mcpl/tags.ts +13 -2
  322. package/src/mcpl/tool-lifecycle.ts +21 -0
  323. package/src/mcpl/tool-presence.ts +716 -0
  324. package/src/mcpl/tool-result-normalize.ts +18 -6
  325. package/src/mcpl/tool-signature.ts +316 -0
  326. package/src/mcpl/toolset-host.ts +727 -0
  327. package/src/mcpl/toolsets-module.ts +545 -0
  328. package/src/mcpl/transport.ts +3 -2
  329. package/src/mcpl/types.ts +25 -2
  330. package/src/mcpl/visible-content.ts +22 -9
  331. package/src/module-registry.ts +85 -26
  332. package/src/modules/discord/index.ts +1 -1
  333. package/src/modules/history/index.ts +70 -30
  334. package/src/modules/history/semantic.ts +308 -54
  335. package/src/modules/workspace/answers.ts +133 -0
  336. package/src/modules/workspace/branch-intent.ts +10 -5
  337. package/src/modules/workspace/direct-write.ts +460 -0
  338. package/src/modules/workspace/disk-agreement.ts +52 -21
  339. package/src/modules/workspace/edit-text.ts +84 -35
  340. package/src/modules/workspace/effects.ts +22 -13
  341. package/src/modules/workspace/index.ts +2142 -723
  342. package/src/modules/workspace/observe.ts +381 -177
  343. package/src/modules/workspace/read-page.ts +228 -0
  344. package/src/modules/workspace/reconcile.ts +27 -15
  345. package/src/modules/workspace/result-text.ts +121 -0
  346. package/src/modules/workspace/sync.ts +16 -0
  347. package/src/modules/workspace/text.ts +58 -32
  348. package/src/modules/workspace/types.ts +137 -60
  349. package/src/modules/workspace/watcher.ts +8 -3
  350. package/src/prose-drafts.ts +4 -3
  351. package/src/reasoning-loss-notice.ts +62 -0
  352. package/src/reasoning-loss.ts +120 -0
  353. package/src/send-tools.ts +3 -2
  354. package/src/sent-rows.ts +162 -0
  355. package/src/silent-heartbeat.ts +17 -10
  356. package/src/stable-hash.ts +14 -0
  357. package/src/stored-injections.ts +131 -0
  358. package/src/text-as-stored.ts +4 -3
  359. package/src/thinking-drops.ts +135 -0
  360. package/src/tool-args.ts +34 -1
  361. package/src/tool-image-ledger.ts +9 -8
  362. package/src/tool-presentation.ts +3 -1
  363. package/src/tool-result-guard.ts +45 -17
  364. package/src/tool-result-history.ts +471 -90
  365. package/src/tune-out/coordinator.ts +80 -37
  366. package/src/tune-out/tools.ts +4 -1
  367. package/src/types/agent.ts +220 -7
  368. package/src/types/events.ts +33 -9
  369. package/src/types/framework.ts +36 -3
  370. package/src/types/module.ts +91 -9
  371. package/src/types/trace.ts +94 -3
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { constants as fsConstants } from 'node:fs';
9
- import type { Stats } from 'node:fs';
9
+ import type { BigIntStats, Stats } from 'node:fs';
10
10
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
11
11
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
12
12
  import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
@@ -37,14 +37,17 @@ import type {
37
37
  } from './types.js';
38
38
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
39
39
  import { MountWatcher, type FsChange } from './watcher.js';
40
- import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
41
- import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
40
+ import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
41
+ import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
42
42
  import { type BranchIntent, BranchIntents } from './branch-intent.js';
43
- import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
43
+ import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
44
44
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
45
45
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
46
46
  import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
47
47
  import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
48
+ import { type FileText, fileText } from './text.js';
49
+ import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
50
+ import { type DiskState, type GrepAnswer, grepText, textAnswer } from './result-text.js';
48
51
  import {
49
52
  type MountRuntime,
50
53
  type PassOptions,
@@ -61,6 +64,26 @@ import {
61
64
  reconcilePass,
62
65
  settleAdoptionBeforeMutation,
63
66
  } from './reconcile.js';
67
+ import { EffectFailed, unlinkContained } from './effects.js';
68
+ import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
69
+ import { unknownToolMessage } from '../unknown-tool.js';
70
+ import { closestNames } from '../../closest-names.js';
71
+ import { RecordJournal } from '../../record-journal.js';
72
+ import {
73
+ type FileIdentity,
74
+ type Staged,
75
+ FileChanged,
76
+ ROOT_MOVED,
77
+ WriteRefused,
78
+ checkUnchanged,
79
+ directWriteTestSeam,
80
+ discard,
81
+ identityOf,
82
+ placedStill,
83
+ renameInto,
84
+ sameIdentity,
85
+ stageContained,
86
+ } from './direct-write.js';
64
87
 
65
88
  /** Default for how long the after-batch scan may hold the agent's next inference. */
66
89
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
@@ -74,10 +97,15 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
74
97
  const CATCH_UP_BOUND_MS = 30_000;
75
98
  /** Why a call is refused once the module has stopped. */
76
99
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
100
+ /** writeBinary's refusal when the store's branch is no longer the one its file is for. */
101
+ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
77
102
  /** The workspace's own deadline must end before the framework's hook bound. */
78
103
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
79
104
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
80
105
  const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
106
+ const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
107
+ + 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
108
+ + 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
81
109
  /**
82
110
  * How many files a direct mount's grep reads, and its listings look at, at
83
111
  * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
@@ -88,6 +116,8 @@ const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git',
88
116
  const DIRECT_READS_AT_ONCE = 16;
89
117
  /** Why sync passes over a direct mount, as its result's `skipped` says. */
90
118
  const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
119
+ /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
120
+ const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
91
121
 
92
122
  export type {
93
123
  WorkspaceConfig,
@@ -110,30 +140,23 @@ export type {
110
140
 
111
141
  type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
112
142
 
113
- class WorkspaceImageReadError extends Error {
114
- constructor(
115
- readonly code:
116
- | 'mount_unavailable'
117
- | 'not_found'
118
- | 'directory'
119
- | 'symlink'
120
- | 'parent_symlink'
121
- | 'other_mount'
122
- | 'escape'
123
- | 'changed'
124
- | 'empty'
125
- | 'too_large'
126
- | 'truncated'
127
- | 'invalid'
128
- | 'unsupported'
129
- | 'blob_missing',
130
- message: string,
131
- ) {
132
- super(message);
143
+ class WorkspaceImageReadError extends Refusal {
144
+ constructor(readonly reason: string, wayOn: string | null) {
145
+ super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
133
146
  this.name = 'WorkspaceImageReadError';
134
147
  }
135
148
  }
136
149
 
150
+ const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
151
+
152
+ function truncatedImage(mountPrefixedPath: string): WorkspaceImageReadError {
153
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
154
+ }
155
+
156
+ function emptyImage(mountPrefixedPath: string): WorkspaceImageReadError {
157
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
158
+ }
159
+
137
160
  /**
138
161
  * Why a workspace-owned filesystem read was refused. Distinguishes the cases a
139
162
  * peer needs to react to differently: a path that never resolved inside the
@@ -151,10 +174,12 @@ class WorkspaceImageReadError extends Error {
151
174
  * directories leading out of it (`escape`), a file swapped underneath the read
152
175
  * (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
153
176
  * and a path in another mount's directory nested in the one named
154
- * (`other_mount`), whose message names the mount that holds it and the path
155
- * to use there. A symlink leading into such a directory is `other_mount`
156
- * only where its refusal names that mount: a directory, or a file that
157
- * mount's include covers. Anywhere else, on a mount that doesn't follow
177
+ * (`other_mount`), whose message names the mount that holds it, or may hold
178
+ * it, and the path to use there. A symlink leading into such a directory is
179
+ * `other_mount` only where its refusal names that mount: a directory, or a
180
+ * file that mount's include covers, and so does the include of each other
181
+ * nested mount whose directory may hold it, out to the first that surely
182
+ * does. Anywhere else, on a mount that doesn't follow
158
183
  * symlinks, it gets the code of a link where it stands, `symlink` at the
159
184
  * path itself and `parent_symlink` on its way, and on one that does,
160
185
  * `escape`, as a path that leaves the mount. On a mount with an include, a
@@ -196,7 +221,7 @@ export type WorkspaceReadStage =
196
221
  | 'read';
197
222
 
198
223
  /** A path the mount's `include` doesn't cover: outside the mount. */
199
- class OutsideIncludeError extends Error {
224
+ class OutsideIncludeError extends Refusal {
200
225
  constructor(shownPath: string, mount: MountConfig) {
201
226
  super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
202
227
  + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
@@ -208,9 +233,16 @@ class OutsideIncludeError extends Error {
208
233
  * mount it names. The message names the mount that holds it and the path to
209
234
  * use there.
210
235
  */
211
- class NestedMountError extends Error {
212
- constructor(path: string, mount: string, holder: string, use: string, how: 'lies in' | 'leads into' = 'lies in') {
213
- super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
236
+ class NestedMountError extends Refusal {
237
+ constructor(path: string, mount: string, holder: { name: string; use: string; guess?: NestedGuess }, how: 'lies in' | 'leads into' = 'lies in') {
238
+ const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
239
+ super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
240
+ }
241
+ }
242
+
243
+ class PathRefusal extends Refusal {
244
+ constructor(readonly code: 'unknown_mount' | 'traversal', message: string) {
245
+ super(message);
214
246
  }
215
247
  }
216
248
 
@@ -232,6 +264,18 @@ export class WorkspaceReadError extends Error {
232
264
  }
233
265
  }
234
266
 
267
+ class NotRegularFile extends WorkspaceReadError {
268
+ constructor(path: string, stage: WorkspaceReadStage) {
269
+ super('not_found', `Not a regular file: ${path}`, stage);
270
+ }
271
+ }
272
+
273
+ class RootUnavailable extends WorkspaceReadError {
274
+ constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
275
+ super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
276
+ }
277
+ }
278
+
235
279
  /** Successful `readFileFromDisk()` result. */
236
280
  export interface WorkspaceDiskReadResult {
237
281
  /**
@@ -284,23 +328,10 @@ function errnoOf(err: unknown): string | undefined {
284
328
  : undefined;
285
329
  }
286
330
 
287
- /**
288
- * What is at a mount root's path now, followed as the root is: for a
289
- * directory, undefined; for anything else, ENOTDIR; and where the path
290
- * can't be followed to anything, the errno its stat failed with, ENOENT
291
- * when nothing is there.
292
- */
293
- async function rootAbsence(root: string): Promise<string | undefined> {
294
- try {
295
- return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
296
- } catch (err) {
297
- return errnoOf(err);
298
- }
299
- }
300
331
 
301
332
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
302
- function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
303
- return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
333
+ function imageTooLarge(maxSize: number, mountPrefixedPath: string, size: string): WorkspaceImageReadError {
334
+ return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
304
335
  }
305
336
 
306
337
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -318,10 +349,6 @@ const JPEG_EOI = 0xd9;
318
349
  const JPEG_SOS = 0xda;
319
350
  const JPEG_TEM = 0x01;
320
351
 
321
- function isErrnoCode(err: unknown, code: string): boolean {
322
- return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === code;
323
- }
324
-
325
352
  function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
326
353
  return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
327
354
  }
@@ -364,7 +391,7 @@ async function linkReadError(
364
391
  const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
365
392
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
366
393
  const code = leads?.kind === 'outside' ? 'escape'
367
- : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
394
+ : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
368
395
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
369
396
  return new WorkspaceReadError(code, reason, 'readlink');
370
397
  }
@@ -396,32 +423,30 @@ function ancestorsOf(path: string): string[] {
396
423
  * directory as configured beneath the outer mount's configured path
397
424
  * (`outerPath`), beneath each directory above its configured path that is
398
425
  * the outer root on disk (`above`, each with where it lies on disk), and
399
- * where its real root (`real`) lies beneath the outer root (`outerReal`),
400
- * each also as disk spells it beneath `spellAt`. Its real root is claimed
401
- * where it doesn't hold the outer root, which it would otherwise claim
402
- * whole. Undefined where it's nested nowhere.
426
+ * where its real root (`real`) lies beneath the outer root (`outerReal`).
427
+ * Its real root is claimed where it doesn't hold the outer root, which it
428
+ * would otherwise claim whole. Undefined where it's nested nowhere.
403
429
  */
404
- async function placementOf(
430
+ function placementOf(
405
431
  outerPath: string,
406
432
  outerReal: string,
407
433
  innerPath: string,
408
434
  real: string,
409
435
  above: ReadonlyArray<{ dir: string; real: string }>,
410
- spellAt: string,
411
- ): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
436
+ ): { configured: string | undefined; fromRoots: NestedMountEntry['fromRoots']; claims: boolean } | undefined {
412
437
  const configured = nestedDir(outerPath, innerPath);
413
- const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
414
- const fromRoots: Array<{ dir: string; root: string }> = [];
415
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
416
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
417
- }
418
- if (fromRoots.length === 0) return undefined;
419
- const spellings = [...fromRoots];
420
- for (const found of fromRoots) {
421
- const dir = await diskSpelling(spellAt, found.dir);
422
- if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
423
- }
424
- return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
438
+ const dirs = new Map<string, boolean>();
439
+ const place = (dir: string | undefined, sure: boolean) => { if (dir !== undefined) dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
440
+ place(configured, true);
441
+ for (const a of above) {
442
+ if (sameRoot(a.real, outerReal)) place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
443
+ }
444
+ const below = beneathRoot(real, outerReal);
445
+ place(below, below !== undefined && surelyAtRoot(real, outerReal));
446
+ if (dirs.size === 0) return undefined;
447
+ const fromRoots: NestedMountEntry['fromRoots'] = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true as const }) }));
448
+ const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
449
+ return { configured, fromRoots, claims };
425
450
  }
426
451
 
427
452
  /** A mount nested in another, as the outer mount's otherMounts holds it. */
@@ -434,9 +459,7 @@ interface NestedMountEntry {
434
459
  */
435
460
  real?: string;
436
461
  /** Its directory as configured and as the real roots place it. */
437
- fromRoots: Array<{ dir: string; root: string }>;
438
- /** Those, and each as disk spells it where that differs. */
439
- spellings: Array<{ dir: string; root: string }>;
462
+ fromRoots: Array<{ dir: string; guessed?: true }>;
440
463
  }
441
464
 
442
465
  /**
@@ -515,7 +538,7 @@ function isContainedPath(root: string, candidate: string): boolean {
515
538
  }
516
539
 
517
540
  function invalidImage(format: string, mountPrefixedPath: string): never {
518
- throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
541
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
519
542
  }
520
543
 
521
544
  function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
@@ -891,7 +914,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
891
914
  return 'image/png';
892
915
  }
893
916
  if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
894
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
917
+ throw truncatedImage(mountPrefixedPath);
895
918
  }
896
919
 
897
920
  if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
@@ -899,7 +922,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
899
922
  return 'image/jpeg';
900
923
  }
901
924
  if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
902
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
925
+ throw truncatedImage(mountPrefixedPath);
903
926
  }
904
927
 
905
928
  if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
@@ -907,7 +930,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
907
930
  return 'image/gif';
908
931
  }
909
932
  if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
910
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
933
+ throw truncatedImage(mountPrefixedPath);
911
934
  }
912
935
 
913
936
  if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
@@ -918,10 +941,10 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
918
941
  (bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
919
942
  || (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
920
943
  ) {
921
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
944
+ throw truncatedImage(mountPrefixedPath);
922
945
  }
923
946
 
924
- throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
947
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows`, "read shows it, if it's text.");
925
948
  }
926
949
 
927
950
  export class WorkspaceModule implements Module {
@@ -949,14 +972,11 @@ export class WorkspaceModule implements Module {
949
972
  private ctimeTrust = new Map<string, string | null>();
950
973
  /**
951
974
  * Each mount's directories that are other mounts nested inside it, with
952
- * each mount's name and real root, in each spelling that names the
953
- * directory: as configured; as the real roots place it, where that differs
954
- * (`fromRoots`); and as disk spells each of those where that differs.
955
- * Each spelling is mount-relative and `/`-separated, with its root beneath
956
- * the mount's resolved root. The constructor nests by configured path,
957
- * with each root as resolved standing for its real one, and each start
958
- * reads it all again with the roots placed where disk has them
959
- * (readNesting).
975
+ * each mount's name and real root: as configured, and as the real roots
976
+ * place it, where that differs (`fromRoots`). Each is mount-relative and
977
+ * `/`-separated. The constructor nests by configured path, with each root
978
+ * as resolved standing for its real one, and each start reads it all again
979
+ * with the roots placed where disk has them (readNesting).
960
980
  */
961
981
  private otherMounts = new Map<string, NestedMountEntry[]>();
962
982
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
@@ -983,6 +1003,28 @@ export class WorkspaceModule implements Module {
983
1003
  */
984
1004
  private notDirect = new Map<string, Array<{ path: string; what: string }>>();
985
1005
 
1006
+ /**
1007
+ * Each agent's latest direct write on each mount it wrote direct, P, with
1008
+ * the notice of its departure from it, N (DirectHead), by mount, then by
1009
+ * agent. Saved at stop() in a global record (headsJournal), not the
1010
+ * module's branch state: they describe disk, which doesn't rewind with a
1011
+ * branch, so a restart on another branch, such as one an offline rollback
1012
+ * made, still finds them.
1013
+ */
1014
+ private directHeads = new Map<string, Map<string, DirectHead>>();
1015
+
1016
+ /** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
1017
+ private headsJournal: RecordJournal<never, DirectHeadsSnapshot> | null = null;
1018
+
1019
+ /** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
1020
+ private loadedHeads: string | null = null;
1021
+
1022
+ /** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
1023
+ private carriedHeads: DirectHeadsEntry[] = [];
1024
+
1025
+ /** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
1026
+ private historyLookup: ((agentName: string, messageId: string) => boolean) | null = null;
1027
+
986
1028
  constructor(config: WorkspaceConfig) {
987
1029
  for (const mount of config.mounts) {
988
1030
  const cap: unknown = mount.walkCap;
@@ -999,24 +1041,18 @@ export class WorkspaceModule implements Module {
999
1041
  if (refused.length > 0) {
1000
1042
  throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
1001
1043
  }
1002
- const include: unknown = mount.include;
1003
- if (include !== undefined) {
1004
- // A pattern the matcher can't read would match nothing, and take every
1005
- // file out of the mount unsaid (includePatternReadable).
1006
- if (!Array.isArray(include) || include.length === 0) {
1007
- throw new Error(
1008
- `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
1009
- );
1044
+ for (const list of ['ignore', 'include'] as const) {
1045
+ const patterns: unknown = mount[list];
1046
+ if (patterns === undefined) continue;
1047
+ if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
1048
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
1010
1049
  }
1011
- // By index: an entry that is itself undefined, or a hole, is unreadable too.
1012
- const at = include.findIndex((p) => !includePatternReadable(p));
1013
- if (at >= 0) {
1014
- const unreadable: unknown = include[at];
1015
- throw new Error(
1016
- `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
1017
- + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
1018
- + `${shownValue(unreadable)} is none of these`,
1019
- );
1050
+ for (let i = 0; i < patterns.length; i++) {
1051
+ const pattern: unknown = patterns[i];
1052
+ const why = patternRefusal(pattern, list);
1053
+ if (why !== undefined) {
1054
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
1055
+ }
1020
1056
  }
1021
1057
  }
1022
1058
  }
@@ -1035,8 +1071,8 @@ export class WorkspaceModule implements Module {
1035
1071
  const innerPath = resolve(inner.path);
1036
1072
  const dir = nestedDir(outerPath, innerPath);
1037
1073
  if (dir === undefined) continue;
1038
- const configured = [{ dir, root: innerPath }];
1039
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
1074
+ const configured = [{ dir }];
1075
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
1040
1076
  console.warn(
1041
1077
  `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
1042
1078
  `at ${dir}/ — passing over it as another mount's directory`,
@@ -1116,10 +1152,64 @@ export class WorkspaceModule implements Module {
1116
1152
  this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
1117
1153
 
1118
1154
  this.chooseKinds();
1155
+ this.loadDirectHeads(store);
1119
1156
  this.applySavedState();
1120
1157
  this.ensureRunning();
1121
1158
  }
1122
1159
 
1160
+ /**
1161
+ * The positions stop() last saved (directHeads), before any call can
1162
+ * write: each saved entry is a mount's positions at one root, and a mount
1163
+ * this session configures direct gets the entry at its own root. Every
1164
+ * other entry, a mount configured at another root, not direct, or not at
1165
+ * all, is kept for the next save (carriedHeads), since its writes are
1166
+ * still on disk beneath that root; a mount removed for good, and each
1167
+ * root a mount has moved from, leave that small entry behind. A record
1168
+ * the store can't read is logged, and this session starts with none and
1169
+ * saves none, so the record stands for a session that can read it. One
1170
+ * read but not of the shape saved is logged too, and this session starts
1171
+ * with none and its stop replaces it, so a damaged record doesn't keep
1172
+ * every later session from saving. Either way, a move away from the
1173
+ * writes before it goes untold, as after a crash.
1174
+ */
1175
+ private loadDirectHeads(store: JsStore): void {
1176
+ const journal = new RecordJournal<never, DirectHeadsSnapshot>(store, { type: DIRECT_HEADS_RECORD_TYPE });
1177
+ const restored = new Map<string, Map<string, DirectHead>>();
1178
+ const carried: DirectHeadsEntry[] = [];
1179
+ let loaded: unknown;
1180
+ try {
1181
+ loaded = journal.load().snapshot;
1182
+ } catch (err) {
1183
+ console.error('[workspace] could not read the saved direct-write positions:', err);
1184
+ return;
1185
+ }
1186
+ let snapshot: DirectHeadsSnapshot;
1187
+ let text: string | null;
1188
+ try {
1189
+ snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
1190
+ text = canonicalHeads(snapshot);
1191
+ } catch (err) {
1192
+ console.error("[workspace] the saved direct-write positions aren't of the shape saved, so this session starts with none and its stop replaces them:", err);
1193
+ snapshot = [];
1194
+ text = null;
1195
+ }
1196
+ for (const saved of snapshot) {
1197
+ // Only a direct mount's writes are the agent's on disk, and a
1198
+ // store-backed one's disk follows its store.
1199
+ const mount = this.mounts.get(saved.mount);
1200
+ if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
1201
+ carried.push(saved);
1202
+ continue;
1203
+ }
1204
+ restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) =>
1205
+ [agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
1206
+ }
1207
+ this.headsJournal = journal;
1208
+ this.loadedHeads = text;
1209
+ this.carriedHeads = carried;
1210
+ for (const [name, heads] of restored) this.directHeads.set(name, heads);
1211
+ }
1212
+
1123
1213
  /**
1124
1214
  * Whether each mount configured direct runs direct: not while the store
1125
1215
  * holds changes there that disk lacks, on the branch selected then, so
@@ -1272,7 +1362,7 @@ export class WorkspaceModule implements Module {
1272
1362
  const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
1273
1363
  let start = base;
1274
1364
  for (const segment of beneath.split('/')) {
1275
- if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
1365
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined)) break;
1276
1366
  const here = await checkDir(view, rootReal, start);
1277
1367
  if (here.kind !== 'checked') break;
1278
1368
  const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
@@ -1325,11 +1415,10 @@ export class WorkspaceModule implements Module {
1325
1415
 
1326
1416
  async start(ctx: ModuleContext): Promise<void> {
1327
1417
  // Which mounts are nested where, with the roots placed where disk has
1328
- // them and each nested mount's directory as disk spells it, and whether
1329
- // each mount's root folds case, read where disk has it, before this start
1330
- // sets anything, so a restart takes no call and walks or watches nothing
1331
- // before they're known. A stop begun meanwhile stands, and this start then
1332
- // sets nothing.
1418
+ // them, and whether each mount's root folds case, read where disk has
1419
+ // it, before this start sets anything, so a restart takes no call
1420
+ // and walks or watches nothing before they're known. A stop begun
1421
+ // meanwhile stands, and this start then sets nothing.
1333
1422
  const stops = this.stopsBegun;
1334
1423
  const { nesting, reals, above } = await this.readNesting();
1335
1424
  const rootFoldsCase = new Map<string, boolean>();
@@ -1445,7 +1534,8 @@ export class WorkspaceModule implements Module {
1445
1534
  return (
1446
1535
  `current branch "${current.name}" has diverged from the branch last materialized to disk ` +
1447
1536
  `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
1448
- `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`
1537
+ `never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
1538
+ 'if the current branch is what disk should hold, pass force: true to overwrite disk from it.'
1449
1539
  );
1450
1540
  }
1451
1541
 
@@ -1612,8 +1702,8 @@ export class WorkspaceModule implements Module {
1612
1702
  const message = (one
1613
1703
  ? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
1614
1704
  : "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
1615
- "sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
1616
- `follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1705
+ "sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
1706
+ `and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1617
1707
  `${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
1618
1708
  console.warn(`[workspace] ${message}`);
1619
1709
  this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
@@ -1683,18 +1773,58 @@ export class WorkspaceModule implements Module {
1683
1773
  }
1684
1774
  this.ctx.setState(state);
1685
1775
  }
1776
+ this.saveDirectHeads();
1686
1777
  this.ctx = null;
1687
1778
  }
1688
1779
 
1689
- /** Mount names + modes, and each mount's include where it sets one, for
1690
- * peer callers (framework spill/journal paths) that need a mount for files
1691
- * of their own, the first writable one without an include
1692
- * (firstWritableMountName), without reaching into private state. */
1693
- getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[] }> {
1780
+ /**
1781
+ * Save directHeads as one global record, the latest of which the next
1782
+ * start loads (loadDirectHeads): each mount's positions with the root
1783
+ * they were made under, and the entries this session didn't take up
1784
+ * (carriedHeads), unchanged. Only when that differs from what the
1785
+ * start loaded, so a session that changed nothing, or a store no direct
1786
+ * write reached, adds no record, and always where the record read wasn't
1787
+ * of the shape saved, which this replaces. Nothing where the store couldn't
1788
+ * read the record, so it stands. A save that fails is logged, and the next
1789
+ * start finds the positions of the last save that landed, as after a
1790
+ * crash.
1791
+ */
1792
+ private saveDirectHeads(): void {
1793
+ if (!this.headsJournal) return;
1794
+ const snapshot: DirectHeadsSnapshot = [
1795
+ ...this.carriedHeads,
1796
+ ...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
1797
+ mount: name,
1798
+ root: resolve(this.mounts.get(name)!.config.path),
1799
+ agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
1800
+ })),
1801
+ ];
1802
+ const text = canonicalHeads(snapshot);
1803
+ if (text === this.loadedHeads) return;
1804
+ try {
1805
+ this.headsJournal.checkpoint(snapshot);
1806
+ this.loadedHeads = text;
1807
+ } catch (err) {
1808
+ console.error('[workspace] could not save the direct-write positions:', err);
1809
+ }
1810
+ }
1811
+
1812
+ /** Mount names + modes, each mount's include where it sets one, and
1813
+ * `backend: 'direct'` for one configured direct, whether it runs direct
1814
+ * this session or, while the store holds work there disk lacks, as
1815
+ * 'chronicle': either way it's a directory agents write directly when it
1816
+ * runs direct, whose files an undo leaves. For the
1817
+ * framework's tool-result spills, saved payloads and fetched references,
1818
+ * which need a mount for files of their own: the first writable one
1819
+ * without an include that isn't direct (firstWritableMountName), without
1820
+ * reaching into private state. */
1821
+ getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[]; backend?: 'direct' }> {
1694
1822
  return [...this.mounts.values()].map((m) => ({
1695
1823
  name: m.config.name,
1696
- mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
1824
+ // Read-only where this session takes no writes there (writeRefusal).
1825
+ mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
1697
1826
  ...(m.config.include ? { include: [...m.config.include] } : {}),
1827
+ ...(m.config.backend === 'direct' ? { backend: 'direct' as const } : {}),
1698
1828
  }));
1699
1829
  }
1700
1830
 
@@ -1702,10 +1832,19 @@ export class WorkspaceModule implements Module {
1702
1832
  // Tool Definitions
1703
1833
  // ==========================================================================
1704
1834
 
1835
+ /**
1836
+ * Whether any of this host's mounts runs direct. The tools' descriptions
1837
+ * and grep's notes mention direct mounts only on such a host, so both ask
1838
+ * this one question.
1839
+ */
1840
+ private anyRunsDirect(): boolean {
1841
+ return [...this.mounts.values()].some((m) => m.direct);
1842
+ }
1843
+
1705
1844
  getTools(): ToolDefinition[] {
1706
1845
  // Where a mount runs direct, four descriptions say what that changes.
1707
1846
  // A host with none keeps them as they were, and its residents' caches.
1708
- const direct = [...this.mounts.values()].some((m) => m.direct);
1847
+ const direct = this.anyRunsDirect();
1709
1848
  const lsDirect = direct
1710
1849
  ? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
1711
1850
  + '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.'
@@ -1716,6 +1855,23 @@ export class WorkspaceModule implements Module {
1716
1855
  : '';
1717
1856
  const globDirect = direct ? ' On a direct mount, a match carries its size, or a note where the mount doesn\'t take it, and no state.' : '';
1718
1857
  const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
1858
+ // Where a direct mount is writable, the four writing tools say what a
1859
+ // write is there: on disk when it answers, and left there by a head move.
1860
+ const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
1861
+ const writeDirect = writable
1862
+ ? ' On a direct mount, the file is on disk, whole and with its mode kept, when the call answers, and an undo or other head move leaves it there.'
1863
+ : '';
1864
+ const editDirect = writable
1865
+ ? " On a direct mount, the edit applies to disk's text as it is now and is on disk when the call answers; a change another writer makes "
1866
+ + 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
1867
+ : '';
1868
+ const editManyDirect = writable
1869
+ ? " On a direct mount, every edit is checked against disk's text before any file is written, and then the files are written one by one: "
1870
+ + "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
1871
+ + 'An undo or other head move leaves them on disk.'
1872
+ : '';
1873
+ const deleteDirect = writable ? ' On a direct mount, the file is removed from disk at once, and an undo or other head move doesn\'t bring it back.' : '';
1874
+ const materializeDirect = writable ? ' A direct mount writes its files to disk as they\'re made, so materialize passes over it, under `skipped` where you name it.' : '';
1719
1875
  return [
1720
1876
  {
1721
1877
  name: 'read',
@@ -1749,7 +1905,7 @@ export class WorkspaceModule implements Module {
1749
1905
  },
1750
1906
  {
1751
1907
  name: 'write',
1752
- description: 'Create or overwrite a file in the workspace.',
1908
+ description: 'Create or overwrite a file in the workspace.' + writeDirect,
1753
1909
  inputSchema: {
1754
1910
  type: 'object' as const,
1755
1911
  properties: {
@@ -1770,7 +1926,7 @@ export class WorkspaceModule implements Module {
1770
1926
  + "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
1771
1927
  + 'A change disk made to the file is taken in first, and the answer says so. '
1772
1928
  + 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
1773
- + 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
1929
+ + 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
1774
1930
  inputSchema: {
1775
1931
  type: 'object' as const,
1776
1932
  properties: {
@@ -1792,7 +1948,8 @@ export class WorkspaceModule implements Module {
1792
1948
  + "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
1793
1949
  + 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
1794
1950
  + "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
1795
- + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
1951
+ + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
1952
+ + editManyDirect,
1796
1953
  inputSchema: {
1797
1954
  type: 'object' as const,
1798
1955
  properties: {
@@ -1821,7 +1978,7 @@ export class WorkspaceModule implements Module {
1821
1978
  name: 'delete',
1822
1979
  description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
1823
1980
  + 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
1824
- + 'or a sync of the path restores it.',
1981
+ + 'or a sync of the path restores it.' + deleteDirect,
1825
1982
  inputSchema: {
1826
1983
  type: 'object' as const,
1827
1984
  properties: {
@@ -1874,7 +2031,7 @@ export class WorkspaceModule implements Module {
1874
2031
  properties: {
1875
2032
  pattern: { type: 'string', description: 'Regular expression pattern' },
1876
2033
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1877
- glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), and in every other mount, as written' },
2034
+ glob: { type: 'string', description: 'Glob pattern to filter files: one with a "/" is matched from the mount\'s root, whatever the path (e.g., "src/**/*.ts"), and in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), in every other mount as written; one with no "/" also matches a file\'s name at any depth (e.g., "*.ts")' },
1878
2035
  contextBefore: { type: 'integer', description: 'Context lines before match' },
1879
2036
  contextAfter: { type: 'integer', description: 'Context lines after match' },
1880
2037
  },
@@ -1896,7 +2053,7 @@ export class WorkspaceModule implements Module {
1896
2053
  description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1897
2054
  + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
1898
2055
  + 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
1899
- + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
2056
+ + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
1900
2057
  inputSchema: {
1901
2058
  type: 'object' as const,
1902
2059
  properties: {
@@ -2017,7 +2174,13 @@ export class WorkspaceModule implements Module {
2017
2174
  return work;
2018
2175
  }
2019
2176
 
2020
- private async runToolCall(call: ToolCall): Promise<ToolResult> {
2177
+ private runToolCall(call: ToolCall): Promise<ToolResult> {
2178
+ const gate = { passed: false };
2179
+ return callGate.run(gate, () => this.answerToolCall(call, gate));
2180
+ }
2181
+
2182
+ private async answerToolCall(call: ToolCall, gate: { passed: boolean }): Promise<ToolResult> {
2183
+ const reopenAtStart = this.agreement?.awaitsReopen === true;
2021
2184
  try {
2022
2185
  // Every handler reads its input as its schema describes it; a call
2023
2186
  // that sends something else is refused here, before anything is read.
@@ -2027,21 +2190,26 @@ export class WorkspaceModule implements Module {
2027
2190
  return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
2028
2191
  }
2029
2192
  const input = call.input as Record<string, unknown>;
2030
- const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2193
+ // The most of this call's result that can reach its caller whole: past
2194
+ // it, a model's result spills into a file, and a script gets
2195
+ // ResultTooLarge in its place. A call made in process carries none and
2196
+ // takes the data itself, so its read pages by its line limit alone.
2197
+ const resultMaxChars = call.resultMaxChars;
2198
+ const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2031
2199
  if (generated) {
2032
2200
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
2033
- return await this.handleRead(input as unknown as ReadInput, generated.read());
2201
+ return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
2034
2202
  }
2035
- return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
2203
+ return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path as string, generated, call.callerAgentName)) };
2036
2204
  }
2037
2205
 
2038
2206
  switch (call.name) {
2039
- case 'read': return await this.handleRead(input as unknown as ReadInput);
2207
+ case 'read': return await this.handleRead(input as unknown as ReadInput, resultMaxChars);
2040
2208
  case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
2041
- case 'write': return await this.handleWrite(input as unknown as WriteInput);
2042
- case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars);
2209
+ case 'write': return await this.handleWrite(input as unknown as WriteInput, call.callerAgentName);
2210
+ case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars, call.callerAgentName);
2043
2211
  case 'edit_many': return await this.handleEditMany(input as unknown as EditManyInput, call.resultMaxChars, call.callerAgentName);
2044
- case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
2212
+ case 'delete': return await this.handleDelete(input as unknown as DeleteInput, call.callerAgentName);
2045
2213
  case 'ls': return await this.handleLs(input as unknown as LsInput);
2046
2214
  case 'glob': return await this.handleGlob(input as unknown as GlobInput);
2047
2215
  case 'grep': return await this.handleGrep(input as unknown as GrepInput);
@@ -2049,13 +2217,41 @@ export class WorkspaceModule implements Module {
2049
2217
  case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
2050
2218
  case 'sync': return await this.handleSync(input as unknown as SyncInput);
2051
2219
  default:
2052
- return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
2220
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
2053
2221
  }
2054
2222
  } catch (err) {
2055
- return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
2223
+ return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
2224
+ }
2225
+ }
2226
+
2227
+ private failureAnswer(call: ToolCall, err: unknown, reopenAtStart: boolean, passedGate: boolean): string {
2228
+ const input = (typeof call.input === 'object' && call.input !== null ? call.input : {}) as { path?: unknown; mount?: unknown };
2229
+ const path = typeof input.path === 'string' ? input.path : undefined;
2230
+ const mountName = path !== undefined ? path.split('/')[0]! : typeof input.mount === 'string' ? input.mount : undefined;
2231
+ const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
2232
+ const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
2233
+ if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
2234
+ return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
2235
+ }
2236
+ if (err instanceof Refusal) return didNothing(call.name, err.message);
2237
+ if (err instanceof WorkspaceReadError) return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
2238
+ const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
2239
+ : mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
2240
+ : 'ls of each mount, with recursive: true, shows how each file stands against disk now';
2241
+ if (err instanceof NotStored || err instanceof AlreadyHeld) {
2242
+ console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
2243
+ return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
2244
+ }
2245
+ console.error(`[workspace] ${call.name} failed:`, err);
2246
+ if (err instanceof CommitUnknownError) {
2247
+ return unexpectedAnswer(call.name, subject, err.setCause, {
2248
+ said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
2249
+ });
2056
2250
  }
2251
+ return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
2057
2252
  }
2058
2253
 
2254
+
2059
2255
  async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
2060
2256
  // Wake agents on filesystem events when the originating mount opted in.
2061
2257
  // The EventGate still has final say — gate policies can further filter by
@@ -2100,7 +2296,7 @@ export class WorkspaceModule implements Module {
2100
2296
  text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2101
2297
  }
2102
2298
  if (conflicts && conflicts.length > 0) {
2103
- text += `\n[conflicts: ${conflicts.join(', ')}]`;
2299
+ text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
2104
2300
  }
2105
2301
 
2106
2302
  return {
@@ -2151,16 +2347,15 @@ export class WorkspaceModule implements Module {
2151
2347
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
2152
2348
  try {
2153
2349
  // Synchronous, so the path as given. The nested-mount check takes it
2154
- // in each spelling readNesting knows for the nested mount's directory,
2155
- // or in any spelling a disk that folds names may fold to one of them,
2156
- // erring toward that mount (holderOf's asGiven, beginsWithDir). The
2157
- // include judges it as given, so on a disk that
2350
+ // in any spelling a disk that folds names may fold to the nested
2351
+ // mount's directory, erring toward that mount (holderOf). The include
2352
+ // judges it as given, so on a disk that
2158
2353
  // folds names a covered name whose file disk spells otherwise returns
2159
2354
  // that file's path, which the tools refuse, and a covered file named in
2160
2355
  // another case is null, which the tools read. A path the mount doesn't
2161
2356
  // hold, outside its include or in another mount's directory nested in
2162
2357
  // it, is null, as for a path that leaves the mount's directory.
2163
- const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
2358
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
2164
2359
  return resolve(mount.config.path, relativePath);
2165
2360
  } catch {
2166
2361
  return null;
@@ -2182,6 +2377,15 @@ export class WorkspaceModule implements Module {
2182
2377
  * withheld original this way, for the branch its stub is on
2183
2378
  * (agent-framework #277).
2184
2379
  *
2380
+ * On a mount running direct, the bytes go to disk as a direct `write`'s
2381
+ * do, whole or not at all, and nothing goes into the store: `branch` is
2382
+ * checked before the write starts and again just before its rename, and
2383
+ * `agentName` names the agent whose write it is for the head-move notice
2384
+ * (a write naming none tells no agent).
2385
+ *
2386
+ * A success's `disk` says where the file stands on disk as the workspace
2387
+ * last saw it, as the write tool's answer does.
2388
+ *
2185
2389
  * A write whose tree entry is set but that then fails returns success
2186
2390
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
2187
2391
  * the store failed after recording the entry, which skips the push where
@@ -2195,7 +2399,7 @@ export class WorkspaceModule implements Module {
2195
2399
  mountPrefixedPath: string,
2196
2400
  data: Buffer,
2197
2401
  mimeType: string,
2198
- options: { branch?: string } = {},
2402
+ options: { branch?: string; agentName?: string } = {},
2199
2403
  ): Promise<ToolResult> {
2200
2404
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
2201
2405
  return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
@@ -2205,7 +2409,7 @@ export class WorkspaceModule implements Module {
2205
2409
  mountPrefixedPath: string,
2206
2410
  data: Buffer,
2207
2411
  mimeType: string,
2208
- options: { branch?: string },
2412
+ options: { branch?: string; agentName?: string },
2209
2413
  ): Promise<ToolResult> {
2210
2414
  let mount: MountState;
2211
2415
  let relativePath: string;
@@ -2218,20 +2422,33 @@ export class WorkspaceModule implements Module {
2218
2422
  isError: true,
2219
2423
  };
2220
2424
  }
2221
- if (mount.config.mode === 'read-only') {
2222
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2223
- }
2425
+ if (relativePath === '') return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
2426
+ const refusal = writeRefusal(mount, this.writableNames());
2427
+ if (refusal) return { success: false, error: refusal, isError: true };
2224
2428
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2225
2429
  if (data.byteLength > maxSize) {
2226
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2430
+ return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
2227
2431
  }
2228
2432
  const store = this.getStore();
2433
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
2434
+ // as the caller's agent's write, where it names one. A branch it names
2435
+ // is checked just before the rename, since disk is no branch's.
2436
+ if (mount.direct) {
2437
+ // Checked first too, so a request already for a left branch makes no
2438
+ // directory and stages nothing.
2439
+ if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
2440
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
2441
+ }
2442
+ const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2443
+ if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2444
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
2445
+ }
2229
2446
  return this.withMount(mount, async () => {
2230
2447
  await this.settleBeforeMutation(mount, relativePath);
2231
2448
  // Checked inside the mount's turn, after anything the write waited on:
2232
2449
  // commitToolChange sets the tree entry before it first yields.
2233
2450
  if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
2234
- return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
2451
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
2235
2452
  }
2236
2453
  const blobHash = store.storeBlob(data, mimeType);
2237
2454
  // An overwrite keeps the file's mode, as the write tool's does.
@@ -2239,20 +2456,24 @@ export class WorkspaceModule implements Module {
2239
2456
  let committed = false;
2240
2457
  let failure: string | null;
2241
2458
  let modeKept: string | null = null;
2459
+ let disk: DiskState | null = null;
2242
2460
  try {
2243
2461
  const answer = await this.commitToolChange(mount, relativePath,
2244
2462
  { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
2245
2463
  modeKept = answer.modeKept;
2464
+ disk = answer.disk;
2246
2465
  failure = answer.refused === null ? null
2247
2466
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
2248
2467
  } catch (error) {
2249
2468
  if (!committed) throw error;
2250
2469
  // As write words it: a store that failed after recording the file
2251
2470
  // skipped its push, so on a mount that pushes, disk lacks it.
2471
+ const reopen = this.agreement?.awaitsReopen === true;
2252
2472
  failure = error instanceof StoredThenFailed
2253
2473
  ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
2254
- + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2474
+ + (error.pushes ? ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.` : '')
2255
2475
  : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2476
+ if (reopen) failure += ` ${AWAITS_REOPEN}`;
2256
2477
  }
2257
2478
  if (failure !== null) {
2258
2479
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -2262,17 +2483,21 @@ export class WorkspaceModule implements Module {
2262
2483
  }
2263
2484
  return {
2264
2485
  success: true,
2265
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
2486
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
2266
2487
  };
2267
2488
  });
2268
2489
  }
2269
2490
 
2270
2491
  /**
2271
- * Read a file's raw bytes from a mount (through the Chronicle tree, synced
2272
- * first like the `read` tool; on a direct mount, from disk as
2273
- * readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
2274
- * API for the framework's synthesized `read_image` tool and other peer
2275
- * callers that need binary content.
2492
+ * Read a file's raw bytes from a mount: through the Chronicle tree, a path
2493
+ * the workspace doesn't hold observed first (ensureSynced); or, on a direct
2494
+ * mount, from disk as readBinaryFromDisk reads it, at most the mount's
2495
+ * `maxFileSize`. A path the workspace holds is read as the workspace holds
2496
+ * it, not settled as the `read` tool settles it: the tree takes in no
2497
+ * binary disk copy, so a pass over a held binary file that disk changed
2498
+ * would drop the workspace's copy. Public API for the framework's
2499
+ * synthesized `read_image` tool, where the workspace module doesn't serve
2500
+ * read_image itself, and other peer callers that need binary content.
2276
2501
  */
2277
2502
  async readBinary(
2278
2503
  mountPrefixedPath: string,
@@ -2297,10 +2522,12 @@ export class WorkspaceModule implements Module {
2297
2522
  return this.withMount(mount, async () => {
2298
2523
  const { report, undecided } = await this.ensureSynced(mount, relativePath);
2299
2524
  const entry = store.treeGet(mount.treeStateId, relativePath);
2300
- if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2301
- const blob = store.getBlob(entry.blobHash);
2302
- if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
2303
- return { data: blob };
2525
+ if (!entry) {
2526
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
2527
+ }
2528
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2529
+ if ('lost' in contents) return { error: nothingToRead(contents.lost) };
2530
+ return { data: contents.bytes };
2304
2531
  });
2305
2532
  }
2306
2533
 
@@ -2383,9 +2610,9 @@ export class WorkspaceModule implements Module {
2383
2610
  }
2384
2611
  }
2385
2612
 
2386
- /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
2613
+ /** The directories of the mounts nested inside a mount, mount-relative. */
2387
2614
  private othersOf(mountName: string): string[] {
2388
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
2615
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
2389
2616
  }
2390
2617
 
2391
2618
  /**
@@ -2401,13 +2628,8 @@ export class WorkspaceModule implements Module {
2401
2628
  * alone, with no real root, since through it it would claim the other's
2402
2629
  * every path.
2403
2630
  *
2404
- * Each way it lies there is a spelling of its directory (`fromRoots`), and
2405
- * so is the spelling disk lists each of those by, where that differs. On a
2406
- * disk that folds names, the outer mount's walks name the directory as
2407
- * disk does, and so does a path parsePath has respelled, while a path given
2408
- * as configured still names it as configured (resolveAbsolutePath). A
2409
- * directory not on disk keeps the spellings the roots give alone. Each
2410
- * mount's real root comes back beside the nesting (`reals`), for what
2631
+ * Each way it lies there is a spelling of its directory (`fromRoots`).
2632
+ * Each mount's real root comes back beside the nesting (`reals`), for what
2411
2633
  * start reads there, and each directory above each configured path with
2412
2634
  * where it lies on disk (`above`), for a direct mount whose root moves
2413
2635
  * (movedNesting).
@@ -2432,16 +2654,15 @@ export class WorkspaceModule implements Module {
2432
2654
  for (const inner of this.config.mounts) {
2433
2655
  if (outer === inner) continue;
2434
2656
  const real = reals.get(inner.name)!;
2435
- const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!, outerPath);
2657
+ const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!);
2436
2658
  if (at === undefined) continue;
2437
- const { configured, fromRoots, spellings, claims } = at;
2438
- nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2659
+ const { configured, fromRoots, claims } = at;
2660
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
2439
2661
  const placed = fromRoots.filter((s) => s.dir !== configured);
2440
2662
  if (placed.length > 0) {
2441
- console.warn(
2442
- `[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2443
- `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`,
2444
- );
2663
+ const at = (sure: boolean) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
2664
+ const where = [at(true) && `where it lies on disk, at ${at(true)}`, at(false) && `where it may lie on disk, at ${at(false)}`].filter(Boolean).join(', and ');
2665
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
2445
2666
  }
2446
2667
  }
2447
2668
  }
@@ -2450,11 +2671,11 @@ export class WorkspaceModule implements Module {
2450
2671
 
2451
2672
  /**
2452
2673
  * The mounts nested in `mountName`, by directory, name, real root where it
2453
- * has one, and include (MountView's `nested`), in each spelling known.
2674
+ * has one, and include (MountView's `nested`).
2454
2675
  */
2455
2676
  private nestedOf(mountName: string): NestedMount[] {
2456
2677
  return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
2457
- o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2678
+ o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2458
2679
  }
2459
2680
 
2460
2681
  /**
@@ -2490,8 +2711,10 @@ export class WorkspaceModule implements Module {
2490
2711
  * mount nested by its directories alone, since its real root holds this
2491
2712
  * one, is judged by the parse's nesting, so a root repointed between a
2492
2713
  * read's parse and its open can have it serve a path in that mount's
2493
- * directory. Each mount is placed as a start places it (placementOf), from
2494
- * where its real root and the directories above its configured path lie on
2714
+ * directory, and one repointed between a direct write's parse and its
2715
+ * staging can have the write land there. Each mount is placed as a start
2716
+ * places it (placementOf), from where its real root and the directories
2717
+ * above its configured path lie on
2495
2718
  * disk. For a mount configured at this mount's own path or beneath it,
2496
2719
  * that's where they lie now: beneath `rootReal` where they lie beneath this
2497
2720
  * mount's path, and the directories above this mount's path as resolving
@@ -2551,10 +2774,12 @@ export class WorkspaceModule implements Module {
2551
2774
  if (real === undefined) continue;
2552
2775
  above = placedAbove.get(inner.name) ?? [];
2553
2776
  }
2554
- const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2777
+ const at = placementOf(outerPath, rootReal, innerPath, real, above);
2555
2778
  if (at === undefined) continue;
2556
2779
  const include = this.mounts.get(inner.name)?.config.include;
2557
- for (const s of at.spellings) nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2780
+ for (const s of at.fromRoots) {
2781
+ nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
2782
+ }
2558
2783
  }
2559
2784
  return nested;
2560
2785
  }
@@ -2564,33 +2789,22 @@ export class WorkspaceModule implements Module {
2564
2789
  * of that mount, judged lexically where the path lands (`.`, `..` and
2565
2790
  * repeated separators resolved, as for the traversal guard; a link of the
2566
2791
  * outer mount isn't followed), with the path to use there; undefined when
2567
- * no nested mount's directory holds it. A path parsePath has respelled is
2568
- * compared with the directory's spellings exactly. One judged as given
2569
- * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2570
- * folds names may compare it (beginsWithDir), so it can't reach that
2571
- * mount's directory by spelling a segment otherwise. On a disk that tells
2572
- * names apart, that takes a directory of this mount's own spelled like the
2573
- * nested one in another way as that mount's too.
2792
+ * no nested mount's directory holds it.
2574
2793
  */
2575
- private holderOf(mountName: string, relativePath: string, asGiven = false, moved: NestedMount[] | null = null): { name: string; use: string } | undefined {
2794
+ private holderOf(mountName: string, relativePath: string, moved: NestedMount[] | null = null): { name: string; use: string; guess?: NestedGuess } | undefined {
2576
2795
  const mount = this.mounts.get(mountName);
2577
2796
  if (!mount) return undefined;
2578
2797
  const root = resolve(mount.config.path);
2579
2798
  const resolved = resolve(root, relativePath);
2580
2799
  if (!isContainedPath(root, resolved)) return undefined;
2581
- const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2800
+ const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
2582
2801
  // A direct mount whose root a call found moved is judged by that call's
2583
2802
  // nesting (movedNesting), each directory beneath its configured path.
2584
- const dirs = moved !== null
2585
- ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2586
- : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2587
- const holder = dirs
2588
- .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2589
- .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2590
- .sort((a, b) => b.depth - a.depth)[0];
2803
+ const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
2591
2804
  if (!holder) return undefined;
2592
- const rest = segments.slice(holder.depth).join('/');
2593
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2805
+ const rest = restBeneath(path, holder);
2806
+ const guess = nestedGuess(mountName, path.split('/'), holder);
2807
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
2594
2808
  }
2595
2809
 
2596
2810
  /**
@@ -2599,10 +2813,10 @@ export class WorkspaceModule implements Module {
2599
2813
  * it; for a listing, whose the path is.
2600
2814
  */
2601
2815
  private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
2602
- const holder = this.holderOf(mountName, relativePath, false, moved);
2816
+ const holder = this.holderOf(mountName, relativePath, moved);
2603
2817
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2604
2818
  return holder
2605
- ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2819
+ ? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
2606
2820
  : `it lies in another mount's directory${what}`;
2607
2821
  }
2608
2822
 
@@ -2622,7 +2836,7 @@ export class WorkspaceModule implements Module {
2622
2836
  // Both checks judge by the nesting the parse found, and the second reads
2623
2837
  // disk beneath the root the parse resolved.
2624
2838
  const { mount, root, moved } = spelled;
2625
- const parsed = this.refuseNotHeld(spelled, path, false, moved);
2839
+ const parsed = this.refuseNotHeld(spelled, path, moved);
2626
2840
  // A path can also reach a nested mount's directory, or a file this
2627
2841
  // mount's own include leaves out, through a symlink on its way. No push
2628
2842
  // of this mount writes it there and no read reads it, so it's refused
@@ -2662,7 +2876,8 @@ export class WorkspaceModule implements Module {
2662
2876
  // operation, in the words a disk read gives a path it can't resolve,
2663
2877
  // whatever the place realpath stopped short of would say.
2664
2878
  if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2665
- const holder = nestedAtReal(real, nested);
2879
+ let holder = await nestedAtReal(real, nested);
2880
+ if (holder?.guess === null) holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
2666
2881
  if (!holder) {
2667
2882
  if (!judged) return null;
2668
2883
  const rootReal = await realBeneath(root);
@@ -2678,11 +2893,11 @@ export class WorkspaceModule implements Module {
2678
2893
  // on the place's way as that mount's, so a place beneath anything else
2679
2894
  // there, such as a file the include leaves out, isn't named; one beneath
2680
2895
  // a directory not there yet is.
2681
- const onItsWay = holder.include === undefined
2896
+ const onItsWay = !holder.judgedByInclude
2682
2897
  || await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
2683
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2684
- if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2685
- return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2898
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
2899
+ if (!onItsWay || !holder.names(directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2900
+ return new NestedMountError(path, mount.config.name, holder, 'leads into');
2686
2901
  }
2687
2902
 
2688
2903
  /**
@@ -2690,13 +2905,12 @@ export class WorkspaceModule implements Module {
2690
2905
  * mount's directory first, naming the mount that holds it, then outside
2691
2906
  * the mount's include. Each is judged on the path as the caller parsed it:
2692
2907
  * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2693
- * which is synchronous and says so (`asGiven`, so the nested-mount check
2694
- * folds where disk does). The include's refusal shows the path as given
2908
+ * which is synchronous. The include's refusal shows the path as given
2695
2909
  * (`given`), since it answers the path the caller asked about.
2696
2910
  */
2697
- private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, asGiven = false, moved: NestedMount[] | null = null): P {
2698
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2699
- if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2911
+ private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, moved: NestedMount[] | null = null): P {
2912
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
2913
+ if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
2700
2914
  if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2701
2915
  throw new OutsideIncludeError(given, parsed.mount.config);
2702
2916
  }
@@ -2746,7 +2960,7 @@ export class WorkspaceModule implements Module {
2746
2960
  * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2747
2961
  */
2748
2962
  private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
2749
- if (this.holderOf(mount.config.name, relativePath, false, moved)) return false;
2963
+ if (this.holderOf(mount.config.name, relativePath, moved)) return false;
2750
2964
  if (insideMount(relativePath, mount.config.include)) return false;
2751
2965
  const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
2752
2966
  if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
@@ -2819,18 +3033,12 @@ export class WorkspaceModule implements Module {
2819
3033
 
2820
3034
  /**
2821
3035
  * How many segments the innermost nested mount's directory that holds
2822
- * `relativePath` has, in a spelling it's known by (othersOf), or, for a
2823
- * direct mount whose root has moved, in the nesting there (`moved`);
3036
+ * `relativePath` has, or, for a direct mount whose root has moved, in the
3037
+ * nesting there (`moved`);
2824
3038
  * undefined when no nested mount's directory holds it.
2825
3039
  */
2826
3040
  private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
2827
- let depth: number | undefined;
2828
- for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
2829
- if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
2830
- const segments = dir.split('/').length;
2831
- if (depth === undefined || segments > depth) depth = segments;
2832
- }
2833
- return depth;
3041
+ return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
2834
3042
  }
2835
3043
 
2836
3044
  /**
@@ -2853,11 +3061,7 @@ export class WorkspaceModule implements Module {
2853
3061
  // Only an entry this mount's include covers spells a draft's path
2854
3062
  // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
2855
3063
  // from an earlier version, nor one the include leaves out, which would
2856
- // respell a covered draft into a name the include leaves out. An entry
2857
- // in a nested mount's directory that this mount's include covers
2858
- // spells one whatever that mount's include says, so on a disk that
2859
- // folds names, a draft naming the directory in another spelling takes
2860
- // the entry's spelling and is judged as in it.
3064
+ // respell a covered draft into a name the include leaves out.
2861
3065
  under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
2862
3066
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
2863
3067
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
@@ -2883,25 +3087,24 @@ export class WorkspaceModule implements Module {
2883
3087
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
2884
3088
  // store would keep one name and disk another.
2885
3089
  if (LONE_SURROGATE.test(path)) {
2886
- throw new Error(`The path ${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Nothing was done.`);
3090
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.`);
2887
3091
  }
2888
3092
  const slashIdx = path.indexOf('/');
2889
3093
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
2890
3094
  const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2891
3095
 
2892
3096
  const mount = this.mounts.get(mountName);
2893
- if (!mount) {
2894
- throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
2895
- }
3097
+ if (!mount) throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
2896
3098
  const segments = pathSegments(given);
2897
3099
  if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
2898
- 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.`);
3100
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root, as "${mountName}/<path>".`);
2899
3101
  }
2900
3102
  const kept: string[] = [];
2901
3103
  for (const segment of segments) {
2902
3104
  if (segment !== '..') kept.push(segment);
2903
3105
  else if (kept.pop() === undefined) {
2904
- throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
3106
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
3107
+ + `Name a file from its own mount's name; ${this.mountList()}.`);
2905
3108
  }
2906
3109
  }
2907
3110
  const relativePath = kept.join('/');
@@ -2916,11 +3119,41 @@ export class WorkspaceModule implements Module {
2916
3119
  const root = resolve(mount.config.path);
2917
3120
  const resolved = resolve(root, relativePath);
2918
3121
  if (!isContainedPath(root, resolved)) {
2919
- throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
3122
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} resolves outside mount "${mountName}", as a drive or root named in it does: give the path from the mount's root, as "${mountName}/<path>".`);
2920
3123
  }
2921
3124
  return { mount, relativePath };
2922
3125
  }
2923
3126
 
3127
+ private writableNames(): string[] {
3128
+ return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
3129
+ }
3130
+
3131
+ private mountList(): string {
3132
+ const names = [...this.mounts.keys()];
3133
+ return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
3134
+ }
3135
+
3136
+ private noSuchMountField(given: string, bare: string): string {
3137
+ const [close] = closestNames(given, [...this.mounts.keys()], 1);
3138
+ return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
3139
+ + (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
3140
+ + ` Leaving "mount" out ${bare}.`;
3141
+ }
3142
+
3143
+ private noSuchMount(path: string, mountName: string): string {
3144
+ const names = [...this.mounts.keys()];
3145
+ const rest = path.slice(mountName.length);
3146
+ const unslashed = path.replace(/^[/\\]+/, '');
3147
+ if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
3148
+ return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
3149
+ }
3150
+ if (mountName === '') return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
3151
+ const [close] = closestNames(mountName, names, 1);
3152
+ const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
3153
+ : `${JSON.stringify(path)} starts with ${JSON.stringify(mountName)}, which is no mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
3154
+ return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
3155
+ }
3156
+
2924
3157
  /**
2925
3158
  * Whether the module takes work: stop hasn't finished. Work admitted while
2926
3159
  * stop drains still runs; once stop has saved the pins and watermarks,
@@ -2944,31 +3177,26 @@ export class WorkspaceModule implements Module {
2944
3177
  maxSize: number,
2945
3178
  ): { bytes: Buffer; mimeType: SupportedImageMimeType } {
2946
3179
  if (bytes.byteLength === 0) {
2947
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3180
+ throw emptyImage(mountPrefixedPath);
2948
3181
  }
2949
- if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
3182
+ if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
2950
3183
  return {
2951
3184
  bytes,
2952
3185
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
2953
3186
  };
2954
3187
  }
2955
3188
 
2956
- private tryReadImageFromTree(
3189
+ private async readImageFromTree(
2957
3190
  mount: MountState,
2958
3191
  relativePath: string,
2959
3192
  mountPrefixedPath: string,
2960
3193
  maxSize: number,
2961
- ): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
2962
- const store = this.getStore();
2963
- const entry = store.treeGet(mount.treeStateId, relativePath);
3194
+ ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> {
3195
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
2964
3196
  if (!entry) return null;
2965
-
2966
- const blob = store.getBlob(entry.blobHash);
2967
- if (!blob) {
2968
- throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
2969
- }
2970
-
2971
- return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
3197
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
3198
+ if ('lost' in contents) return contents;
3199
+ return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
2972
3200
  }
2973
3201
 
2974
3202
  /**
@@ -2985,19 +3213,14 @@ export class WorkspaceModule implements Module {
2985
3213
  * another directory carries none. The identity is rootIdentityForRead's,
2986
3214
  * which answers while the evidence awaits reopening; where even it can't
2987
3215
  * be read, as when the evidence was never read back whole, the read is
2988
- * refused too, with an errno only where no directory is at the root's path.
3216
+ * refused before its root is resolved, with an errno only where no
3217
+ * directory is at the root's path.
2989
3218
  * A direct mount's root is checked against no identity, and its read reads
2990
3219
  * no evidence: it promises what's at its root now (diskView).
2991
3220
  */
2992
3221
  private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
2993
3222
  const name = mount.config.name;
2994
3223
  const root = resolve(mount.config.path);
2995
- let real: string;
2996
- try {
2997
- real = await realpath(root);
2998
- } catch (err) {
2999
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
3000
- }
3001
3224
  let rootIdentity: MountView['rootIdentity'];
3002
3225
  if (!mount.direct) {
3003
3226
  try {
@@ -3005,17 +3228,22 @@ export class WorkspaceModule implements Module {
3005
3228
  } catch (err) {
3006
3229
  // The evidence can't be read back, so neither can the check be made.
3007
3230
  // Where no directory is at the root's path, its errno still says so.
3008
- throw new WorkspaceReadError('mount_unavailable',
3009
- `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
3231
+ throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
3010
3232
  }
3011
3233
  }
3234
+ let real: string;
3235
+ try {
3236
+ real = await realpath(root);
3237
+ } catch (err) {
3238
+ throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
3239
+ }
3012
3240
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
3013
3241
  if ('dir' in checked) return { real, dir: checked.dir };
3014
3242
  // Another directory is no absence. For any other refusal, what is at the
3015
3243
  // root's path now decides: nothing, something that isn't a directory, or
3016
3244
  // a directory the path has come to name since it was resolved (no errno).
3017
3245
  const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
3018
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
3246
+ throw new RootUnavailable(name, checked.reason, 'root', errno);
3019
3247
  }
3020
3248
 
3021
3249
  /**
@@ -3030,7 +3258,7 @@ export class WorkspaceModule implements Module {
3030
3258
  private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
3031
3259
  const errno = await rootAbsence(resolve(mount.config.path));
3032
3260
  const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
3033
- return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
3261
+ return new RootUnavailable(mount.config.name, reason, 'root', errno);
3034
3262
  }
3035
3263
 
3036
3264
  /**
@@ -3132,7 +3360,7 @@ export class WorkspaceModule implements Module {
3132
3360
  }
3133
3361
  if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
3134
3362
  // A FIFO, socket or device is never opened.
3135
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
3363
+ throw new NotRegularFile(mountPrefixedPath, 'lstat');
3136
3364
  }
3137
3365
 
3138
3366
  let handle: Awaited<ReturnType<typeof open>>;
@@ -3168,7 +3396,7 @@ export class WorkspaceModule implements Module {
3168
3396
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
3169
3397
  }
3170
3398
  if (!fileStat.isFile()) {
3171
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
3399
+ throw new NotRegularFile(mountPrefixedPath, 'fstat');
3172
3400
  }
3173
3401
 
3174
3402
  if (!follow && !useNoFollow) {
@@ -3207,13 +3435,13 @@ export class WorkspaceModule implements Module {
3207
3435
  if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
3208
3436
 
3209
3437
  const inside = isContainedPath(realMountRoot, realFilePath);
3210
- const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3438
+ const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
3211
3439
  // A place in a nested mount's directory that its include leaves out is
3212
3440
  // no mount's, so the path leaves this one (namesNestedPlace), and so
3213
3441
  // does one reaching a file this mount's own include leaves out
3214
3442
  // (coversLanding).
3215
3443
  const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
3216
- if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
3444
+ if (!inside || own || (holder !== undefined && !holder.names(false))) {
3217
3445
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
3218
3446
  }
3219
3447
  if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
@@ -3281,7 +3509,9 @@ export class WorkspaceModule implements Module {
3281
3509
  * (`outside_include`), a path in another mount's directory nested in the one named
3282
3510
  * (`other_mount`), unavailable mount, missing or non-regular file,
3283
3511
  * directory, policy-denied symlink, outside-mount target, and
3284
- * file-changed-during-read.
3512
+ * file-changed-during-read. An error met while parsing the path that is
3513
+ * none of these refusals, such as the store's, is thrown as it is, and a
3514
+ * `maxBytes` that isn't a non-negative integer throws a `RangeError`.
3285
3515
  */
3286
3516
  async readFileFromDisk(
3287
3517
  mountPrefixedPath: string,
@@ -3297,11 +3527,11 @@ export class WorkspaceModule implements Module {
3297
3527
  } catch (err) {
3298
3528
  // A link's refusal (parseFilePath) carries its own code.
3299
3529
  if (err instanceof WorkspaceReadError) throw err;
3300
- const message = err instanceof Error ? err.message : String(err);
3301
- const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
3530
+ const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
3302
3531
  : err instanceof NestedMountError ? 'other_mount'
3303
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
3304
- throw new WorkspaceReadError(code, message, 'parse');
3532
+ : err instanceof PathRefusal ? err.code : null;
3533
+ if (code === null) throw err;
3534
+ throw new WorkspaceReadError(code, (err as Error).message, 'parse');
3305
3535
  }
3306
3536
  if (!relativePath) {
3307
3537
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
@@ -3329,92 +3559,25 @@ export class WorkspaceModule implements Module {
3329
3559
  }
3330
3560
  }
3331
3561
 
3332
- /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
3333
- private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
3334
- const p = mountPrefixedPath;
3335
- switch (err.code) {
3336
- case 'mount_unavailable':
3337
- // With why, where the root's check says, such as a directory that
3338
- // isn't the mount's.
3339
- return new WorkspaceImageReadError('mount_unavailable', err.message);
3340
- case 'directory':
3341
- return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
3342
- case 'symlink':
3343
- // The core reader's own words for a link at the path: "Symlinks are not
3344
- // allowed: <path>".
3345
- return new WorkspaceImageReadError('symlink', err.message);
3346
- case 'parent_symlink':
3347
- // The core reader's own words for a link on its way: which link and
3348
- // where it leads, with the setting for a link to a directory.
3349
- return new WorkspaceImageReadError('parent_symlink', err.message);
3350
- case 'other_mount':
3351
- // The core reader's own words for a path a link takes into a nested
3352
- // mount's directory: that mount and the path to use there. (#460's
3353
- // own other_mount, a path in that directory, is refused at parse,
3354
- // and so is a link found there before the read.)
3355
- return new WorkspaceImageReadError('other_mount', err.message);
3356
- case 'escape':
3357
- // The core reader's own words: "Symlink escape detected: <path>" for a
3358
- // followed link that resolves outside the mount, and for a link on the
3359
- // way that leads outside a mount that doesn't follow links, which link.
3360
- return new WorkspaceImageReadError('escape', err.message);
3361
- case 'changed':
3362
- return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
3363
- case 'not_found':
3364
- default:
3365
- // The core reader's own words for a file that isn't regular, and for
3366
- // a directory on the way it couldn't ask about.
3367
- if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
3368
- return new WorkspaceImageReadError('not_found', err.message);
3369
- }
3370
- if (err.message.startsWith('File not found')) {
3371
- return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
3372
- }
3373
- if (err.stage === 'realpath') {
3374
- return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
3375
- }
3376
- if (err.stage === 'stat') {
3377
- return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
3378
- }
3379
- return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
3380
- }
3381
- }
3382
-
3383
3562
  private async readImageFromFilesystem(
3384
3563
  mount: MountState,
3385
3564
  relativePath: string,
3386
3565
  mountPrefixedPath: string,
3387
3566
  maxSize: number,
3388
3567
  ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
3389
- let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3390
- try {
3391
- opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3392
- if (fileStat.size === 0) {
3393
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3394
- }
3395
- if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
3396
- });
3397
- } catch (err) {
3398
- if (err instanceof WorkspaceImageReadError) throw err;
3399
- if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
3400
- if (isErrnoCode(err, 'ENOENT')) {
3401
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3402
- }
3403
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3404
- }
3405
- const { handle, fileStat } = opened;
3568
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3569
+ if (fileStat.size === 0) throw emptyImage(mountPrefixedPath);
3570
+ if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
3571
+ });
3406
3572
  try {
3407
3573
  // No more than maxSize is loaded: a file grown past it since the fstat
3408
3574
  // above is refused here, as one already over it was there.
3409
3575
  const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
3410
- if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
3576
+ if (more) throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
3411
3577
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
3412
3578
  } catch (err) {
3413
3579
  if (err instanceof WorkspaceImageReadError) throw err;
3414
- if (isErrnoCode(err, 'ENOENT')) {
3415
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3416
- }
3417
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3580
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
3418
3581
  } finally {
3419
3582
  await handle.close();
3420
3583
  }
@@ -3447,18 +3610,19 @@ export class WorkspaceModule implements Module {
3447
3610
  * downstream agents. And, apart from it, why a write whose bytes reached
3448
3611
  * disk left the file's mode as disk had it (`modeKept`), and, for a
3449
3612
  * deletion that cleared a conflict naming a disk file the workspace never
3450
- * took in, that nothing was unlinked (`note`). The caller holds the mount's
3451
- * turn and has settled the path first (settleBeforeMutation, or a pass over
3452
- * the path, as an edit makes).
3613
+ * took in, that nothing was unlinked (`note`). And the answer's `disk` for
3614
+ * the path, unless it was refused. The caller holds the mount's turn and has
3615
+ * settled the path first (settleBeforeMutation, or a pass over the path, as
3616
+ * an edit makes).
3453
3617
  */
3454
3618
  private async commitToolChange(
3455
3619
  mount: MountState,
3456
3620
  relativePath: string,
3457
3621
  change: ToolChange,
3458
3622
  onCommitted?: () => void,
3459
- ): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
3460
- const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3461
- return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3623
+ ): Promise<{ refused: string | null; modeKept: string | null; note: string | null; disk: DiskState | null }> {
3624
+ const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3625
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
3462
3626
  }
3463
3627
 
3464
3628
  /**
@@ -3474,15 +3638,16 @@ export class WorkspaceModule implements Module {
3474
3638
  * (changesLanded).
3475
3639
  * Answers, by path, the reason for each that reached the workspace but not
3476
3640
  * disk (`refused`), and, apart from it, why one whose bytes reached disk
3477
- * left the file's mode as disk had it (`modeKept`), and that a deletion
3478
- * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3479
- * commitToolChange answers them for one path.
3641
+ * left the file's mode as disk had it (`modeKept`), that a deletion
3642
+ * clearing a conflict that named a disk file unlinked nothing (`notes`),
3643
+ * and the answer's `disk` for each path not refused, as commitToolChange
3644
+ * answers them for one path.
3480
3645
  */
3481
3646
  private async commitToolChanges(
3482
3647
  mount: MountState,
3483
3648
  changes: Array<{ path: string; change: ToolChange }>,
3484
3649
  onCommitted?: (path: string) => void,
3485
- ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
3650
+ ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string>; disk: Map<string, DiskState> }> {
3486
3651
  const store = this.getStore();
3487
3652
  const agreement = this.agreementOrThrow();
3488
3653
  const intents = this.intents.get(mount.config.name)!;
@@ -3621,7 +3786,7 @@ export class WorkspaceModule implements Module {
3621
3786
  // nothing.
3622
3787
  if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3623
3788
  putBack();
3624
- throw error;
3789
+ throw changing.length === 0 ? new AlreadyHeld(error) : error;
3625
3790
  }
3626
3791
  landedThenThrew = { error };
3627
3792
  }
@@ -3635,7 +3800,9 @@ export class WorkspaceModule implements Module {
3635
3800
  if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3636
3801
  const refused = new Map<string, string>();
3637
3802
  const modeKept = new Map<string, string>();
3638
- if (!this.pushesOnCommit(mount) || pushing.length === 0) return { refused, modeKept, notes };
3803
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) {
3804
+ return { refused, modeKept, notes, disk: new Map(changes.map(({ path }): [string, DiskState] => [path, this.diskOf(mount, path)])) };
3805
+ }
3639
3806
 
3640
3807
  const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3641
3808
  applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
@@ -3657,7 +3824,7 @@ export class WorkspaceModule implements Module {
3657
3824
  error: skipped.reason,
3658
3825
  } as ProcessEvent);
3659
3826
  }
3660
- return { refused, modeKept, notes };
3827
+ return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }): [string, DiskState] => [path, 'done'])) };
3661
3828
  }
3662
3829
 
3663
3830
  // ==========================================================================
@@ -3681,15 +3848,19 @@ export class WorkspaceModule implements Module {
3681
3848
  }
3682
3849
 
3683
3850
  /**
3684
- * Before a read: a path the workspace doesn't hold is observed under the
3685
- * three-way rule, which ingests a new disk file but never brings back a
3686
- * workspace deletion or a file absent from this branch. The pass names the
3687
- * path, as a file tool names the one it acts on: a path the mount passes
3688
- * over by design is observed, as a sync naming it would observe it (#276),
3689
- * while a path in another mount's directory is refused before this, and
3690
- * passed over by any pass. Answers the pass's report on the path, and why
3691
- * the pass decided nothing where it did (undecidedOf), which leaves disk
3692
- * unchecked. The caller holds the mount's turn.
3851
+ * Before a deletion, and for readBinary: a path the workspace doesn't hold
3852
+ * is observed under the three-way rule, which ingests a new disk file but
3853
+ * never brings back a workspace deletion or a file absent from this branch.
3854
+ * A path it holds is left alone. For a deletion, as for a write, taking
3855
+ * disk's change in first would let the push remove that change unseen; for
3856
+ * readBinary, a pass would drop a held binary file that disk changed, since
3857
+ * the tree takes in no binary disk copy. The pass names the path, as a file
3858
+ * tool names the one it acts on: a path the mount passes over by design is
3859
+ * observed, as a sync naming it would observe it (#276), while a path in
3860
+ * another mount's directory is refused before this, and passed over by any
3861
+ * pass. Answers the pass's report on the path, and why the pass decided
3862
+ * nothing where it did (undecidedOf), which leaves disk unchecked. The
3863
+ * caller holds the mount's turn.
3693
3864
  */
3694
3865
  private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
3695
3866
  const store = this.getStore();
@@ -3704,45 +3875,80 @@ export class WorkspaceModule implements Module {
3704
3875
  * call made again checks it.
3705
3876
  */
3706
3877
  private notCheckedYet(path: string, reason: string): string {
3707
- return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3878
+ return `${JSON.stringify(path)} isn't in the workspace, and disk couldn't be checked: ${reason}. Make the same call again, which checks disk first.`;
3708
3879
  }
3709
3880
 
3710
3881
  /** Why the workspace holds no entry at a path, when disk explains it. */
3711
- private notInWorkspace(path: string, report: PathReport | undefined): string {
3882
+ private notInWorkspace(path: string, report: PathReport | undefined, mount: MountState, missing?: string): string {
3883
+ const shown = JSON.stringify(path);
3712
3884
  switch (report?.state) {
3713
3885
  case 'workspace-deleted':
3714
- return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3886
+ return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3715
3887
  case 'not-in-branch':
3716
- return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3717
- case 'disk-only':
3718
- return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
3719
- (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
3888
+ return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3889
+ case 'disk-only': {
3890
+ const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
3891
+ return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
3892
+ + 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
3893
+ + (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
3894
+ }
3720
3895
  case 'conflict':
3721
- return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
3896
+ return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3722
3897
  'Sync this path to take the disk version.';
3723
3898
  case 'unverified':
3724
- return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
3899
+ return uncheckedFile(shown, report.note);
3725
3900
  default:
3726
- return `File not found: ${path}`;
3901
+ return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3727
3902
  }
3728
3903
  }
3729
3904
 
3730
3905
  /**
3731
- * A file's bytes for read, from where the mount keeps what it reads: the
3732
- * workspace's copy, taken in from disk first where it holds none
3733
- * (ensureSynced); or, on a direct mount, disk's file as it is now
3734
- * (bytesOnDisk). An error says why there are none to read.
3906
+ * A file's bytes for read, and the notes that say which copy they are. On a
3907
+ * 'chronicle' mount it's the workspace's copy, after a pass over the path
3908
+ * takes disk's change to it in first. On a direct mount it's disk's file as
3909
+ * it is now (bytesOnDisk), with no notes, since nothing else is kept. An
3910
+ * error says why there are none to read.
3735
3911
  */
3736
- private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3737
- if (mount.direct) return this.bytesOnDisk(mount, relativePath, path);
3912
+ private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> {
3913
+ if (mount.direct) {
3914
+ const read = await this.bytesOnDisk(mount, relativePath, path);
3915
+ return 'error' in read ? read : { bytes: read.bytes, notes: [] };
3916
+ }
3738
3917
  const store = this.getStore();
3739
- return this.withMount(mount, async () => {
3740
- const { report } = await this.ensureSynced(mount, relativePath);
3918
+ return this.withMount(mount, async (): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> => {
3919
+ // A read shows the path as disk holds it now: a change on disk to a
3920
+ // file the workspace holds is taken in first, by the rule a listing
3921
+ // uses, as an edit's is. Where the stored copy is a draft and disk
3922
+ // changed too, the read shows the draft and names the conflict. The
3923
+ // pass names the path, as a file tool names the one it acts on, so a
3924
+ // path the mount passes over by design is observed, as a sync naming
3925
+ // it would observe it (#276).
3926
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3927
+ // A pass that decided nothing (undecidedOf: the selected branch kept
3928
+ // changing) leaves no report. It's read as a copy disk couldn't be
3929
+ // checked, with its reason, so the answer says the check didn't finish.
3930
+ const undecided = undecidedOf(pass);
3931
+ const report = pass.reports.get(relativePath)
3932
+ ?? (undecided !== null ? { path: relativePath, state: 'unverified' as const, note: undecided } : undefined);
3933
+ // A check that decided nothing may well finish on the next read, as
3934
+ // grep's answer to the same says of grep.
3935
+ const again = undecided !== null ? ['Read again to check it.'] : [];
3936
+ // A mode taken in alone isn't a change to the text, so it leaves the
3937
+ // read nothing to say, as it leaves an edit nothing.
3938
+ const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
3741
3939
  const entry = store.treeGet(mount.treeStateId, relativePath);
3742
- if (!entry) return { error: this.notInWorkspace(path, report) };
3743
- const blob = store.getBlob(entry.blobHash);
3744
- if (!blob) return { error: `Blob not found for: ${path}` };
3745
- return { bytes: blob };
3940
+ if (!entry) {
3941
+ // A pass also drops a copy disk made binary or too large, which
3942
+ // the disk-only answer describes; any other drop is disk's deletion.
3943
+ return {
3944
+ error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
3945
+ : undecided !== null ? this.notCheckedYet(path, undecided)
3946
+ : this.notInWorkspace(path, report, mount),
3947
+ };
3948
+ }
3949
+ const contents = await this.contentsOf(mount, relativePath, path, entry);
3950
+ if ('lost' in contents) return { error: nothingToRead(contents.lost) };
3951
+ return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3746
3952
  });
3747
3953
  }
3748
3954
 
@@ -3756,153 +3962,201 @@ export class WorkspaceModule implements Module {
3756
3962
  */
3757
3963
  private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3758
3964
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3759
- const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3965
+ const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
3760
3966
  let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3761
3967
  try {
3762
3968
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3763
- if (fileStat.size > maxSize) throw new Error(over);
3969
+ if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
3764
3970
  });
3765
3971
  } catch (err) {
3766
- // A refusal that names only the path says why with its errno, as a
3767
- // failed read below does.
3768
- const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
3769
- return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
3972
+ if (err instanceof Refusal) return { error: err.message };
3973
+ if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
3974
+ throw err;
3770
3975
  }
3771
3976
  try {
3772
3977
  // No more than the limit is loaded, as for a file over it at its fstat.
3773
3978
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
3774
- if (more) return { error: over };
3979
+ if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
3775
3980
  if (looksBinary(bytes)) {
3776
- return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3981
+ return { error: `${JSON.stringify(path)} is binary, so read doesn't show it as text. ${sniffImageMime(bytes) ? 'read_image shows it, if its data holds together as an image.' : 'No tool shows it as text, so calling again won\'t change that.'}` };
3777
3982
  }
3778
3983
  return { bytes };
3779
3984
  } catch (err) {
3780
- const errno = errnoOf(err);
3781
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3985
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
3782
3986
  } finally {
3783
3987
  await opened.handle.close();
3784
3988
  }
3785
3989
  }
3786
3990
 
3991
+ private async contentsOf(
3992
+ mount: MountState,
3993
+ relativePath: string,
3994
+ path: string,
3995
+ version: { blobHash: string; size: number },
3996
+ what: 'saved' | 'conflict-disk' = 'saved',
3997
+ tell: 'now' | 'later' = 'now',
3998
+ ): Promise<{ bytes: Buffer } | { lost: LostContents }> {
3999
+ const store = this.getStore();
4000
+ const stored = storedBytes(store, version.blobHash);
4001
+ if ('bytes' in stored) return stored;
4002
+ const otherSize = new Error('other size');
4003
+ let found: { bytes: Buffer } | { disk: LostContents['disk'] };
4004
+ try {
4005
+ const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4006
+ if (fileStat.size !== version.size) throw otherSize;
4007
+ });
4008
+ try {
4009
+ const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
4010
+ found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
4011
+ } finally {
4012
+ await opened.handle.close();
4013
+ }
4014
+ } catch (err) {
4015
+ found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
4016
+ }
4017
+ const subject = contentsSubject(what, path);
4018
+ const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
4019
+ if ('disk' in found) {
4020
+ const now = storedBytes(store, version.blobHash);
4021
+ if ('bytes' in now) return now;
4022
+ const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
4023
+ if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost);
4024
+ return { lost };
4025
+ }
4026
+ const { bytes } = found;
4027
+ if ('bytes' in storedBytes(store, version.blobHash)) return { bytes };
4028
+ let thrown: string | null = null;
4029
+ try {
4030
+ store.storeBlob(bytes, 'application/octet-stream');
4031
+ } catch (error) {
4032
+ thrown = error instanceof Error ? error.message : String(error);
4033
+ }
4034
+ // Chronicle's storeBlob takes any file already at the hash's path as
4035
+ // stored, so whether the bytes took is what a read of them gives now.
4036
+ const now = storedBytes(store, version.blobHash);
4037
+ if ('bytes' in now) {
4038
+ this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace',
4039
+ `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
4040
+ } else {
4041
+ const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
4042
+ this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace',
4043
+ `Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
4044
+ + ('unreadable' in now
4045
+ ? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them."
4046
+ : 'Each read does this again until the store takes them.'),
4047
+ { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
4048
+ }
4049
+ return { bytes };
4050
+ }
4051
+
4052
+ private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void {
4053
+ this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace',
4054
+ `Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
4055
+ + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.",
4056
+ { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
4057
+ }
4058
+
4059
+ private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
4060
+ const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
4061
+ if (!absent) return { unreadable: containedRefusal(err, path) };
4062
+ const root = resolve(mount.config.path);
4063
+ const rootReal = await realpath(root).catch(() => root);
4064
+ const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
4065
+ const proof = await confirmAbsent(view, rootReal, relativePath);
4066
+ if (typeof proof !== 'string') return 'none';
4067
+ return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
4068
+ }
4069
+
4070
+ private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
4071
+ mount: MountState,
4072
+ versions: readonly T[],
4073
+ each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void,
4074
+ ): Promise<void> {
4075
+ const byHash = new Map<string, T[]>();
4076
+ for (const version of versions) {
4077
+ const sharing = byHash.get(version.hash);
4078
+ if (sharing) sharing.push(version);
4079
+ else byHash.set(version.hash, [version]);
4080
+ }
4081
+ for (const [hash, sharing] of byHash) {
4082
+ let bytes: Buffer | null = null;
4083
+ const lost: Array<[T, LostContents]> = [];
4084
+ for (const version of sharing) {
4085
+ const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
4086
+ if ('bytes' in contents) {
4087
+ bytes = contents.bytes;
4088
+ break;
4089
+ }
4090
+ lost.push([version, contents.lost]);
4091
+ }
4092
+ if (bytes === null) {
4093
+ for (const [version, found] of lost) {
4094
+ this.tellLost(mount.config.name, version.what, hash, found);
4095
+ each(version, { lost: found });
4096
+ }
4097
+ } else {
4098
+ for (const version of sharing) each(version, { bytes });
4099
+ }
4100
+ }
4101
+ }
4102
+
3787
4103
  // ==========================================================================
3788
4104
  // Tool Handlers
3789
4105
  // ==========================================================================
3790
4106
 
3791
- private async handleRead(input: ReadInput, generatedContent?: string): Promise<ToolResult> {
4107
+ /**
4108
+ * A read's page, held to `maxChars`, the most of this call's result that
4109
+ * can reach its caller whole. A call that carries none, made in process,
4110
+ * takes the data itself: its page holds to its line limit alone, with every
4111
+ * line whole, as it did before pages had a budget.
4112
+ */
4113
+ private async handleRead(input: ReadInput, maxChars: number | undefined, generatedContent?: string): Promise<ToolResult> {
3792
4114
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
3793
- const offsetChars = input.offsetChars ?? 0;
3794
- const limitChars = input.limitChars ?? 2000;
3795
4115
  // Each number is a whole number of at least its schema's minimum by now
3796
4116
  // (inputProblem).
3797
4117
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
3798
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
4118
+ return { success: false, isError: true, error: didNothing('read', 'it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.') };
3799
4119
  }
3800
- let content = generatedContent;
3801
- if (content === undefined) {
4120
+ let file: FileText;
4121
+ let notes: string[] = [];
4122
+ if (generatedContent === undefined) {
3802
4123
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
4124
+ if (relativePath === '') return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
3803
4125
  input = { ...input, path };
3804
4126
  const found = await this.bytesOf(mount, relativePath, path);
3805
- if ('error' in found) return { success: false, error: found.error, isError: true };
3806
- content = found.bytes.toString('utf-8');
3807
- }
3808
- if (characterPaging) {
3809
- const start = Math.min(offsetChars, content.length);
3810
- const splitsPair = (at: number): boolean =>
3811
- at > 0 && at < content.length
3812
- && content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
3813
- && content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
3814
- if (splitsPair(start)) {
3815
- return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
3816
- }
3817
- let end = start + Math.min(limitChars, content.length - start);
3818
- if (splitsPair(end)) end++;
3819
- return {
3820
- success: true,
3821
- data: {
3822
- path: input.path,
3823
- totalChars: content.length,
3824
- offsetChars: start,
3825
- nextOffsetChars: end < content.length ? end : null,
3826
- content: content.slice(start, end),
3827
- },
3828
- };
3829
- }
3830
- const lines = linesOf(content);
3831
-
3832
- // Apply offset/limit. An unlimited read of a large file would inject the
3833
- // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
3834
- // past the model context and 400s), so an omitted limit falls back to a
3835
- // default cap; the result reports total/from/to so the agent can page.
3836
- const DEFAULT_READ_LINE_LIMIT = 2000;
3837
- const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
3838
- if (startLine >= lines.length) {
3839
- // Nothing to show: an empty file, or an offset past the last line. No
3840
- // range is claimed, and the line count says where the file ends.
3841
- return {
3842
- success: true,
3843
- data: {
3844
- path: input.path,
3845
- totalLines: lines.length,
3846
- // Reading an empty file from its start is reading it whole.
3847
- ...(startLine > 0
3848
- ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
3849
- : {}),
3850
- content: '',
3851
- },
3852
- };
4127
+ if ('error' in found) return { success: false, error: didNothing('read', found.error), isError: true };
4128
+ file = fileText(found.bytes);
4129
+ notes = found.notes;
4130
+ } else {
4131
+ file = fileText(Buffer.from(generatedContent, 'utf-8'));
3853
4132
  }
3854
- const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
3855
- const endLine = startLine + effectiveLimit;
3856
- const slice = lines.slice(startLine, endLine);
3857
- const truncated = endLine < lines.length;
3858
-
3859
- // Format with line numbers (cat -n style)
3860
- const formatted = slice
3861
- .map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
3862
- .join('\n');
3863
-
3864
- return {
3865
- success: true,
3866
- data: {
3867
- path: input.path,
3868
- totalLines: lines.length,
3869
- fromLine: startLine + 1,
3870
- toLine: Math.min(endLine, lines.length),
3871
- ...(truncated
3872
- ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
3873
- : {}),
3874
- content: formatted,
3875
- },
3876
- };
4133
+ // An unlimited read of a large file would inject the whole thing into the
4134
+ // turn (a 2MB file ≈ 600k tokens → the request blows past the model
4135
+ // context and 400s), so an omitted limit falls back to a default, and a
4136
+ // page stops at the call's result limit where it has one.
4137
+ return characterPaging
4138
+ ? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
4139
+ : linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
3877
4140
  }
3878
4141
 
3879
4142
  private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
3880
- let mount: MountState;
3881
- let relativePath: string;
3882
- try {
3883
- let path: string;
3884
- ({ mount, relativePath, path } = await this.parseFilePath(input.path));
3885
- input = { ...input, path };
3886
- } catch (err) {
3887
- return {
3888
- success: false,
3889
- error: err instanceof Error ? err.message : String(err),
3890
- isError: true,
3891
- };
3892
- }
3893
- if (!relativePath) {
3894
- return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
3895
- }
4143
+ const given = input.path;
4144
+ const refused = (body: string): ToolResult => ({ success: false, isError: true, error: didNothing('read_image', body) });
4145
+ const { mount, relativePath, path } = await this.parseFilePath(given);
4146
+ if (!relativePath) return refused(namesRoot(given, mount.config.name));
4147
+ input = { ...input, path };
3896
4148
 
3897
4149
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3898
4150
  let treeError: WorkspaceImageReadError | null = null;
4151
+ let lost: LostContents | null = null;
3899
4152
 
3900
4153
  // The workspace's copy first, where it holds one, then disk's. A direct
3901
4154
  // mount holds none, so its image is disk's.
3902
4155
  if (!mount.direct) {
3903
4156
  try {
3904
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
3905
- if (image) return imageResult(input.path, image);
4157
+ const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
4158
+ if (image && 'lost' in image) lost = image.lost;
4159
+ else if (image) return imageResult(input.path, image);
3906
4160
  } catch (err) {
3907
4161
  if (err instanceof WorkspaceImageReadError) {
3908
4162
  treeError = err;
@@ -3913,41 +4167,53 @@ export class WorkspaceModule implements Module {
3913
4167
  }
3914
4168
 
3915
4169
  try {
3916
- return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
4170
+ const note = lost === null ? undefined
4171
+ : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
4172
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
3917
4173
  } catch (err) {
3918
- if (err instanceof WorkspaceImageReadError) {
3919
- if (err.code === 'not_found' && treeError) {
3920
- return { success: false, error: treeError.message, isError: true };
3921
- }
3922
- return { success: false, error: err.message, isError: true };
4174
+ if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError)) throw err;
4175
+ const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
4176
+ if (lost) {
4177
+ const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
4178
+ return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
4179
+ : `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
3923
4180
  }
3924
- throw err;
4181
+ if (noFile && treeError) return refused(treeError.message);
4182
+ return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
3925
4183
  }
3926
4184
  }
3927
4185
 
3928
- private async handleWrite(input: WriteInput): Promise<ToolResult> {
4186
+ private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
3929
4187
  if (LONE_SURROGATE.test(input.content)) {
3930
- return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
4188
+ return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store. Give it without the surrogate, or with the other half of its pair.' };
3931
4189
  }
3932
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4190
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
4191
+ if (relativePath === '') return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
3933
4192
  input = { ...input, path };
3934
- if (mount.config.mode === 'read-only') {
3935
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
3936
- }
4193
+ const refusal = writeRefusal(mount, this.writableNames());
4194
+ if (refusal) return { success: false, error: didNothing('write', refusal), isError: true };
3937
4195
 
3938
4196
  const store = this.getStore();
3939
4197
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3940
- if (Buffer.byteLength(input.content) > maxSize) {
3941
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
4198
+ const bytes = Buffer.byteLength(input.content);
4199
+ if (bytes > maxSize) {
4200
+ return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
3942
4201
  }
3943
4202
 
3944
4203
  const buffer = Buffer.from(input.content, 'utf-8');
3945
4204
  const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
4205
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
4206
+ if (mount.direct) {
4207
+ const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
4208
+ if ('error' in written) return { success: false, isError: true, error: written.error };
4209
+ return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
4210
+ }
3946
4211
  let committed = false;
3947
4212
  let refused: string | null;
3948
4213
  let modeKept: string | null;
4214
+ let disk: DiskState | null;
3949
4215
  try {
3950
- ({ refused, modeKept } = await this.withMount(mount, async () => {
4216
+ ({ refused, modeKept, disk } = await this.withMount(mount, async () => {
3951
4217
  await this.settleBeforeMutation(mount, relativePath);
3952
4218
  const blobHash = store.storeBlob(buffer, 'text/plain');
3953
4219
  // An overwrite keeps the file's mode, as an edit does.
@@ -3956,23 +4222,23 @@ export class WorkspaceModule implements Module {
3956
4222
  () => { committed = true; });
3957
4223
  }));
3958
4224
  } catch (error) {
3959
- if (!committed) throw error;
4225
+ if (!committed) throw notStored(error);
3960
4226
  return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
3961
4227
  ? { storeFailed: error.message, pushes: error.pushes }
3962
- : { threw: error instanceof Error ? error.message : String(error) });
4228
+ : { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
3963
4229
  }
3964
4230
  if (refused !== null) return storedButUnfinished('write', input.path, { refused });
3965
4231
 
3966
- return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
4232
+ return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
3967
4233
  }
3968
4234
 
3969
- private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS): Promise<ToolResult> {
4235
+ private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
3970
4236
  return this.applyEdits('edit', [{
3971
4237
  path: input.path,
3972
4238
  oldString: input.oldString,
3973
4239
  newString: input.newString,
3974
4240
  expected: input.replaceAll ? 'all' : 1,
3975
- }], maxChars);
4241
+ }], maxChars, callerAgentName);
3976
4242
  }
3977
4243
 
3978
4244
  private async handleEditMany(
@@ -4010,12 +4276,14 @@ export class WorkspaceModule implements Module {
4010
4276
  * stored where its record landed, and that it can't tell where they
4011
4277
  * couldn't be read back.
4012
4278
  * Under the mount's turn, one pass settles every path with disk, by the
4013
- * rule a listing uses, so the edits apply to what disk now holds. That pass
4014
- * is the only step that awaits before the commit: every file is then read,
4015
- * every edit planned, and every entry set (commitToolChanges) with no turn
4016
- * of the event loop between, so no pass, branch change or other call moves
4017
- * the tree between a file's read and its set. Disk then takes what it can
4018
- * of the stored change.
4279
+ * rule a listing uses, so the edits apply to what disk now holds. Each
4280
+ * file's stored contents are then read; where the store has lost some,
4281
+ * they're restored from disk's matching copy (contentsOf) and the pass runs
4282
+ * again, so the plan reads a pass taken after every await. From there
4283
+ * nothing awaits before the commit: every edit is planned and every entry
4284
+ * set (commitToolChanges) with no turn of the event loop between, so no
4285
+ * pass, branch change or other call moves the tree between a file's read
4286
+ * and its set. Disk then takes what it can of the stored change.
4019
4287
  *
4020
4288
  * The answer is held to `maxChars`, the length the call's result may have
4021
4289
  * (ToolCall.resultMaxChars), as edit-text.ts describes.
@@ -4056,7 +4324,7 @@ export class WorkspaceModule implements Module {
4056
4324
  }
4057
4325
  for (const field of ['oldString', 'newString'] as const) {
4058
4326
  if (LONE_SURROGATE.test(edit[field])) {
4059
- problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
4327
+ problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.` });
4060
4328
  }
4061
4329
  }
4062
4330
  if (problems.length === found) specs.set(index, spec);
@@ -4093,6 +4361,9 @@ export class WorkspaceModule implements Module {
4093
4361
  const [mount] = mounts.values();
4094
4362
  if (tool === 'edit' && problems.length) return refuse(problems);
4095
4363
  if (!mount || mounts.size > 1) return refuse(problems);
4364
+ // A direct mount's edits apply to disk's text as it is now, and land on
4365
+ // disk or don't happen (applyDirectEdits).
4366
+ if (mount.direct) return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
4096
4367
 
4097
4368
  const store = this.getStore();
4098
4369
  // Whether the commit set the call's entries, all of them at once.
@@ -4107,7 +4378,26 @@ export class WorkspaceModule implements Module {
4107
4378
  return await this.withMount(mount, async (): Promise<ToolResult> => {
4108
4379
  // As every file tool's pass does, it names the paths it acts on, so a
4109
4380
  // file the mount passes over by design is observed (see PassOptions.named).
4110
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4381
+ let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4382
+ let ops = pass.ops;
4383
+ const contents = new Map<string, Buffer>();
4384
+ const lost = new Map<string, { blobHash: string; lost: LostContents }>();
4385
+ const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = [];
4386
+ for (const target of targets.values()) {
4387
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4388
+ if (!entry || contents.has(entry.blobHash)) continue;
4389
+ const stored = storedBytes(store, entry.blobHash);
4390
+ if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes);
4391
+ else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
4392
+ }
4393
+ if (toRecover.length > 0) {
4394
+ await this.restoreEach(mount, toRecover, (version, found) => {
4395
+ if ('bytes' in found) contents.set(version.hash, found.bytes);
4396
+ else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
4397
+ });
4398
+ pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4399
+ ops = [...ops, ...pass.ops];
4400
+ }
4111
4401
  // A pass that decided nothing checked no file's disk copy.
4112
4402
  const undecided = undecidedOf(pass);
4113
4403
  // Nothing below awaits until the commit's push.
@@ -4126,7 +4416,7 @@ export class WorkspaceModule implements Module {
4126
4416
  // A mode taken in alone isn't a change to the text: it brings nothing
4127
4417
  // new to read, as it makes no event (emitFsEvents), and the edits
4128
4418
  // then write at the mode disk has, so it leaves nothing to say.
4129
- const took = (op: TreeOp) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4419
+ const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4130
4420
  // Which copy the edits used, when it isn't the one the workspace
4131
4421
  // last agreed on with disk: disk's newer text, or the workspace's
4132
4422
  // copy because disk's couldn't be read or disk has none. A file the
@@ -4191,59 +4481,32 @@ export class WorkspaceModule implements Module {
4191
4481
  // A pass also drops a copy disk made binary or too large, which
4192
4482
  // the disk-only answer describes; any other drop is disk's deletion.
4193
4483
  const text = took('deleted') && report?.state !== 'disk-only'
4194
- ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
4195
- : this.notInWorkspace(target.path, report);
4196
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
4197
- continue;
4198
- }
4199
- notes.push(...lookedIn);
4200
- const blob = store.getBlob(entry.blobHash);
4201
- if (!blob) {
4202
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4203
- continue;
4204
- }
4205
- // Decoding would turn each invalid sequence into U+FFFD, and writing
4206
- // the text back would change bytes the edits never named.
4207
- const file = decodeForEdit(blob);
4208
- if (!file) {
4209
- problems.push({
4210
- kind: 'file',
4211
- edits: target.edits,
4212
- path: target.path,
4213
- text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
4214
- + '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.',
4215
- whole: false,
4216
- });
4217
- continue;
4218
- }
4219
- if (!target.specs.length) continue;
4220
- const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
4221
- if ('problems' in planned) {
4222
- // One at a time: a call of many edits can have more problems
4223
- // than a spread passes as arguments.
4224
- for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
4484
+ ? deletedOnDisk(target.path)
4485
+ : this.notInWorkspace(target.path, report, mount);
4486
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
4225
4487
  continue;
4226
4488
  }
4227
- // The planner refuses a file its counts take past the limit before
4228
- // building it, from the least its bytes can be; one it built past
4229
- // the limit, as its CRs or multi-byte characters can take it, is
4230
- // refused here.
4231
- if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
4232
- const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
4489
+ const known = contents.get(entry.blobHash);
4490
+ const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
4491
+ if (!('bytes' in stored)) {
4492
+ const found = lost.get(target.relativePath);
4233
4493
  problems.push({
4234
4494
  kind: 'file',
4235
4495
  edits: target.edits,
4236
4496
  path: target.path,
4237
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
4238
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
4239
- + "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.",
4240
- whole: false,
4497
+ text: found?.blobHash === entry.blobHash
4498
+ ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
4499
+ : `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`,
4500
+ whole: true,
4241
4501
  });
4242
4502
  continue;
4243
4503
  }
4504
+ notes.push(...lookedIn);
4505
+ const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
4506
+ if (plan === null) continue;
4244
4507
  plans.push({
4245
4508
  target,
4246
- plan: planned.plan,
4509
+ plan,
4247
4510
  mode: entry.mode,
4248
4511
  backOnDisk,
4249
4512
  conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
@@ -4253,32 +4516,29 @@ export class WorkspaceModule implements Module {
4253
4516
  }
4254
4517
  if (problems.length) return refuse(problems, notes);
4255
4518
 
4256
- const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4519
+ const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4257
4520
  path: target.relativePath,
4258
4521
  change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4259
4522
  })), () => { committed = true; });
4260
4523
  if (refused.size > 0) return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
4261
- return {
4262
- success: true,
4263
- data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4264
- path: target.path,
4265
- plan,
4266
- notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4267
- modeKept: modeKept.get(target.relativePath) ?? null,
4268
- })), given.length, maxChars),
4269
- };
4524
+ return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4525
+ path: target.path,
4526
+ plan,
4527
+ notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4528
+ modeKept: modeKept.get(target.relativePath) ?? null,
4529
+ })), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath)!)));
4270
4530
  });
4271
4531
  } catch (error) {
4272
4532
  const message = error instanceof Error ? error.message : String(error);
4273
4533
  if (committed) {
4274
4534
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
4275
4535
  ? { storeFailed: message, pushes: error.pushes }
4276
- : { threw: message }, maxChars);
4536
+ : { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
4277
4537
  }
4278
4538
  // A set that threw and couldn't be read back may have stored the edits,
4279
4539
  // and a second call could then apply them twice.
4280
4540
  if (error instanceof CommitUnknownError) {
4281
- const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4541
+ const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
4282
4542
  return {
4283
4543
  success: false,
4284
4544
  isError: true,
@@ -4291,9 +4551,8 @@ export class WorkspaceModule implements Module {
4291
4551
  }
4292
4552
  // The commit sets every entry in one record or none, and a set that
4293
4553
  // threw was read back, so nothing is stored; or every change was one
4294
- // the tree already held, which leaves the tree as it was either way.
4295
- if (tool === 'edit_many') return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
4296
- throw error;
4554
+ // the tree already held (AlreadyHeld).
4555
+ throw notStored(error);
4297
4556
  }
4298
4557
  }
4299
4558
 
@@ -4311,7 +4570,7 @@ export class WorkspaceModule implements Module {
4311
4570
  path: string,
4312
4571
  callerAgentName: string | undefined,
4313
4572
  ): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
4314
- const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: true });
4573
+ const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: false });
4315
4574
  try {
4316
4575
  // In the one spelling every file tool reads, as disk spells it, so two
4317
4576
  // spellings of a file are one entry and, in one call, one plan
@@ -4320,13 +4579,16 @@ export class WorkspaceModule implements Module {
4320
4579
  const where = await this.parseFilePath(path);
4321
4580
  // One that names the mount's root, its bare name included, names no
4322
4581
  // file.
4323
- if (where.relativePath === '') return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
4324
- if (where.mount.config.mode === 'read-only') return unusable(`Mount "${where.mount.config.name}" is read-only`);
4582
+ if (where.relativePath === '') return unusable(namesRoot(path, where.mount.config.name));
4583
+ const refusal = writeRefusal(where.mount, this.writableNames());
4584
+ if (refusal) return unusable(refusal);
4325
4585
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4326
- if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4586
+ if (generated) return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
4327
4587
  return where;
4328
4588
  } catch (error) {
4329
- return unusable(error instanceof Error ? error.message : String(error));
4589
+ if (error instanceof Refusal) return unusable(error.message);
4590
+ if (error instanceof WorkspaceReadError) return unusable(readRefusal(error, path));
4591
+ throw notStored(error);
4330
4592
  }
4331
4593
  }
4332
4594
 
@@ -4335,10 +4597,10 @@ export class WorkspaceModule implements Module {
4335
4597
  * and the agent's own is read-only, since it shows the agent's tool
4336
4598
  * presentation, which set_tool_visibility and set_tool_description change.
4337
4599
  */
4338
- private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
4600
+ private generatedFileRefusal(path: string, generated: { agentName: string }, callerAgentName: string | undefined): string {
4339
4601
  return callerAgentName !== generated.agentName
4340
- ? 'Generated file belongs to another agent'
4341
- : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4602
+ ? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
4603
+ : `${JSON.stringify(path)} is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.`;
4342
4604
  }
4343
4605
 
4344
4606
  /**
@@ -4362,12 +4624,13 @@ export class WorkspaceModule implements Module {
4362
4624
  conflicts: Map<EditTarget, string>,
4363
4625
  outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
4364
4626
  maxChars: number,
4627
+ reopen = false,
4365
4628
  ): ToolResult {
4366
4629
  const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4367
4630
  if (tool === 'edit') {
4368
4631
  const [target] = targets;
4369
4632
  const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
4370
- return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
4633
+ return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null, reopen);
4371
4634
  }
4372
4635
  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.";
4373
4636
  // The head is never cut, so what stands for the whole call goes in it,
@@ -4388,9 +4651,10 @@ export class WorkspaceModule implements Module {
4388
4651
  }
4389
4652
  } else if ('storeFailed' in outcome) {
4390
4653
  head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4391
- + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4654
+ + (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
4655
+ + (reopen ? ` ${AWAITS_REOPEN}` : '');
4392
4656
  } else {
4393
- head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4657
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
4394
4658
  }
4395
4659
  const kept = 'modeKept' in outcome
4396
4660
  ? targets.flatMap((target) => {
@@ -4405,47 +4669,553 @@ export class WorkspaceModule implements Module {
4405
4669
  return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
4406
4670
  }
4407
4671
 
4408
- private async handleDelete(input: DeleteInput): Promise<ToolResult> {
4409
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4410
- input = { ...input, path };
4411
- if (mount.config.mode === 'read-only') {
4412
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
4413
- }
4672
+ // ==========================================================================
4673
+ // Direct writes
4674
+ // ==========================================================================
4414
4675
 
4415
- const store = this.getStore();
4416
- let committed = false;
4417
- try {
4418
- return await this.withMount(mount, async (): Promise<ToolResult> => {
4419
- const { report, undecided } = await this.ensureSynced(mount, relativePath);
4420
- await this.settleBeforeMutation(mount, relativePath);
4421
- const entry = store.treeGet(mount.treeStateId, relativePath);
4422
- if (!entry) {
4423
- const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4424
- return { success: false, error, isError: true };
4676
+ /**
4677
+ * A direct mount's write of a whole file: its bytes staged beside it and
4678
+ * renamed over it (direct-write.ts), under the mount's turn, so the
4679
+ * module's own calls take turns. Answers a note when the file had other
4680
+ * hard links, which keep its old text, or why nothing was written. One
4681
+ * that lands is the caller's latest write on the mount (recordDirectWrite).
4682
+ * With `branch`, nothing is written once the store's branch is another,
4683
+ * checked with no turn of the event loop before the rename (writeBinary's).
4684
+ */
4685
+ private async writeDirect(
4686
+ mount: MountState,
4687
+ relativePath: string,
4688
+ path: string,
4689
+ bytes: Buffer,
4690
+ agentName: string | undefined,
4691
+ branch?: string,
4692
+ ): Promise<{ note: string | null } | { error: string; branchLeft?: true }> {
4693
+ return this.withMount(mount, async () => {
4694
+ const { view, rootReal } = await this.diskView(mount);
4695
+ let staged: Staged;
4696
+ try {
4697
+ staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4698
+ } catch (error) {
4699
+ return { error: directDidNothing('write', path, error) };
4700
+ }
4701
+ try {
4702
+ await directWriteTestSeam.afterStage?.([staged.path]);
4703
+ await placedStill(staged);
4704
+ // A write reads nothing first, so it replaces whatever is there.
4705
+ checkUnchanged(staged, null);
4706
+ if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
4707
+ await discard(staged);
4708
+ return { error: BRANCH_LEFT, branchLeft: true };
4425
4709
  }
4426
-
4427
- const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
4428
- () => { committed = true; });
4710
+ renameInto(staged);
4711
+ } catch (error) {
4712
+ await discard(staged);
4713
+ return { error: directDidNothing('write', path, error) };
4714
+ }
4715
+ this.recordDirectWrite(mount, agentName);
4716
+ return { note: linksNote(path, staged, 'write') };
4717
+ });
4718
+ }
4719
+
4720
+ /**
4721
+ * applyEdits on a direct mount, under the mount's turn, whose edits land
4722
+ * on disk or don't happen. Each attempt reads every file from disk as it
4723
+ * is now (directEditText), plans every edit against those bytes as on any
4724
+ * mount (planTargetEdits), stages every file beside itself, checks every
4725
+ * one unchanged since its read, and then renames each over its file in
4726
+ * turn (direct-write.ts). A file another writer changed between its read
4727
+ * and its check has the call read and planned again against its new
4728
+ * text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
4729
+ * longer apply to it, a file that changed at every attempt, or a file the
4730
+ * next read refuses as any read would (now past the size limit, say) are
4731
+ * refused, with nothing written. A rename that fails after others leaves
4732
+ * those written and the rest as they were, and the answer names each
4733
+ * file's outcome; nothing puts the written ones back.
4734
+ */
4735
+ private async applyDirectEdits(
4736
+ tool: EditTool,
4737
+ mount: MountState,
4738
+ targets: EditTarget[],
4739
+ early: EditProblem[],
4740
+ editCount: number,
4741
+ maxChars: number,
4742
+ agentName: string | undefined,
4743
+ ): Promise<ToolResult> {
4744
+ const changedMeanwhile = new Set<EditTarget>();
4745
+ return this.withMount(mount, async () => {
4746
+ for (let attempt = 1; ; attempt++) {
4747
+ const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
4748
+ if (outcome !== 'changed') return outcome;
4749
+ if (attempt === DIRECT_EDIT_ATTEMPTS) {
4750
+ const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
4751
+ // Each attempt stops at the first file it finds changed, so these are
4752
+ // the files some attempt found changed, at most one per attempt.
4753
+ return {
4754
+ success: false,
4755
+ isError: true,
4756
+ error: tool === 'edit'
4757
+ ? `${changed[0]} kept changing on disk while this edit was being applied (${DIRECT_EDIT_ATTEMPTS} attempts), so nothing was written. Read it again before retrying.`
4758
+ : `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
4759
+ + `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
4760
+ };
4761
+ }
4762
+ }
4763
+ });
4764
+ }
4765
+
4766
+ /**
4767
+ * One attempt of applyDirectEdits: its answer, or 'changed' when another
4768
+ * writer changed a file since its read, with nothing written. `early` are
4769
+ * the problems found before any file was read, which a refusal names with
4770
+ * the rest. The caller holds the mount's turn.
4771
+ */
4772
+ private async attemptDirectEdits(
4773
+ tool: EditTool,
4774
+ mount: MountState,
4775
+ targets: EditTarget[],
4776
+ early: EditProblem[],
4777
+ changedMeanwhile: Set<EditTarget>,
4778
+ editCount: number,
4779
+ maxChars: number,
4780
+ agentName: string | undefined,
4781
+ ): Promise<ToolResult | 'changed'> {
4782
+ const problems = [...early];
4783
+ const read = new Map<EditTarget, { bytes: Buffer; identity: FileIdentity }>();
4784
+ for (const target of targets) {
4785
+ const got = await this.directEditText(mount, target.relativePath, target.path);
4786
+ if ('changed' in got) {
4787
+ changedMeanwhile.add(target);
4788
+ return 'changed';
4789
+ }
4790
+ if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
4791
+ else read.set(target, got);
4792
+ }
4793
+ // Two paths that reach one file, through a link this mount follows, or
4794
+ // as two hard links (a disk that folds case has its two spellings made
4795
+ // one path by the parse, short of a directory link on the way, past
4796
+ // which they're refused here as one file named twice): each
4797
+ // would be planned against the same text and renamed over the same file
4798
+ // or split from it, so one's edits would be lost or kept apart from the
4799
+ // other's. They're refused. One file read twice has one identity here
4800
+ // unless a writer changed it between the reads, and the check before the
4801
+ // renames then sees that and reads again.
4802
+ const byIdentity = new Map<string, EditTarget>();
4803
+ for (const [target, { identity }] of read) {
4804
+ const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
4805
+ const other = byIdentity.get(key);
4806
+ if (!other) {
4807
+ byIdentity.set(key, target);
4808
+ continue;
4809
+ }
4810
+ problems.push({
4811
+ kind: 'file',
4812
+ edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
4813
+ path: other.path,
4814
+ text: `${other.path} and ${target.path} are one file, reached through a link or as two hard links; name it one way, so its edits are matched in one text`,
4815
+ whole: true,
4816
+ });
4817
+ }
4818
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4819
+ const hints = hintBudget();
4820
+ // As much as the largest file the call names holds, at least, so the
4821
+ // budget is the same whatever order its files are planned in.
4822
+ let largest = maxSize;
4823
+ for (const { bytes } of read.values()) largest = Math.max(largest, bytes.length);
4824
+ const counts = countBudget(largest);
4825
+ const plans: Array<{ target: EditTarget; plan: FilePlan }> = [];
4826
+ for (const target of targets) {
4827
+ const got = read.get(target);
4828
+ if (!got) continue;
4829
+ const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
4830
+ if (plan !== null) plans.push({ target, plan });
4831
+ }
4832
+ // A file read again for another writer's change says so, whichever way
4833
+ // its edits went.
4834
+ const reread = (target: EditTarget, verb: string): string | null => !changedMeanwhile.has(target) ? null
4835
+ : `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
4836
+ + `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
4837
+ const refuse = (found: EditProblem[]): ToolResult => ({
4838
+ success: false,
4839
+ isError: true,
4840
+ error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note): note is string => note !== null), maxChars, 'wrote'),
4841
+ });
4842
+ if (problems.length) return refuse(problems);
4843
+
4844
+ const { view, rootReal } = await this.diskView(mount);
4845
+ const staged: Array<{ target: EditTarget; staged: Staged }> = [];
4846
+ const discardAll = async (from = 0): Promise<void> => {
4847
+ for (const { staged: file } of staged.slice(from)) await discard(file);
4848
+ };
4849
+ // A refusal before any file is renamed says so where the call names several.
4850
+ const nothingWritten = (target: EditTarget, error: unknown): ToolResult => refuse([{
4851
+ kind: 'file',
4852
+ edits: target.edits,
4853
+ path: target.path,
4854
+ text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4855
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
4856
+ + (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
4857
+ whole: true,
4858
+ }]);
4859
+ for (const { target, plan } of plans) {
4860
+ try {
4861
+ staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
4862
+ } catch (error) {
4863
+ await discardAll();
4864
+ if (error instanceof FileChanged) {
4865
+ changedMeanwhile.add(target);
4866
+ return 'changed';
4867
+ }
4868
+ return nothingWritten(target, error);
4869
+ }
4870
+ }
4871
+ try {
4872
+ await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
4873
+ } catch (error) {
4874
+ await discardAll();
4875
+ throw error;
4876
+ }
4877
+ for (const { target, staged: file } of staged) {
4878
+ try {
4879
+ await placedStill(file);
4880
+ } catch (error) {
4881
+ await discardAll();
4882
+ return nothingWritten(target, error);
4883
+ }
4884
+ }
4885
+ // Every file is checked, then every file renamed, with no turn of the
4886
+ // event loop between: a change to any of them before its check is seen
4887
+ // before any is written, and one landing between its check and the last
4888
+ // rename isn't (checkUnchanged).
4889
+ for (const { target, staged: file } of staged) {
4890
+ try {
4891
+ checkUnchanged(file, read.get(target)!);
4892
+ } catch (error) {
4893
+ await discardAll();
4894
+ if (error instanceof FileChanged) {
4895
+ changedMeanwhile.add(target);
4896
+ return 'changed';
4897
+ }
4898
+ return nothingWritten(target, error);
4899
+ }
4900
+ }
4901
+ for (const [i, { target, staged: file }] of staged.entries()) {
4902
+ try {
4903
+ renameInto(file);
4904
+ } catch (error) {
4905
+ if (i === 0) {
4906
+ await discardAll();
4907
+ return nothingWritten(target, error);
4908
+ }
4909
+ // Read with no turn of the event loop since the last rename.
4910
+ this.recordDirectWrite(mount, agentName);
4911
+ await discardAll(i);
4912
+ return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
4913
+ }
4914
+ }
4915
+ this.recordDirectWrite(mount, agentName);
4916
+ return editReceipt(tool, staged.map(({ target, staged: file }) => ({
4917
+ path: target.path,
4918
+ plan: plans.find((p) => p.target === target)!.plan,
4919
+ notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
4920
+ modeKept: null,
4921
+ })), editCount, maxChars, 'done');
4922
+ }
4923
+
4924
+ /**
4925
+ * The answer to an edit_many call on a direct mount whose rename of its
4926
+ * `failed`th file failed after the ones before it were written: what was
4927
+ * written, the failure, and that nothing puts them back, then each file's
4928
+ * outcome, within `maxChars`, with the hard links a written one split
4929
+ * from, as a full answer names them (linksNote).
4930
+ */
4931
+ private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
4932
+ // The failed file is named among the outcomes, so the head stays as
4933
+ // long as its reason, whatever the paths' lengths.
4934
+ const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
4935
+ const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}.${can !== undefined ? ` Whoever runs this host can ${can}.` : ''} `
4936
+ + "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
4937
+ const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
4938
+ const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
4939
+ return fitted(head, parts, notes, maxChars, 'written');
4940
+ }
4941
+
4942
+ /**
4943
+ * A direct mount's file as an edit reads it: its bytes on disk as they are
4944
+ * now, through the mount's boundary as read reaches them
4945
+ * (openContainedFile), with the file's identity when the read began, which
4946
+ * the write checks it against just before its rename. A file changed while
4947
+ * it was read is `changed`, so the call reads it again. What the
4948
+ * workspace doesn't show as text, it doesn't edit either: a file over the
4949
+ * mount's size limit, and a binary one.
4950
+ */
4951
+ private async directEditText(
4952
+ mount: MountState,
4953
+ relativePath: string,
4954
+ path: string,
4955
+ ): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
4956
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4957
+ const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
4958
+ let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
4959
+ try {
4960
+ opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4961
+ if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
4962
+ });
4963
+ } catch (err) {
4964
+ if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
4965
+ if (err instanceof Refusal) return { error: err.message };
4966
+ if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
4967
+ throw err;
4968
+ }
4969
+ try {
4970
+ const before = identityOf(await opened.handle.stat({ bigint: true }));
4971
+ const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4972
+ if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4973
+ if (looksBinary(bytes)) {
4974
+ return { error: `${JSON.stringify(path)} is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.` };
4975
+ }
4976
+ if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
4977
+ return { bytes, identity: before };
4978
+ } catch (err) {
4979
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4980
+ } finally {
4981
+ await opened.handle.close();
4982
+ }
4983
+ }
4984
+
4985
+ /**
4986
+ * A direct mount's deletion: the file the read tools reach, by their
4987
+ * refusals (openContainedFile), unlinked through the mount's boundary as
4988
+ * a push's deletion unlinks one (unlinkContained). A followed link is the
4989
+ * entry removed, as there. One that lands is the caller's latest write on
4990
+ * the mount (recordDirectWrite).
4991
+ */
4992
+ private async deleteDirect(mount: MountState, relativePath: string, path: string, agentName: string | undefined): Promise<ToolResult> {
4993
+ return this.withMount(mount, async (): Promise<ToolResult> => {
4994
+ // One root for the call, by its identity as the call starts: the
4995
+ // open's checks and the unlink's own refuse any other directory there,
4996
+ // in ROOT_MOVED's words, so a root replaced after this stat is refused
4997
+ // rather than taken, but in the instant between the unlink's last
4998
+ // check and the unlink, which Node can't close (effects.ts's header).
4999
+ // One that can't be read now is refused here.
5000
+ const disk = await this.diskView(mount);
5001
+ let rootStat: BigIntStats;
5002
+ try {
5003
+ rootStat = await stat(disk.rootReal, { bigint: true });
5004
+ } catch (err) {
5005
+ const code = errnoOf(err);
5006
+ const why = code === 'ENOENT' || code === 'ENOTDIR'
5007
+ ? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
5008
+ : `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
5009
+ return { success: false, isError: true, error: didNothing('delete', why, path) };
5010
+ }
5011
+ const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
5012
+ const { rootReal } = disk;
5013
+ try {
5014
+ const opened = await this.openContainedFile(mount, relativePath, path);
5015
+ await opened.handle.close();
5016
+ } catch (err) {
5017
+ // The open's own words for a replaced root speak of reading.
5018
+ if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
5019
+ return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
5020
+ }
5021
+ if (err instanceof WorkspaceReadError) return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
5022
+ throw err;
5023
+ }
5024
+ // The unlink is awaited, unlike a write's rename, so where the head
5025
+ // was just before it says whether the head moved while it ran.
5026
+ const since = this.positionNow();
5027
+ try {
5028
+ await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
5029
+ } catch (error) {
5030
+ // A failure after the unlink reached the file's entry can't say
5031
+ // whether the file is gone, so it's taken as the agent's write too.
5032
+ if (error instanceof EffectFailed && error.touched) {
5033
+ this.recordDirectWrite(mount, agentName, since);
5034
+ return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
5035
+ }
5036
+ return { success: false, isError: true, error: directDidNothing('delete', path, error) };
5037
+ }
5038
+ this.recordDirectWrite(mount, agentName, since);
5039
+ return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
5040
+ });
5041
+ }
5042
+
5043
+ /**
5044
+ * After a direct write lands: the agent's latest write on the mount, P,
5045
+ * moves to it, where the agent's history holds P or the notice of its
5046
+ * departure from P, N (historyHoldsWrites), and N clears. Otherwise P
5047
+ * stays where it is: the head moved away from it under the call's turn,
5048
+ * and the next start's notice covers what it left (headMoveNotice). P is
5049
+ * the branch and the store's head as it is now, read with no turn of the
5050
+ * event loop since the write, so no record that lands after the write is
5051
+ * taken for one before it. A deletion, whose unlink is awaited, passes
5052
+ * where the head was just before its unlink (`since`): where the branch
5053
+ * changed meanwhile, the head moved under the call, and P is set there,
5054
+ * so the next start judges the deletion by the branch its turn was on,
5055
+ * unless the history doesn't hold P already, whose notice is owed. A write no agent
5056
+ * made tells no one. The store's reads here come after the write landed,
5057
+ * so one that throws is logged and P stays, as for a history that can't
5058
+ * be read, and the write still answers that it landed.
5059
+ */
5060
+ private recordDirectWrite(mount: MountState, agentName: string | undefined, since?: DirectPosition | null): void {
5061
+ if (agentName === undefined) return;
5062
+ try {
5063
+ const store = this.getStore();
5064
+ let heads = this.directHeads.get(mount.config.name);
5065
+ if (!heads) this.directHeads.set(mount.config.name, (heads = new Map()));
5066
+ const head = heads.get(agentName);
5067
+ const branchId = store.currentBranch().id;
5068
+ if (since && since.branchId !== branchId) {
5069
+ if (!head || this.historyHoldsWrites(agentName, head) === true) heads.set(agentName, { ...since, notice: null });
5070
+ return;
5071
+ }
5072
+ if (head && this.historyHoldsWrites(agentName, head) !== true) return;
5073
+ heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
5074
+ } catch (err) {
5075
+ console.error('[workspace] could not record a direct write\'s position:', err);
5076
+ }
5077
+ }
5078
+
5079
+ /** The store's branch and head now, or null where the store can't say. */
5080
+ private positionNow(): DirectPosition | null {
5081
+ try {
5082
+ const store = this.getStore();
5083
+ return { branchId: store.currentBranch().id, after: store.currentSequence() };
5084
+ } catch {
5085
+ return null;
5086
+ }
5087
+ }
5088
+
5089
+ /**
5090
+ * Whether an agent's history holds its latest direct write on a mount
5091
+ * (holdsPosition), or the notice of its departure from it, judged by the
5092
+ * notice's own presence there, through the lookup the framework hands the
5093
+ * module (useHistory); null where that lookup threw, which is logged. A
5094
+ * history that can't be read decides nothing: a write takes it as not
5095
+ * held, so its position stays and the notice stays owed, and a start
5096
+ * gives no notice, which may not be true of a history it can't read, and
5097
+ * leaves the next start to decide (headMoveNotice).
5098
+ */
5099
+ private historyHoldsWrites(agentName: string, head: DirectHead): boolean | null {
5100
+ if (this.holdsPosition(head)) return true;
5101
+ if (head.notice === null) return false;
5102
+ try {
5103
+ return this.historyLookup?.(agentName, head.notice) === true;
5104
+ } catch (err) {
5105
+ console.error('[workspace] history lookup for the head-move notice failed:', err);
5106
+ return null;
5107
+ }
5108
+ }
5109
+
5110
+ /**
5111
+ * Whether the store's current branch holds what came before a direct
5112
+ * write: it's the write's branch, or continues it with every fork point on
5113
+ * the way after `after`, the store's head when the write landed. A fork at
5114
+ * exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
5115
+ * which is the head at the write when nothing was recorded in the turn
5116
+ * before it. A missing branch or one without fork metadata holds nothing,
5117
+ * as for the materialize guard (isLinearContinuation).
5118
+ */
5119
+ private holdsPosition({ branchId, after }: DirectHead): boolean {
5120
+ const store = this.getStore();
5121
+ const current = store.currentBranch();
5122
+ if (current.id === branchId) return true;
5123
+ const byId = new Map(store.listBranches().map((b) => [b.id, b]));
5124
+ const visited = new Set<string>();
5125
+ let cursor = byId.get(current.id);
5126
+ while (cursor && !visited.has(cursor.id)) {
5127
+ visited.add(cursor.id);
5128
+ if (cursor.id === branchId) return true;
5129
+ if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after) return false;
5130
+ cursor = byId.get(cursor.parentId);
5131
+ }
5132
+ return false;
5133
+ }
5134
+
5135
+ /**
5136
+ * The framework's check at every start of an agent's turn, opening,
5137
+ * continuing or resuming: the notice due for each mount the agent wrote
5138
+ * direct where its history holds neither its latest write there nor the
5139
+ * notice of its departure from it (historyHoldsWrites), with those
5140
+ * mounts' names; or null, as on a head that hasn't moved. The framework
5141
+ * stores the text in the agent's history before the turn's compile and
5142
+ * hands its message id back (headMoveNoticeStored).
5143
+ */
5144
+ headMoveNotice(agentName: string): { text: string; mounts: string[] } | null {
5145
+ if (!this.store) return null;
5146
+ const due: string[] = [];
5147
+ // Every mount where the agent wrote direct, whether it runs direct this
5148
+ // session or as 'chronicle': the writes are on disk either way.
5149
+ for (const name of this.mounts.keys()) {
5150
+ const head = this.directHeads.get(name)?.get(agentName);
5151
+ if (!head) continue;
5152
+ const held = this.historyHoldsWrites(agentName, head);
5153
+ // A history that can't be read decides nothing at this start.
5154
+ if (held === null) return null;
5155
+ if (!held) due.push(name);
5156
+ }
5157
+ return due.length ? { text: headMoveText(due), mounts: due } : null;
5158
+ }
5159
+
5160
+ /** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
5161
+ headMoveNoticeStored(agentName: string, mounts: readonly string[], messageId: string): void {
5162
+ for (const name of mounts) {
5163
+ const head = this.directHeads.get(name)?.get(agentName);
5164
+ if (head) head.notice = messageId;
5165
+ }
5166
+ }
5167
+
5168
+ /**
5169
+ * How the module asks whether an agent's history holds a message, as the
5170
+ * framework judges its own notices (`cm.getMessage(id) !== null`): handed
5171
+ * over once, when the module is added. Without it, no notice is held.
5172
+ */
5173
+ useHistory(lookup: (agentName: string, messageId: string) => boolean): void {
5174
+ this.historyLookup = lookup;
5175
+ }
5176
+
5177
+ private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
5178
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
5179
+ if (relativePath === '') return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
5180
+ input = { ...input, path };
5181
+ const refusal = writeRefusal(mount, this.writableNames());
5182
+ if (refusal) return { success: false, error: didNothing('delete', refusal), isError: true };
5183
+ if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
5184
+
5185
+ const store = this.getStore();
5186
+ let committed = false;
5187
+ try {
5188
+ return await this.withMount(mount, async (): Promise<ToolResult> => {
5189
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
5190
+ await this.settleBeforeMutation(mount, relativePath);
5191
+ const entry = store.treeGet(mount.treeStateId, relativePath);
5192
+ if (!entry) {
5193
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
5194
+ return { success: false, error: didNothing('delete', error), isError: true };
5195
+ }
5196
+
5197
+ const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
5198
+ () => { committed = true; });
4429
5199
  if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
4430
5200
 
4431
5201
  // Its whole receipt is what it changed: kept in the stub if the guard
4432
5202
  // withholds the result (agent-framework #277).
4433
5203
  return {
4434
5204
  success: true,
4435
- data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
4436
- keepWhenWithheld: ['path', 'deleted', 'note'],
5205
+ data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
5206
+ keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
4437
5207
  };
4438
5208
  });
4439
5209
  } catch (error) {
4440
- if (!committed) throw error;
5210
+ if (!committed) throw notStored(error);
4441
5211
  if (error instanceof StoredThenFailed) {
4442
5212
  // Its note, as the success answer's: a deletion that cleared a
4443
5213
  // conflict naming a disk file unlinked nothing.
4444
5214
  const note = error.notes.get(relativePath);
4445
5215
  return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
4446
- note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
5216
+ note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
4447
5217
  }
4448
- return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
5218
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
4449
5219
  }
4450
5220
  }
4451
5221
 
@@ -4458,7 +5228,9 @@ export class WorkspaceModule implements Module {
4458
5228
  const mounts = [...this.mounts.entries()].map(([name, m]) => ({
4459
5229
  name,
4460
5230
  path: m.config.path,
4461
- mode: m.config.mode,
5231
+ // A mount configured direct says its mode where its config names
5232
+ // none, so a writable one shows as writable.
5233
+ mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
4462
5234
  ...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
4463
5235
  ...(m.config.include ? { include: [...m.config.include] } : {}),
4464
5236
  }));
@@ -4469,7 +5241,7 @@ export class WorkspaceModule implements Module {
4469
5241
  // A path outside the mount's include that's no scope (refusesAsOutside)
4470
5242
  // is refused, as glob refuses it, and shown as given.
4471
5243
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4472
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5244
+ return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4473
5245
  }
4474
5246
  input = { ...input, path };
4475
5247
  const recursive = input.recursive === true;
@@ -4479,6 +5251,9 @@ export class WorkspaceModule implements Module {
4479
5251
  // shows disk's.
4480
5252
  const listing = await this.listingOf(mount, relativePath, recursive);
4481
5253
  const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
5254
+ const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
5255
+ const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
5256
+ const noted = note !== null ? { note } : {};
4482
5257
  if (recursive) {
4483
5258
  return {
4484
5259
  success: true,
@@ -4487,6 +5262,7 @@ export class WorkspaceModule implements Module {
4487
5262
  entries: listing.files,
4488
5263
  count: listing.files.length,
4489
5264
  ...incomplete,
5265
+ ...noted,
4490
5266
  },
4491
5267
  };
4492
5268
  }
@@ -4504,10 +5280,34 @@ export class WorkspaceModule implements Module {
4504
5280
  entries: children,
4505
5281
  count: children.length,
4506
5282
  ...incomplete,
5283
+ ...noted,
4507
5284
  },
4508
5285
  };
4509
5286
  }
4510
5287
 
5288
+ private async emptyScopeNote(mount: MountState, relativePath: string, path: string): Promise<string | null> {
5289
+ const shown = JSON.stringify(path);
5290
+ const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
5291
+ const { view, rootReal } = await this.diskView(mount);
5292
+ const seen = await observePath(view, rootReal, relativePath);
5293
+ if (seen.kind === 'file') return isFile;
5294
+ if (seen.kind !== 'absent') return null;
5295
+ if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) return isFile;
5296
+ return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
5297
+ }
5298
+
5299
+ private async absentOnDisk(mount: MountState, relativePath: string): Promise<boolean> {
5300
+ const { view, rootReal } = await this.diskView(mount);
5301
+ return (await observePath(view, rootReal, relativePath)).kind === 'absent';
5302
+ }
5303
+
5304
+ private holdsAnythingAt(mount: MountState, relativePath: string): boolean {
5305
+ if (mount.direct) return false;
5306
+ const store = this.getStore();
5307
+ const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree): tree is string => tree !== undefined);
5308
+ return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
5309
+ }
5310
+
4511
5311
  /**
4512
5312
  * What a listing shows of a directory scope, from where the mount keeps
4513
5313
  * what it reads: the store-backed kind's pass (listingFromStore), or, on a
@@ -4611,7 +5411,7 @@ export class WorkspaceModule implements Module {
4611
5411
  if (input.path) {
4612
5412
  const { mount, relativePath, moved, root } = mountsToSearch[0]!;
4613
5413
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4614
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5414
+ return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4615
5415
  }
4616
5416
  }
4617
5417
 
@@ -4619,15 +5419,26 @@ export class WorkspaceModule implements Module {
4619
5419
  // starts there: past the walk's cap, a walk of the whole scope can stop
4620
5420
  // before it gets there. In the mount a pattern's first segment names, the
4621
5421
  // pattern is read mount-prefixed (prefixedGlob), from its rest's directory
4622
- // within `path`.
5422
+ // within `path`. A `path` that directory can't overlap leaves no file the
5423
+ // pattern can match, so the glob is refused, as grep's is, saying how
5424
+ // the pattern is read, rather than answered with no matches; but a path
5425
+ // in another mount's directory is that mount's, and is said so, as grep
5426
+ // says it. Only a `path` can do either, and it names one mount, so
5427
+ // nothing has been searched yet.
4623
5428
  const patternDir = literalDirOf(input.pattern);
4624
- for (const { mount, relativePath } of mountsToSearch) {
5429
+ for (const { mount, relativePath, moved, root } of mountsToSearch) {
4625
5430
  const prefixed = prefixedGlob(input.pattern, mount.config.name);
4626
5431
  let start: string;
4627
5432
  let matched: (path: string) => boolean;
4628
5433
  if (prefixed) {
4629
5434
  const dir = deeperOf(relativePath, prefixed.dir);
4630
- if (dir === null) continue;
5435
+ if (dir === null) {
5436
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5437
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5438
+ continue;
5439
+ }
5440
+ return { success: false, error: await this.disjointRefusal('glob', input.path!, input.pattern, true, mount, relativePath, root), isError: true };
5441
+ }
4631
5442
  start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
4632
5443
  matched = (path: string) => prefixed.regex.test(path);
4633
5444
  } else {
@@ -4657,16 +5468,32 @@ export class WorkspaceModule implements Module {
4657
5468
  try {
4658
5469
  regex = new RegExp(input.pattern);
4659
5470
  } catch (e) {
4660
- // The parser's reason says where the pattern goes wrong.
4661
- return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
5471
+ // V8's message ends with the parser's reason, after the pattern: "Invalid
5472
+ // regular expression: /foo(/: Unterminated group".
5473
+ const message = e instanceof Error ? e.message : String(e);
5474
+ const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
5475
+ return {
5476
+ success: false,
5477
+ isError: true,
5478
+ error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
5479
+ + 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
5480
+ };
4662
5481
  }
4663
5482
 
4664
5483
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4665
- // The filter matches mount-relative paths, so every file it lets through
4666
- // lies beneath its literal directory, and the walk starts at the deeper of
4667
- // that and `path`. In the mount a filter's first segment names, its
4668
- // mount-prefixed reading takes the filter's place (below).
5484
+ // The filter matches mount-relative paths, from the mount's root whatever
5485
+ // the `path`, so every file it lets through lies beneath its literal
5486
+ // directory, and the walk starts at the deeper of that and `path`. In the
5487
+ // mount a filter's first segment names, its mount-prefixed reading takes
5488
+ // the filter's place (below). One with no "/" also matches a file's name,
5489
+ // wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
5490
+ // file through, where tested against the path alone it let through only
5491
+ // those at the mount's root. The path is still tried, so a "**" in such a
5492
+ // filter spans directories as it did, and nothing it let through before
5493
+ // is lost. A filter read mount-prefixed always holds a "/" after the
5494
+ // mount's name, so it never takes the name test.
4669
5495
  const filterDir = input.glob ? literalDirOf(input.glob) : '';
5496
+ const byName = fileGlob !== null && !input.glob!.includes('/');
4670
5497
  const contextBefore = input.contextBefore ?? 0;
4671
5498
  const contextAfter = input.contextAfter ?? 0;
4672
5499
 
@@ -4678,17 +5505,33 @@ export class WorkspaceModule implements Module {
4678
5505
  if (input.path) {
4679
5506
  const { mount, relativePath, moved, root } = mountsToSearch[0]!;
4680
5507
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4681
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5508
+ return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4682
5509
  }
4683
5510
  }
4684
5511
 
4685
5512
  type GrepMatch = { line: number; text: string; context?: string[] };
4686
- const results: Array<{ file: string; state?: string; version: GrepText['version']; matches: GrepMatch[] }> = [];
5513
+ type Searched = { lineEndings?: 'CRLF' | 'mixed'; matches: GrepMatch[] };
5514
+ const results: Array<{ file: string; state?: string; version: GrepText['version'] } & Searched> = [];
4687
5515
  const skipped: GrepSkip[] = [];
4688
5516
  const incomplete: Array<{ path: string; reason: string }> = [];
4689
5517
 
4690
- const search = (content: string): GrepMatch[] => {
4691
- const lines = linesOf(content);
5518
+ // Lines as read shows them (fileText): a CRLF end's CR is left out, for
5519
+ // matching as well as display, unless the line's own text ends in a CR, so
5520
+ // `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
5521
+ // the line copies into an edit as read's do. ripgrep's --crlf still prints
5522
+ // the line with its CR.
5523
+ //
5524
+ // Whether a file has CRLF ends or a BOM is a fact about the file, not a
5525
+ // line, so each searched file that has either is kept here by its path,
5526
+ // for the note on a pattern that names a CR or U+FEFF to name it. A
5527
+ // conflict's disk version is kept, for each, only where the workspace's
5528
+ // copy of its path doesn't have it, and is named as that version, since
5529
+ // read doesn't show it.
5530
+ const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
5531
+ const bomFiles = new Map<string, GrepText['version']>();
5532
+ const search = (text: GrepText): Searched & { bom: boolean } => {
5533
+ const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5534
+ const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
4692
5535
  const found: GrepMatch[] = [];
4693
5536
  for (let i = 0; i < lines.length; i++) {
4694
5537
  if (regex.test(lines[i])) {
@@ -4701,7 +5544,7 @@ export class WorkspaceModule implements Module {
4701
5544
  found.push(match);
4702
5545
  }
4703
5546
  }
4704
- return found;
5547
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
4705
5548
  };
4706
5549
 
4707
5550
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
@@ -4709,49 +5552,91 @@ export class WorkspaceModule implements Module {
4709
5552
  // is that mount's: grep searches nothing of this mount's there, and
4710
5553
  // says whose it is. A direct mount's is judged by the nesting its parse
4711
5554
  // found where its root lies now (movedNesting).
4712
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5555
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
4713
5556
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4714
5557
  continue;
4715
5558
  }
4716
5559
  // In the mount a filter's first segment names, the filter is read
4717
5560
  // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
4718
- // a filter whose directories don't overlap leave no file the filter can
4719
- // let through, so nothing is searched.
5561
+ // a filter whose directories, in the reading this mount takes, don't
5562
+ // overlap leave no file the filter can let through, so the grep is
5563
+ // refused, saying how the filter is read, rather than answered with no
5564
+ // matches: written relative to the `path`, as glob's pattern is, it
5565
+ // names nothing there. Only a `path` can do this, and it names one
5566
+ // mount, so nothing has been searched yet.
4720
5567
  const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
4721
5568
  const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
4722
- if (deeper === null) continue;
5569
+ if (deeper === null) {
5570
+ return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
5571
+ }
4723
5572
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
4724
- const searched = (text: GrepText): void => {
4725
- const matches = search(text.text);
4726
- if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
5573
+ const searched = (text: GrepText): (() => void) => {
5574
+ const { bom, ...hits } = search(text);
5575
+ const { file, state, version } = text;
5576
+ return () => {
5577
+ if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version });
5578
+ if (bom && !bomFiles.has(file)) bomFiles.set(file, version);
5579
+ if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
5580
+ };
4727
5581
  };
4728
5582
  const kept = prefixed
4729
5583
  ? (path: string): boolean => prefixed.regex.test(path)
4730
- : (path: string): boolean => fileGlob === null || fileGlob.test(path);
5584
+ : (path: string): boolean => fileGlob === null || fileGlob.test(path)
5585
+ || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
4731
5586
  const found = mount.direct
4732
- ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
5587
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
4733
5588
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
4734
5589
  skipped.push(...found.skipped);
4735
5590
  incomplete.push(...found.incomplete);
4736
5591
  }
4737
5592
 
4738
- return {
4739
- success: true,
4740
- data: {
4741
- pattern: input.pattern,
4742
- results,
4743
- totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
4744
- ...(skipped.length > 0 ? { skipped } : {}),
4745
- ...(incomplete.length > 0 ? { incomplete } : {}),
4746
- },
5593
+ const answer: GrepAnswer = {
5594
+ pattern: input.pattern,
5595
+ results,
5596
+ totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
5597
+ // A pattern written for a CR can't find a CRLF end's, nor one written
5598
+ // for U+FEFF a BOM, which lines as read shows them leave out, so the
5599
+ // answer says why it didn't, and names the files that have them.
5600
+ ...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
5601
+ ...(skipped.length > 0 ? { skipped } : {}),
5602
+ ...(incomplete.length > 0 ? { incomplete } : {}),
4747
5603
  };
5604
+ return textAnswer(grepText(answer, contextBefore), { ...answer });
5605
+ }
5606
+
5607
+ /**
5608
+ * Why a grep or a glob whose `path` (`given`, in the mount at
5609
+ * `relativePath`) and filter or pattern can't overlap did nothing. grep's
5610
+ * filter is matched from the mount's root, and in the mount its first
5611
+ * segment names, either tool's is read mount-prefixed (`prefixed`), from
5612
+ * that mount's root; either way no file at or beneath the path can pass
5613
+ * it. Where the path isn't a file, it names the one that reaches beneath
5614
+ * it, the one meant if it was written relative to the path, mount-prefixed
5615
+ * as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
5616
+ * `?`, which matches each of them, and since the search stays beneath the
5617
+ * path, a `?` that would match a sibling's name too takes nothing in from
5618
+ * outside it. One with an empty, `.` or `..` segment gets no suggestion,
5619
+ * since no path holds such a segment. Segments are read as written, so one
5620
+ * a brace group splits around isn't told apart.
5621
+ */
5622
+ private async disjointRefusal(tool: 'grep' | 'glob', given: string, filter: string, prefixed: boolean, mount: MountState, relativePath: string, root: string): Promise<string> {
5623
+ const name = mount.config.name;
5624
+ const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
5625
+ const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
5626
+ + (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
5627
+ if (await this.namesFile(mount, relativePath, root)) return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
5628
+ const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
5629
+ if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) return within;
5630
+ const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
5631
+ return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
4748
5632
  }
4749
5633
 
4750
5634
  /**
4751
5635
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
4752
5636
  * single file `relativePath` names, those `keep` lets through, each handed
4753
- * to `each` in path order: a pass over the scope, then each path's
4754
- * workspace copy, labelled with its state, and a conflict's disk side.
5637
+ * to `search`, whose answers are recorded in path order: a pass over the
5638
+ * scope, then each path's workspace copy, labelled with its state, and a
5639
+ * conflict's disk side.
4755
5640
  * What it doesn't search is in `skipped`, and what it couldn't see in
4756
5641
  * `incomplete`, each mount-prefixed.
4757
5642
  */
@@ -4760,7 +5645,7 @@ export class WorkspaceModule implements Module {
4760
5645
  relativePath: string,
4761
5646
  start: string,
4762
5647
  keep: (path: string) => boolean,
4763
- each: (text: GrepText) => void,
5648
+ search: (text: GrepText) => () => void,
4764
5649
  ): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
4765
5650
  const store = this.getStore();
4766
5651
  const skipped: GrepSkip[] = [];
@@ -4813,16 +5698,18 @@ export class WorkspaceModule implements Module {
4813
5698
  }
4814
5699
 
4815
5700
  const intents = this.intents.get(mount.config.name)!;
5701
+ type Lost = Omit<GrepText, 'bytes'> & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' };
5702
+ const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = [];
4816
5703
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
4817
5704
  if (!keep(report.path)) continue;
4818
5705
  const file = `${mount.config.name}/${report.path}`;
4819
5706
  if (report.state === 'disk-only') {
4820
- skipped.push({
5707
+ found.push({ skip: {
4821
5708
  file,
4822
5709
  reason: 'disk-only: binary or over the size limit, never stored',
4823
5710
  ...(report.size !== undefined ? { size: report.size } : {}),
4824
5711
  ...(report.mimeType ? { mimeType: report.mimeType } : {}),
4825
- });
5712
+ } });
4826
5713
  continue;
4827
5714
  }
4828
5715
  // A path in another mount's directory is that mount's: grep gives
@@ -4831,16 +5718,16 @@ export class WorkspaceModule implements Module {
4831
5718
  if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
4832
5719
  const refusal = refused.get(report.path);
4833
5720
  if (refusal !== undefined) {
4834
- skipped.push({ file, reason: refusal });
5721
+ found.push({ skip: { file, reason: refusal } });
4835
5722
  continue;
4836
5723
  }
4837
5724
  // The workspace's version, labelled with the path's state.
4838
5725
  const entry = store.treeGet(mount.treeStateId, report.path);
4839
- const blob = entry ? store.getBlob(entry.blobHash) : null;
4840
- if (blob) {
4841
- each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
4842
- } else if (entry) {
4843
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
5726
+ if (entry) {
5727
+ const stored = storedBytes(store, entry.blobHash);
5728
+ found.push('bytes' in stored
5729
+ ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
5730
+ : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
4844
5731
  }
4845
5732
  if (report.state !== 'conflict') continue;
4846
5733
  // A conflict's disk side. The version recorded with the conflict is
@@ -4850,21 +5737,37 @@ export class WorkspaceModule implements Module {
4850
5737
  // disk version was never stored either.
4851
5738
  const disk = intents.get(report.path)?.conflict?.disk;
4852
5739
  const changed = report.conflict?.diskChangedSinceRecorded === true;
4853
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
4854
- if (diskBlob) {
4855
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
4856
- }
4857
- if (disk?.stored && !diskBlob) {
4858
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
5740
+ if (disk?.stored) {
5741
+ const version = changed ? 'recorded-disk' : 'conflicting-disk';
5742
+ const stored = storedBytes(store, disk.stored);
5743
+ found.push('bytes' in stored
5744
+ ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
5745
+ : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
4859
5746
  }
4860
5747
  if (changed) {
4861
5748
  if (report.diskNow === 'file') {
4862
- 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' });
5749
+ found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } });
4863
5750
  }
4864
5751
  } else if (disk && !disk.stored) {
4865
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
5752
+ found.push({ skip: { file, reason: `conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; ${settleConflict('file', mount.config.mode === 'read-only').replace(/\.$/, '')}`, size: disk.size } });
4866
5753
  }
4867
5754
  }
5755
+ const answers = new Map<Lost, { searched: () => void } | { skip: GrepSkip }>();
5756
+ await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
5757
+ const { file, state, version, what } = lost;
5758
+ answers.set(lost, 'bytes' in contents
5759
+ ? { searched: search({ file, state, version, bytes: contents.bytes }) }
5760
+ : { skip: {
5761
+ file,
5762
+ reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
5763
+ + `and ${lostOnDisk(contents.lost)}, so it was not searched`,
5764
+ } });
5765
+ });
5766
+ for (const item of found) {
5767
+ const answer = 'lost' in item ? answers.get(item.lost)! : item;
5768
+ if ('searched' in answer) answer.searched();
5769
+ else skipped.push(answer.skip);
5770
+ }
4868
5771
  });
4869
5772
  return { skipped, incomplete };
4870
5773
  }
@@ -4905,7 +5808,7 @@ export class WorkspaceModule implements Module {
4905
5808
  const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
4906
5809
  if (read.bytes === null) skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
4907
5810
  else if (looksBinary(read.head)) skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
4908
- else each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
5811
+ else each({ file, version: 'disk', bytes: read.bytes });
4909
5812
  });
4910
5813
  return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
4911
5814
  }
@@ -4914,7 +5817,7 @@ export class WorkspaceModule implements Module {
4914
5817
  const store = this.getStore();
4915
5818
  const status: Record<string, unknown> = {};
4916
5819
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
4917
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5820
+ return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
4918
5821
  }
4919
5822
 
4920
5823
  for (const [name, mount] of this.mounts) {
@@ -4922,7 +5825,7 @@ export class WorkspaceModule implements Module {
4922
5825
  if (mount.direct) {
4923
5826
  // A direct mount keeps nothing to count: its kind, and the ignore
4924
5827
  // list its walks go by, whose default no config shows.
4925
- status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
5828
+ status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
4926
5829
  continue;
4927
5830
  }
4928
5831
  // What the workspace's history holds outside the mount's include isn't the mount's,
@@ -4937,7 +5840,7 @@ export class WorkspaceModule implements Module {
4937
5840
  const currentBranch = store.currentBranch();
4938
5841
  status[name] = {
4939
5842
  path: mount.config.path,
4940
- mode: mount.config.mode,
5843
+ mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
4941
5844
  // A mount configured direct runs as 'chronicle' this session
4942
5845
  // (chooseKinds), by the direct kind's ignore list where its config
4943
5846
  // names none.
@@ -5057,9 +5960,6 @@ export class WorkspaceModule implements Module {
5057
5960
  */
5058
5961
  private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
5059
5962
  const store = this.getStore();
5060
- const agreement = this.agreementOrThrow();
5061
- const name = mount.config.name;
5062
- const intents = this.intents.get(name)!;
5063
5963
  const owed: string[] = [];
5064
5964
  const unchecked: string[] = [];
5065
5965
  let agreed = 0;
@@ -5068,17 +5968,32 @@ export class WorkspaceModule implements Module {
5068
5968
  // Outside the mount's include, or in another mount's directory nested
5069
5969
  // in it: neither owed to disk nor counted as on it.
5070
5970
  if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
5071
- const p = agreement.get(name, e.path);
5072
- if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
5073
- else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
5074
- // A mode the store records, other than the one disk last agreed with,
5075
- // where modes are observed: elsewhere no push applies one.
5076
- else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
5971
+ const kind = this.evidenceOf(mount, e);
5972
+ if (kind === 'owed') owed.push(e.path);
5973
+ else if (kind === 'unchecked') unchecked.push(e.path);
5077
5974
  else agreed++;
5078
5975
  }
5079
5976
  return { owed, unchecked, agreed };
5080
5977
  }
5081
5978
 
5979
+ private evidenceOf(mount: MountState, e: { path: string; blobHash: string; mode: number }): 'owed' | 'unchecked' | 'agreed' {
5980
+ const p = this.agreementOrThrow().get(mount.config.name, e.path);
5981
+ if (p === undefined) return this.intents.get(mount.config.name)!.get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
5982
+ if (p.kind !== 'content' || p.hash !== e.blobHash) return 'owed';
5983
+ // A mode the store records, other than the one disk last agreed with,
5984
+ // where modes are observed: elsewhere no push applies one.
5985
+ if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) return 'owed';
5986
+ return 'agreed';
5987
+ }
5988
+
5989
+ private diskOf(mount: MountState, relativePath: string): DiskState {
5990
+ const intent = this.intents.get(mount.config.name)!.get(relativePath);
5991
+ if (intent?.conflict) return 'in conflict with disk';
5992
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
5993
+ if (!entry) return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
5994
+ return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
5995
+ }
5996
+
5082
5997
  private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
5083
5998
  const store = this.getStore();
5084
5999
 
@@ -5095,7 +6010,7 @@ export class WorkspaceModule implements Module {
5095
6010
  // is refused; beneath a directory, the push leaves out what the include
5096
6011
  // doesn't cover.
5097
6012
  if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
5098
- return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
6013
+ return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
5099
6014
  }
5100
6015
  }
5101
6016
 
@@ -5105,12 +6020,13 @@ export class WorkspaceModule implements Module {
5105
6020
  } else if (input.mount) {
5106
6021
  const m = this.mounts.get(input.mount);
5107
6022
  if (!m) {
5108
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
6023
+ return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
5109
6024
  }
5110
6025
  mountsToMaterialize = [{ name: input.mount, mount: m }];
5111
6026
  } else {
6027
+ // Not a mount running direct, whose writes are on disk already.
5112
6028
  mountsToMaterialize = [...this.mounts.entries()]
5113
- .filter(([, m]) => m.config.mode === 'read-write')
6029
+ .filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
5114
6030
  .map(([name, mount]) => ({ name, mount }));
5115
6031
  }
5116
6032
 
@@ -5131,6 +6047,12 @@ export class WorkspaceModule implements Module {
5131
6047
  let proceeded = 0;
5132
6048
 
5133
6049
  for (const { name, mount } of mountsToMaterialize) {
6050
+ // Only a writable direct mount the caller named gets here: it keeps no
6051
+ // tree to push. A read-only one is named as any read-only mount is.
6052
+ if (mount.direct && mount.config.mode !== 'read-only') {
6053
+ blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
6054
+ continue;
6055
+ }
5134
6056
  if (mount.config.mode === 'read-only') {
5135
6057
  // Only a mount the caller named gets here read-only (a bare
5136
6058
  // materialize takes the read-write ones): say why nothing was written.
@@ -5206,16 +6128,21 @@ export class WorkspaceModule implements Module {
5206
6128
  if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
5207
6129
  return {
5208
6130
  success: false,
5209
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
6131
+ error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
5210
6132
  isError: true,
5211
6133
  };
5212
6134
  }
5213
6135
 
6136
+ const held = explicit;
6137
+ const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
6138
+ && allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
6139
+ && !this.holdsAnythingAt(held.mount, held.relativePath);
5214
6140
  return {
5215
6141
  success: true,
5216
6142
  data: {
5217
6143
  materialized: allWritten,
5218
6144
  count: allWritten.length,
6145
+ ...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
5219
6146
  ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
5220
6147
  ...(pendingDeletions.length > 0
5221
6148
  ? {
@@ -5243,7 +6170,8 @@ export class WorkspaceModule implements Module {
5243
6170
  if (!this.running()) return [];
5244
6171
  const store = this.getStore();
5245
6172
  const mount = this.mounts.get(mountName);
5246
- if (!mount || mount.config.mode === 'read-only') return [];
6173
+ // A direct mount keeps no tree to push: disk is its only copy.
6174
+ if (!mount || mount.config.mode === 'read-only' || mount.direct) return [];
5247
6175
 
5248
6176
  // Reset tracking — we're deliberately materializing on the new branch
5249
6177
  mount.lastMaterializedBranchId = null;
@@ -5285,7 +6213,13 @@ export class WorkspaceModule implements Module {
5285
6213
  const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
5286
6214
  const rootsAccepted: string[] = [];
5287
6215
  if (input.acceptRoot && input.path) {
5288
- return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
6216
+ const named = input.path.split('/')[0]!;
6217
+ return {
6218
+ success: false,
6219
+ isError: true,
6220
+ error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
6221
+ + `${this.mounts.has(named) ? `Give "mount" (${JSON.stringify(named)}) with acceptRoot to take that mount's root` : 'Give "mount", naming the mount whose root to take, with acceptRoot'}, or leave acceptRoot out to sync the path.`),
6222
+ };
5289
6223
  }
5290
6224
 
5291
6225
  // A path names its own mount; otherwise the given mount, or every mount.
@@ -5296,13 +6230,13 @@ export class WorkspaceModule implements Module {
5296
6230
  // is refused; beneath a directory, the walk passes over what the include
5297
6231
  // doesn't cover.
5298
6232
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5299
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
6233
+ return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5300
6234
  }
5301
6235
  targets = [{ name: mount.config.name, mount, relativePath }];
5302
6236
  } else if (input.mount) {
5303
6237
  const m = this.mounts.get(input.mount);
5304
6238
  if (!m) {
5305
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
6239
+ return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5306
6240
  }
5307
6241
  targets = [{ name: input.mount, mount: m, relativePath: '' }];
5308
6242
  } else {
@@ -5316,6 +6250,7 @@ export class WorkspaceModule implements Module {
5316
6250
  allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
5317
6251
  continue;
5318
6252
  }
6253
+ let namedDirectory = false;
5319
6254
  const pass = await this.withMount(mount, async () => {
5320
6255
  if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
5321
6256
  if (!relativePath) {
@@ -5330,6 +6265,7 @@ export class WorkspaceModule implements Module {
5330
6265
  store.treeList(mount.treeStateId, prefix).length > 0 ||
5331
6266
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
5332
6267
  this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
6268
+ namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
5333
6269
  // named: ignore patterns covering the path asked for don't apply
5334
6270
  // beneath it (a directory scope's walk; a path scope doesn't walk).
5335
6271
  const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
@@ -5388,7 +6324,7 @@ export class WorkspaceModule implements Module {
5388
6324
  // without a path passes over it, so nothing else keeps it current
5389
6325
  // (agent-framework #276). A path in another mount's directory isn't
5390
6326
  // synced at all, and the note says so.
5391
- const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
6327
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
5392
6328
  if (passedOver !== undefined) {
5393
6329
  allIgnored.push({
5394
6330
  mount: name,
@@ -5402,10 +6338,17 @@ export class WorkspaceModule implements Module {
5402
6338
  }
5403
6339
  }
5404
6340
 
6341
+ const named = input.path !== undefined && input.path !== '' ? targets[0]! : null;
6342
+ const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
6343
+ && allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
6344
+ && allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
6345
+ && !this.holdsAnythingAt(named.mount, named.relativePath)
6346
+ && await this.absentOnDisk(named.mount, named.relativePath);
5405
6347
  return {
5406
6348
  success: true,
5407
6349
  data: {
5408
6350
  results: allResults,
6351
+ ...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
5409
6352
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
5410
6353
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
5411
6354
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
@@ -5470,9 +6413,9 @@ export class WorkspaceModule implements Module {
5470
6413
  * on disk (a shell command's files, a deletion) is in the workspace when the
5471
6414
  * agent next looks. On a mount over its walkCap, that holds for the first
5472
6415
  * walkCap entries in the walk's order, which every batch's scan takes; files
5473
- * past them come in through syncs and the listings that reach them, while a
5474
- * read takes in only a file the workspace doesn't hold yet. A scan that
5475
- * outlasts the deadline stops holding the round: the miss is recorded on
6416
+ * past them come in through syncs, the listings that reach them, and a read
6417
+ * of the path, which takes disk's change in first as a listing does. A scan
6418
+ * that outlasts the deadline stops holding the round: the miss is recorded on
5476
6419
  * the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
5477
6420
  * event, and the scan continues, deciding on the branch selected when it
5478
6421
  * applies.
@@ -5631,13 +6574,14 @@ export class WorkspaceModule implements Module {
5631
6574
 
5632
6575
  /**
5633
6576
  * A text grep searches (textsFromStore, textsFromDisk): its mount-prefixed
5634
- * file, which copy it is, and on a 'chronicle' mount, the path's state.
6577
+ * file, which copy it is, on a 'chronicle' mount the path's state, and its
6578
+ * bytes, whose lines grep matches as read shows them (fileText).
5635
6579
  */
5636
6580
  interface GrepText {
5637
6581
  file: string;
5638
6582
  state?: string;
5639
6583
  version: 'workspace' | 'conflicting-disk' | 'recorded-disk' | 'disk';
5640
- text: string;
6584
+ bytes: Buffer;
5641
6585
  }
5642
6586
 
5643
6587
  /** A file, or a copy of one, grep doesn't search, with why. */
@@ -5678,13 +6622,13 @@ export async function inOrder<T, R>(items: readonly T[], width: number, work: (i
5678
6622
  }
5679
6623
 
5680
6624
  /** read_image's answer for an image, wherever it was read from. */
5681
- function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
6625
+ function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult {
5682
6626
  return {
5683
6627
  success: true,
5684
6628
  data: [
5685
6629
  {
5686
6630
  type: 'text',
5687
- text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
6631
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
5688
6632
  },
5689
6633
  {
5690
6634
  type: 'image',
@@ -5736,16 +6680,36 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
5736
6680
  return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
5737
6681
  }
5738
6682
 
6683
+ /**
6684
+ * Why a write, edit, deletion or peer module's writeBinary to this mount is
6685
+ * refused, or null where it may go ahead: a read-only mount, and a mount
6686
+ * configured direct that runs as 'chronicle' this session (chooseKinds),
6687
+ * since the store holds changes there that disk lacks. A write there would
6688
+ * stay in the store, beside an agent told the mount is its checkout, and
6689
+ * add to what keeps it off direct; refused, what's held only shrinks, and
6690
+ * the next start once none is held runs it direct.
6691
+ */
6692
+ function writeRefusal(mount: MountState, writable: readonly string[] = []): string | null {
6693
+ const name = mount.config.name;
6694
+ if (mount.config.mode === 'read-only') {
6695
+ return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
6696
+ + (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
6697
+ }
6698
+ if (mount.config.backend === 'direct' && !mount.direct) {
6699
+ return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6700
+ + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6701
+ + `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct.`;
6702
+ }
6703
+ return null;
6704
+ }
6705
+
5739
6706
  /**
5740
6707
  * What a direct mount's config sets that would never act there, each with
5741
- * the way out (MountConfig.backend): a direct mount is read-only, and has no
5742
- * watcher to tune, no change event to wake on and no push to make.
6708
+ * the way out (MountConfig.backend): a direct mount has no watcher to tune,
6709
+ * no change event to wake on and no push to make. Either mode is its own.
5743
6710
  */
5744
6711
  function directRefusals(mount: MountConfig): string[] {
5745
6712
  const refused: string[] = [];
5746
- if (mount.mode !== 'read-only') {
5747
- 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)}`);
5748
- }
5749
6713
  if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
5750
6714
  for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
5751
6715
  if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);
@@ -5756,6 +6720,261 @@ function directRefusals(mount: MountConfig): string[] {
5756
6720
  return refused;
5757
6721
  }
5758
6722
 
6723
+ /**
6724
+ * An agent's latest direct write on a mount, P: the store's branch, and its
6725
+ * head when the write landed (`after`); and the id of the message that last
6726
+ * told the agent its history no longer holds P, N, or null.
6727
+ */
6728
+ interface DirectHead {
6729
+ branchId: string;
6730
+ after: number;
6731
+ notice: string | null;
6732
+ }
6733
+
6734
+ /** A store position: a branch and its head. */
6735
+ type DirectPosition = Pick<DirectHead, 'branchId' | 'after'>;
6736
+
6737
+ /** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
6738
+ const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
6739
+
6740
+ /** One mount's positions at one root (resolved, as DiskAgreement keeps a root), by agent, with N only where it's set. */
6741
+ interface DirectHeadsEntry {
6742
+ mount: string;
6743
+ root: string;
6744
+ agents: Record<string, { branchId: string; after: number; notice?: string }>;
6745
+ }
6746
+
6747
+ /** directHeads as saved: an entry per mount and root its agents wrote direct under. */
6748
+ type DirectHeadsSnapshot = DirectHeadsEntry[];
6749
+
6750
+ /**
6751
+ * A saved record as saveDirectHeads writes it, or a throw naming what isn't:
6752
+ * a list of entries, each a mount's name, root and agents, and each agent's
6753
+ * branch, head and, where set, notice. Checked whole, carried entries
6754
+ * included, so a record of another shape is refused at load rather than
6755
+ * carried into the next save.
6756
+ */
6757
+ function checkedHeads(value: unknown): DirectHeadsSnapshot {
6758
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
6759
+ if (!Array.isArray(value)) throw new Error("the record isn't of the shape saved");
6760
+ for (const saved of value as unknown[]) {
6761
+ if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
6762
+ throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
6763
+ }
6764
+ for (const [agent, head] of Object.entries(saved.agents)) {
6765
+ if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
6766
+ || (head.notice !== undefined && typeof head.notice !== 'string')) {
6767
+ throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
6768
+ }
6769
+ }
6770
+ }
6771
+ return value as DirectHeadsSnapshot;
6772
+ }
6773
+
6774
+ /** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
6775
+ function canonicalHeads(snapshot: DirectHeadsSnapshot): string {
6776
+ return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
6777
+ const { branchId, after, notice } = agents[agent]!;
6778
+ return [agent, branchId, after, notice ?? null];
6779
+ })]).sort((a, b) => {
6780
+ const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
6781
+ return x < y ? -1 : x > y ? 1 : 0;
6782
+ }));
6783
+ }
6784
+
6785
+ /**
6786
+ * How many times a direct edit reads and plans a file another writer keeps
6787
+ * changing: enough to land on top of a writer that changes it now and then,
6788
+ * such as a formatter or a build, and bounded for one that never stops.
6789
+ */
6790
+ const DIRECT_EDIT_ATTEMPTS = 3;
6791
+
6792
+ /** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
6793
+ function directReason(error: unknown): string {
6794
+ if (error instanceof FileChanged) return 'it changed on disk while this call was being applied';
6795
+ // A deletion's root check (unlinkContained) has a store-backed mount's
6796
+ // words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
6797
+ if (error instanceof Error && error.message === ROOT_REPLACED) return ROOT_MOVED;
6798
+ return error instanceof Error ? error.message : String(error);
6799
+ }
6800
+
6801
+ function directWayOn(error: unknown): string | null {
6802
+ return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
6803
+ }
6804
+
6805
+ function directDidNothing(tool: string, path: string, error: unknown): string {
6806
+ const on = directWayOn(error);
6807
+ return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
6808
+ }
6809
+
6810
+ /** "a", "a and b", or "a, b and c". */
6811
+ function listed(items: readonly string[]): string {
6812
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
6813
+ }
6814
+
6815
+ /**
6816
+ * The note for a direct write that gave a hard-linked path a file of its
6817
+ * own: the other links keep the old text. Null for a file with none.
6818
+ */
6819
+ function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): string | null {
6820
+ const n = staged.otherLinks;
6821
+ if (n <= 0) return null;
6822
+ return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
6823
+ + `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
6824
+ }
6825
+
6826
+ /**
6827
+ * A refusal of openContainedFile's as a direct tool gives it: its message,
6828
+ * and for one that names only the path, its errno too, as a direct read's
6829
+ * answer gives it (bytesOnDisk).
6830
+ */
6831
+ function containedRefusal(err: unknown, path: string): string {
6832
+ const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6833
+ return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
6834
+ }
6835
+
6836
+ function deletedOnDisk(path: string): string {
6837
+ return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
6838
+ }
6839
+
6840
+ function uncheckedFile(shown: string, note: string | undefined): string {
6841
+ if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6842
+ const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
6843
+ : note === ROOT_REPLACED ? null
6844
+ : note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
6845
+ : note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
6846
+ : note === NOT_REGULAR ? onlyTheHost('put a regular file there')
6847
+ : note === CHANGED ? 'Calling again checks it afresh.'
6848
+ : 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
6849
+ return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
6850
+ }
6851
+
6852
+ function namesRoot(path: string, mount: string): string {
6853
+ return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
6854
+ }
6855
+
6856
+ function tooLargeToWrite(path: string, bytes: number, maxSize: number): string {
6857
+ return pastSizeLimit(`${JSON.stringify(path)} would be ${bytes.toLocaleString('en-US')} bytes`, maxSize, "the workspace couldn't keep it", 'Content within the limit is written.');
6858
+ }
6859
+
6860
+ function pastSizeLimit(what: string, maxSize: number, outcome: string, within: string | null): string {
6861
+ return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
6862
+ + "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
6863
+ }
6864
+
6865
+ function sentence(text: string): string {
6866
+ return /[.!?]$/.test(text) ? text : `${text}.`;
6867
+ }
6868
+
6869
+ function unreadable(shown: string, errno: string | undefined): string {
6870
+ return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
6871
+ }
6872
+
6873
+ function readRefusal(err: WorkspaceReadError, path: string): string {
6874
+ const shown = JSON.stringify(path);
6875
+ if (err instanceof RootUnavailable) {
6876
+ if (err.refusal) return err.refusal.message;
6877
+ const mount = JSON.stringify(err.mount);
6878
+ if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
6879
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
6880
+ return `the directory of mount ${mount} isn't on disk, so nothing in it can be read. ${onlyTheHost("put the mount's directory back at its path")}`;
6881
+ }
6882
+ if (err.errno !== undefined) {
6883
+ return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
6884
+ + errnoWayOn(err.errno, ROOT_RECHECK, true);
6885
+ }
6886
+ return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
6887
+ }
6888
+ return `${shown} can't be read: ${sentence(err.reason)}`;
6889
+ }
6890
+ if (err.stage === 'readlink') return sentence(err.message);
6891
+ switch (err.code) {
6892
+ case 'directory':
6893
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6894
+ case 'symlink':
6895
+ return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6896
+ + 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
6897
+ case 'escape':
6898
+ return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6899
+ + 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
6900
+ case 'changed':
6901
+ return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6902
+ case 'not_found':
6903
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
6904
+ if (err instanceof NotRegularFile) {
6905
+ return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
6906
+ }
6907
+ if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
6908
+ return unreadable(shown, err.errno);
6909
+ default:
6910
+ return sentence(err.message);
6911
+ }
6912
+ }
6913
+
6914
+ type LostContents = {
6915
+ path: string;
6916
+ store: Exclude<StoredBytes, { bytes: Buffer }>;
6917
+ disk: 'other' | 'none' | { unreadable: string };
6918
+ writable: boolean;
6919
+ reopen: boolean;
6920
+ };
6921
+
6922
+ function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string {
6923
+ return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
6924
+ }
6925
+
6926
+ function inTheStore(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
6927
+ return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
6928
+ }
6929
+
6930
+ function lostInStore(lost: Pick<LostContents, 'store'>): string {
6931
+ return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
6932
+ }
6933
+
6934
+ function lostOnDisk(lost: LostContents): string {
6935
+ return lost.disk === 'other' ? "disk's copy holds other contents"
6936
+ : lost.disk === 'none' ? 'disk has no copy'
6937
+ : `disk's copy can't be read (${lost.disk.unreadable})`;
6938
+ }
6939
+
6940
+ function lostText(lost: LostContents, the: 'The' | 'the' = 'The'): string {
6941
+ return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
6942
+ }
6943
+
6944
+ function nothingToRead(lost: LostContents): string {
6945
+ return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
6946
+ }
6947
+
6948
+ function lostRemedy(lost: LostContents): string {
6949
+ const after = remedyOfLost(lost);
6950
+ if (!lost.reopen) return after;
6951
+ return "Nothing you do clears this until the workspace's store is reopened, and calling again won't change that; whoever runs this host can reopen it with a restart. "
6952
+ + `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
6953
+ }
6954
+
6955
+ function remedyOfLost(lost: LostContents): string {
6956
+ const rewrite = lost.writable && 'missing' in lost.store;
6957
+ if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
6958
+ if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
6959
+ return lost.disk === 'none'
6960
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy."
6961
+ : "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's.";
6962
+ }
6963
+
6964
+ /**
6965
+ * The notice that writes an agent made direct remain on disk from
6966
+ * turns the agent's history no longer holds. `git status` stands beside
6967
+ * `git diff` because a departed turn's new file is untracked, which `git
6968
+ * diff` doesn't show; and the warning says "unstaged", since `git checkout
6969
+ * -- <file>` puts back a file's staged text, so a staged change survives it.
6970
+ */
6971
+ function headMoveText(mounts: readonly string[]): string {
6972
+ const one = mounts.length === 1;
6973
+ return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6974
+ + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6975
+ + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6976
+ }
6977
+
5759
6978
  /** A change commitToolChanges makes to one path's tree entry. */
5760
6979
  type ToolChange = { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string };
5761
6980
 
@@ -5770,6 +6989,63 @@ interface EditTarget {
5770
6989
  specs: EditSpec[];
5771
6990
  }
5772
6991
 
6992
+ /**
6993
+ * One file's edits planned against its bytes, as both kinds of mount plan
6994
+ * them: its text decoded, every edit matched in it (edit-plan.ts), and the
6995
+ * result held to the mount's size limit. Each problem found goes into
6996
+ * `problems`, and then, as for a file named only by edits whose own input
6997
+ * was refused, there's no plan (null).
6998
+ */
6999
+ function planTargetEdits(
7000
+ tool: EditTool,
7001
+ target: EditTarget,
7002
+ bytes: Buffer,
7003
+ hints: ReturnType<typeof hintBudget>,
7004
+ maxSize: number,
7005
+ counts: ReturnType<typeof countBudget>,
7006
+ problems: EditProblem[],
7007
+ ): FilePlan | null {
7008
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
7009
+ // the text back would change bytes the edits never named.
7010
+ const file = decodeForEdit(bytes);
7011
+ if (!file) {
7012
+ problems.push({
7013
+ kind: 'file',
7014
+ edits: target.edits,
7015
+ path: target.path,
7016
+ text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
7017
+ + '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.',
7018
+ whole: false,
7019
+ });
7020
+ return null;
7021
+ }
7022
+ if (!target.specs.length) return null;
7023
+ const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
7024
+ if ('problems' in planned) {
7025
+ // One at a time: a call of many edits can have more problems
7026
+ // than a spread passes as arguments.
7027
+ for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
7028
+ return null;
7029
+ }
7030
+ // The planner refuses a file its counts take past the limit before
7031
+ // building it, from the least its bytes can be; one it built past
7032
+ // the limit, as its CRs or multi-byte characters can take it, is
7033
+ // refused here.
7034
+ if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
7035
+ const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
7036
+ problems.push({
7037
+ kind: 'file',
7038
+ edits: target.edits,
7039
+ path: target.path,
7040
+ text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize,
7041
+ "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
7042
+ whole: false,
7043
+ });
7044
+ return null;
7045
+ }
7046
+ return planned.plan;
7047
+ }
7048
+
5773
7049
  /**
5774
7050
  * Whether a tree entry is what a change leaves: for a set, an entry with its
5775
7051
  * blob, size and mode; for a removal, none.
@@ -5805,17 +7081,36 @@ function changesLanded(
5805
7081
  * change is stored isn't known. Its message says so after both errors.
5806
7082
  */
5807
7083
  class CommitUnknownError extends Error {
5808
- readonly setError: string;
5809
- readonly readError: string;
5810
- constructor(setError: unknown, readError: unknown) {
7084
+ constructor(readonly setCause: unknown, readonly readCause: unknown) {
5811
7085
  const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
5812
- super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
7086
+ super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
5813
7087
  this.name = 'CommitUnknownError';
5814
- this.setError = text(setError);
5815
- this.readError = text(readError);
5816
7088
  }
5817
7089
  }
5818
7090
 
7091
+ function storeWords(error: unknown): string {
7092
+ const code = errnoCode(error);
7093
+ return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
7094
+ }
7095
+
7096
+ class NotStored extends Error {
7097
+ constructor(readonly thrown: unknown) {
7098
+ super('stopped before the change was stored');
7099
+ }
7100
+ }
7101
+
7102
+ class AlreadyHeld extends Error {
7103
+ constructor(readonly thrown: unknown) {
7104
+ super(thrown instanceof Error ? thrown.message : String(thrown));
7105
+ }
7106
+ }
7107
+
7108
+ function notStored(error: unknown): unknown {
7109
+ return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
7110
+ ? error : new NotStored(error);
7111
+ }
7112
+
7113
+
5819
7114
  /**
5820
7115
  * A store's set that threw after recording the change (changesLanded): the
5821
7116
  * entries are set and their intents follow, and the store's error goes on in
@@ -5829,6 +7124,14 @@ class StoredThenFailed extends Error {
5829
7124
  }
5830
7125
  }
5831
7126
 
7127
+ const ONCE_REOPENED = ', once the store is reopened';
7128
+
7129
+ const DISK_ORDER: DiskState[] = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
7130
+
7131
+ function furthestFromDisk(values: DiskState[]): DiskState {
7132
+ return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
7133
+ }
7134
+
5832
7135
  /**
5833
7136
  * The answer to a write, edit or delete that the workspace stored but
5834
7137
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -5845,6 +7148,7 @@ function storedButUnfinished(
5845
7148
  path: string,
5846
7149
  outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
5847
7150
  note: string | null = null,
7151
+ reopen = false,
5848
7152
  ): ToolResult {
5849
7153
  const change = tool === 'delete' ? 'deletion' : tool;
5850
7154
  const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
@@ -5854,21 +7158,55 @@ function storedButUnfinished(
5854
7158
  : 'storeFailed' in outcome
5855
7159
  ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
5856
7160
  : tool === 'delete'
5857
- ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
5858
- : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
5859
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
5860
- return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
7161
+ ? ` Its push to disk didn't run, so disk keeps the file until a materialize of the path with applyDeletions deletes it${reopen ? ONCE_REOPENED : ''}.`
7162
+ : ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
7163
+ : ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
7164
+ return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
5861
7165
  }
5862
7166
 
5863
7167
  /**
5864
- * A text's lines, as read and grep number them: a final line end ends the
5865
- * last line rather than starting an empty one, so "a\nb\n" is two lines,
5866
- * and an empty text has none.
7168
+ * What a read says about the copy it shows, when that isn't simply the one the
7169
+ * workspace last agreed on with disk: disk's newer text, taken in first; the
7170
+ * workspace's copy, because disk's couldn't be checked or disk has none that
7171
+ * anything accounts for; or a draft in conflict with disk's change. A file
7172
+ * the workspace didn't hold has no older copy, so taking it in needs no word.
7173
+ * A conflict's note names the ways to settle it that work on the path's mount.
5867
7174
  */
5868
- function linesOf(content: string): string[] {
5869
- const lines = content.split('\n');
5870
- if (lines.at(-1) === '') lines.pop();
5871
- return lines;
7175
+ function whichCopyRead(report: PathReport | undefined, tookChange: boolean, readOnly: boolean): string[] {
7176
+ if (tookChange) {
7177
+ return ['Disk\'s copy had changed since the workspace last agreed with it, so it was taken in first, and this read shows that text.'];
7178
+ }
7179
+ if (report?.state === 'unverified') {
7180
+ return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
7181
+ }
7182
+ if (report?.state === 'disk-missing-provenance-unknown') {
7183
+ return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
7184
+ }
7185
+ if (report?.state === 'conflict') {
7186
+ const kind = report.conflict?.kind ?? 'both-changed';
7187
+ // A sync takes disk's state as it is now, which a recorded conflict's kind
7188
+ // doesn't follow: where disk has no copy, the sync takes its deletion, and
7189
+ // the workspace's copy goes with it. Where disk has one, the sync keeps it
7190
+ // and gives up the workspace's, taking disk's in only where the workspace
7191
+ // can hold it (not binary or too large). A read-only mount can hold a
7192
+ // copy disk doesn't, such as one a branch switch selected, but a
7193
+ // materialize, forced or not, writes nothing to it.
7194
+ const settle = settleConflict(report.diskNow, readOnly);
7195
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0]!.toUpperCase()}${settle.slice(1)}`];
7196
+ }
7197
+ return [];
7198
+ }
7199
+
7200
+ function settleConflict(diskNow: PathReport['diskNow'], readOnly: boolean): string {
7201
+ const sync = diskNow === 'absent'
7202
+ ? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
7203
+ : 'sync the path to keep disk\'s copy and give up the workspace\'s';
7204
+ const force = readOnly
7205
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
7206
+ : diskNow === 'absent'
7207
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
7208
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
7209
+ return `${sync}${force}`;
5872
7210
  }
5873
7211
 
5874
7212
  /**
@@ -5939,6 +7277,87 @@ function inputProblem(
5939
7277
  return null;
5940
7278
  }
5941
7279
 
7280
+ /** How many files a grep note names before it gives the count of the rest. */
7281
+ const NOTE_FILES_MAX = 20;
7282
+
7283
+ /**
7284
+ * The files a grep note names, in the order grep searched them, as its
7285
+ * results are: at most NOTE_FILES_MAX of them, then how many more and how to
7286
+ * see them. Each is named by its mount-prefixed path, with what `detail` says
7287
+ * of it.
7288
+ */
7289
+ function namedFiles<T>(files: Map<string, T>, detail: (value: T) => string): string {
7290
+ const names = [...files.keys()];
7291
+ const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name)!)}`);
7292
+ const rest = names.length - shown.length;
7293
+ return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
7294
+ }
7295
+
7296
+ /** What a note says of a conflict's disk version, which read doesn't show. */
7297
+ const ofVersion = (version: GrepText['version']): string =>
7298
+ version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
7299
+
7300
+ /**
7301
+ * grep's note for a pattern that names a character its lines leave out, where
7302
+ * a file it searched has one: it says how many files searched have it and
7303
+ * names them, so a search for a CR or a BOM still finds the files that have
7304
+ * them. The CR note gives each file's ends, CRLF or mixed, and either note
7305
+ * names a conflict's disk version as that version. The note says a
7306
+ * character read shows it in the workspace's copy, since grep also searches a
7307
+ * conflict's disk version, which read doesn't show. A direct mount keeps no
7308
+ * workspace copy, and its read shows disk's file, so on a host with one
7309
+ * (`direct`) the note names that too, as the tools' descriptions mention
7310
+ * direct mounts only on such a host.
7311
+ */
7312
+ function leftOutNote(
7313
+ pattern: string,
7314
+ crlfFiles: Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>,
7315
+ bomFiles: Map<string, GrepText['version']>,
7316
+ direct: boolean,
7317
+ ): { note?: string } {
7318
+ if (crlfFiles.size === 0 && bomFiles.size === 0) return {};
7319
+ const named = namedLeftOut(pattern);
7320
+ const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
7321
+ const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
7322
+ const notes = [
7323
+ named.cr && crlfFiles.size > 0
7324
+ ? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
7325
+ + `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
7326
+ + `which leaves out a CRLF end's CR unless the line's own text ends in a CR. read with offsetChars shows every CR in the workspace's copy${onDisk}.`
7327
+ : null,
7328
+ named.bom && bomFiles.size > 0
7329
+ ? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
7330
+ + `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
7331
+ + `and a BOM isn't part of the first line. read with offsetChars shows any BOM ${direct ? `in the workspace's copy${onDisk}` : 'the workspace\'s copy has'}.`
7332
+ : null,
7333
+ ].filter((n) => n !== null);
7334
+ return notes.length ? { note: notes.join(' ') } : {};
7335
+ }
7336
+
7337
+ /**
7338
+ * Which of the characters grep's lines leave out a pattern names: a CR, as a
7339
+ * literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
7340
+ * before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
7341
+ * before a literal one. An escape counts only where its backslash isn't itself
7342
+ * escaped. A pattern compiled without flags can match a CR other ways too,
7343
+ * such as `\15` in a pattern of fewer than 15 groups or a class range that
7344
+ * spans U+000D, and those go unnoticed.
7345
+ */
7346
+ function namedLeftOut(pattern: string): { cr: boolean; bom: boolean } {
7347
+ const named = { cr: false, bom: false };
7348
+ for (let i = 0; i < pattern.length; i++) {
7349
+ if (pattern[i] === '\r') named.cr = true;
7350
+ else if (pattern[i] === '\uFEFF') named.bom = true;
7351
+ else if (pattern[i] === '\\') {
7352
+ const next = pattern.slice(i + 1, i + 6);
7353
+ if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next)) named.cr = true;
7354
+ else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next)) named.bom = true;
7355
+ i++;
7356
+ }
7357
+ }
7358
+ return named;
7359
+ }
7360
+
5942
7361
  /**
5943
7362
  * A glob's reading in the mount its first segment names, in place of the
5944
7363
  * pattern as written there. Every path the tools take and return is
@@ -5987,13 +7406,13 @@ function globToRegex(pattern: string): RegExp {
5987
7406
  if (pattern[j] === '{') depth++;
5988
7407
  else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
5989
7408
  }
5990
- if (closeIdx < 0) throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
7409
+ if (closeIdx < 0) throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
5991
7410
  const group = pattern.slice(i + 1, closeIdx);
5992
7411
  if (group.includes('{')) {
5993
- throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
7412
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
5994
7413
  }
5995
7414
  if (!group.includes(',')) {
5996
- throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
7415
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
5997
7416
  }
5998
7417
  // A `/` right after the group goes into each alternative, as `{a,b}/x`
5999
7418
  // and `{a/,b/}x` name the same paths, so an alternative's own `**`