@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
@@ -9,15 +9,24 @@ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises
9
9
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
10
10
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
11
11
  import { MountWatcher } from './watcher.js';
12
- import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
13
- import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
12
+ import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE } from './sync.js';
13
+ import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
14
14
  import { BranchIntents } from './branch-intent.js';
15
- import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes } from './observe.js';
15
+ 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, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes } from './observe.js';
16
16
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
17
17
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
18
18
  import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
19
19
  import { cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
20
+ import { fileText } from './text.js';
21
+ import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
22
+ import { grepText, textAnswer } from './result-text.js';
20
23
  import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
24
+ import { EffectFailed, unlinkContained } from './effects.js';
25
+ import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
26
+ import { unknownToolMessage } from '../unknown-tool.js';
27
+ import { closestNames } from '../../closest-names.js';
28
+ import { RecordJournal } from '../../record-journal.js';
29
+ import { FileChanged, ROOT_MOVED, WriteRefused, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
21
30
  /** Default for how long the after-batch scan may hold the agent's next inference. */
22
31
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
23
32
  /**
@@ -30,10 +39,15 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
30
39
  const CATCH_UP_BOUND_MS = 30_000;
31
40
  /** Why a call is refused once the module has stopped. */
32
41
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
42
+ /** writeBinary's refusal when the store's branch is no longer the one its file is for. */
43
+ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
33
44
  /** The workspace's own deadline must end before the framework's hook bound. */
34
45
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
35
46
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
36
47
  const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
48
+ const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
49
+ + 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
50
+ + 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
37
51
  /**
38
52
  * How many files a direct mount's grep reads, and its listings look at, at
39
53
  * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
@@ -44,16 +58,25 @@ const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage'
44
58
  const DIRECT_READS_AT_ONCE = 16;
45
59
  /** Why sync passes over a direct mount, as its result's `skipped` says. */
46
60
  const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
47
- class WorkspaceImageReadError extends Error {
48
- code;
49
- constructor(code, message) {
50
- super(message);
51
- this.code = code;
61
+ /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
62
+ const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
63
+ class WorkspaceImageReadError extends Refusal {
64
+ reason;
65
+ constructor(reason, wayOn) {
66
+ super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
67
+ this.reason = reason;
52
68
  this.name = 'WorkspaceImageReadError';
53
69
  }
54
70
  }
71
+ const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
72
+ function truncatedImage(mountPrefixedPath) {
73
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
74
+ }
75
+ function emptyImage(mountPrefixedPath) {
76
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
77
+ }
55
78
  /** A path the mount's `include` doesn't cover: outside the mount. */
56
- class OutsideIncludeError extends Error {
79
+ class OutsideIncludeError extends Refusal {
57
80
  constructor(shownPath, mount) {
58
81
  super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
59
82
  + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
@@ -64,9 +87,17 @@ class OutsideIncludeError extends Error {
64
87
  * mount it names. The message names the mount that holds it and the path to
65
88
  * use there.
66
89
  */
67
- class NestedMountError extends Error {
68
- constructor(path, mount, holder, use, how = 'lies in') {
69
- super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
90
+ class NestedMountError extends Refusal {
91
+ constructor(path, mount, holder, how = 'lies in') {
92
+ const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
93
+ super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
94
+ }
95
+ }
96
+ class PathRefusal extends Refusal {
97
+ code;
98
+ constructor(code, message) {
99
+ super(message);
100
+ this.code = code;
70
101
  }
71
102
  }
72
103
  /**
@@ -88,6 +119,22 @@ export class WorkspaceReadError extends Error {
88
119
  this.name = 'WorkspaceReadError';
89
120
  }
90
121
  }
122
+ class NotRegularFile extends WorkspaceReadError {
123
+ constructor(path, stage) {
124
+ super('not_found', `Not a regular file: ${path}`, stage);
125
+ }
126
+ }
127
+ class RootUnavailable extends WorkspaceReadError {
128
+ mount;
129
+ reason;
130
+ refusal;
131
+ constructor(mount, reason, stage, errno, refusal) {
132
+ super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
133
+ this.mount = mount;
134
+ this.reason = reason;
135
+ this.refusal = refusal;
136
+ }
137
+ }
91
138
  /**
92
139
  * Why a pass decided nothing, or null where it decided its paths. A pass gives
93
140
  * up deciding, and reports no path, when the selected branch kept changing
@@ -102,23 +149,9 @@ function errnoOf(err) {
102
149
  ? err.code
103
150
  : undefined;
104
151
  }
105
- /**
106
- * What is at a mount root's path now, followed as the root is: for a
107
- * directory, undefined; for anything else, ENOTDIR; and where the path
108
- * can't be followed to anything, the errno its stat failed with, ENOENT
109
- * when nothing is there.
110
- */
111
- async function rootAbsence(root) {
112
- try {
113
- return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
114
- }
115
- catch (err) {
116
- return errnoOf(err);
117
- }
118
- }
119
152
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
120
- function imageTooLarge(maxSize, mountPrefixedPath) {
121
- return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
153
+ function imageTooLarge(maxSize, mountPrefixedPath, size) {
154
+ return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
122
155
  }
123
156
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
124
157
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
@@ -134,9 +167,6 @@ const JPEG_SOI = 0xd8;
134
167
  const JPEG_EOI = 0xd9;
135
168
  const JPEG_SOS = 0xda;
136
169
  const JPEG_TEM = 0x01;
137
- function isErrnoCode(err, code) {
138
- return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
139
- }
140
170
  function startsWithBytes(buffer, prefix) {
141
171
  return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
142
172
  }
@@ -170,7 +200,7 @@ async function linkReadError(mount, found, mountPrefixedPath, relativePath, nest
170
200
  const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
171
201
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
172
202
  const code = leads?.kind === 'outside' ? 'escape'
173
- : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
203
+ : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
174
204
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
175
205
  return new WorkspaceReadError(code, reason, 'readlink');
176
206
  }
@@ -201,28 +231,27 @@ function ancestorsOf(path) {
201
231
  * directory as configured beneath the outer mount's configured path
202
232
  * (`outerPath`), beneath each directory above its configured path that is
203
233
  * the outer root on disk (`above`, each with where it lies on disk), and
204
- * where its real root (`real`) lies beneath the outer root (`outerReal`),
205
- * each also as disk spells it beneath `spellAt`. Its real root is claimed
206
- * where it doesn't hold the outer root, which it would otherwise claim
207
- * whole. Undefined where it's nested nowhere.
234
+ * where its real root (`real`) lies beneath the outer root (`outerReal`).
235
+ * Its real root is claimed where it doesn't hold the outer root, which it
236
+ * would otherwise claim whole. Undefined where it's nested nowhere.
208
237
  */
209
- async function placementOf(outerPath, outerReal, innerPath, real, above, spellAt) {
238
+ function placementOf(outerPath, outerReal, innerPath, real, above) {
210
239
  const configured = nestedDir(outerPath, innerPath);
211
- const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
212
- const fromRoots = [];
213
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
214
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
215
- fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
216
- }
217
- if (fromRoots.length === 0)
240
+ const dirs = new Map();
241
+ const place = (dir, sure) => { if (dir !== undefined)
242
+ dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
243
+ place(configured, true);
244
+ for (const a of above) {
245
+ if (sameRoot(a.real, outerReal))
246
+ place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
247
+ }
248
+ const below = beneathRoot(real, outerReal);
249
+ place(below, below !== undefined && surelyAtRoot(real, outerReal));
250
+ if (dirs.size === 0)
218
251
  return undefined;
219
- const spellings = [...fromRoots];
220
- for (const found of fromRoots) {
221
- const dir = await diskSpelling(spellAt, found.dir);
222
- if (!spellings.some((s) => s.dir === dir))
223
- spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
224
- }
225
- return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
252
+ const fromRoots = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true }) }));
253
+ const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
254
+ return { configured, fromRoots, claims };
226
255
  }
227
256
  /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
228
257
  const LINKS_FOLLOWED = 40;
@@ -288,7 +317,7 @@ function isContainedPath(root, candidate) {
288
317
  return candidate.startsWith(normalizedRoot);
289
318
  }
290
319
  function invalidImage(format, mountPrefixedPath) {
291
- throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
320
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
292
321
  }
293
322
  function requireBufferRange(bytes, start, length, format, mountPrefixedPath) {
294
323
  if (start < 0 || length < 0 || start + length > bytes.length) {
@@ -624,21 +653,21 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
624
653
  return 'image/png';
625
654
  }
626
655
  if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
627
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
656
+ throw truncatedImage(mountPrefixedPath);
628
657
  }
629
658
  if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
630
659
  validateJpeg(bytes, mountPrefixedPath);
631
660
  return 'image/jpeg';
632
661
  }
633
662
  if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
634
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
663
+ throw truncatedImage(mountPrefixedPath);
635
664
  }
636
665
  if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
637
666
  validateGif(bytes, mountPrefixedPath);
638
667
  return 'image/gif';
639
668
  }
640
669
  if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
641
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
670
+ throw truncatedImage(mountPrefixedPath);
642
671
  }
643
672
  if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
644
673
  validateWebp(bytes, mountPrefixedPath);
@@ -646,9 +675,9 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
646
675
  }
647
676
  if ((bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
648
677
  || (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)) {
649
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
678
+ throw truncatedImage(mountPrefixedPath);
650
679
  }
651
- throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
680
+ 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.");
652
681
  }
653
682
  export class WorkspaceModule {
654
683
  name = 'workspace';
@@ -674,14 +703,11 @@ export class WorkspaceModule {
674
703
  ctimeTrust = new Map();
675
704
  /**
676
705
  * Each mount's directories that are other mounts nested inside it, with
677
- * each mount's name and real root, in each spelling that names the
678
- * directory: as configured; as the real roots place it, where that differs
679
- * (`fromRoots`); and as disk spells each of those where that differs.
680
- * Each spelling is mount-relative and `/`-separated, with its root beneath
681
- * the mount's resolved root. The constructor nests by configured path,
682
- * with each root as resolved standing for its real one, and each start
683
- * reads it all again with the roots placed where disk has them
684
- * (readNesting).
706
+ * each mount's name and real root: as configured, and as the real roots
707
+ * place it, where that differs (`fromRoots`). Each is mount-relative and
708
+ * `/`-separated. The constructor nests by configured path, with each root
709
+ * as resolved standing for its real one, and each start reads it all again
710
+ * with the roots placed where disk has them (readNesting).
685
711
  */
686
712
  otherMounts = new Map();
687
713
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
@@ -707,6 +733,23 @@ export class WorkspaceModule {
707
733
  * ensureRunning to say once both halves of the lifecycle have run.
708
734
  */
709
735
  notDirect = new Map();
736
+ /**
737
+ * Each agent's latest direct write on each mount it wrote direct, P, with
738
+ * the notice of its departure from it, N (DirectHead), by mount, then by
739
+ * agent. Saved at stop() in a global record (headsJournal), not the
740
+ * module's branch state: they describe disk, which doesn't rewind with a
741
+ * branch, so a restart on another branch, such as one an offline rollback
742
+ * made, still finds them.
743
+ */
744
+ directHeads = new Map();
745
+ /** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
746
+ headsJournal = null;
747
+ /** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
748
+ loadedHeads = null;
749
+ /** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
750
+ carriedHeads = [];
751
+ /** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
752
+ historyLookup = null;
710
753
  constructor(config) {
711
754
  for (const mount of config.mounts) {
712
755
  const cap = mount.walkCap;
@@ -721,20 +764,19 @@ export class WorkspaceModule {
721
764
  if (refused.length > 0) {
722
765
  throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
723
766
  }
724
- const include = mount.include;
725
- if (include !== undefined) {
726
- // A pattern the matcher can't read would match nothing, and take every
727
- // file out of the mount unsaid (includePatternReadable).
728
- if (!Array.isArray(include) || include.length === 0) {
729
- throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`);
767
+ for (const list of ['ignore', 'include']) {
768
+ const patterns = mount[list];
769
+ if (patterns === undefined)
770
+ continue;
771
+ if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
772
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
730
773
  }
731
- // By index: an entry that is itself undefined, or a hole, is unreadable too.
732
- const at = include.findIndex((p) => !includePatternReadable(p));
733
- if (at >= 0) {
734
- const unreadable = include[at];
735
- throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
736
- + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
737
- + `${shownValue(unreadable)} is none of these`);
774
+ for (let i = 0; i < patterns.length; i++) {
775
+ const pattern = patterns[i];
776
+ const why = patternRefusal(pattern, list);
777
+ if (why !== undefined) {
778
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
779
+ }
738
780
  }
739
781
  }
740
782
  }
@@ -755,8 +797,8 @@ export class WorkspaceModule {
755
797
  const dir = nestedDir(outerPath, innerPath);
756
798
  if (dir === undefined)
757
799
  continue;
758
- const configured = [{ dir, root: innerPath }];
759
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
800
+ const configured = [{ dir }];
801
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
760
802
  console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
761
803
  `at ${dir}/ — passing over it as another mount's directory`);
762
804
  }
@@ -830,9 +872,64 @@ export class WorkspaceModule {
830
872
  this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
831
873
  this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
832
874
  this.chooseKinds();
875
+ this.loadDirectHeads(store);
833
876
  this.applySavedState();
834
877
  this.ensureRunning();
835
878
  }
879
+ /**
880
+ * The positions stop() last saved (directHeads), before any call can
881
+ * write: each saved entry is a mount's positions at one root, and a mount
882
+ * this session configures direct gets the entry at its own root. Every
883
+ * other entry, a mount configured at another root, not direct, or not at
884
+ * all, is kept for the next save (carriedHeads), since its writes are
885
+ * still on disk beneath that root; a mount removed for good, and each
886
+ * root a mount has moved from, leave that small entry behind. A record
887
+ * the store can't read is logged, and this session starts with none and
888
+ * saves none, so the record stands for a session that can read it. One
889
+ * read but not of the shape saved is logged too, and this session starts
890
+ * with none and its stop replaces it, so a damaged record doesn't keep
891
+ * every later session from saving. Either way, a move away from the
892
+ * writes before it goes untold, as after a crash.
893
+ */
894
+ loadDirectHeads(store) {
895
+ const journal = new RecordJournal(store, { type: DIRECT_HEADS_RECORD_TYPE });
896
+ const restored = new Map();
897
+ const carried = [];
898
+ let loaded;
899
+ try {
900
+ loaded = journal.load().snapshot;
901
+ }
902
+ catch (err) {
903
+ console.error('[workspace] could not read the saved direct-write positions:', err);
904
+ return;
905
+ }
906
+ let snapshot;
907
+ let text;
908
+ try {
909
+ snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
910
+ text = canonicalHeads(snapshot);
911
+ }
912
+ catch (err) {
913
+ 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);
914
+ snapshot = [];
915
+ text = null;
916
+ }
917
+ for (const saved of snapshot) {
918
+ // Only a direct mount's writes are the agent's on disk, and a
919
+ // store-backed one's disk follows its store.
920
+ const mount = this.mounts.get(saved.mount);
921
+ if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
922
+ carried.push(saved);
923
+ continue;
924
+ }
925
+ restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) => [agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
926
+ }
927
+ this.headsJournal = journal;
928
+ this.loadedHeads = text;
929
+ this.carriedHeads = carried;
930
+ for (const [name, heads] of restored)
931
+ this.directHeads.set(name, heads);
932
+ }
836
933
  /**
837
934
  * Whether each mount configured direct runs direct: not while the store
838
935
  * holds changes there that disk lacks, on the branch selected then, so
@@ -985,7 +1082,7 @@ export class WorkspaceModule {
985
1082
  const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
986
1083
  let start = base;
987
1084
  for (const segment of beneath.split('/')) {
988
- if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
1085
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined))
989
1086
  break;
990
1087
  const here = await checkDir(view, rootReal, start);
991
1088
  if (here.kind !== 'checked')
@@ -1043,11 +1140,10 @@ export class WorkspaceModule {
1043
1140
  }
1044
1141
  async start(ctx) {
1045
1142
  // Which mounts are nested where, with the roots placed where disk has
1046
- // them and each nested mount's directory as disk spells it, and whether
1047
- // each mount's root folds case, read where disk has it, before this start
1048
- // sets anything, so a restart takes no call and walks or watches nothing
1049
- // before they're known. A stop begun meanwhile stands, and this start then
1050
- // sets nothing.
1143
+ // them, and whether each mount's root folds case, read where disk has
1144
+ // it, before this start sets anything, so a restart takes no call
1145
+ // and walks or watches nothing before they're known. A stop begun
1146
+ // meanwhile stands, and this start then sets nothing.
1051
1147
  const stops = this.stopsBegun;
1052
1148
  const { nesting, reals, above } = await this.readNesting();
1053
1149
  const rootFoldsCase = new Map();
@@ -1160,7 +1256,8 @@ export class WorkspaceModule {
1160
1256
  const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
1161
1257
  return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
1162
1258
  `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
1163
- `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`);
1259
+ `never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
1260
+ 'if the current branch is what disk should hold, pass force: true to overwrite disk from it.');
1164
1261
  }
1165
1262
  /**
1166
1263
  * Re-pin mounts whose last-materialized branch is a proven ancestor of the
@@ -1318,8 +1415,8 @@ export class WorkspaceModule {
1318
1415
  const message = (one
1319
1416
  ? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
1320
1417
  : "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
1321
- "sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
1322
- `follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1418
+ "sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
1419
+ `and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1323
1420
  `${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
1324
1421
  console.warn(`[workspace] ${message}`);
1325
1422
  this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
@@ -1388,26 +1485,76 @@ export class WorkspaceModule {
1388
1485
  }
1389
1486
  this.ctx.setState(state);
1390
1487
  }
1488
+ this.saveDirectHeads();
1391
1489
  this.ctx = null;
1392
1490
  }
1393
- /** Mount names + modes, and each mount's include where it sets one, for
1394
- * peer callers (framework spill/journal paths) that need a mount for files
1395
- * of their own, the first writable one without an include
1396
- * (firstWritableMountName), without reaching into private state. */
1491
+ /**
1492
+ * Save directHeads as one global record, the latest of which the next
1493
+ * start loads (loadDirectHeads): each mount's positions with the root
1494
+ * they were made under, and the entries this session didn't take up
1495
+ * (carriedHeads), unchanged. Only when that differs from what the
1496
+ * start loaded, so a session that changed nothing, or a store no direct
1497
+ * write reached, adds no record, and always where the record read wasn't
1498
+ * of the shape saved, which this replaces. Nothing where the store couldn't
1499
+ * read the record, so it stands. A save that fails is logged, and the next
1500
+ * start finds the positions of the last save that landed, as after a
1501
+ * crash.
1502
+ */
1503
+ saveDirectHeads() {
1504
+ if (!this.headsJournal)
1505
+ return;
1506
+ const snapshot = [
1507
+ ...this.carriedHeads,
1508
+ ...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
1509
+ mount: name,
1510
+ root: resolve(this.mounts.get(name).config.path),
1511
+ agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
1512
+ })),
1513
+ ];
1514
+ const text = canonicalHeads(snapshot);
1515
+ if (text === this.loadedHeads)
1516
+ return;
1517
+ try {
1518
+ this.headsJournal.checkpoint(snapshot);
1519
+ this.loadedHeads = text;
1520
+ }
1521
+ catch (err) {
1522
+ console.error('[workspace] could not save the direct-write positions:', err);
1523
+ }
1524
+ }
1525
+ /** Mount names + modes, each mount's include where it sets one, and
1526
+ * `backend: 'direct'` for one configured direct, whether it runs direct
1527
+ * this session or, while the store holds work there disk lacks, as
1528
+ * 'chronicle': either way it's a directory agents write directly when it
1529
+ * runs direct, whose files an undo leaves. For the
1530
+ * framework's tool-result spills, saved payloads and fetched references,
1531
+ * which need a mount for files of their own: the first writable one
1532
+ * without an include that isn't direct (firstWritableMountName), without
1533
+ * reaching into private state. */
1397
1534
  getMounts() {
1398
1535
  return [...this.mounts.values()].map((m) => ({
1399
1536
  name: m.config.name,
1400
- mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
1537
+ // Read-only where this session takes no writes there (writeRefusal).
1538
+ mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
1401
1539
  ...(m.config.include ? { include: [...m.config.include] } : {}),
1540
+ ...(m.config.backend === 'direct' ? { backend: 'direct' } : {}),
1402
1541
  }));
1403
1542
  }
1404
1543
  // ==========================================================================
1405
1544
  // Tool Definitions
1406
1545
  // ==========================================================================
1546
+ /**
1547
+ * Whether any of this host's mounts runs direct. The tools' descriptions
1548
+ * and grep's notes mention direct mounts only on such a host, so both ask
1549
+ * this one question.
1550
+ */
1551
+ anyRunsDirect() {
1552
+ return [...this.mounts.values()].some((m) => m.direct);
1553
+ }
1407
1554
  getTools() {
1408
1555
  // Where a mount runs direct, four descriptions say what that changes.
1409
1556
  // A host with none keeps them as they were, and its residents' caches.
1410
- const direct = [...this.mounts.values()].some((m) => m.direct);
1557
+ const direct = this.anyRunsDirect();
1411
1558
  const lsDirect = direct
1412
1559
  ? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
1413
1560
  + '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.'
@@ -1418,6 +1565,23 @@ export class WorkspaceModule {
1418
1565
  : '';
1419
1566
  const globDirect = direct ? ' On a direct mount, a match carries its size, or a note where the mount doesn\'t take it, and no state.' : '';
1420
1567
  const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
1568
+ // Where a direct mount is writable, the four writing tools say what a
1569
+ // write is there: on disk when it answers, and left there by a head move.
1570
+ const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
1571
+ const writeDirect = writable
1572
+ ? ' 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.'
1573
+ : '';
1574
+ const editDirect = writable
1575
+ ? " 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 "
1576
+ + 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
1577
+ : '';
1578
+ const editManyDirect = writable
1579
+ ? " 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: "
1580
+ + "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
1581
+ + 'An undo or other head move leaves them on disk.'
1582
+ : '';
1583
+ 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.' : '';
1584
+ 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.' : '';
1421
1585
  return [
1422
1586
  {
1423
1587
  name: 'read',
@@ -1451,7 +1615,7 @@ export class WorkspaceModule {
1451
1615
  },
1452
1616
  {
1453
1617
  name: 'write',
1454
- description: 'Create or overwrite a file in the workspace.',
1618
+ description: 'Create or overwrite a file in the workspace.' + writeDirect,
1455
1619
  inputSchema: {
1456
1620
  type: 'object',
1457
1621
  properties: {
@@ -1472,7 +1636,7 @@ export class WorkspaceModule {
1472
1636
  + "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
1473
1637
  + 'A change disk made to the file is taken in first, and the answer says so. '
1474
1638
  + 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
1475
- + 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
1639
+ + 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
1476
1640
  inputSchema: {
1477
1641
  type: 'object',
1478
1642
  properties: {
@@ -1494,7 +1658,8 @@ export class WorkspaceModule {
1494
1658
  + "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
1495
1659
  + 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
1496
1660
  + "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
1497
- + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
1661
+ + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
1662
+ + editManyDirect,
1498
1663
  inputSchema: {
1499
1664
  type: 'object',
1500
1665
  properties: {
@@ -1523,7 +1688,7 @@ export class WorkspaceModule {
1523
1688
  name: 'delete',
1524
1689
  description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
1525
1690
  + 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
1526
- + 'or a sync of the path restores it.',
1691
+ + 'or a sync of the path restores it.' + deleteDirect,
1527
1692
  inputSchema: {
1528
1693
  type: 'object',
1529
1694
  properties: {
@@ -1576,7 +1741,7 @@ export class WorkspaceModule {
1576
1741
  properties: {
1577
1742
  pattern: { type: 'string', description: 'Regular expression pattern' },
1578
1743
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1579
- 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' },
1744
+ 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")' },
1580
1745
  contextBefore: { type: 'integer', description: 'Context lines before match' },
1581
1746
  contextAfter: { type: 'integer', description: 'Context lines after match' },
1582
1747
  },
@@ -1598,7 +1763,7 @@ export class WorkspaceModule {
1598
1763
  description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1599
1764
  + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
1600
1765
  + 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
1601
- + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
1766
+ + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
1602
1767
  inputSchema: {
1603
1768
  type: 'object',
1604
1769
  properties: {
@@ -1716,7 +1881,12 @@ export class WorkspaceModule {
1716
1881
  work.then(done, done);
1717
1882
  return work;
1718
1883
  }
1719
- async runToolCall(call) {
1884
+ runToolCall(call) {
1885
+ const gate = { passed: false };
1886
+ return callGate.run(gate, () => this.answerToolCall(call, gate));
1887
+ }
1888
+ async answerToolCall(call, gate) {
1889
+ const reopenAtStart = this.agreement?.awaitsReopen === true;
1720
1890
  try {
1721
1891
  // Every handler reads its input as its schema describes it; a call
1722
1892
  // that sends something else is refused here, before anything is read.
@@ -1726,20 +1896,25 @@ export class WorkspaceModule {
1726
1896
  return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
1727
1897
  }
1728
1898
  const input = call.input;
1729
- const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1899
+ // The most of this call's result that can reach its caller whole: past
1900
+ // it, a model's result spills into a file, and a script gets
1901
+ // ResultTooLarge in its place. A call made in process carries none and
1902
+ // takes the data itself, so its read pages by its line limit alone.
1903
+ const resultMaxChars = call.resultMaxChars;
1904
+ const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1730
1905
  if (generated) {
1731
1906
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
1732
- return await this.handleRead(input, generated.read());
1907
+ return await this.handleRead(input, resultMaxChars, generated.read());
1733
1908
  }
1734
- return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
1909
+ return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path, generated, call.callerAgentName)) };
1735
1910
  }
1736
1911
  switch (call.name) {
1737
- case 'read': return await this.handleRead(input);
1912
+ case 'read': return await this.handleRead(input, resultMaxChars);
1738
1913
  case 'read_image': return await this.handleReadImage(input);
1739
- case 'write': return await this.handleWrite(input);
1740
- case 'edit': return await this.handleEdit(input, call.resultMaxChars);
1914
+ case 'write': return await this.handleWrite(input, call.callerAgentName);
1915
+ case 'edit': return await this.handleEdit(input, call.resultMaxChars, call.callerAgentName);
1741
1916
  case 'edit_many': return await this.handleEditMany(input, call.resultMaxChars, call.callerAgentName);
1742
- case 'delete': return await this.handleDelete(input);
1917
+ case 'delete': return await this.handleDelete(input, call.callerAgentName);
1743
1918
  case 'ls': return await this.handleLs(input);
1744
1919
  case 'glob': return await this.handleGlob(input);
1745
1920
  case 'grep': return await this.handleGrep(input);
@@ -1747,12 +1922,40 @@ export class WorkspaceModule {
1747
1922
  case 'materialize': return await this.handleMaterialize(input);
1748
1923
  case 'sync': return await this.handleSync(input);
1749
1924
  default:
1750
- return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
1925
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
1751
1926
  }
1752
1927
  }
1753
1928
  catch (err) {
1754
- return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
1929
+ return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
1930
+ }
1931
+ }
1932
+ failureAnswer(call, err, reopenAtStart, passedGate) {
1933
+ const input = (typeof call.input === 'object' && call.input !== null ? call.input : {});
1934
+ const path = typeof input.path === 'string' ? input.path : undefined;
1935
+ const mountName = path !== undefined ? path.split('/')[0] : typeof input.mount === 'string' ? input.mount : undefined;
1936
+ const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
1937
+ const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
1938
+ if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
1939
+ return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
1940
+ }
1941
+ if (err instanceof Refusal)
1942
+ return didNothing(call.name, err.message);
1943
+ if (err instanceof WorkspaceReadError)
1944
+ return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
1945
+ const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
1946
+ : mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
1947
+ : 'ls of each mount, with recursive: true, shows how each file stands against disk now';
1948
+ if (err instanceof NotStored || err instanceof AlreadyHeld) {
1949
+ console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
1950
+ return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
1951
+ }
1952
+ console.error(`[workspace] ${call.name} failed:`, err);
1953
+ if (err instanceof CommitUnknownError) {
1954
+ return unexpectedAnswer(call.name, subject, err.setCause, {
1955
+ said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
1956
+ });
1755
1957
  }
1958
+ return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
1756
1959
  }
1757
1960
  async onProcess(event, _state) {
1758
1961
  // Wake agents on filesystem events when the originating mount opted in.
@@ -1800,7 +2003,7 @@ export class WorkspaceModule {
1800
2003
  text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
1801
2004
  }
1802
2005
  if (conflicts && conflicts.length > 0) {
1803
- text += `\n[conflicts: ${conflicts.join(', ')}]`;
2006
+ text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
1804
2007
  }
1805
2008
  return {
1806
2009
  requestInference: true,
@@ -1848,16 +2051,15 @@ export class WorkspaceModule {
1848
2051
  resolveAbsolutePath(mountPrefixedPath) {
1849
2052
  try {
1850
2053
  // Synchronous, so the path as given. The nested-mount check takes it
1851
- // in each spelling readNesting knows for the nested mount's directory,
1852
- // or in any spelling a disk that folds names may fold to one of them,
1853
- // erring toward that mount (holderOf's asGiven, beginsWithDir). The
1854
- // include judges it as given, so on a disk that
2054
+ // in any spelling a disk that folds names may fold to the nested
2055
+ // mount's directory, erring toward that mount (holderOf). The include
2056
+ // judges it as given, so on a disk that
1855
2057
  // folds names a covered name whose file disk spells otherwise returns
1856
2058
  // that file's path, which the tools refuse, and a covered file named in
1857
2059
  // another case is null, which the tools read. A path the mount doesn't
1858
2060
  // hold, outside its include or in another mount's directory nested in
1859
2061
  // it, is null, as for a path that leaves the mount's directory.
1860
- const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
2062
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
1861
2063
  return resolve(mount.config.path, relativePath);
1862
2064
  }
1863
2065
  catch {
@@ -1879,6 +2081,15 @@ export class WorkspaceModule {
1879
2081
  * withheld original this way, for the branch its stub is on
1880
2082
  * (agent-framework #277).
1881
2083
  *
2084
+ * On a mount running direct, the bytes go to disk as a direct `write`'s
2085
+ * do, whole or not at all, and nothing goes into the store: `branch` is
2086
+ * checked before the write starts and again just before its rename, and
2087
+ * `agentName` names the agent whose write it is for the head-move notice
2088
+ * (a write naming none tells no agent).
2089
+ *
2090
+ * A success's `disk` says where the file stands on disk as the workspace
2091
+ * last saw it, as the write tool's answer does.
2092
+ *
1882
2093
  * A write whose tree entry is set but that then fails returns success
1883
2094
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
1884
2095
  * the store failed after recording the entry, which skips the push where
@@ -1906,20 +2117,36 @@ export class WorkspaceModule {
1906
2117
  isError: true,
1907
2118
  };
1908
2119
  }
1909
- if (mount.config.mode === 'read-only') {
1910
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
1911
- }
2120
+ if (relativePath === '')
2121
+ return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
2122
+ const refusal = writeRefusal(mount, this.writableNames());
2123
+ if (refusal)
2124
+ return { success: false, error: refusal, isError: true };
1912
2125
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
1913
2126
  if (data.byteLength > maxSize) {
1914
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2127
+ return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
1915
2128
  }
1916
2129
  const store = this.getStore();
2130
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
2131
+ // as the caller's agent's write, where it names one. A branch it names
2132
+ // is checked just before the rename, since disk is no branch's.
2133
+ if (mount.direct) {
2134
+ // Checked first too, so a request already for a left branch makes no
2135
+ // directory and stages nothing.
2136
+ if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
2137
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
2138
+ }
2139
+ const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2140
+ if ('error' in written)
2141
+ return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2142
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
2143
+ }
1917
2144
  return this.withMount(mount, async () => {
1918
2145
  await this.settleBeforeMutation(mount, relativePath);
1919
2146
  // Checked inside the mount's turn, after anything the write waited on:
1920
2147
  // commitToolChange sets the tree entry before it first yields.
1921
2148
  if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
1922
- return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
2149
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
1923
2150
  }
1924
2151
  const blobHash = store.storeBlob(data, mimeType);
1925
2152
  // An overwrite keeps the file's mode, as the write tool's does.
@@ -1927,9 +2154,11 @@ export class WorkspaceModule {
1927
2154
  let committed = false;
1928
2155
  let failure;
1929
2156
  let modeKept = null;
2157
+ let disk = null;
1930
2158
  try {
1931
2159
  const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
1932
2160
  modeKept = answer.modeKept;
2161
+ disk = answer.disk;
1933
2162
  failure = answer.refused === null ? null
1934
2163
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
1935
2164
  }
@@ -1938,10 +2167,13 @@ export class WorkspaceModule {
1938
2167
  throw error;
1939
2168
  // As write words it: a store that failed after recording the file
1940
2169
  // skipped its push, so on a mount that pushes, disk lacks it.
2170
+ const reopen = this.agreement?.awaitsReopen === true;
1941
2171
  failure = error instanceof StoredThenFailed
1942
2172
  ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
1943
- + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2173
+ + (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 : ''}.` : '')
1944
2174
  : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2175
+ if (reopen)
2176
+ failure += ` ${AWAITS_REOPEN}`;
1945
2177
  }
1946
2178
  if (failure !== null) {
1947
2179
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -1951,16 +2183,20 @@ export class WorkspaceModule {
1951
2183
  }
1952
2184
  return {
1953
2185
  success: true,
1954
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
2186
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
1955
2187
  };
1956
2188
  });
1957
2189
  }
1958
2190
  /**
1959
- * Read a file's raw bytes from a mount (through the Chronicle tree, synced
1960
- * first like the `read` tool; on a direct mount, from disk as
1961
- * readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
1962
- * API for the framework's synthesized `read_image` tool and other peer
1963
- * callers that need binary content.
2191
+ * Read a file's raw bytes from a mount: through the Chronicle tree, a path
2192
+ * the workspace doesn't hold observed first (ensureSynced); or, on a direct
2193
+ * mount, from disk as readBinaryFromDisk reads it, at most the mount's
2194
+ * `maxFileSize`. A path the workspace holds is read as the workspace holds
2195
+ * it, not settled as the `read` tool settles it: the tree takes in no
2196
+ * binary disk copy, so a pass over a held binary file that disk changed
2197
+ * would drop the workspace's copy. Public API for the framework's
2198
+ * synthesized `read_image` tool, where the workspace module doesn't serve
2199
+ * read_image itself, and other peer callers that need binary content.
1964
2200
  */
1965
2201
  async readBinary(mountPrefixedPath) {
1966
2202
  if (!this.running())
@@ -1983,12 +2219,13 @@ export class WorkspaceModule {
1983
2219
  return this.withMount(mount, async () => {
1984
2220
  const { report, undecided } = await this.ensureSynced(mount, relativePath);
1985
2221
  const entry = store.treeGet(mount.treeStateId, relativePath);
1986
- if (!entry)
1987
- return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
1988
- const blob = store.getBlob(entry.blobHash);
1989
- if (!blob)
1990
- return { error: `Blob not found for: ${mountPrefixedPath}` };
1991
- return { data: blob };
2222
+ if (!entry) {
2223
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
2224
+ }
2225
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2226
+ if ('lost' in contents)
2227
+ return { error: nothingToRead(contents.lost) };
2228
+ return { data: contents.bytes };
1992
2229
  });
1993
2230
  }
1994
2231
  /**
@@ -2066,9 +2303,9 @@ export class WorkspaceModule {
2066
2303
  return { error: error instanceof Error ? error.message : String(error) };
2067
2304
  }
2068
2305
  }
2069
- /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
2306
+ /** The directories of the mounts nested inside a mount, mount-relative. */
2070
2307
  othersOf(mountName) {
2071
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
2308
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
2072
2309
  }
2073
2310
  /**
2074
2311
  * Which mounts are nested in which, read afresh at each start. A mount is
@@ -2083,13 +2320,8 @@ export class WorkspaceModule {
2083
2320
  * alone, with no real root, since through it it would claim the other's
2084
2321
  * every path.
2085
2322
  *
2086
- * Each way it lies there is a spelling of its directory (`fromRoots`), and
2087
- * so is the spelling disk lists each of those by, where that differs. On a
2088
- * disk that folds names, the outer mount's walks name the directory as
2089
- * disk does, and so does a path parsePath has respelled, while a path given
2090
- * as configured still names it as configured (resolveAbsolutePath). A
2091
- * directory not on disk keeps the spellings the roots give alone. Each
2092
- * mount's real root comes back beside the nesting (`reals`), for what
2323
+ * Each way it lies there is a spelling of its directory (`fromRoots`).
2324
+ * Each mount's real root comes back beside the nesting (`reals`), for what
2093
2325
  * start reads there, and each directory above each configured path with
2094
2326
  * where it lies on disk (`above`), for a direct mount whose root moves
2095
2327
  * (movedNesting).
@@ -2116,15 +2348,16 @@ export class WorkspaceModule {
2116
2348
  if (outer === inner)
2117
2349
  continue;
2118
2350
  const real = reals.get(inner.name);
2119
- const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name), outerPath);
2351
+ const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name));
2120
2352
  if (at === undefined)
2121
2353
  continue;
2122
- const { configured, fromRoots, spellings, claims } = at;
2123
- nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2354
+ const { configured, fromRoots, claims } = at;
2355
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
2124
2356
  const placed = fromRoots.filter((s) => s.dir !== configured);
2125
2357
  if (placed.length > 0) {
2126
- console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2127
- `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`);
2358
+ const at = (sure) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
2359
+ 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 ');
2360
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
2128
2361
  }
2129
2362
  }
2130
2363
  }
@@ -2132,10 +2365,10 @@ export class WorkspaceModule {
2132
2365
  }
2133
2366
  /**
2134
2367
  * The mounts nested in `mountName`, by directory, name, real root where it
2135
- * has one, and include (MountView's `nested`), in each spelling known.
2368
+ * has one, and include (MountView's `nested`).
2136
2369
  */
2137
2370
  nestedOf(mountName) {
2138
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2371
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => 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 })));
2139
2372
  }
2140
2373
  /**
2141
2374
  * The mounts nested in `mount` as a call of it that resolved its root to
@@ -2169,8 +2402,10 @@ export class WorkspaceModule {
2169
2402
  * mount nested by its directories alone, since its real root holds this
2170
2403
  * one, is judged by the parse's nesting, so a root repointed between a
2171
2404
  * read's parse and its open can have it serve a path in that mount's
2172
- * directory. Each mount is placed as a start places it (placementOf), from
2173
- * where its real root and the directories above its configured path lie on
2405
+ * directory, and one repointed between a direct write's parse and its
2406
+ * staging can have the write land there. Each mount is placed as a start
2407
+ * places it (placementOf), from where its real root and the directories
2408
+ * above its configured path lie on
2174
2409
  * disk. For a mount configured at this mount's own path or beneath it,
2175
2410
  * that's where they lie now: beneath `rootReal` where they lie beneath this
2176
2411
  * mount's path, and the directories above this mount's path as resolving
@@ -2235,12 +2470,13 @@ export class WorkspaceModule {
2235
2470
  continue;
2236
2471
  above = placedAbove.get(inner.name) ?? [];
2237
2472
  }
2238
- const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2473
+ const at = placementOf(outerPath, rootReal, innerPath, real, above);
2239
2474
  if (at === undefined)
2240
2475
  continue;
2241
2476
  const include = this.mounts.get(inner.name)?.config.include;
2242
- for (const s of at.spellings)
2243
- nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2477
+ for (const s of at.fromRoots) {
2478
+ nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
2479
+ }
2244
2480
  }
2245
2481
  return nested;
2246
2482
  }
@@ -2249,15 +2485,9 @@ export class WorkspaceModule {
2249
2485
  * of that mount, judged lexically where the path lands (`.`, `..` and
2250
2486
  * repeated separators resolved, as for the traversal guard; a link of the
2251
2487
  * outer mount isn't followed), with the path to use there; undefined when
2252
- * no nested mount's directory holds it. A path parsePath has respelled is
2253
- * compared with the directory's spellings exactly. One judged as given
2254
- * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2255
- * folds names may compare it (beginsWithDir), so it can't reach that
2256
- * mount's directory by spelling a segment otherwise. On a disk that tells
2257
- * names apart, that takes a directory of this mount's own spelled like the
2258
- * nested one in another way as that mount's too.
2488
+ * no nested mount's directory holds it.
2259
2489
  */
2260
- holderOf(mountName, relativePath, asGiven = false, moved = null) {
2490
+ holderOf(mountName, relativePath, moved = null) {
2261
2491
  const mount = this.mounts.get(mountName);
2262
2492
  if (!mount)
2263
2493
  return undefined;
@@ -2265,20 +2495,15 @@ export class WorkspaceModule {
2265
2495
  const resolved = resolve(root, relativePath);
2266
2496
  if (!isContainedPath(root, resolved))
2267
2497
  return undefined;
2268
- const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2498
+ const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
2269
2499
  // A direct mount whose root a call found moved is judged by that call's
2270
2500
  // nesting (movedNesting), each directory beneath its configured path.
2271
- const dirs = moved !== null
2272
- ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2273
- : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2274
- const holder = dirs
2275
- .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2276
- .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2277
- .sort((a, b) => b.depth - a.depth)[0];
2501
+ const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
2278
2502
  if (!holder)
2279
2503
  return undefined;
2280
- const rest = segments.slice(holder.depth).join('/');
2281
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2504
+ const rest = restBeneath(path, holder);
2505
+ const guess = nestedGuess(mountName, path.split('/'), holder);
2506
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
2282
2507
  }
2283
2508
  /**
2284
2509
  * Why a mount leaves a path in a nested mount's directory, naming that
@@ -2286,10 +2511,10 @@ export class WorkspaceModule {
2286
2511
  * it; for a listing, whose the path is.
2287
2512
  */
2288
2513
  otherMountNote(mountName, relativePath, by, moved = null) {
2289
- const holder = this.holderOf(mountName, relativePath, false, moved);
2514
+ const holder = this.holderOf(mountName, relativePath, moved);
2290
2515
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2291
2516
  return holder
2292
- ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2517
+ ? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
2293
2518
  : `it lies in another mount's directory${what}`;
2294
2519
  }
2295
2520
  /**
@@ -2308,7 +2533,7 @@ export class WorkspaceModule {
2308
2533
  // Both checks judge by the nesting the parse found, and the second reads
2309
2534
  // disk beneath the root the parse resolved.
2310
2535
  const { mount, root, moved } = spelled;
2311
- const parsed = this.refuseNotHeld(spelled, path, false, moved);
2536
+ const parsed = this.refuseNotHeld(spelled, path, moved);
2312
2537
  // A path can also reach a nested mount's directory, or a file this
2313
2538
  // mount's own include leaves out, through a symlink on its way. No push
2314
2539
  // of this mount writes it there and no read reads it, so it's refused
@@ -2350,7 +2575,9 @@ export class WorkspaceModule {
2350
2575
  // whatever the place realpath stopped short of would say.
2351
2576
  if (judged && unresolved.code !== undefined)
2352
2577
  return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2353
- const holder = nestedAtReal(real, nested);
2578
+ let holder = await nestedAtReal(real, nested);
2579
+ if (holder?.guess === null)
2580
+ holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
2354
2581
  if (!holder) {
2355
2582
  if (!judged)
2356
2583
  return null;
@@ -2369,26 +2596,25 @@ export class WorkspaceModule {
2369
2596
  // on the place's way as that mount's, so a place beneath anything else
2370
2597
  // there, such as a file the include leaves out, isn't named; one beneath
2371
2598
  // a directory not there yet is.
2372
- const onItsWay = holder.include === undefined
2599
+ const onItsWay = !holder.judgedByInclude
2373
2600
  || await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
2374
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2375
- if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory))
2601
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
2602
+ if (!onItsWay || !holder.names(directory))
2376
2603
  return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2377
- return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2604
+ return new NestedMountError(path, mount.config.name, holder, 'leads into');
2378
2605
  }
2379
2606
  /**
2380
2607
  * A parsed path, refused where the mount doesn't hold it: in a nested
2381
2608
  * mount's directory first, naming the mount that holds it, then outside
2382
2609
  * the mount's include. Each is judged on the path as the caller parsed it:
2383
2610
  * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2384
- * which is synchronous and says so (`asGiven`, so the nested-mount check
2385
- * folds where disk does). The include's refusal shows the path as given
2611
+ * which is synchronous. The include's refusal shows the path as given
2386
2612
  * (`given`), since it answers the path the caller asked about.
2387
2613
  */
2388
- refuseNotHeld(parsed, given, asGiven = false, moved = null) {
2389
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2614
+ refuseNotHeld(parsed, given, moved = null) {
2615
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
2390
2616
  if (holder)
2391
- throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2617
+ throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
2392
2618
  if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2393
2619
  throw new OutsideIncludeError(given, parsed.mount.config);
2394
2620
  }
@@ -2434,7 +2660,7 @@ export class WorkspaceModule {
2434
2660
  * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2435
2661
  */
2436
2662
  async refusesAsOutside(mount, relativePath, moved, root) {
2437
- if (this.holderOf(mount.config.name, relativePath, false, moved))
2663
+ if (this.holderOf(mount.config.name, relativePath, moved))
2438
2664
  return false;
2439
2665
  if (insideMount(relativePath, mount.config.include))
2440
2666
  return false;
@@ -2509,20 +2735,12 @@ export class WorkspaceModule {
2509
2735
  }
2510
2736
  /**
2511
2737
  * How many segments the innermost nested mount's directory that holds
2512
- * `relativePath` has, in a spelling it's known by (othersOf), or, for a
2513
- * direct mount whose root has moved, in the nesting there (`moved`);
2738
+ * `relativePath` has, or, for a direct mount whose root has moved, in the
2739
+ * nesting there (`moved`);
2514
2740
  * undefined when no nested mount's directory holds it.
2515
2741
  */
2516
2742
  nestedDepth(mountName, relativePath, moved = null) {
2517
- let depth;
2518
- for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
2519
- if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
2520
- continue;
2521
- const segments = dir.split('/').length;
2522
- if (depth === undefined || segments > depth)
2523
- depth = segments;
2524
- }
2525
- return depth;
2743
+ return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
2526
2744
  }
2527
2745
  /**
2528
2746
  * A mount's entries on the current branch, and the paths it records an
@@ -2544,11 +2762,7 @@ export class WorkspaceModule {
2544
2762
  // Only an entry this mount's include covers spells a draft's path
2545
2763
  // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
2546
2764
  // from an earlier version, nor one the include leaves out, which would
2547
- // respell a covered draft into a name the include leaves out. An entry
2548
- // in a nested mount's directory that this mount's include covers
2549
- // spells one whatever that mount's include says, so on a disk that
2550
- // folds names, a draft naming the directory in another spelling takes
2551
- // the entry's spelling and is judged as in it.
2765
+ // respell a covered draft into a name the include leaves out.
2552
2766
  under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
2553
2767
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
2554
2768
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
@@ -2573,25 +2787,25 @@ export class WorkspaceModule {
2573
2787
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
2574
2788
  // store would keep one name and disk another.
2575
2789
  if (LONE_SURROGATE.test(path)) {
2576
- 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.`);
2790
+ 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.`);
2577
2791
  }
2578
2792
  const slashIdx = path.indexOf('/');
2579
2793
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
2580
2794
  const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2581
2795
  const mount = this.mounts.get(mountName);
2582
- if (!mount) {
2583
- throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
2584
- }
2796
+ if (!mount)
2797
+ throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
2585
2798
  const segments = pathSegments(given);
2586
2799
  if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
2587
- 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.`);
2800
+ 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>".`);
2588
2801
  }
2589
2802
  const kept = [];
2590
2803
  for (const segment of segments) {
2591
2804
  if (segment !== '..')
2592
2805
  kept.push(segment);
2593
2806
  else if (kept.pop() === undefined) {
2594
- throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
2807
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
2808
+ + `Name a file from its own mount's name; ${this.mountList()}.`);
2595
2809
  }
2596
2810
  }
2597
2811
  const relativePath = kept.join('/');
@@ -2605,10 +2819,37 @@ export class WorkspaceModule {
2605
2819
  const root = resolve(mount.config.path);
2606
2820
  const resolved = resolve(root, relativePath);
2607
2821
  if (!isContainedPath(root, resolved)) {
2608
- throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
2822
+ 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>".`);
2609
2823
  }
2610
2824
  return { mount, relativePath };
2611
2825
  }
2826
+ writableNames() {
2827
+ return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
2828
+ }
2829
+ mountList() {
2830
+ const names = [...this.mounts.keys()];
2831
+ return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
2832
+ }
2833
+ noSuchMountField(given, bare) {
2834
+ const [close] = closestNames(given, [...this.mounts.keys()], 1);
2835
+ return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
2836
+ + (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
2837
+ + ` Leaving "mount" out ${bare}.`;
2838
+ }
2839
+ noSuchMount(path, mountName) {
2840
+ const names = [...this.mounts.keys()];
2841
+ const rest = path.slice(mountName.length);
2842
+ const unslashed = path.replace(/^[/\\]+/, '');
2843
+ if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
2844
+ return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
2845
+ }
2846
+ if (mountName === '')
2847
+ return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
2848
+ const [close] = closestNames(mountName, names, 1);
2849
+ const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
2850
+ : `${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()}.`;
2851
+ return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
2852
+ }
2612
2853
  /**
2613
2854
  * Whether the module takes work: stop hasn't finished. Work admitted while
2614
2855
  * stop drains still runs; once stop has saved the pins and watermarks,
@@ -2627,25 +2868,23 @@ export class WorkspaceModule {
2627
2868
  }
2628
2869
  validateImageBytes(bytes, mountPrefixedPath, maxSize) {
2629
2870
  if (bytes.byteLength === 0) {
2630
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
2871
+ throw emptyImage(mountPrefixedPath);
2631
2872
  }
2632
2873
  if (bytes.byteLength > maxSize)
2633
- throw imageTooLarge(maxSize, mountPrefixedPath);
2874
+ throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
2634
2875
  return {
2635
2876
  bytes,
2636
2877
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
2637
2878
  };
2638
2879
  }
2639
- tryReadImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
2640
- const store = this.getStore();
2641
- const entry = store.treeGet(mount.treeStateId, relativePath);
2880
+ async readImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
2881
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
2642
2882
  if (!entry)
2643
2883
  return null;
2644
- const blob = store.getBlob(entry.blobHash);
2645
- if (!blob) {
2646
- throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
2647
- }
2648
- return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
2884
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2885
+ if ('lost' in contents)
2886
+ return contents;
2887
+ return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
2649
2888
  }
2650
2889
  /**
2651
2890
  * The mount root a disk read reads beneath: resolved, then checked as
@@ -2661,20 +2900,14 @@ export class WorkspaceModule {
2661
2900
  * another directory carries none. The identity is rootIdentityForRead's,
2662
2901
  * which answers while the evidence awaits reopening; where even it can't
2663
2902
  * be read, as when the evidence was never read back whole, the read is
2664
- * refused too, with an errno only where no directory is at the root's path.
2903
+ * refused before its root is resolved, with an errno only where no
2904
+ * directory is at the root's path.
2665
2905
  * A direct mount's root is checked against no identity, and its read reads
2666
2906
  * no evidence: it promises what's at its root now (diskView).
2667
2907
  */
2668
2908
  async readRoot(mount) {
2669
2909
  const name = mount.config.name;
2670
2910
  const root = resolve(mount.config.path);
2671
- let real;
2672
- try {
2673
- real = await realpath(root);
2674
- }
2675
- catch (err) {
2676
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
2677
- }
2678
2911
  let rootIdentity;
2679
2912
  if (!mount.direct) {
2680
2913
  try {
@@ -2683,9 +2916,16 @@ export class WorkspaceModule {
2683
2916
  catch (err) {
2684
2917
  // The evidence can't be read back, so neither can the check be made.
2685
2918
  // Where no directory is at the root's path, its errno still says so.
2686
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2919
+ throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
2687
2920
  }
2688
2921
  }
2922
+ let real;
2923
+ try {
2924
+ real = await realpath(root);
2925
+ }
2926
+ catch (err) {
2927
+ throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
2928
+ }
2689
2929
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2690
2930
  if ('dir' in checked)
2691
2931
  return { real, dir: checked.dir };
@@ -2693,7 +2933,7 @@ export class WorkspaceModule {
2693
2933
  // root's path now decides: nothing, something that isn't a directory, or
2694
2934
  // a directory the path has come to name since it was resolved (no errno).
2695
2935
  const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2696
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2936
+ throw new RootUnavailable(name, checked.reason, 'root', errno);
2697
2937
  }
2698
2938
  /**
2699
2939
  * The refusal for a root that stopped being the directory a read checked,
@@ -2707,7 +2947,7 @@ export class WorkspaceModule {
2707
2947
  async rootMoved(mount) {
2708
2948
  const errno = await rootAbsence(resolve(mount.config.path));
2709
2949
  const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
2710
- return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2950
+ return new RootUnavailable(mount.config.name, reason, 'root', errno);
2711
2951
  }
2712
2952
  /**
2713
2953
  * Open a mount-relative file for reading with the mount boundary enforced on
@@ -2797,7 +3037,7 @@ export class WorkspaceModule {
2797
3037
  }
2798
3038
  if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2799
3039
  // A FIFO, socket or device is never opened.
2800
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
3040
+ throw new NotRegularFile(mountPrefixedPath, 'lstat');
2801
3041
  }
2802
3042
  let handle;
2803
3043
  try {
@@ -2828,7 +3068,7 @@ export class WorkspaceModule {
2828
3068
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
2829
3069
  }
2830
3070
  if (!fileStat.isFile()) {
2831
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
3071
+ throw new NotRegularFile(mountPrefixedPath, 'fstat');
2832
3072
  }
2833
3073
  if (!follow && !useNoFollow) {
2834
3074
  let postOpenInfo;
@@ -2866,13 +3106,13 @@ export class WorkspaceModule {
2866
3106
  if (resolvedThrough !== null)
2867
3107
  throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
2868
3108
  const inside = isContainedPath(realMountRoot, realFilePath);
2869
- const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3109
+ const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
2870
3110
  // A place in a nested mount's directory that its include leaves out is
2871
3111
  // no mount's, so the path leaves this one (namesNestedPlace), and so
2872
3112
  // does one reaching a file this mount's own include leaves out
2873
3113
  // (coversLanding).
2874
3114
  const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
2875
- if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
3115
+ if (!inside || own || (holder !== undefined && !holder.names(false))) {
2876
3116
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
2877
3117
  }
2878
3118
  if (holder)
@@ -2941,7 +3181,9 @@ export class WorkspaceModule {
2941
3181
  * (`outside_include`), a path in another mount's directory nested in the one named
2942
3182
  * (`other_mount`), unavailable mount, missing or non-regular file,
2943
3183
  * directory, policy-denied symlink, outside-mount target, and
2944
- * file-changed-during-read.
3184
+ * file-changed-during-read. An error met while parsing the path that is
3185
+ * none of these refusals, such as the store's, is thrown as it is, and a
3186
+ * `maxBytes` that isn't a non-negative integer throws a `RangeError`.
2945
3187
  */
2946
3188
  async readFileFromDisk(mountPrefixedPath, options = {}) {
2947
3189
  if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
@@ -2956,11 +3198,12 @@ export class WorkspaceModule {
2956
3198
  // A link's refusal (parseFilePath) carries its own code.
2957
3199
  if (err instanceof WorkspaceReadError)
2958
3200
  throw err;
2959
- const message = err instanceof Error ? err.message : String(err);
2960
3201
  const code = err instanceof OutsideIncludeError ? 'outside_include'
2961
3202
  : err instanceof NestedMountError ? 'other_mount'
2962
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
2963
- throw new WorkspaceReadError(code, message, 'parse');
3203
+ : err instanceof PathRefusal ? err.code : null;
3204
+ if (code === null)
3205
+ throw err;
3206
+ throw new WorkspaceReadError(code, err.message, 'parse');
2964
3207
  }
2965
3208
  if (!relativePath) {
2966
3209
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
@@ -2990,93 +3233,25 @@ export class WorkspaceModule {
2990
3233
  await handle.close();
2991
3234
  }
2992
3235
  }
2993
- /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
2994
- imageErrorFromRead(err, mount, mountPrefixedPath) {
2995
- const p = mountPrefixedPath;
2996
- switch (err.code) {
2997
- case 'mount_unavailable':
2998
- // With why, where the root's check says, such as a directory that
2999
- // isn't the mount's.
3000
- return new WorkspaceImageReadError('mount_unavailable', err.message);
3001
- case 'directory':
3002
- return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
3003
- case 'symlink':
3004
- // The core reader's own words for a link at the path: "Symlinks are not
3005
- // allowed: <path>".
3006
- return new WorkspaceImageReadError('symlink', err.message);
3007
- case 'parent_symlink':
3008
- // The core reader's own words for a link on its way: which link and
3009
- // where it leads, with the setting for a link to a directory.
3010
- return new WorkspaceImageReadError('parent_symlink', err.message);
3011
- case 'other_mount':
3012
- // The core reader's own words for a path a link takes into a nested
3013
- // mount's directory: that mount and the path to use there. (#460's
3014
- // own other_mount, a path in that directory, is refused at parse,
3015
- // and so is a link found there before the read.)
3016
- return new WorkspaceImageReadError('other_mount', err.message);
3017
- case 'escape':
3018
- // The core reader's own words: "Symlink escape detected: <path>" for a
3019
- // followed link that resolves outside the mount, and for a link on the
3020
- // way that leads outside a mount that doesn't follow links, which link.
3021
- return new WorkspaceImageReadError('escape', err.message);
3022
- case 'changed':
3023
- return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
3024
- case 'not_found':
3025
- default:
3026
- // The core reader's own words for a file that isn't regular, and for
3027
- // a directory on the way it couldn't ask about.
3028
- if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
3029
- return new WorkspaceImageReadError('not_found', err.message);
3030
- }
3031
- if (err.message.startsWith('File not found')) {
3032
- return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
3033
- }
3034
- if (err.stage === 'realpath') {
3035
- return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
3036
- }
3037
- if (err.stage === 'stat') {
3038
- return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
3039
- }
3040
- return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
3041
- }
3042
- }
3043
3236
  async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
3044
- let opened;
3045
- try {
3046
- opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3047
- if (fileStat.size === 0) {
3048
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3049
- }
3050
- if (fileStat.size > maxSize)
3051
- throw imageTooLarge(maxSize, mountPrefixedPath);
3052
- });
3053
- }
3054
- catch (err) {
3055
- if (err instanceof WorkspaceImageReadError)
3056
- throw err;
3057
- if (err instanceof WorkspaceReadError)
3058
- throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
3059
- if (isErrnoCode(err, 'ENOENT')) {
3060
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3061
- }
3062
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3063
- }
3064
- const { handle, fileStat } = opened;
3237
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3238
+ if (fileStat.size === 0)
3239
+ throw emptyImage(mountPrefixedPath);
3240
+ if (fileStat.size > maxSize)
3241
+ throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
3242
+ });
3065
3243
  try {
3066
3244
  // No more than maxSize is loaded: a file grown past it since the fstat
3067
3245
  // above is refused here, as one already over it was there.
3068
3246
  const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
3069
3247
  if (more)
3070
- throw imageTooLarge(maxSize, mountPrefixedPath);
3248
+ throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
3071
3249
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
3072
3250
  }
3073
3251
  catch (err) {
3074
3252
  if (err instanceof WorkspaceImageReadError)
3075
3253
  throw err;
3076
- if (isErrnoCode(err, 'ENOENT')) {
3077
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3078
- }
3079
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3254
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
3080
3255
  }
3081
3256
  finally {
3082
3257
  await handle.close();
@@ -3108,13 +3283,14 @@ export class WorkspaceModule {
3108
3283
  * downstream agents. And, apart from it, why a write whose bytes reached
3109
3284
  * disk left the file's mode as disk had it (`modeKept`), and, for a
3110
3285
  * deletion that cleared a conflict naming a disk file the workspace never
3111
- * took in, that nothing was unlinked (`note`). The caller holds the mount's
3112
- * turn and has settled the path first (settleBeforeMutation, or a pass over
3113
- * the path, as an edit makes).
3286
+ * took in, that nothing was unlinked (`note`). And the answer's `disk` for
3287
+ * the path, unless it was refused. The caller holds the mount's turn and has
3288
+ * settled the path first (settleBeforeMutation, or a pass over the path, as
3289
+ * an edit makes).
3114
3290
  */
3115
3291
  async commitToolChange(mount, relativePath, change, onCommitted) {
3116
- const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3117
- return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3292
+ const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3293
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
3118
3294
  }
3119
3295
  /**
3120
3296
  * commitToolChange for several paths of one mount at once, as edit_many
@@ -3129,9 +3305,10 @@ export class WorkspaceModule {
3129
3305
  * (changesLanded).
3130
3306
  * Answers, by path, the reason for each that reached the workspace but not
3131
3307
  * disk (`refused`), and, apart from it, why one whose bytes reached disk
3132
- * left the file's mode as disk had it (`modeKept`), and that a deletion
3133
- * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3134
- * commitToolChange answers them for one path.
3308
+ * left the file's mode as disk had it (`modeKept`), that a deletion
3309
+ * clearing a conflict that named a disk file unlinked nothing (`notes`),
3310
+ * and the answer's `disk` for each path not refused, as commitToolChange
3311
+ * answers them for one path.
3135
3312
  */
3136
3313
  async commitToolChanges(mount, changes, onCommitted) {
3137
3314
  const store = this.getStore();
@@ -3280,7 +3457,7 @@ export class WorkspaceModule {
3280
3457
  // nothing.
3281
3458
  if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3282
3459
  putBack();
3283
- throw error;
3460
+ throw changing.length === 0 ? new AlreadyHeld(error) : error;
3284
3461
  }
3285
3462
  landedThenThrew = { error };
3286
3463
  }
@@ -3298,8 +3475,9 @@ export class WorkspaceModule {
3298
3475
  throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3299
3476
  const refused = new Map();
3300
3477
  const modeKept = new Map();
3301
- if (!this.pushesOnCommit(mount) || pushing.length === 0)
3302
- return { refused, modeKept, notes };
3478
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) {
3479
+ return { refused, modeKept, notes, disk: new Map(changes.map(({ path }) => [path, this.diskOf(mount, path)])) };
3480
+ }
3303
3481
  const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3304
3482
  applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
3305
3483
  branchId: store.currentBranch().id,
@@ -3321,7 +3499,7 @@ export class WorkspaceModule {
3321
3499
  error: skipped.reason,
3322
3500
  });
3323
3501
  }
3324
- return { refused, modeKept, notes };
3502
+ return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }) => [path, 'done'])) };
3325
3503
  }
3326
3504
  // ==========================================================================
3327
3505
  // Lazy Sync
@@ -3343,15 +3521,19 @@ export class WorkspaceModule {
3343
3521
  await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3344
3522
  }
3345
3523
  /**
3346
- * Before a read: a path the workspace doesn't hold is observed under the
3347
- * three-way rule, which ingests a new disk file but never brings back a
3348
- * workspace deletion or a file absent from this branch. The pass names the
3349
- * path, as a file tool names the one it acts on: a path the mount passes
3350
- * over by design is observed, as a sync naming it would observe it (#276),
3351
- * while a path in another mount's directory is refused before this, and
3352
- * passed over by any pass. Answers the pass's report on the path, and why
3353
- * the pass decided nothing where it did (undecidedOf), which leaves disk
3354
- * unchecked. The caller holds the mount's turn.
3524
+ * Before a deletion, and for readBinary: a path the workspace doesn't hold
3525
+ * is observed under the three-way rule, which ingests a new disk file but
3526
+ * never brings back a workspace deletion or a file absent from this branch.
3527
+ * A path it holds is left alone. For a deletion, as for a write, taking
3528
+ * disk's change in first would let the push remove that change unseen; for
3529
+ * readBinary, a pass would drop a held binary file that disk changed, since
3530
+ * the tree takes in no binary disk copy. The pass names the path, as a file
3531
+ * tool names the one it acts on: a path the mount passes over by design is
3532
+ * observed, as a sync naming it would observe it (#276), while a path in
3533
+ * another mount's directory is refused before this, and passed over by any
3534
+ * pass. Answers the pass's report on the path, and why the pass decided
3535
+ * nothing where it did (undecidedOf), which leaves disk unchecked. The
3536
+ * caller holds the mount's turn.
3355
3537
  */
3356
3538
  async ensureSynced(mount, relativePath) {
3357
3539
  const store = this.getStore();
@@ -3366,46 +3548,79 @@ export class WorkspaceModule {
3366
3548
  * call made again checks it.
3367
3549
  */
3368
3550
  notCheckedYet(path, reason) {
3369
- return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3551
+ 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.`;
3370
3552
  }
3371
3553
  /** Why the workspace holds no entry at a path, when disk explains it. */
3372
- notInWorkspace(path, report) {
3554
+ notInWorkspace(path, report, mount, missing) {
3555
+ const shown = JSON.stringify(path);
3373
3556
  switch (report?.state) {
3374
3557
  case 'workspace-deleted':
3375
- return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3558
+ return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3376
3559
  case 'not-in-branch':
3377
- return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3378
- case 'disk-only':
3379
- return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
3380
- (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
3560
+ return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3561
+ case 'disk-only': {
3562
+ const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
3563
+ return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
3564
+ + 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
3565
+ + (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
3566
+ }
3381
3567
  case 'conflict':
3382
- return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
3568
+ return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3383
3569
  'Sync this path to take the disk version.';
3384
3570
  case 'unverified':
3385
- return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
3571
+ return uncheckedFile(shown, report.note);
3386
3572
  default:
3387
- return `File not found: ${path}`;
3573
+ return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3388
3574
  }
3389
3575
  }
3390
3576
  /**
3391
- * A file's bytes for read, from where the mount keeps what it reads: the
3392
- * workspace's copy, taken in from disk first where it holds none
3393
- * (ensureSynced); or, on a direct mount, disk's file as it is now
3394
- * (bytesOnDisk). An error says why there are none to read.
3577
+ * A file's bytes for read, and the notes that say which copy they are. On a
3578
+ * 'chronicle' mount it's the workspace's copy, after a pass over the path
3579
+ * takes disk's change to it in first. On a direct mount it's disk's file as
3580
+ * it is now (bytesOnDisk), with no notes, since nothing else is kept. An
3581
+ * error says why there are none to read.
3395
3582
  */
3396
3583
  async bytesOf(mount, relativePath, path) {
3397
- if (mount.direct)
3398
- return this.bytesOnDisk(mount, relativePath, path);
3584
+ if (mount.direct) {
3585
+ const read = await this.bytesOnDisk(mount, relativePath, path);
3586
+ return 'error' in read ? read : { bytes: read.bytes, notes: [] };
3587
+ }
3399
3588
  const store = this.getStore();
3400
3589
  return this.withMount(mount, async () => {
3401
- const { report } = await this.ensureSynced(mount, relativePath);
3590
+ // A read shows the path as disk holds it now: a change on disk to a
3591
+ // file the workspace holds is taken in first, by the rule a listing
3592
+ // uses, as an edit's is. Where the stored copy is a draft and disk
3593
+ // changed too, the read shows the draft and names the conflict. The
3594
+ // pass names the path, as a file tool names the one it acts on, so a
3595
+ // path the mount passes over by design is observed, as a sync naming
3596
+ // it would observe it (#276).
3597
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3598
+ // A pass that decided nothing (undecidedOf: the selected branch kept
3599
+ // changing) leaves no report. It's read as a copy disk couldn't be
3600
+ // checked, with its reason, so the answer says the check didn't finish.
3601
+ const undecided = undecidedOf(pass);
3602
+ const report = pass.reports.get(relativePath)
3603
+ ?? (undecided !== null ? { path: relativePath, state: 'unverified', note: undecided } : undefined);
3604
+ // A check that decided nothing may well finish on the next read, as
3605
+ // grep's answer to the same says of grep.
3606
+ const again = undecided !== null ? ['Read again to check it.'] : [];
3607
+ // A mode taken in alone isn't a change to the text, so it leaves the
3608
+ // read nothing to say, as it leaves an edit nothing.
3609
+ const took = (op) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
3402
3610
  const entry = store.treeGet(mount.treeStateId, relativePath);
3403
- if (!entry)
3404
- return { error: this.notInWorkspace(path, report) };
3405
- const blob = store.getBlob(entry.blobHash);
3406
- if (!blob)
3407
- return { error: `Blob not found for: ${path}` };
3408
- return { bytes: blob };
3611
+ if (!entry) {
3612
+ // A pass also drops a copy disk made binary or too large, which
3613
+ // the disk-only answer describes; any other drop is disk's deletion.
3614
+ return {
3615
+ error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
3616
+ : undecided !== null ? this.notCheckedYet(path, undecided)
3617
+ : this.notInWorkspace(path, report, mount),
3618
+ };
3619
+ }
3620
+ const contents = await this.contentsOf(mount, relativePath, path, entry);
3621
+ if ('lost' in contents)
3622
+ return { error: nothingToRead(contents.lost) };
3623
+ return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3409
3624
  });
3410
3625
  }
3411
3626
  /**
@@ -3418,152 +3633,203 @@ export class WorkspaceModule {
3418
3633
  */
3419
3634
  async bytesOnDisk(mount, relativePath, path) {
3420
3635
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3421
- const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3636
+ const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
3422
3637
  let opened;
3423
3638
  try {
3424
3639
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3425
3640
  if (fileStat.size > maxSize)
3426
- throw new Error(over);
3641
+ throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
3427
3642
  });
3428
3643
  }
3429
3644
  catch (err) {
3430
- // A refusal that names only the path says why with its errno, as a
3431
- // failed read below does.
3432
- const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
3433
- return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
3645
+ if (err instanceof Refusal)
3646
+ return { error: err.message };
3647
+ if (err instanceof WorkspaceReadError)
3648
+ return { error: readRefusal(err, path) };
3649
+ throw err;
3434
3650
  }
3435
3651
  try {
3436
3652
  // No more than the limit is loaded, as for a file over it at its fstat.
3437
3653
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
3438
3654
  if (more)
3439
- return { error: over };
3655
+ return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
3440
3656
  if (looksBinary(bytes)) {
3441
- return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3657
+ 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.'}` };
3442
3658
  }
3443
3659
  return { bytes };
3444
3660
  }
3445
3661
  catch (err) {
3446
- const errno = errnoOf(err);
3447
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3662
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
3448
3663
  }
3449
3664
  finally {
3450
3665
  await opened.handle.close();
3451
3666
  }
3452
3667
  }
3668
+ async contentsOf(mount, relativePath, path, version, what = 'saved', tell = 'now') {
3669
+ const store = this.getStore();
3670
+ const stored = storedBytes(store, version.blobHash);
3671
+ if ('bytes' in stored)
3672
+ return stored;
3673
+ const otherSize = new Error('other size');
3674
+ let found;
3675
+ try {
3676
+ const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3677
+ if (fileStat.size !== version.size)
3678
+ throw otherSize;
3679
+ });
3680
+ try {
3681
+ const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
3682
+ found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
3683
+ }
3684
+ finally {
3685
+ await opened.handle.close();
3686
+ }
3687
+ }
3688
+ catch (err) {
3689
+ found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
3690
+ }
3691
+ const subject = contentsSubject(what, path);
3692
+ const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
3693
+ if ('disk' in found) {
3694
+ const now = storedBytes(store, version.blobHash);
3695
+ if ('bytes' in now)
3696
+ return now;
3697
+ const lost = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
3698
+ if (tell === 'now')
3699
+ this.tellLost(mount.config.name, what, version.blobHash, lost);
3700
+ return { lost };
3701
+ }
3702
+ const { bytes } = found;
3703
+ if ('bytes' in storedBytes(store, version.blobHash))
3704
+ return { bytes };
3705
+ let thrown = null;
3706
+ try {
3707
+ store.storeBlob(bytes, 'application/octet-stream');
3708
+ }
3709
+ catch (error) {
3710
+ thrown = error instanceof Error ? error.message : String(error);
3711
+ }
3712
+ // Chronicle's storeBlob takes any file already at the hash's path as
3713
+ // stored, so whether the bytes took is what a read of them gives now.
3714
+ const now = storedBytes(store, version.blobHash);
3715
+ if ('bytes' in now) {
3716
+ this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
3717
+ }
3718
+ else {
3719
+ const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
3720
+ this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', `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}. `
3721
+ + ('unreadable' in now
3722
+ ? "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."
3723
+ : 'Each read does this again until the store takes them.'), { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
3724
+ }
3725
+ return { bytes };
3726
+ }
3727
+ tellLost(mountName, what, blobHash, lost) {
3728
+ this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', `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)}. `
3729
+ + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
3730
+ }
3731
+ async diskLacks(mount, relativePath, path, err) {
3732
+ const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
3733
+ if (!absent)
3734
+ return { unreadable: containedRefusal(err, path) };
3735
+ const root = resolve(mount.config.path);
3736
+ const rootReal = await realpath(root).catch(() => root);
3737
+ const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
3738
+ const proof = await confirmAbsent(view, rootReal, relativePath);
3739
+ if (typeof proof !== 'string')
3740
+ return 'none';
3741
+ return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
3742
+ }
3743
+ async restoreEach(mount, versions, each) {
3744
+ const byHash = new Map();
3745
+ for (const version of versions) {
3746
+ const sharing = byHash.get(version.hash);
3747
+ if (sharing)
3748
+ sharing.push(version);
3749
+ else
3750
+ byHash.set(version.hash, [version]);
3751
+ }
3752
+ for (const [hash, sharing] of byHash) {
3753
+ let bytes = null;
3754
+ const lost = [];
3755
+ for (const version of sharing) {
3756
+ const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
3757
+ if ('bytes' in contents) {
3758
+ bytes = contents.bytes;
3759
+ break;
3760
+ }
3761
+ lost.push([version, contents.lost]);
3762
+ }
3763
+ if (bytes === null) {
3764
+ for (const [version, found] of lost) {
3765
+ this.tellLost(mount.config.name, version.what, hash, found);
3766
+ each(version, { lost: found });
3767
+ }
3768
+ }
3769
+ else {
3770
+ for (const version of sharing)
3771
+ each(version, { bytes });
3772
+ }
3773
+ }
3774
+ }
3453
3775
  // ==========================================================================
3454
3776
  // Tool Handlers
3455
3777
  // ==========================================================================
3456
- async handleRead(input, generatedContent) {
3778
+ /**
3779
+ * A read's page, held to `maxChars`, the most of this call's result that
3780
+ * can reach its caller whole. A call that carries none, made in process,
3781
+ * takes the data itself: its page holds to its line limit alone, with every
3782
+ * line whole, as it did before pages had a budget.
3783
+ */
3784
+ async handleRead(input, maxChars, generatedContent) {
3457
3785
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
3458
- const offsetChars = input.offsetChars ?? 0;
3459
- const limitChars = input.limitChars ?? 2000;
3460
3786
  // Each number is a whole number of at least its schema's minimum by now
3461
3787
  // (inputProblem).
3462
3788
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
3463
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
3789
+ 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.') };
3464
3790
  }
3465
- let content = generatedContent;
3466
- if (content === undefined) {
3791
+ let file;
3792
+ let notes = [];
3793
+ if (generatedContent === undefined) {
3467
3794
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3795
+ if (relativePath === '')
3796
+ return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
3468
3797
  input = { ...input, path };
3469
3798
  const found = await this.bytesOf(mount, relativePath, path);
3470
3799
  if ('error' in found)
3471
- return { success: false, error: found.error, isError: true };
3472
- content = found.bytes.toString('utf-8');
3473
- }
3474
- if (characterPaging) {
3475
- const start = Math.min(offsetChars, content.length);
3476
- const splitsPair = (at) => at > 0 && at < content.length
3477
- && content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
3478
- && content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
3479
- if (splitsPair(start)) {
3480
- return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
3481
- }
3482
- let end = start + Math.min(limitChars, content.length - start);
3483
- if (splitsPair(end))
3484
- end++;
3485
- return {
3486
- success: true,
3487
- data: {
3488
- path: input.path,
3489
- totalChars: content.length,
3490
- offsetChars: start,
3491
- nextOffsetChars: end < content.length ? end : null,
3492
- content: content.slice(start, end),
3493
- },
3494
- };
3800
+ return { success: false, error: didNothing('read', found.error), isError: true };
3801
+ file = fileText(found.bytes);
3802
+ notes = found.notes;
3495
3803
  }
3496
- const lines = linesOf(content);
3497
- // Apply offset/limit. An unlimited read of a large file would inject the
3498
- // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
3499
- // past the model context and 400s), so an omitted limit falls back to a
3500
- // default cap; the result reports total/from/to so the agent can page.
3501
- const DEFAULT_READ_LINE_LIMIT = 2000;
3502
- const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
3503
- if (startLine >= lines.length) {
3504
- // Nothing to show: an empty file, or an offset past the last line. No
3505
- // range is claimed, and the line count says where the file ends.
3506
- return {
3507
- success: true,
3508
- data: {
3509
- path: input.path,
3510
- totalLines: lines.length,
3511
- // Reading an empty file from its start is reading it whole.
3512
- ...(startLine > 0
3513
- ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
3514
- : {}),
3515
- content: '',
3516
- },
3517
- };
3804
+ else {
3805
+ file = fileText(Buffer.from(generatedContent, 'utf-8'));
3518
3806
  }
3519
- const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
3520
- const endLine = startLine + effectiveLimit;
3521
- const slice = lines.slice(startLine, endLine);
3522
- const truncated = endLine < lines.length;
3523
- // Format with line numbers (cat -n style)
3524
- const formatted = slice
3525
- .map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
3526
- .join('\n');
3527
- return {
3528
- success: true,
3529
- data: {
3530
- path: input.path,
3531
- totalLines: lines.length,
3532
- fromLine: startLine + 1,
3533
- toLine: Math.min(endLine, lines.length),
3534
- ...(truncated
3535
- ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
3536
- : {}),
3537
- content: formatted,
3538
- },
3539
- };
3807
+ // An unlimited read of a large file would inject the whole thing into the
3808
+ // turn (a 2MB file ≈ 600k tokens → the request blows past the model
3809
+ // context and 400s), so an omitted limit falls back to a default, and a
3810
+ // page stops at the call's result limit where it has one.
3811
+ return characterPaging
3812
+ ? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
3813
+ : linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
3540
3814
  }
3541
3815
  async handleReadImage(input) {
3542
- let mount;
3543
- let relativePath;
3544
- try {
3545
- let path;
3546
- ({ mount, relativePath, path } = await this.parseFilePath(input.path));
3547
- input = { ...input, path };
3548
- }
3549
- catch (err) {
3550
- return {
3551
- success: false,
3552
- error: err instanceof Error ? err.message : String(err),
3553
- isError: true,
3554
- };
3555
- }
3556
- if (!relativePath) {
3557
- return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
3558
- }
3816
+ const given = input.path;
3817
+ const refused = (body) => ({ success: false, isError: true, error: didNothing('read_image', body) });
3818
+ const { mount, relativePath, path } = await this.parseFilePath(given);
3819
+ if (!relativePath)
3820
+ return refused(namesRoot(given, mount.config.name));
3821
+ input = { ...input, path };
3559
3822
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3560
3823
  let treeError = null;
3824
+ let lost = null;
3561
3825
  // The workspace's copy first, where it holds one, then disk's. A direct
3562
3826
  // mount holds none, so its image is disk's.
3563
3827
  if (!mount.direct) {
3564
3828
  try {
3565
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
3566
- if (image)
3829
+ const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
3830
+ if (image && 'lost' in image)
3831
+ lost = image.lost;
3832
+ else if (image)
3567
3833
  return imageResult(input.path, image);
3568
3834
  }
3569
3835
  catch (err) {
@@ -3576,39 +3842,56 @@ export class WorkspaceModule {
3576
3842
  }
3577
3843
  }
3578
3844
  try {
3579
- return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
3845
+ const note = lost === null ? undefined
3846
+ : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
3847
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
3580
3848
  }
3581
3849
  catch (err) {
3582
- if (err instanceof WorkspaceImageReadError) {
3583
- if (err.code === 'not_found' && treeError) {
3584
- return { success: false, error: treeError.message, isError: true };
3585
- }
3586
- return { success: false, error: err.message, isError: true };
3850
+ if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError))
3851
+ throw err;
3852
+ const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
3853
+ if (lost) {
3854
+ const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
3855
+ return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
3856
+ : `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
3587
3857
  }
3588
- throw err;
3858
+ if (noFile && treeError)
3859
+ return refused(treeError.message);
3860
+ return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
3589
3861
  }
3590
3862
  }
3591
- async handleWrite(input) {
3863
+ async handleWrite(input, callerAgentName) {
3592
3864
  if (LONE_SURROGATE.test(input.content)) {
3593
- return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
3865
+ 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.' };
3594
3866
  }
3595
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
3867
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
3868
+ if (relativePath === '')
3869
+ return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
3596
3870
  input = { ...input, path };
3597
- if (mount.config.mode === 'read-only') {
3598
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
3599
- }
3871
+ const refusal = writeRefusal(mount, this.writableNames());
3872
+ if (refusal)
3873
+ return { success: false, error: didNothing('write', refusal), isError: true };
3600
3874
  const store = this.getStore();
3601
3875
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3602
- if (Buffer.byteLength(input.content) > maxSize) {
3603
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
3876
+ const bytes = Buffer.byteLength(input.content);
3877
+ if (bytes > maxSize) {
3878
+ return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
3604
3879
  }
3605
3880
  const buffer = Buffer.from(input.content, 'utf-8');
3606
3881
  const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
3882
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
3883
+ if (mount.direct) {
3884
+ const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
3885
+ if ('error' in written)
3886
+ return { success: false, isError: true, error: written.error };
3887
+ return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
3888
+ }
3607
3889
  let committed = false;
3608
3890
  let refused;
3609
3891
  let modeKept;
3892
+ let disk;
3610
3893
  try {
3611
- ({ refused, modeKept } = await this.withMount(mount, async () => {
3894
+ ({ refused, modeKept, disk } = await this.withMount(mount, async () => {
3612
3895
  await this.settleBeforeMutation(mount, relativePath);
3613
3896
  const blobHash = store.storeBlob(buffer, 'text/plain');
3614
3897
  // An overwrite keeps the file's mode, as an edit does.
@@ -3618,22 +3901,22 @@ export class WorkspaceModule {
3618
3901
  }
3619
3902
  catch (error) {
3620
3903
  if (!committed)
3621
- throw error;
3904
+ throw notStored(error);
3622
3905
  return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
3623
3906
  ? { storeFailed: error.message, pushes: error.pushes }
3624
- : { threw: error instanceof Error ? error.message : String(error) });
3907
+ : { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
3625
3908
  }
3626
3909
  if (refused !== null)
3627
3910
  return storedButUnfinished('write', input.path, { refused });
3628
- return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
3911
+ return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
3629
3912
  }
3630
- async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS) {
3913
+ async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3631
3914
  return this.applyEdits('edit', [{
3632
3915
  path: input.path,
3633
3916
  oldString: input.oldString,
3634
3917
  newString: input.newString,
3635
3918
  expected: input.replaceAll ? 'all' : 1,
3636
- }], maxChars);
3919
+ }], maxChars, callerAgentName);
3637
3920
  }
3638
3921
  async handleEditMany(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3639
3922
  if (input.edits.length === 0) {
@@ -3665,12 +3948,14 @@ export class WorkspaceModule {
3665
3948
  * stored where its record landed, and that it can't tell where they
3666
3949
  * couldn't be read back.
3667
3950
  * Under the mount's turn, one pass settles every path with disk, by the
3668
- * rule a listing uses, so the edits apply to what disk now holds. That pass
3669
- * is the only step that awaits before the commit: every file is then read,
3670
- * every edit planned, and every entry set (commitToolChanges) with no turn
3671
- * of the event loop between, so no pass, branch change or other call moves
3672
- * the tree between a file's read and its set. Disk then takes what it can
3673
- * of the stored change.
3951
+ * rule a listing uses, so the edits apply to what disk now holds. Each
3952
+ * file's stored contents are then read; where the store has lost some,
3953
+ * they're restored from disk's matching copy (contentsOf) and the pass runs
3954
+ * again, so the plan reads a pass taken after every await. From there
3955
+ * nothing awaits before the commit: every edit is planned and every entry
3956
+ * set (commitToolChanges) with no turn of the event loop between, so no
3957
+ * pass, branch change or other call moves the tree between a file's read
3958
+ * and its set. Disk then takes what it can of the stored change.
3674
3959
  *
3675
3960
  * The answer is held to `maxChars`, the length the call's result may have
3676
3961
  * (ToolCall.resultMaxChars), as edit-text.ts describes.
@@ -3705,7 +3990,7 @@ export class WorkspaceModule {
3705
3990
  }
3706
3991
  for (const field of ['oldString', 'newString']) {
3707
3992
  if (LONE_SURROGATE.test(edit[field])) {
3708
- problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
3993
+ 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.` });
3709
3994
  }
3710
3995
  }
3711
3996
  if (problems.length === found)
@@ -3750,6 +4035,10 @@ export class WorkspaceModule {
3750
4035
  return refuse(problems);
3751
4036
  if (!mount || mounts.size > 1)
3752
4037
  return refuse(problems);
4038
+ // A direct mount's edits apply to disk's text as it is now, and land on
4039
+ // disk or don't happen (applyDirectEdits).
4040
+ if (mount.direct)
4041
+ return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
3753
4042
  const store = this.getStore();
3754
4043
  // Whether the commit set the call's entries, all of them at once.
3755
4044
  let committed = false;
@@ -3763,7 +4052,31 @@ export class WorkspaceModule {
3763
4052
  return await this.withMount(mount, async () => {
3764
4053
  // As every file tool's pass does, it names the paths it acts on, so a
3765
4054
  // file the mount passes over by design is observed (see PassOptions.named).
3766
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4055
+ let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4056
+ let ops = pass.ops;
4057
+ const contents = new Map();
4058
+ const lost = new Map();
4059
+ const toRecover = [];
4060
+ for (const target of targets.values()) {
4061
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4062
+ if (!entry || contents.has(entry.blobHash))
4063
+ continue;
4064
+ const stored = storedBytes(store, entry.blobHash);
4065
+ if ('bytes' in stored)
4066
+ contents.set(entry.blobHash, stored.bytes);
4067
+ else
4068
+ toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
4069
+ }
4070
+ if (toRecover.length > 0) {
4071
+ await this.restoreEach(mount, toRecover, (version, found) => {
4072
+ if ('bytes' in found)
4073
+ contents.set(version.hash, found.bytes);
4074
+ else
4075
+ lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
4076
+ });
4077
+ pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4078
+ ops = [...ops, ...pass.ops];
4079
+ }
3767
4080
  // A pass that decided nothing checked no file's disk copy.
3768
4081
  const undecided = undecidedOf(pass);
3769
4082
  // Nothing below awaits until the commit's push.
@@ -3783,7 +4096,7 @@ export class WorkspaceModule {
3783
4096
  // A mode taken in alone isn't a change to the text: it brings nothing
3784
4097
  // new to read, as it makes no event (emitFsEvents), and the edits
3785
4098
  // then write at the mode disk has, so it leaves nothing to say.
3786
- const took = (op) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4099
+ const took = (op) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
3787
4100
  // Which copy the edits used, when it isn't the one the workspace
3788
4101
  // last agreed on with disk: disk's newer text, or the workspace's
3789
4102
  // copy because disk's couldn't be read or disk has none. A file the
@@ -3847,61 +4160,33 @@ export class WorkspaceModule {
3847
4160
  // A pass also drops a copy disk made binary or too large, which
3848
4161
  // the disk-only answer describes; any other drop is disk's deletion.
3849
4162
  const text = took('deleted') && report?.state !== 'disk-only'
3850
- ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3851
- : this.notInWorkspace(target.path, report);
3852
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
3853
- continue;
3854
- }
3855
- notes.push(...lookedIn);
3856
- const blob = store.getBlob(entry.blobHash);
3857
- if (!blob) {
3858
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4163
+ ? deletedOnDisk(target.path)
4164
+ : this.notInWorkspace(target.path, report, mount);
4165
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
3859
4166
  continue;
3860
4167
  }
3861
- // Decoding would turn each invalid sequence into U+FFFD, and writing
3862
- // the text back would change bytes the edits never named.
3863
- const file = decodeForEdit(blob);
3864
- if (!file) {
4168
+ const known = contents.get(entry.blobHash);
4169
+ const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
4170
+ if (!('bytes' in stored)) {
4171
+ const found = lost.get(target.relativePath);
3865
4172
  problems.push({
3866
4173
  kind: 'file',
3867
4174
  edits: target.edits,
3868
4175
  path: target.path,
3869
- text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
3870
- + '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.',
3871
- whole: false,
4176
+ text: found?.blobHash === entry.blobHash
4177
+ ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
4178
+ : `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.`,
4179
+ whole: true,
3872
4180
  });
3873
4181
  continue;
3874
4182
  }
3875
- if (!target.specs.length)
3876
- continue;
3877
- const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
3878
- if ('problems' in planned) {
3879
- // One at a time: a call of many edits can have more problems
3880
- // than a spread passes as arguments.
3881
- for (const problem of planned.problems)
3882
- problems.push({ ...problem, path: target.path });
3883
- continue;
3884
- }
3885
- // The planner refuses a file its counts take past the limit before
3886
- // building it, from the least its bytes can be; one it built past
3887
- // the limit, as its CRs or multi-byte characters can take it, is
3888
- // refused here.
3889
- if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
3890
- const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
3891
- problems.push({
3892
- kind: 'file',
3893
- edits: target.edits,
3894
- path: target.path,
3895
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
3896
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
3897
- + "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.",
3898
- whole: false,
3899
- });
4183
+ notes.push(...lookedIn);
4184
+ const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
4185
+ if (plan === null)
3900
4186
  continue;
3901
- }
3902
4187
  plans.push({
3903
4188
  target,
3904
- plan: planned.plan,
4189
+ plan,
3905
4190
  mode: entry.mode,
3906
4191
  backOnDisk,
3907
4192
  conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
@@ -3912,21 +4197,18 @@ export class WorkspaceModule {
3912
4197
  }
3913
4198
  if (problems.length)
3914
4199
  return refuse(problems, notes);
3915
- const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4200
+ const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
3916
4201
  path: target.relativePath,
3917
4202
  change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
3918
4203
  })), () => { committed = true; });
3919
4204
  if (refused.size > 0)
3920
4205
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
3921
- return {
3922
- success: true,
3923
- data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
3924
- path: target.path,
3925
- plan,
3926
- notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
3927
- modeKept: modeKept.get(target.relativePath) ?? null,
3928
- })), given.length, maxChars),
3929
- };
4206
+ return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4207
+ path: target.path,
4208
+ plan,
4209
+ notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
4210
+ modeKept: modeKept.get(target.relativePath) ?? null,
4211
+ })), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath))));
3930
4212
  });
3931
4213
  }
3932
4214
  catch (error) {
@@ -3934,12 +4216,12 @@ export class WorkspaceModule {
3934
4216
  if (committed) {
3935
4217
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
3936
4218
  ? { storeFailed: message, pushes: error.pushes }
3937
- : { threw: message }, maxChars);
4219
+ : { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
3938
4220
  }
3939
4221
  // A set that threw and couldn't be read back may have stored the edits,
3940
4222
  // and a second call could then apply them twice.
3941
4223
  if (error instanceof CommitUnknownError) {
3942
- const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4224
+ const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
3943
4225
  return {
3944
4226
  success: false,
3945
4227
  isError: true,
@@ -3952,10 +4234,8 @@ export class WorkspaceModule {
3952
4234
  }
3953
4235
  // The commit sets every entry in one record or none, and a set that
3954
4236
  // threw was read back, so nothing is stored; or every change was one
3955
- // the tree already held, which leaves the tree as it was either way.
3956
- if (tool === 'edit_many')
3957
- return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
3958
- throw error;
4237
+ // the tree already held (AlreadyHeld).
4238
+ throw notStored(error);
3959
4239
  }
3960
4240
  }
3961
4241
  /**
@@ -3968,7 +4248,7 @@ export class WorkspaceModule {
3968
4248
  * (handleToolCall checks a call's `path`, which edit_many's input lacks).
3969
4249
  */
3970
4250
  async editablePath(tool, path, callerAgentName) {
3971
- const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: true });
4251
+ const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: false });
3972
4252
  try {
3973
4253
  // In the one spelling every file tool reads, as disk spells it, so two
3974
4254
  // spellings of a file are one entry and, in one call, one plan
@@ -3978,16 +4258,21 @@ export class WorkspaceModule {
3978
4258
  // One that names the mount's root, its bare name included, names no
3979
4259
  // file.
3980
4260
  if (where.relativePath === '')
3981
- 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.`);
3982
- if (where.mount.config.mode === 'read-only')
3983
- return unusable(`Mount "${where.mount.config.name}" is read-only`);
4261
+ return unusable(namesRoot(path, where.mount.config.name));
4262
+ const refusal = writeRefusal(where.mount, this.writableNames());
4263
+ if (refusal)
4264
+ return unusable(refusal);
3984
4265
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
3985
4266
  if (generated)
3986
- return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4267
+ return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
3987
4268
  return where;
3988
4269
  }
3989
4270
  catch (error) {
3990
- return unusable(error instanceof Error ? error.message : String(error));
4271
+ if (error instanceof Refusal)
4272
+ return unusable(error.message);
4273
+ if (error instanceof WorkspaceReadError)
4274
+ return unusable(readRefusal(error, path));
4275
+ throw notStored(error);
3991
4276
  }
3992
4277
  }
3993
4278
  /**
@@ -3995,10 +4280,10 @@ export class WorkspaceModule {
3995
4280
  * and the agent's own is read-only, since it shows the agent's tool
3996
4281
  * presentation, which set_tool_visibility and set_tool_description change.
3997
4282
  */
3998
- generatedFileRefusal(generated, callerAgentName) {
4283
+ generatedFileRefusal(path, generated, callerAgentName) {
3999
4284
  return callerAgentName !== generated.agentName
4000
- ? 'Generated file belongs to another agent'
4001
- : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4285
+ ? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
4286
+ : `${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.`;
4002
4287
  }
4003
4288
  /**
4004
4289
  * The answer to edits stored but not finished: the commit set every file's
@@ -4014,12 +4299,12 @@ export class WorkspaceModule {
4014
4299
  * which stand as on a success, it names each conflict the success answer
4015
4300
  * would (`conflicts`, found only where the mount doesn't push).
4016
4301
  */
4017
- editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
4302
+ editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars, reopen = false) {
4018
4303
  const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4019
4304
  if (tool === 'edit') {
4020
4305
  const [target] = targets;
4021
4306
  const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
4022
- return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
4307
+ return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null, reopen);
4023
4308
  }
4024
4309
  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.";
4025
4310
  // The head is never cut, so what stands for the whole call goes in it,
@@ -4042,10 +4327,11 @@ export class WorkspaceModule {
4042
4327
  }
4043
4328
  else if ('storeFailed' in outcome) {
4044
4329
  head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4045
- + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4330
+ + (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
4331
+ + (reopen ? ` ${AWAITS_REOPEN}` : '');
4046
4332
  }
4047
4333
  else {
4048
- head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4334
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
4049
4335
  }
4050
4336
  const kept = 'modeKept' in outcome
4051
4337
  ? targets.flatMap((target) => {
@@ -4059,48 +4345,559 @@ export class WorkspaceModule {
4059
4345
  ];
4060
4346
  return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
4061
4347
  }
4062
- async handleDelete(input) {
4063
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4064
- input = { ...input, path };
4065
- if (mount.config.mode === 'read-only') {
4066
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
4067
- }
4068
- const store = this.getStore();
4069
- let committed = false;
4070
- try {
4071
- return await this.withMount(mount, async () => {
4072
- const { report, undecided } = await this.ensureSynced(mount, relativePath);
4073
- await this.settleBeforeMutation(mount, relativePath);
4074
- const entry = store.treeGet(mount.treeStateId, relativePath);
4075
- if (!entry) {
4076
- const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4077
- return { success: false, error, isError: true };
4348
+ // ==========================================================================
4349
+ // Direct writes
4350
+ // ==========================================================================
4351
+ /**
4352
+ * A direct mount's write of a whole file: its bytes staged beside it and
4353
+ * renamed over it (direct-write.ts), under the mount's turn, so the
4354
+ * module's own calls take turns. Answers a note when the file had other
4355
+ * hard links, which keep its old text, or why nothing was written. One
4356
+ * that lands is the caller's latest write on the mount (recordDirectWrite).
4357
+ * With `branch`, nothing is written once the store's branch is another,
4358
+ * checked with no turn of the event loop before the rename (writeBinary's).
4359
+ */
4360
+ async writeDirect(mount, relativePath, path, bytes, agentName, branch) {
4361
+ return this.withMount(mount, async () => {
4362
+ const { view, rootReal } = await this.diskView(mount);
4363
+ let staged;
4364
+ try {
4365
+ staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4366
+ }
4367
+ catch (error) {
4368
+ return { error: directDidNothing('write', path, error) };
4369
+ }
4370
+ try {
4371
+ await directWriteTestSeam.afterStage?.([staged.path]);
4372
+ await placedStill(staged);
4373
+ // A write reads nothing first, so it replaces whatever is there.
4374
+ checkUnchanged(staged, null);
4375
+ if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
4376
+ await discard(staged);
4377
+ return { error: BRANCH_LEFT, branchLeft: true };
4078
4378
  }
4079
- const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
4080
- if (refused !== null)
4081
- return storedButUnfinished('delete', input.path, { refused });
4082
- // Its whole receipt is what it changed: kept in the stub if the guard
4083
- // withholds the result (agent-framework #277).
4084
- return {
4085
- success: true,
4086
- data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
4087
- keepWhenWithheld: ['path', 'deleted', 'note'],
4088
- };
4089
- });
4090
- }
4091
- catch (error) {
4092
- if (!committed)
4093
- throw error;
4094
- if (error instanceof StoredThenFailed) {
4095
- // Its note, as the success answer's: a deletion that cleared a
4096
- // conflict naming a disk file unlinked nothing.
4097
- const note = error.notes.get(relativePath);
4098
- return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
4379
+ renameInto(staged);
4099
4380
  }
4100
- return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
4101
- }
4102
- }
4103
- async handleLs(input) {
4381
+ catch (error) {
4382
+ await discard(staged);
4383
+ return { error: directDidNothing('write', path, error) };
4384
+ }
4385
+ this.recordDirectWrite(mount, agentName);
4386
+ return { note: linksNote(path, staged, 'write') };
4387
+ });
4388
+ }
4389
+ /**
4390
+ * applyEdits on a direct mount, under the mount's turn, whose edits land
4391
+ * on disk or don't happen. Each attempt reads every file from disk as it
4392
+ * is now (directEditText), plans every edit against those bytes as on any
4393
+ * mount (planTargetEdits), stages every file beside itself, checks every
4394
+ * one unchanged since its read, and then renames each over its file in
4395
+ * turn (direct-write.ts). A file another writer changed between its read
4396
+ * and its check has the call read and planned again against its new
4397
+ * text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
4398
+ * longer apply to it, a file that changed at every attempt, or a file the
4399
+ * next read refuses as any read would (now past the size limit, say) are
4400
+ * refused, with nothing written. A rename that fails after others leaves
4401
+ * those written and the rest as they were, and the answer names each
4402
+ * file's outcome; nothing puts the written ones back.
4403
+ */
4404
+ async applyDirectEdits(tool, mount, targets, early, editCount, maxChars, agentName) {
4405
+ const changedMeanwhile = new Set();
4406
+ return this.withMount(mount, async () => {
4407
+ for (let attempt = 1;; attempt++) {
4408
+ const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
4409
+ if (outcome !== 'changed')
4410
+ return outcome;
4411
+ if (attempt === DIRECT_EDIT_ATTEMPTS) {
4412
+ const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
4413
+ // Each attempt stops at the first file it finds changed, so these are
4414
+ // the files some attempt found changed, at most one per attempt.
4415
+ return {
4416
+ success: false,
4417
+ isError: true,
4418
+ error: tool === 'edit'
4419
+ ? `${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.`
4420
+ : `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
4421
+ + `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
4422
+ };
4423
+ }
4424
+ }
4425
+ });
4426
+ }
4427
+ /**
4428
+ * One attempt of applyDirectEdits: its answer, or 'changed' when another
4429
+ * writer changed a file since its read, with nothing written. `early` are
4430
+ * the problems found before any file was read, which a refusal names with
4431
+ * the rest. The caller holds the mount's turn.
4432
+ */
4433
+ async attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName) {
4434
+ const problems = [...early];
4435
+ const read = new Map();
4436
+ for (const target of targets) {
4437
+ const got = await this.directEditText(mount, target.relativePath, target.path);
4438
+ if ('changed' in got) {
4439
+ changedMeanwhile.add(target);
4440
+ return 'changed';
4441
+ }
4442
+ if ('error' in got)
4443
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
4444
+ else
4445
+ read.set(target, got);
4446
+ }
4447
+ // Two paths that reach one file, through a link this mount follows, or
4448
+ // as two hard links (a disk that folds case has its two spellings made
4449
+ // one path by the parse, short of a directory link on the way, past
4450
+ // which they're refused here as one file named twice): each
4451
+ // would be planned against the same text and renamed over the same file
4452
+ // or split from it, so one's edits would be lost or kept apart from the
4453
+ // other's. They're refused. One file read twice has one identity here
4454
+ // unless a writer changed it between the reads, and the check before the
4455
+ // renames then sees that and reads again.
4456
+ const byIdentity = new Map();
4457
+ for (const [target, { identity }] of read) {
4458
+ const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
4459
+ const other = byIdentity.get(key);
4460
+ if (!other) {
4461
+ byIdentity.set(key, target);
4462
+ continue;
4463
+ }
4464
+ problems.push({
4465
+ kind: 'file',
4466
+ edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
4467
+ path: other.path,
4468
+ 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`,
4469
+ whole: true,
4470
+ });
4471
+ }
4472
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4473
+ const hints = hintBudget();
4474
+ // As much as the largest file the call names holds, at least, so the
4475
+ // budget is the same whatever order its files are planned in.
4476
+ let largest = maxSize;
4477
+ for (const { bytes } of read.values())
4478
+ largest = Math.max(largest, bytes.length);
4479
+ const counts = countBudget(largest);
4480
+ const plans = [];
4481
+ for (const target of targets) {
4482
+ const got = read.get(target);
4483
+ if (!got)
4484
+ continue;
4485
+ const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
4486
+ if (plan !== null)
4487
+ plans.push({ target, plan });
4488
+ }
4489
+ // A file read again for another writer's change says so, whichever way
4490
+ // its edits went.
4491
+ const reread = (target, verb) => !changedMeanwhile.has(target) ? null
4492
+ : `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
4493
+ + `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
4494
+ const refuse = (found) => ({
4495
+ success: false,
4496
+ isError: true,
4497
+ error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note) => note !== null), maxChars, 'wrote'),
4498
+ });
4499
+ if (problems.length)
4500
+ return refuse(problems);
4501
+ const { view, rootReal } = await this.diskView(mount);
4502
+ const staged = [];
4503
+ const discardAll = async (from = 0) => {
4504
+ for (const { staged: file } of staged.slice(from))
4505
+ await discard(file);
4506
+ };
4507
+ // A refusal before any file is renamed says so where the call names several.
4508
+ const nothingWritten = (target, error) => refuse([{
4509
+ kind: 'file',
4510
+ edits: target.edits,
4511
+ path: target.path,
4512
+ text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4513
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
4514
+ + (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
4515
+ whole: true,
4516
+ }]);
4517
+ for (const { target, plan } of plans) {
4518
+ try {
4519
+ staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
4520
+ }
4521
+ catch (error) {
4522
+ await discardAll();
4523
+ if (error instanceof FileChanged) {
4524
+ changedMeanwhile.add(target);
4525
+ return 'changed';
4526
+ }
4527
+ return nothingWritten(target, error);
4528
+ }
4529
+ }
4530
+ try {
4531
+ await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
4532
+ }
4533
+ catch (error) {
4534
+ await discardAll();
4535
+ throw error;
4536
+ }
4537
+ for (const { target, staged: file } of staged) {
4538
+ try {
4539
+ await placedStill(file);
4540
+ }
4541
+ catch (error) {
4542
+ await discardAll();
4543
+ return nothingWritten(target, error);
4544
+ }
4545
+ }
4546
+ // Every file is checked, then every file renamed, with no turn of the
4547
+ // event loop between: a change to any of them before its check is seen
4548
+ // before any is written, and one landing between its check and the last
4549
+ // rename isn't (checkUnchanged).
4550
+ for (const { target, staged: file } of staged) {
4551
+ try {
4552
+ checkUnchanged(file, read.get(target));
4553
+ }
4554
+ catch (error) {
4555
+ await discardAll();
4556
+ if (error instanceof FileChanged) {
4557
+ changedMeanwhile.add(target);
4558
+ return 'changed';
4559
+ }
4560
+ return nothingWritten(target, error);
4561
+ }
4562
+ }
4563
+ for (const [i, { target, staged: file }] of staged.entries()) {
4564
+ try {
4565
+ renameInto(file);
4566
+ }
4567
+ catch (error) {
4568
+ if (i === 0) {
4569
+ await discardAll();
4570
+ return nothingWritten(target, error);
4571
+ }
4572
+ // Read with no turn of the event loop since the last rename.
4573
+ this.recordDirectWrite(mount, agentName);
4574
+ await discardAll(i);
4575
+ return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
4576
+ }
4577
+ }
4578
+ this.recordDirectWrite(mount, agentName);
4579
+ return editReceipt(tool, staged.map(({ target, staged: file }) => ({
4580
+ path: target.path,
4581
+ plan: plans.find((p) => p.target === target).plan,
4582
+ notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
4583
+ modeKept: null,
4584
+ })), editCount, maxChars, 'done');
4585
+ }
4586
+ /**
4587
+ * The answer to an edit_many call on a direct mount whose rename of its
4588
+ * `failed`th file failed after the ones before it were written: what was
4589
+ * written, the failure, and that nothing puts them back, then each file's
4590
+ * outcome, within `maxChars`, with the hard links a written one split
4591
+ * from, as a full answer names them (linksNote).
4592
+ */
4593
+ directEditsPartway(staged, failed, error, maxChars) {
4594
+ // The failed file is named among the outcomes, so the head stays as
4595
+ // long as its reason, whatever the paths' lengths.
4596
+ const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
4597
+ 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}.` : ''} `
4598
+ + "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:";
4599
+ 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'}`);
4600
+ const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
4601
+ return fitted(head, parts, notes, maxChars, 'written');
4602
+ }
4603
+ /**
4604
+ * A direct mount's file as an edit reads it: its bytes on disk as they are
4605
+ * now, through the mount's boundary as read reaches them
4606
+ * (openContainedFile), with the file's identity when the read began, which
4607
+ * the write checks it against just before its rename. A file changed while
4608
+ * it was read is `changed`, so the call reads it again. What the
4609
+ * workspace doesn't show as text, it doesn't edit either: a file over the
4610
+ * mount's size limit, and a binary one.
4611
+ */
4612
+ async directEditText(mount, relativePath, path) {
4613
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4614
+ const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
4615
+ let opened;
4616
+ try {
4617
+ opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4618
+ if (fileStat.size > maxSize)
4619
+ throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
4620
+ });
4621
+ }
4622
+ catch (err) {
4623
+ if (err instanceof WorkspaceReadError && err.code === 'changed')
4624
+ return { changed: true };
4625
+ if (err instanceof Refusal)
4626
+ return { error: err.message };
4627
+ if (err instanceof WorkspaceReadError)
4628
+ return { error: readRefusal(err, path) };
4629
+ throw err;
4630
+ }
4631
+ try {
4632
+ const before = identityOf(await opened.handle.stat({ bigint: true }));
4633
+ const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4634
+ if (more)
4635
+ return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4636
+ if (looksBinary(bytes)) {
4637
+ 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.` };
4638
+ }
4639
+ if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
4640
+ return { changed: true };
4641
+ return { bytes, identity: before };
4642
+ }
4643
+ catch (err) {
4644
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4645
+ }
4646
+ finally {
4647
+ await opened.handle.close();
4648
+ }
4649
+ }
4650
+ /**
4651
+ * A direct mount's deletion: the file the read tools reach, by their
4652
+ * refusals (openContainedFile), unlinked through the mount's boundary as
4653
+ * a push's deletion unlinks one (unlinkContained). A followed link is the
4654
+ * entry removed, as there. One that lands is the caller's latest write on
4655
+ * the mount (recordDirectWrite).
4656
+ */
4657
+ async deleteDirect(mount, relativePath, path, agentName) {
4658
+ return this.withMount(mount, async () => {
4659
+ // One root for the call, by its identity as the call starts: the
4660
+ // open's checks and the unlink's own refuse any other directory there,
4661
+ // in ROOT_MOVED's words, so a root replaced after this stat is refused
4662
+ // rather than taken, but in the instant between the unlink's last
4663
+ // check and the unlink, which Node can't close (effects.ts's header).
4664
+ // One that can't be read now is refused here.
4665
+ const disk = await this.diskView(mount);
4666
+ let rootStat;
4667
+ try {
4668
+ rootStat = await stat(disk.rootReal, { bigint: true });
4669
+ }
4670
+ catch (err) {
4671
+ const code = errnoOf(err);
4672
+ const why = code === 'ENOENT' || code === 'ENOTDIR'
4673
+ ? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
4674
+ : `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
4675
+ return { success: false, isError: true, error: didNothing('delete', why, path) };
4676
+ }
4677
+ const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4678
+ const { rootReal } = disk;
4679
+ try {
4680
+ const opened = await this.openContainedFile(mount, relativePath, path);
4681
+ await opened.handle.close();
4682
+ }
4683
+ catch (err) {
4684
+ // The open's own words for a replaced root speak of reading.
4685
+ if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
4686
+ return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
4687
+ }
4688
+ if (err instanceof WorkspaceReadError)
4689
+ return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
4690
+ throw err;
4691
+ }
4692
+ // The unlink is awaited, unlike a write's rename, so where the head
4693
+ // was just before it says whether the head moved while it ran.
4694
+ const since = this.positionNow();
4695
+ try {
4696
+ await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
4697
+ }
4698
+ catch (error) {
4699
+ // A failure after the unlink reached the file's entry can't say
4700
+ // whether the file is gone, so it's taken as the agent's write too.
4701
+ if (error instanceof EffectFailed && error.touched) {
4702
+ this.recordDirectWrite(mount, agentName, since);
4703
+ 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.` };
4704
+ }
4705
+ return { success: false, isError: true, error: directDidNothing('delete', path, error) };
4706
+ }
4707
+ this.recordDirectWrite(mount, agentName, since);
4708
+ return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
4709
+ });
4710
+ }
4711
+ /**
4712
+ * After a direct write lands: the agent's latest write on the mount, P,
4713
+ * moves to it, where the agent's history holds P or the notice of its
4714
+ * departure from P, N (historyHoldsWrites), and N clears. Otherwise P
4715
+ * stays where it is: the head moved away from it under the call's turn,
4716
+ * and the next start's notice covers what it left (headMoveNotice). P is
4717
+ * the branch and the store's head as it is now, read with no turn of the
4718
+ * event loop since the write, so no record that lands after the write is
4719
+ * taken for one before it. A deletion, whose unlink is awaited, passes
4720
+ * where the head was just before its unlink (`since`): where the branch
4721
+ * changed meanwhile, the head moved under the call, and P is set there,
4722
+ * so the next start judges the deletion by the branch its turn was on,
4723
+ * unless the history doesn't hold P already, whose notice is owed. A write no agent
4724
+ * made tells no one. The store's reads here come after the write landed,
4725
+ * so one that throws is logged and P stays, as for a history that can't
4726
+ * be read, and the write still answers that it landed.
4727
+ */
4728
+ recordDirectWrite(mount, agentName, since) {
4729
+ if (agentName === undefined)
4730
+ return;
4731
+ try {
4732
+ const store = this.getStore();
4733
+ let heads = this.directHeads.get(mount.config.name);
4734
+ if (!heads)
4735
+ this.directHeads.set(mount.config.name, (heads = new Map()));
4736
+ const head = heads.get(agentName);
4737
+ const branchId = store.currentBranch().id;
4738
+ if (since && since.branchId !== branchId) {
4739
+ if (!head || this.historyHoldsWrites(agentName, head) === true)
4740
+ heads.set(agentName, { ...since, notice: null });
4741
+ return;
4742
+ }
4743
+ if (head && this.historyHoldsWrites(agentName, head) !== true)
4744
+ return;
4745
+ heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
4746
+ }
4747
+ catch (err) {
4748
+ console.error('[workspace] could not record a direct write\'s position:', err);
4749
+ }
4750
+ }
4751
+ /** The store's branch and head now, or null where the store can't say. */
4752
+ positionNow() {
4753
+ try {
4754
+ const store = this.getStore();
4755
+ return { branchId: store.currentBranch().id, after: store.currentSequence() };
4756
+ }
4757
+ catch {
4758
+ return null;
4759
+ }
4760
+ }
4761
+ /**
4762
+ * Whether an agent's history holds its latest direct write on a mount
4763
+ * (holdsPosition), or the notice of its departure from it, judged by the
4764
+ * notice's own presence there, through the lookup the framework hands the
4765
+ * module (useHistory); null where that lookup threw, which is logged. A
4766
+ * history that can't be read decides nothing: a write takes it as not
4767
+ * held, so its position stays and the notice stays owed, and a start
4768
+ * gives no notice, which may not be true of a history it can't read, and
4769
+ * leaves the next start to decide (headMoveNotice).
4770
+ */
4771
+ historyHoldsWrites(agentName, head) {
4772
+ if (this.holdsPosition(head))
4773
+ return true;
4774
+ if (head.notice === null)
4775
+ return false;
4776
+ try {
4777
+ return this.historyLookup?.(agentName, head.notice) === true;
4778
+ }
4779
+ catch (err) {
4780
+ console.error('[workspace] history lookup for the head-move notice failed:', err);
4781
+ return null;
4782
+ }
4783
+ }
4784
+ /**
4785
+ * Whether the store's current branch holds what came before a direct
4786
+ * write: it's the write's branch, or continues it with every fork point on
4787
+ * the way after `after`, the store's head when the write landed. A fork at
4788
+ * exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
4789
+ * which is the head at the write when nothing was recorded in the turn
4790
+ * before it. A missing branch or one without fork metadata holds nothing,
4791
+ * as for the materialize guard (isLinearContinuation).
4792
+ */
4793
+ holdsPosition({ branchId, after }) {
4794
+ const store = this.getStore();
4795
+ const current = store.currentBranch();
4796
+ if (current.id === branchId)
4797
+ return true;
4798
+ const byId = new Map(store.listBranches().map((b) => [b.id, b]));
4799
+ const visited = new Set();
4800
+ let cursor = byId.get(current.id);
4801
+ while (cursor && !visited.has(cursor.id)) {
4802
+ visited.add(cursor.id);
4803
+ if (cursor.id === branchId)
4804
+ return true;
4805
+ if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after)
4806
+ return false;
4807
+ cursor = byId.get(cursor.parentId);
4808
+ }
4809
+ return false;
4810
+ }
4811
+ /**
4812
+ * The framework's check at every start of an agent's turn, opening,
4813
+ * continuing or resuming: the notice due for each mount the agent wrote
4814
+ * direct where its history holds neither its latest write there nor the
4815
+ * notice of its departure from it (historyHoldsWrites), with those
4816
+ * mounts' names; or null, as on a head that hasn't moved. The framework
4817
+ * stores the text in the agent's history before the turn's compile and
4818
+ * hands its message id back (headMoveNoticeStored).
4819
+ */
4820
+ headMoveNotice(agentName) {
4821
+ if (!this.store)
4822
+ return null;
4823
+ const due = [];
4824
+ // Every mount where the agent wrote direct, whether it runs direct this
4825
+ // session or as 'chronicle': the writes are on disk either way.
4826
+ for (const name of this.mounts.keys()) {
4827
+ const head = this.directHeads.get(name)?.get(agentName);
4828
+ if (!head)
4829
+ continue;
4830
+ const held = this.historyHoldsWrites(agentName, head);
4831
+ // A history that can't be read decides nothing at this start.
4832
+ if (held === null)
4833
+ return null;
4834
+ if (!held)
4835
+ due.push(name);
4836
+ }
4837
+ return due.length ? { text: headMoveText(due), mounts: due } : null;
4838
+ }
4839
+ /** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
4840
+ headMoveNoticeStored(agentName, mounts, messageId) {
4841
+ for (const name of mounts) {
4842
+ const head = this.directHeads.get(name)?.get(agentName);
4843
+ if (head)
4844
+ head.notice = messageId;
4845
+ }
4846
+ }
4847
+ /**
4848
+ * How the module asks whether an agent's history holds a message, as the
4849
+ * framework judges its own notices (`cm.getMessage(id) !== null`): handed
4850
+ * over once, when the module is added. Without it, no notice is held.
4851
+ */
4852
+ useHistory(lookup) {
4853
+ this.historyLookup = lookup;
4854
+ }
4855
+ async handleDelete(input, callerAgentName) {
4856
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
4857
+ if (relativePath === '')
4858
+ return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
4859
+ input = { ...input, path };
4860
+ const refusal = writeRefusal(mount, this.writableNames());
4861
+ if (refusal)
4862
+ return { success: false, error: didNothing('delete', refusal), isError: true };
4863
+ if (mount.direct)
4864
+ return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
4865
+ const store = this.getStore();
4866
+ let committed = false;
4867
+ try {
4868
+ return await this.withMount(mount, async () => {
4869
+ const { report, undecided } = await this.ensureSynced(mount, relativePath);
4870
+ await this.settleBeforeMutation(mount, relativePath);
4871
+ const entry = store.treeGet(mount.treeStateId, relativePath);
4872
+ if (!entry) {
4873
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
4874
+ return { success: false, error: didNothing('delete', error), isError: true };
4875
+ }
4876
+ const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
4877
+ if (refused !== null)
4878
+ return storedButUnfinished('delete', input.path, { refused });
4879
+ // Its whole receipt is what it changed: kept in the stub if the guard
4880
+ // withholds the result (agent-framework #277).
4881
+ return {
4882
+ success: true,
4883
+ data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
4884
+ keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
4885
+ };
4886
+ });
4887
+ }
4888
+ catch (error) {
4889
+ if (!committed)
4890
+ throw notStored(error);
4891
+ if (error instanceof StoredThenFailed) {
4892
+ // Its note, as the success answer's: a deletion that cleared a
4893
+ // conflict naming a disk file unlinked nothing.
4894
+ const note = error.notes.get(relativePath);
4895
+ return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
4896
+ }
4897
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
4898
+ }
4899
+ }
4900
+ async handleLs(input) {
4104
4901
  if (!input.path) {
4105
4902
  // List all mounts, with the kind a mount configured direct runs as
4106
4903
  // this session and the ignore list it walks by, whose default no
@@ -4109,7 +4906,9 @@ export class WorkspaceModule {
4109
4906
  const mounts = [...this.mounts.entries()].map(([name, m]) => ({
4110
4907
  name,
4111
4908
  path: m.config.path,
4112
- mode: m.config.mode,
4909
+ // A mount configured direct says its mode where its config names
4910
+ // none, so a writable one shows as writable.
4911
+ mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
4113
4912
  ...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
4114
4913
  ...(m.config.include ? { include: [...m.config.include] } : {}),
4115
4914
  }));
@@ -4119,7 +4918,7 @@ export class WorkspaceModule {
4119
4918
  // A path outside the mount's include that's no scope (refusesAsOutside)
4120
4919
  // is refused, as glob refuses it, and shown as given.
4121
4920
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4122
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4921
+ return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4123
4922
  }
4124
4923
  input = { ...input, path };
4125
4924
  const recursive = input.recursive === true;
@@ -4128,6 +4927,9 @@ export class WorkspaceModule {
4128
4927
  // shows disk's.
4129
4928
  const listing = await this.listingOf(mount, relativePath, recursive);
4130
4929
  const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
4930
+ const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
4931
+ const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
4932
+ const noted = note !== null ? { note } : {};
4131
4933
  if (recursive) {
4132
4934
  return {
4133
4935
  success: true,
@@ -4136,6 +4938,7 @@ export class WorkspaceModule {
4136
4938
  entries: listing.files,
4137
4939
  count: listing.files.length,
4138
4940
  ...incomplete,
4941
+ ...noted,
4139
4942
  },
4140
4943
  };
4141
4944
  }
@@ -4152,9 +4955,34 @@ export class WorkspaceModule {
4152
4955
  entries: children,
4153
4956
  count: children.length,
4154
4957
  ...incomplete,
4958
+ ...noted,
4155
4959
  },
4156
4960
  };
4157
4961
  }
4962
+ async emptyScopeNote(mount, relativePath, path) {
4963
+ const shown = JSON.stringify(path);
4964
+ const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
4965
+ const { view, rootReal } = await this.diskView(mount);
4966
+ const seen = await observePath(view, rootReal, relativePath);
4967
+ if (seen.kind === 'file')
4968
+ return isFile;
4969
+ if (seen.kind !== 'absent')
4970
+ return null;
4971
+ if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null)
4972
+ return isFile;
4973
+ return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
4974
+ }
4975
+ async absentOnDisk(mount, relativePath) {
4976
+ const { view, rootReal } = await this.diskView(mount);
4977
+ return (await observePath(view, rootReal, relativePath)).kind === 'absent';
4978
+ }
4979
+ holdsAnythingAt(mount, relativePath) {
4980
+ if (mount.direct)
4981
+ return false;
4982
+ const store = this.getStore();
4983
+ const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree) => tree !== undefined);
4984
+ return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
4985
+ }
4158
4986
  /**
4159
4987
  * What a listing shows of a directory scope, from where the mount keeps
4160
4988
  * what it reads: the store-backed kind's pass (listingFromStore), or, on a
@@ -4256,23 +5084,33 @@ export class WorkspaceModule {
4256
5084
  if (input.path) {
4257
5085
  const { mount, relativePath, moved, root } = mountsToSearch[0];
4258
5086
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4259
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5087
+ return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4260
5088
  }
4261
5089
  }
4262
5090
  // Every match lies beneath the pattern's literal directory, so the walk
4263
5091
  // starts there: past the walk's cap, a walk of the whole scope can stop
4264
5092
  // before it gets there. In the mount a pattern's first segment names, the
4265
5093
  // pattern is read mount-prefixed (prefixedGlob), from its rest's directory
4266
- // within `path`.
5094
+ // within `path`. A `path` that directory can't overlap leaves no file the
5095
+ // pattern can match, so the glob is refused, as grep's is, saying how
5096
+ // the pattern is read, rather than answered with no matches; but a path
5097
+ // in another mount's directory is that mount's, and is said so, as grep
5098
+ // says it. Only a `path` can do either, and it names one mount, so
5099
+ // nothing has been searched yet.
4267
5100
  const patternDir = literalDirOf(input.pattern);
4268
- for (const { mount, relativePath } of mountsToSearch) {
5101
+ for (const { mount, relativePath, moved, root } of mountsToSearch) {
4269
5102
  const prefixed = prefixedGlob(input.pattern, mount.config.name);
4270
5103
  let start;
4271
5104
  let matched;
4272
5105
  if (prefixed) {
4273
5106
  const dir = deeperOf(relativePath, prefixed.dir);
4274
- if (dir === null)
4275
- continue;
5107
+ if (dir === null) {
5108
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5109
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5110
+ continue;
5111
+ }
5112
+ return { success: false, error: await this.disjointRefusal('glob', input.path, input.pattern, true, mount, relativePath, root), isError: true };
5113
+ }
4276
5114
  start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
4277
5115
  matched = (path) => prefixed.regex.test(path);
4278
5116
  }
@@ -4303,15 +5141,31 @@ export class WorkspaceModule {
4303
5141
  regex = new RegExp(input.pattern);
4304
5142
  }
4305
5143
  catch (e) {
4306
- // The parser's reason says where the pattern goes wrong.
4307
- return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
5144
+ // V8's message ends with the parser's reason, after the pattern: "Invalid
5145
+ // regular expression: /foo(/: Unterminated group".
5146
+ const message = e instanceof Error ? e.message : String(e);
5147
+ const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
5148
+ return {
5149
+ success: false,
5150
+ isError: true,
5151
+ error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
5152
+ + 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
5153
+ };
4308
5154
  }
4309
5155
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4310
- // The filter matches mount-relative paths, so every file it lets through
4311
- // lies beneath its literal directory, and the walk starts at the deeper of
4312
- // that and `path`. In the mount a filter's first segment names, its
4313
- // mount-prefixed reading takes the filter's place (below).
5156
+ // The filter matches mount-relative paths, from the mount's root whatever
5157
+ // the `path`, so every file it lets through lies beneath its literal
5158
+ // directory, and the walk starts at the deeper of that and `path`. In the
5159
+ // mount a filter's first segment names, its mount-prefixed reading takes
5160
+ // the filter's place (below). One with no "/" also matches a file's name,
5161
+ // wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
5162
+ // file through, where tested against the path alone it let through only
5163
+ // those at the mount's root. The path is still tried, so a "**" in such a
5164
+ // filter spans directories as it did, and nothing it let through before
5165
+ // is lost. A filter read mount-prefixed always holds a "/" after the
5166
+ // mount's name, so it never takes the name test.
4314
5167
  const filterDir = input.glob ? literalDirOf(input.glob) : '';
5168
+ const byName = fileGlob !== null && !input.glob.includes('/');
4315
5169
  const contextBefore = input.contextBefore ?? 0;
4316
5170
  const contextAfter = input.contextAfter ?? 0;
4317
5171
  const mountsToSearch = input.path
@@ -4322,14 +5176,29 @@ export class WorkspaceModule {
4322
5176
  if (input.path) {
4323
5177
  const { mount, relativePath, moved, root } = mountsToSearch[0];
4324
5178
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4325
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5179
+ return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4326
5180
  }
4327
5181
  }
4328
5182
  const results = [];
4329
5183
  const skipped = [];
4330
5184
  const incomplete = [];
4331
- const search = (content) => {
4332
- const lines = linesOf(content);
5185
+ // Lines as read shows them (fileText): a CRLF end's CR is left out, for
5186
+ // matching as well as display, unless the line's own text ends in a CR, so
5187
+ // `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
5188
+ // the line copies into an edit as read's do. ripgrep's --crlf still prints
5189
+ // the line with its CR.
5190
+ //
5191
+ // Whether a file has CRLF ends or a BOM is a fact about the file, not a
5192
+ // line, so each searched file that has either is kept here by its path,
5193
+ // for the note on a pattern that names a CR or U+FEFF to name it. A
5194
+ // conflict's disk version is kept, for each, only where the workspace's
5195
+ // copy of its path doesn't have it, and is named as that version, since
5196
+ // read doesn't show it.
5197
+ const crlfFiles = new Map();
5198
+ const bomFiles = new Map();
5199
+ const search = (text) => {
5200
+ const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5201
+ const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
4333
5202
  const found = [];
4334
5203
  for (let i = 0; i < lines.length; i++) {
4335
5204
  if (regex.test(lines[i])) {
@@ -4342,60 +5211,104 @@ export class WorkspaceModule {
4342
5211
  found.push(match);
4343
5212
  }
4344
5213
  }
4345
- return found;
5214
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
4346
5215
  };
4347
5216
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
4348
5217
  // A path named in another mount's directory, as holderOf resolves it,
4349
5218
  // is that mount's: grep searches nothing of this mount's there, and
4350
5219
  // says whose it is. A direct mount's is judged by the nesting its parse
4351
5220
  // found where its root lies now (movedNesting).
4352
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5221
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
4353
5222
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4354
5223
  continue;
4355
5224
  }
4356
5225
  // In the mount a filter's first segment names, the filter is read
4357
5226
  // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
4358
- // a filter whose directories don't overlap leave no file the filter can
4359
- // let through, so nothing is searched.
5227
+ // a filter whose directories, in the reading this mount takes, don't
5228
+ // overlap leave no file the filter can let through, so the grep is
5229
+ // refused, saying how the filter is read, rather than answered with no
5230
+ // matches: written relative to the `path`, as glob's pattern is, it
5231
+ // names nothing there. Only a `path` can do this, and it names one
5232
+ // mount, so nothing has been searched yet.
4360
5233
  const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
4361
5234
  const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
4362
- if (deeper === null)
4363
- continue;
5235
+ if (deeper === null) {
5236
+ return { success: false, error: await this.disjointRefusal('grep', input.path, input.glob, prefixed !== null, mount, relativePath, root), isError: true };
5237
+ }
4364
5238
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
4365
5239
  const searched = (text) => {
4366
- const matches = search(text.text);
4367
- if (matches.length > 0)
4368
- results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
5240
+ const { bom, ...hits } = search(text);
5241
+ const { file, state, version } = text;
5242
+ return () => {
5243
+ if (hits.lineEndings !== undefined && !crlfFiles.has(file))
5244
+ crlfFiles.set(file, { ends: hits.lineEndings, version });
5245
+ if (bom && !bomFiles.has(file))
5246
+ bomFiles.set(file, version);
5247
+ if (hits.matches.length > 0)
5248
+ results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
5249
+ };
4369
5250
  };
4370
5251
  const kept = prefixed
4371
5252
  ? (path) => prefixed.regex.test(path)
4372
- : (path) => fileGlob === null || fileGlob.test(path);
5253
+ : (path) => fileGlob === null || fileGlob.test(path)
5254
+ || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
4373
5255
  const found = mount.direct
4374
- ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
5256
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
4375
5257
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
4376
5258
  skipped.push(...found.skipped);
4377
5259
  incomplete.push(...found.incomplete);
4378
5260
  }
4379
- return {
4380
- success: true,
4381
- data: {
4382
- pattern: input.pattern,
4383
- results,
4384
- totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
4385
- ...(skipped.length > 0 ? { skipped } : {}),
4386
- ...(incomplete.length > 0 ? { incomplete } : {}),
4387
- },
5261
+ const answer = {
5262
+ pattern: input.pattern,
5263
+ results,
5264
+ totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
5265
+ // A pattern written for a CR can't find a CRLF end's, nor one written
5266
+ // for U+FEFF a BOM, which lines as read shows them leave out, so the
5267
+ // answer says why it didn't, and names the files that have them.
5268
+ ...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
5269
+ ...(skipped.length > 0 ? { skipped } : {}),
5270
+ ...(incomplete.length > 0 ? { incomplete } : {}),
4388
5271
  };
5272
+ return textAnswer(grepText(answer, contextBefore), { ...answer });
5273
+ }
5274
+ /**
5275
+ * Why a grep or a glob whose `path` (`given`, in the mount at
5276
+ * `relativePath`) and filter or pattern can't overlap did nothing. grep's
5277
+ * filter is matched from the mount's root, and in the mount its first
5278
+ * segment names, either tool's is read mount-prefixed (`prefixed`), from
5279
+ * that mount's root; either way no file at or beneath the path can pass
5280
+ * it. Where the path isn't a file, it names the one that reaches beneath
5281
+ * it, the one meant if it was written relative to the path, mount-prefixed
5282
+ * as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
5283
+ * `?`, which matches each of them, and since the search stays beneath the
5284
+ * path, a `?` that would match a sibling's name too takes nothing in from
5285
+ * outside it. One with an empty, `.` or `..` segment gets no suggestion,
5286
+ * since no path holds such a segment. Segments are read as written, so one
5287
+ * a brace group splits around isn't told apart.
5288
+ */
5289
+ async disjointRefusal(tool, given, filter, prefixed, mount, relativePath, root) {
5290
+ const name = mount.config.name;
5291
+ const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
5292
+ const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
5293
+ + (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
5294
+ if (await this.namesFile(mount, relativePath, root))
5295
+ return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
5296
+ const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
5297
+ if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..'))
5298
+ return within;
5299
+ const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
5300
+ return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
4389
5301
  }
4390
5302
  /**
4391
5303
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
4392
5304
  * single file `relativePath` names, those `keep` lets through, each handed
4393
- * to `each` in path order: a pass over the scope, then each path's
4394
- * workspace copy, labelled with its state, and a conflict's disk side.
5305
+ * to `search`, whose answers are recorded in path order: a pass over the
5306
+ * scope, then each path's workspace copy, labelled with its state, and a
5307
+ * conflict's disk side.
4395
5308
  * What it doesn't search is in `skipped`, and what it couldn't see in
4396
5309
  * `incomplete`, each mount-prefixed.
4397
5310
  */
4398
- async textsFromStore(mount, relativePath, start, keep, each) {
5311
+ async textsFromStore(mount, relativePath, start, keep, search) {
4399
5312
  const store = this.getStore();
4400
5313
  const skipped = [];
4401
5314
  const incomplete = [];
@@ -4448,17 +5361,18 @@ export class WorkspaceModule {
4448
5361
  incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
4449
5362
  }
4450
5363
  const intents = this.intents.get(mount.config.name);
5364
+ const found = [];
4451
5365
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
4452
5366
  if (!keep(report.path))
4453
5367
  continue;
4454
5368
  const file = `${mount.config.name}/${report.path}`;
4455
5369
  if (report.state === 'disk-only') {
4456
- skipped.push({
4457
- file,
4458
- reason: 'disk-only: binary or over the size limit, never stored',
4459
- ...(report.size !== undefined ? { size: report.size } : {}),
4460
- ...(report.mimeType ? { mimeType: report.mimeType } : {}),
4461
- });
5370
+ found.push({ skip: {
5371
+ file,
5372
+ reason: 'disk-only: binary or over the size limit, never stored',
5373
+ ...(report.size !== undefined ? { size: report.size } : {}),
5374
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5375
+ } });
4462
5376
  continue;
4463
5377
  }
4464
5378
  // A path in another mount's directory is that mount's: grep gives
@@ -4468,17 +5382,16 @@ export class WorkspaceModule {
4468
5382
  continue;
4469
5383
  const refusal = refused.get(report.path);
4470
5384
  if (refusal !== undefined) {
4471
- skipped.push({ file, reason: refusal });
5385
+ found.push({ skip: { file, reason: refusal } });
4472
5386
  continue;
4473
5387
  }
4474
5388
  // The workspace's version, labelled with the path's state.
4475
5389
  const entry = store.treeGet(mount.treeStateId, report.path);
4476
- const blob = entry ? store.getBlob(entry.blobHash) : null;
4477
- if (blob) {
4478
- each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
4479
- }
4480
- else if (entry) {
4481
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
5390
+ if (entry) {
5391
+ const stored = storedBytes(store, entry.blobHash);
5392
+ found.push('bytes' in stored
5393
+ ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
5394
+ : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
4482
5395
  }
4483
5396
  if (report.state !== 'conflict')
4484
5397
  continue;
@@ -4489,22 +5402,40 @@ export class WorkspaceModule {
4489
5402
  // disk version was never stored either.
4490
5403
  const disk = intents.get(report.path)?.conflict?.disk;
4491
5404
  const changed = report.conflict?.diskChangedSinceRecorded === true;
4492
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
4493
- if (diskBlob) {
4494
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
4495
- }
4496
- if (disk?.stored && !diskBlob) {
4497
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
5405
+ if (disk?.stored) {
5406
+ const version = changed ? 'recorded-disk' : 'conflicting-disk';
5407
+ const stored = storedBytes(store, disk.stored);
5408
+ found.push('bytes' in stored
5409
+ ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
5410
+ : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
4498
5411
  }
4499
5412
  if (changed) {
4500
5413
  if (report.diskNow === 'file') {
4501
- 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' });
5414
+ 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' } });
4502
5415
  }
4503
5416
  }
4504
5417
  else if (disk && !disk.stored) {
4505
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
5418
+ 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 } });
4506
5419
  }
4507
5420
  }
5421
+ const answers = new Map();
5422
+ await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
5423
+ const { file, state, version, what } = lost;
5424
+ answers.set(lost, 'bytes' in contents
5425
+ ? { searched: search({ file, state, version, bytes: contents.bytes }) }
5426
+ : { skip: {
5427
+ file,
5428
+ reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
5429
+ + `and ${lostOnDisk(contents.lost)}, so it was not searched`,
5430
+ } });
5431
+ });
5432
+ for (const item of found) {
5433
+ const answer = 'lost' in item ? answers.get(item.lost) : item;
5434
+ if ('searched' in answer)
5435
+ answer.searched();
5436
+ else
5437
+ skipped.push(answer.skip);
5438
+ }
4508
5439
  });
4509
5440
  return { skipped, incomplete };
4510
5441
  }
@@ -4541,7 +5472,7 @@ export class WorkspaceModule {
4541
5472
  else if (looksBinary(read.head))
4542
5473
  skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
4543
5474
  else
4544
- each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
5475
+ each({ file, version: 'disk', bytes: read.bytes });
4545
5476
  });
4546
5477
  return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
4547
5478
  }
@@ -4549,7 +5480,7 @@ export class WorkspaceModule {
4549
5480
  const store = this.getStore();
4550
5481
  const status = {};
4551
5482
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
4552
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5483
+ return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
4553
5484
  }
4554
5485
  for (const [name, mount] of this.mounts) {
4555
5486
  if (input.mount !== undefined && name !== input.mount)
@@ -4557,7 +5488,7 @@ export class WorkspaceModule {
4557
5488
  if (mount.direct) {
4558
5489
  // A direct mount keeps nothing to count: its kind, and the ignore
4559
5490
  // list its walks go by, whose default no config shows.
4560
- status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
5491
+ status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
4561
5492
  continue;
4562
5493
  }
4563
5494
  // What the workspace's history holds outside the mount's include isn't the mount's,
@@ -4571,7 +5502,7 @@ export class WorkspaceModule {
4571
5502
  const currentBranch = store.currentBranch();
4572
5503
  status[name] = {
4573
5504
  path: mount.config.path,
4574
- mode: mount.config.mode,
5505
+ mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
4575
5506
  // A mount configured direct runs as 'chronicle' this session
4576
5507
  // (chooseKinds), by the direct kind's ignore list where its config
4577
5508
  // names none.
@@ -4691,9 +5622,6 @@ export class WorkspaceModule {
4691
5622
  */
4692
5623
  entriesByEvidence(mount) {
4693
5624
  const store = this.getStore();
4694
- const agreement = this.agreementOrThrow();
4695
- const name = mount.config.name;
4696
- const intents = this.intents.get(name);
4697
5625
  const owed = [];
4698
5626
  const unchecked = [];
4699
5627
  let agreed = 0;
@@ -4703,20 +5631,37 @@ export class WorkspaceModule {
4703
5631
  // in it: neither owed to disk nor counted as on it.
4704
5632
  if (!insideMount(e.path, mount.config.include) || !own(e.path))
4705
5633
  continue;
4706
- const p = agreement.get(name, e.path);
4707
- if (p === undefined)
4708
- (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
4709
- else if (p.kind !== 'content' || p.hash !== e.blobHash)
4710
- owed.push(e.path);
4711
- // A mode the store records, other than the one disk last agreed with,
4712
- // where modes are observed: elsewhere no push applies one.
4713
- else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
5634
+ const kind = this.evidenceOf(mount, e);
5635
+ if (kind === 'owed')
4714
5636
  owed.push(e.path);
5637
+ else if (kind === 'unchecked')
5638
+ unchecked.push(e.path);
4715
5639
  else
4716
5640
  agreed++;
4717
5641
  }
4718
5642
  return { owed, unchecked, agreed };
4719
5643
  }
5644
+ evidenceOf(mount, e) {
5645
+ const p = this.agreementOrThrow().get(mount.config.name, e.path);
5646
+ if (p === undefined)
5647
+ return this.intents.get(mount.config.name).get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
5648
+ if (p.kind !== 'content' || p.hash !== e.blobHash)
5649
+ return 'owed';
5650
+ // A mode the store records, other than the one disk last agreed with,
5651
+ // where modes are observed: elsewhere no push applies one.
5652
+ if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
5653
+ return 'owed';
5654
+ return 'agreed';
5655
+ }
5656
+ diskOf(mount, relativePath) {
5657
+ const intent = this.intents.get(mount.config.name).get(relativePath);
5658
+ if (intent?.conflict)
5659
+ return 'in conflict with disk';
5660
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
5661
+ if (!entry)
5662
+ return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
5663
+ return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
5664
+ }
4720
5665
  async handleMaterialize(input) {
4721
5666
  const store = this.getStore();
4722
5667
  const allWritten = [];
@@ -4731,7 +5676,7 @@ export class WorkspaceModule {
4731
5676
  // is refused; beneath a directory, the push leaves out what the include
4732
5677
  // doesn't cover.
4733
5678
  if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
4734
- return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
5679
+ return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
4735
5680
  }
4736
5681
  }
4737
5682
  let mountsToMaterialize;
@@ -4741,13 +5686,14 @@ export class WorkspaceModule {
4741
5686
  else if (input.mount) {
4742
5687
  const m = this.mounts.get(input.mount);
4743
5688
  if (!m) {
4744
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5689
+ 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 };
4745
5690
  }
4746
5691
  mountsToMaterialize = [{ name: input.mount, mount: m }];
4747
5692
  }
4748
5693
  else {
5694
+ // Not a mount running direct, whose writes are on disk already.
4749
5695
  mountsToMaterialize = [...this.mounts.entries()]
4750
- .filter(([, m]) => m.config.mode === 'read-write')
5696
+ .filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
4751
5697
  .map(([name, mount]) => ({ name, mount }));
4752
5698
  }
4753
5699
  // Branch guard, scoped to the mounts actually being materialized: a
@@ -4766,6 +5712,12 @@ export class WorkspaceModule {
4766
5712
  const guarded = [];
4767
5713
  let proceeded = 0;
4768
5714
  for (const { name, mount } of mountsToMaterialize) {
5715
+ // Only a writable direct mount the caller named gets here: it keeps no
5716
+ // tree to push. A read-only one is named as any read-only mount is.
5717
+ if (mount.direct && mount.config.mode !== 'read-only') {
5718
+ blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
5719
+ continue;
5720
+ }
4769
5721
  if (mount.config.mode === 'read-only') {
4770
5722
  // Only a mount the caller named gets here read-only (a bare
4771
5723
  // materialize takes the read-write ones): say why nothing was written.
@@ -4848,15 +5800,20 @@ export class WorkspaceModule {
4848
5800
  if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
4849
5801
  return {
4850
5802
  success: false,
4851
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
5803
+ error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
4852
5804
  isError: true,
4853
5805
  };
4854
5806
  }
5807
+ const held = explicit;
5808
+ const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
5809
+ && allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
5810
+ && !this.holdsAnythingAt(held.mount, held.relativePath);
4855
5811
  return {
4856
5812
  success: true,
4857
5813
  data: {
4858
5814
  materialized: allWritten,
4859
5815
  count: allWritten.length,
5816
+ ...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
4860
5817
  ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
4861
5818
  ...(pendingDeletions.length > 0
4862
5819
  ? {
@@ -4884,7 +5841,8 @@ export class WorkspaceModule {
4884
5841
  return [];
4885
5842
  const store = this.getStore();
4886
5843
  const mount = this.mounts.get(mountName);
4887
- if (!mount || mount.config.mode === 'read-only')
5844
+ // A direct mount keeps no tree to push: disk is its only copy.
5845
+ if (!mount || mount.config.mode === 'read-only' || mount.direct)
4888
5846
  return [];
4889
5847
  // Reset tracking — we're deliberately materializing on the new branch
4890
5848
  mount.lastMaterializedBranchId = null;
@@ -4917,7 +5875,13 @@ export class WorkspaceModule {
4917
5875
  const allIgnored = [];
4918
5876
  const rootsAccepted = [];
4919
5877
  if (input.acceptRoot && input.path) {
4920
- return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
5878
+ const named = input.path.split('/')[0];
5879
+ return {
5880
+ success: false,
5881
+ isError: true,
5882
+ error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
5883
+ + `${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.`),
5884
+ };
4921
5885
  }
4922
5886
  // A path names its own mount; otherwise the given mount, or every mount.
4923
5887
  let targets;
@@ -4927,14 +5891,14 @@ export class WorkspaceModule {
4927
5891
  // is refused; beneath a directory, the walk passes over what the include
4928
5892
  // doesn't cover.
4929
5893
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4930
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5894
+ return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4931
5895
  }
4932
5896
  targets = [{ name: mount.config.name, mount, relativePath }];
4933
5897
  }
4934
5898
  else if (input.mount) {
4935
5899
  const m = this.mounts.get(input.mount);
4936
5900
  if (!m) {
4937
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5901
+ return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
4938
5902
  }
4939
5903
  targets = [{ name: input.mount, mount: m, relativePath: '' }];
4940
5904
  }
@@ -4948,6 +5912,7 @@ export class WorkspaceModule {
4948
5912
  allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
4949
5913
  continue;
4950
5914
  }
5915
+ let namedDirectory = false;
4951
5916
  const pass = await this.withMount(mount, async () => {
4952
5917
  if (input.acceptRoot && await this.acceptRootUnlocked(mount))
4953
5918
  rootsAccepted.push(name);
@@ -4962,6 +5927,7 @@ export class WorkspaceModule {
4962
5927
  store.treeList(mount.treeStateId, prefix).length > 0 ||
4963
5928
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
4964
5929
  this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
5930
+ namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
4965
5931
  // named: ignore patterns covering the path asked for don't apply
4966
5932
  // beneath it (a directory scope's walk; a path scope doesn't walk).
4967
5933
  const opts = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
@@ -5025,7 +5991,7 @@ export class WorkspaceModule {
5025
5991
  // without a path passes over it, so nothing else keeps it current
5026
5992
  // (agent-framework #276). A path in another mount's directory isn't
5027
5993
  // synced at all, and the note says so.
5028
- const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
5994
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
5029
5995
  if (passedOver !== undefined) {
5030
5996
  allIgnored.push({
5031
5997
  mount: name,
@@ -5038,10 +6004,17 @@ export class WorkspaceModule {
5038
6004
  });
5039
6005
  }
5040
6006
  }
6007
+ const named = input.path !== undefined && input.path !== '' ? targets[0] : null;
6008
+ const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
6009
+ && allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
6010
+ && allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
6011
+ && !this.holdsAnythingAt(named.mount, named.relativePath)
6012
+ && await this.absentOnDisk(named.mount, named.relativePath);
5041
6013
  return {
5042
6014
  success: true,
5043
6015
  data: {
5044
6016
  results: allResults,
6017
+ ...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
5045
6018
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
5046
6019
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
5047
6020
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
@@ -5108,9 +6081,9 @@ export class WorkspaceModule {
5108
6081
  * on disk (a shell command's files, a deletion) is in the workspace when the
5109
6082
  * agent next looks. On a mount over its walkCap, that holds for the first
5110
6083
  * walkCap entries in the walk's order, which every batch's scan takes; files
5111
- * past them come in through syncs and the listings that reach them, while a
5112
- * read takes in only a file the workspace doesn't hold yet. A scan that
5113
- * outlasts the deadline stops holding the round: the miss is recorded on
6084
+ * past them come in through syncs, the listings that reach them, and a read
6085
+ * of the path, which takes disk's change in first as a listing does. A scan
6086
+ * that outlasts the deadline stops holding the round: the miss is recorded on
5114
6087
  * the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
5115
6088
  * event, and the scan continues, deciding on the branch selected when it
5116
6089
  * applies.
@@ -5287,13 +6260,13 @@ export async function inOrder(items, width, work, each) {
5287
6260
  }
5288
6261
  }
5289
6262
  /** read_image's answer for an image, wherever it was read from. */
5290
- function imageResult(path, image) {
6263
+ function imageResult(path, image, note) {
5291
6264
  return {
5292
6265
  success: true,
5293
6266
  data: [
5294
6267
  {
5295
6268
  type: 'text',
5296
- text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
6269
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
5297
6270
  },
5298
6271
  {
5299
6272
  type: 'image',
@@ -5338,16 +6311,35 @@ const UNREADABLE_INTENT = "its workspace intent can't be read; a sync of the pat
5338
6311
  function watchOf(config) {
5339
6312
  return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
5340
6313
  }
6314
+ /**
6315
+ * Why a write, edit, deletion or peer module's writeBinary to this mount is
6316
+ * refused, or null where it may go ahead: a read-only mount, and a mount
6317
+ * configured direct that runs as 'chronicle' this session (chooseKinds),
6318
+ * since the store holds changes there that disk lacks. A write there would
6319
+ * stay in the store, beside an agent told the mount is its checkout, and
6320
+ * add to what keeps it off direct; refused, what's held only shrinks, and
6321
+ * the next start once none is held runs it direct.
6322
+ */
6323
+ function writeRefusal(mount, writable = []) {
6324
+ const name = mount.config.name;
6325
+ if (mount.config.mode === 'read-only') {
6326
+ return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
6327
+ + (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
6328
+ }
6329
+ if (mount.config.backend === 'direct' && !mount.direct) {
6330
+ return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6331
+ + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6332
+ + `(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.`;
6333
+ }
6334
+ return null;
6335
+ }
5341
6336
  /**
5342
6337
  * What a direct mount's config sets that would never act there, each with
5343
- * the way out (MountConfig.backend): a direct mount is read-only, and has no
5344
- * watcher to tune, no change event to wake on and no push to make.
6338
+ * the way out (MountConfig.backend): a direct mount has no watcher to tune,
6339
+ * no change event to wake on and no push to make. Either mode is its own.
5345
6340
  */
5346
6341
  function directRefusals(mount) {
5347
6342
  const refused = [];
5348
- if (mount.mode !== 'read-only') {
5349
- 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)}`);
5350
- }
5351
6343
  if (mount.watch !== undefined && mount.watch !== 'never')
5352
6344
  refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
5353
6345
  for (const key of ['watchDebounceMs', 'watchRootPollMs']) {
@@ -5361,6 +6353,263 @@ function directRefusals(mount) {
5361
6353
  refused.push('autoMaterialize would never push (leave it out)');
5362
6354
  return refused;
5363
6355
  }
6356
+ /** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
6357
+ const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
6358
+ /**
6359
+ * A saved record as saveDirectHeads writes it, or a throw naming what isn't:
6360
+ * a list of entries, each a mount's name, root and agents, and each agent's
6361
+ * branch, head and, where set, notice. Checked whole, carried entries
6362
+ * included, so a record of another shape is refused at load rather than
6363
+ * carried into the next save.
6364
+ */
6365
+ function checkedHeads(value) {
6366
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
6367
+ if (!Array.isArray(value))
6368
+ throw new Error("the record isn't of the shape saved");
6369
+ for (const saved of value) {
6370
+ if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
6371
+ throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
6372
+ }
6373
+ for (const [agent, head] of Object.entries(saved.agents)) {
6374
+ if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
6375
+ || (head.notice !== undefined && typeof head.notice !== 'string')) {
6376
+ throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
6377
+ }
6378
+ }
6379
+ }
6380
+ return value;
6381
+ }
6382
+ /** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
6383
+ function canonicalHeads(snapshot) {
6384
+ return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
6385
+ const { branchId, after, notice } = agents[agent];
6386
+ return [agent, branchId, after, notice ?? null];
6387
+ })]).sort((a, b) => {
6388
+ const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
6389
+ return x < y ? -1 : x > y ? 1 : 0;
6390
+ }));
6391
+ }
6392
+ /**
6393
+ * How many times a direct edit reads and plans a file another writer keeps
6394
+ * changing: enough to land on top of a writer that changes it now and then,
6395
+ * such as a formatter or a build, and bounded for one that never stops.
6396
+ */
6397
+ const DIRECT_EDIT_ATTEMPTS = 3;
6398
+ /** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
6399
+ function directReason(error) {
6400
+ if (error instanceof FileChanged)
6401
+ return 'it changed on disk while this call was being applied';
6402
+ // A deletion's root check (unlinkContained) has a store-backed mount's
6403
+ // words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
6404
+ if (error instanceof Error && error.message === ROOT_REPLACED)
6405
+ return ROOT_MOVED;
6406
+ return error instanceof Error ? error.message : String(error);
6407
+ }
6408
+ function directWayOn(error) {
6409
+ return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
6410
+ }
6411
+ function directDidNothing(tool, path, error) {
6412
+ const on = directWayOn(error);
6413
+ return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
6414
+ }
6415
+ /** "a", "a and b", or "a, b and c". */
6416
+ function listed(items) {
6417
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
6418
+ }
6419
+ /**
6420
+ * The note for a direct write that gave a hard-linked path a file of its
6421
+ * own: the other links keep the old text. Null for a file with none.
6422
+ */
6423
+ function linksNote(path, staged, tool) {
6424
+ const n = staged.otherLinks;
6425
+ if (n <= 0)
6426
+ return null;
6427
+ return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
6428
+ + `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
6429
+ }
6430
+ /**
6431
+ * A refusal of openContainedFile's as a direct tool gives it: its message,
6432
+ * and for one that names only the path, its errno too, as a direct read's
6433
+ * answer gives it (bytesOnDisk).
6434
+ */
6435
+ function containedRefusal(err, path) {
6436
+ const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6437
+ return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
6438
+ }
6439
+ function deletedOnDisk(path) {
6440
+ 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.`;
6441
+ }
6442
+ function uncheckedFile(shown, note) {
6443
+ if (note === DIRECTORY)
6444
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6445
+ const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
6446
+ : note === ROOT_REPLACED ? null
6447
+ : note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
6448
+ : note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
6449
+ : note === NOT_REGULAR ? onlyTheHost('put a regular file there')
6450
+ : note === CHANGED ? 'Calling again checks it afresh.'
6451
+ : 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
6452
+ return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
6453
+ }
6454
+ function namesRoot(path, mount) {
6455
+ return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
6456
+ }
6457
+ function tooLargeToWrite(path, bytes, maxSize) {
6458
+ 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.');
6459
+ }
6460
+ function pastSizeLimit(what, maxSize, outcome, within) {
6461
+ return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
6462
+ + "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
6463
+ }
6464
+ function sentence(text) {
6465
+ return /[.!?]$/.test(text) ? text : `${text}.`;
6466
+ }
6467
+ function unreadable(shown, errno) {
6468
+ return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
6469
+ }
6470
+ function readRefusal(err, path) {
6471
+ const shown = JSON.stringify(path);
6472
+ if (err instanceof RootUnavailable) {
6473
+ if (err.refusal)
6474
+ return err.refusal.message;
6475
+ const mount = JSON.stringify(err.mount);
6476
+ if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
6477
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
6478
+ 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")}`;
6479
+ }
6480
+ if (err.errno !== undefined) {
6481
+ return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
6482
+ + errnoWayOn(err.errno, ROOT_RECHECK, true);
6483
+ }
6484
+ return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
6485
+ }
6486
+ return `${shown} can't be read: ${sentence(err.reason)}`;
6487
+ }
6488
+ if (err.stage === 'readlink')
6489
+ return sentence(err.message);
6490
+ switch (err.code) {
6491
+ case 'directory':
6492
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6493
+ case 'symlink':
6494
+ return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6495
+ + 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
6496
+ case 'escape':
6497
+ return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6498
+ + 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
6499
+ case 'changed':
6500
+ return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6501
+ case 'not_found':
6502
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR')
6503
+ return `there's no file at ${shown}. ls or glob lists the files that exist.`;
6504
+ if (err instanceof NotRegularFile) {
6505
+ 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')}`;
6506
+ }
6507
+ if (err.stage === 'realpath')
6508
+ return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
6509
+ return unreadable(shown, err.errno);
6510
+ default:
6511
+ return sentence(err.message);
6512
+ }
6513
+ }
6514
+ function contentsSubject(what, path) {
6515
+ return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
6516
+ }
6517
+ function inTheStore(stored) {
6518
+ return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
6519
+ }
6520
+ function lostInStore(lost) {
6521
+ return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
6522
+ }
6523
+ function lostOnDisk(lost) {
6524
+ return lost.disk === 'other' ? "disk's copy holds other contents"
6525
+ : lost.disk === 'none' ? 'disk has no copy'
6526
+ : `disk's copy can't be read (${lost.disk.unreadable})`;
6527
+ }
6528
+ function lostText(lost, the = 'The') {
6529
+ return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
6530
+ }
6531
+ function nothingToRead(lost) {
6532
+ return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
6533
+ }
6534
+ function lostRemedy(lost) {
6535
+ const after = remedyOfLost(lost);
6536
+ if (!lost.reopen)
6537
+ return after;
6538
+ 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. "
6539
+ + `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
6540
+ }
6541
+ function remedyOfLost(lost) {
6542
+ const rewrite = lost.writable && 'missing' in lost.store;
6543
+ if (lost.disk === 'other')
6544
+ return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
6545
+ if (lost.writable)
6546
+ return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
6547
+ return lost.disk === 'none'
6548
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy."
6549
+ : "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.";
6550
+ }
6551
+ /**
6552
+ * The notice that writes an agent made direct remain on disk from
6553
+ * turns the agent's history no longer holds. `git status` stands beside
6554
+ * `git diff` because a departed turn's new file is untracked, which `git
6555
+ * diff` doesn't show; and the warning says "unstaged", since `git checkout
6556
+ * -- <file>` puts back a file's staged text, so a staged change survives it.
6557
+ */
6558
+ function headMoveText(mounts) {
6559
+ const one = mounts.length === 1;
6560
+ return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6561
+ + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6562
+ + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6563
+ }
6564
+ /**
6565
+ * One file's edits planned against its bytes, as both kinds of mount plan
6566
+ * them: its text decoded, every edit matched in it (edit-plan.ts), and the
6567
+ * result held to the mount's size limit. Each problem found goes into
6568
+ * `problems`, and then, as for a file named only by edits whose own input
6569
+ * was refused, there's no plan (null).
6570
+ */
6571
+ function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems) {
6572
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
6573
+ // the text back would change bytes the edits never named.
6574
+ const file = decodeForEdit(bytes);
6575
+ if (!file) {
6576
+ problems.push({
6577
+ kind: 'file',
6578
+ edits: target.edits,
6579
+ path: target.path,
6580
+ text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
6581
+ + '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.',
6582
+ whole: false,
6583
+ });
6584
+ return null;
6585
+ }
6586
+ if (!target.specs.length)
6587
+ return null;
6588
+ const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
6589
+ if ('problems' in planned) {
6590
+ // One at a time: a call of many edits can have more problems
6591
+ // than a spread passes as arguments.
6592
+ for (const problem of planned.problems)
6593
+ problems.push({ ...problem, path: target.path });
6594
+ return null;
6595
+ }
6596
+ // The planner refuses a file its counts take past the limit before
6597
+ // building it, from the least its bytes can be; one it built past
6598
+ // the limit, as its CRs or multi-byte characters can take it, is
6599
+ // refused here.
6600
+ if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
6601
+ const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
6602
+ problems.push({
6603
+ kind: 'file',
6604
+ edits: target.edits,
6605
+ path: target.path,
6606
+ text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize, "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
6607
+ whole: false,
6608
+ });
6609
+ return null;
6610
+ }
6611
+ return planned.plan;
6612
+ }
5364
6613
  /**
5365
6614
  * Whether a tree entry is what a change leaves: for a set, an entry with its
5366
6615
  * blob, size and mode; for a removal, none.
@@ -5390,16 +6639,38 @@ function changesLanded(store, treeStateId, changes, setError) {
5390
6639
  * change is stored isn't known. Its message says so after both errors.
5391
6640
  */
5392
6641
  class CommitUnknownError extends Error {
5393
- setError;
5394
- readError;
5395
- constructor(setError, readError) {
6642
+ setCause;
6643
+ readCause;
6644
+ constructor(setCause, readCause) {
5396
6645
  const text = (error) => (error instanceof Error ? error.message : String(error));
5397
- super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
6646
+ super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
6647
+ this.setCause = setCause;
6648
+ this.readCause = readCause;
5398
6649
  this.name = 'CommitUnknownError';
5399
- this.setError = text(setError);
5400
- this.readError = text(readError);
5401
6650
  }
5402
6651
  }
6652
+ function storeWords(error) {
6653
+ const code = errnoCode(error);
6654
+ return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
6655
+ }
6656
+ class NotStored extends Error {
6657
+ thrown;
6658
+ constructor(thrown) {
6659
+ super('stopped before the change was stored');
6660
+ this.thrown = thrown;
6661
+ }
6662
+ }
6663
+ class AlreadyHeld extends Error {
6664
+ thrown;
6665
+ constructor(thrown) {
6666
+ super(thrown instanceof Error ? thrown.message : String(thrown));
6667
+ this.thrown = thrown;
6668
+ }
6669
+ }
6670
+ function notStored(error) {
6671
+ return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
6672
+ ? error : new NotStored(error);
6673
+ }
5403
6674
  /**
5404
6675
  * A store's set that threw after recording the change (changesLanded): the
5405
6676
  * entries are set and their intents follow, and the store's error goes on in
@@ -5416,6 +6687,11 @@ class StoredThenFailed extends Error {
5416
6687
  this.name = 'StoredThenFailed';
5417
6688
  }
5418
6689
  }
6690
+ const ONCE_REOPENED = ', once the store is reopened';
6691
+ const DISK_ORDER = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
6692
+ function furthestFromDisk(values) {
6693
+ return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
6694
+ }
5419
6695
  /**
5420
6696
  * The answer to a write, edit or delete that the workspace stored but
5421
6697
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -5427,7 +6703,7 @@ class StoredThenFailed extends Error {
5427
6703
  * script. A note comes last: an edit's on which copy it applied to, or a
5428
6704
  * deletion's that it unlinked nothing.
5429
6705
  */
5430
- function storedButUnfinished(tool, path, outcome, note = null) {
6706
+ function storedButUnfinished(tool, path, outcome, note = null, reopen = false) {
5431
6707
  const change = tool === 'delete' ? 'deletion' : tool;
5432
6708
  const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
5433
6709
  + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
@@ -5436,21 +6712,53 @@ function storedButUnfinished(tool, path, outcome, note = null) {
5436
6712
  : 'storeFailed' in outcome
5437
6713
  ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
5438
6714
  : tool === 'delete'
5439
- ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
5440
- : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
5441
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
5442
- return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
6715
+ ? ` 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 : ''}.`
6716
+ : ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
6717
+ : ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
6718
+ return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
5443
6719
  }
5444
6720
  /**
5445
- * A text's lines, as read and grep number them: a final line end ends the
5446
- * last line rather than starting an empty one, so "a\nb\n" is two lines,
5447
- * and an empty text has none.
6721
+ * What a read says about the copy it shows, when that isn't simply the one the
6722
+ * workspace last agreed on with disk: disk's newer text, taken in first; the
6723
+ * workspace's copy, because disk's couldn't be checked or disk has none that
6724
+ * anything accounts for; or a draft in conflict with disk's change. A file
6725
+ * the workspace didn't hold has no older copy, so taking it in needs no word.
6726
+ * A conflict's note names the ways to settle it that work on the path's mount.
5448
6727
  */
5449
- function linesOf(content) {
5450
- const lines = content.split('\n');
5451
- if (lines.at(-1) === '')
5452
- lines.pop();
5453
- return lines;
6728
+ function whichCopyRead(report, tookChange, readOnly) {
6729
+ if (tookChange) {
6730
+ 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.'];
6731
+ }
6732
+ if (report?.state === 'unverified') {
6733
+ return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
6734
+ }
6735
+ if (report?.state === 'disk-missing-provenance-unknown') {
6736
+ return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
6737
+ }
6738
+ if (report?.state === 'conflict') {
6739
+ const kind = report.conflict?.kind ?? 'both-changed';
6740
+ // A sync takes disk's state as it is now, which a recorded conflict's kind
6741
+ // doesn't follow: where disk has no copy, the sync takes its deletion, and
6742
+ // the workspace's copy goes with it. Where disk has one, the sync keeps it
6743
+ // and gives up the workspace's, taking disk's in only where the workspace
6744
+ // can hold it (not binary or too large). A read-only mount can hold a
6745
+ // copy disk doesn't, such as one a branch switch selected, but a
6746
+ // materialize, forced or not, writes nothing to it.
6747
+ const settle = settleConflict(report.diskNow, readOnly);
6748
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0].toUpperCase()}${settle.slice(1)}`];
6749
+ }
6750
+ return [];
6751
+ }
6752
+ function settleConflict(diskNow, readOnly) {
6753
+ const sync = diskNow === 'absent'
6754
+ ? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6755
+ : 'sync the path to keep disk\'s copy and give up the workspace\'s';
6756
+ const force = readOnly
6757
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6758
+ : diskNow === 'absent'
6759
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6760
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
6761
+ return `${sync}${force}`;
5454
6762
  }
5455
6763
  /**
5456
6764
  * The least each tool's whole-number fields may be, beside their schemas,
@@ -5517,6 +6825,81 @@ function inputProblem(schema, input, minimums = {}) {
5517
6825
  }
5518
6826
  return null;
5519
6827
  }
6828
+ /** How many files a grep note names before it gives the count of the rest. */
6829
+ const NOTE_FILES_MAX = 20;
6830
+ /**
6831
+ * The files a grep note names, in the order grep searched them, as its
6832
+ * results are: at most NOTE_FILES_MAX of them, then how many more and how to
6833
+ * see them. Each is named by its mount-prefixed path, with what `detail` says
6834
+ * of it.
6835
+ */
6836
+ function namedFiles(files, detail) {
6837
+ const names = [...files.keys()];
6838
+ const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name))}`);
6839
+ const rest = names.length - shown.length;
6840
+ return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
6841
+ }
6842
+ /** What a note says of a conflict's disk version, which read doesn't show. */
6843
+ const ofVersion = (version) => version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
6844
+ /**
6845
+ * grep's note for a pattern that names a character its lines leave out, where
6846
+ * a file it searched has one: it says how many files searched have it and
6847
+ * names them, so a search for a CR or a BOM still finds the files that have
6848
+ * them. The CR note gives each file's ends, CRLF or mixed, and either note
6849
+ * names a conflict's disk version as that version. The note says a
6850
+ * character read shows it in the workspace's copy, since grep also searches a
6851
+ * conflict's disk version, which read doesn't show. A direct mount keeps no
6852
+ * workspace copy, and its read shows disk's file, so on a host with one
6853
+ * (`direct`) the note names that too, as the tools' descriptions mention
6854
+ * direct mounts only on such a host.
6855
+ */
6856
+ function leftOutNote(pattern, crlfFiles, bomFiles, direct) {
6857
+ if (crlfFiles.size === 0 && bomFiles.size === 0)
6858
+ return {};
6859
+ const named = namedLeftOut(pattern);
6860
+ const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
6861
+ const count = (n, one, many) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
6862
+ const notes = [
6863
+ named.cr && crlfFiles.size > 0
6864
+ ? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
6865
+ + `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
6866
+ + `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}.`
6867
+ : null,
6868
+ named.bom && bomFiles.size > 0
6869
+ ? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
6870
+ + `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
6871
+ + `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'}.`
6872
+ : null,
6873
+ ].filter((n) => n !== null);
6874
+ return notes.length ? { note: notes.join(' ') } : {};
6875
+ }
6876
+ /**
6877
+ * Which of the characters grep's lines leave out a pattern names: a CR, as a
6878
+ * literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
6879
+ * before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
6880
+ * before a literal one. An escape counts only where its backslash isn't itself
6881
+ * escaped. A pattern compiled without flags can match a CR other ways too,
6882
+ * such as `\15` in a pattern of fewer than 15 groups or a class range that
6883
+ * spans U+000D, and those go unnoticed.
6884
+ */
6885
+ function namedLeftOut(pattern) {
6886
+ const named = { cr: false, bom: false };
6887
+ for (let i = 0; i < pattern.length; i++) {
6888
+ if (pattern[i] === '\r')
6889
+ named.cr = true;
6890
+ else if (pattern[i] === '\uFEFF')
6891
+ named.bom = true;
6892
+ else if (pattern[i] === '\\') {
6893
+ const next = pattern.slice(i + 1, i + 6);
6894
+ if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next))
6895
+ named.cr = true;
6896
+ else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next))
6897
+ named.bom = true;
6898
+ i++;
6899
+ }
6900
+ }
6901
+ return named;
6902
+ }
5520
6903
  /**
5521
6904
  * A glob's reading in the mount its first segment names, in place of the
5522
6905
  * pattern as written there. Every path the tools take and return is
@@ -5568,13 +6951,13 @@ function globToRegex(pattern) {
5568
6951
  closeIdx = j;
5569
6952
  }
5570
6953
  if (closeIdx < 0)
5571
- throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
6954
+ throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
5572
6955
  const group = pattern.slice(i + 1, closeIdx);
5573
6956
  if (group.includes('{')) {
5574
- throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
6957
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
5575
6958
  }
5576
6959
  if (!group.includes(',')) {
5577
- throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
6960
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
5578
6961
  }
5579
6962
  // A `/` right after the group goes into each alternative, as `{a,b}/x`
5580
6963
  // and `{a/,b/}x` name the same paths, so an alternative's own `**`