@animalabs/agent-framework 0.27.0 → 0.29.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 (344) hide show
  1. package/README.md +145 -41
  2. package/dist/src/agent.d.ts +29 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +73 -12
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +3 -12
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/api/server.d.ts.map +1 -1
  10. package/dist/src/api/server.js +11 -5
  11. package/dist/src/api/server.js.map +1 -1
  12. package/dist/src/auto-continue.d.ts +5 -2
  13. package/dist/src/auto-continue.d.ts.map +1 -1
  14. package/dist/src/auto-continue.js +9 -5
  15. package/dist/src/auto-continue.js.map +1 -1
  16. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  17. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/runner.js +7 -5
  19. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +3 -2
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/cut-off-round.d.ts +24 -0
  24. package/dist/src/cut-off-round.d.ts.map +1 -0
  25. package/dist/src/cut-off-round.js +87 -0
  26. package/dist/src/cut-off-round.js.map +1 -0
  27. package/dist/src/framework.d.ts +468 -125
  28. package/dist/src/framework.d.ts.map +1 -1
  29. package/dist/src/framework.js +3651 -1111
  30. package/dist/src/framework.js.map +1 -1
  31. package/dist/src/gate/event-gate.d.ts +133 -13
  32. package/dist/src/gate/event-gate.d.ts.map +1 -1
  33. package/dist/src/gate/event-gate.js +654 -59
  34. package/dist/src/gate/event-gate.js.map +1 -1
  35. package/dist/src/gate/index.d.ts +3 -2
  36. package/dist/src/gate/index.d.ts.map +1 -1
  37. package/dist/src/gate/index.js +2 -1
  38. package/dist/src/gate/index.js.map +1 -1
  39. package/dist/src/gate/reply-judge.d.ts +116 -0
  40. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  41. package/dist/src/gate/reply-judge.js +467 -0
  42. package/dist/src/gate/reply-judge.js.map +1 -0
  43. package/dist/src/gate/types.d.ts +173 -2
  44. package/dist/src/gate/types.d.ts.map +1 -1
  45. package/dist/src/heartbeat/heartbeat.d.ts +202 -0
  46. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  47. package/dist/src/heartbeat/heartbeat.js +778 -0
  48. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  49. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  50. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  51. package/dist/src/heartbeat/reminder-time.js +107 -0
  52. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  53. package/dist/src/held-writes.d.ts +3 -3
  54. package/dist/src/held-writes.js +3 -3
  55. package/dist/src/host-presentation.d.ts +170 -0
  56. package/dist/src/host-presentation.d.ts.map +1 -0
  57. package/dist/src/host-presentation.js +561 -0
  58. package/dist/src/host-presentation.js.map +1 -0
  59. package/dist/src/index.d.ts +5 -1
  60. package/dist/src/index.d.ts.map +1 -1
  61. package/dist/src/index.js +3 -1
  62. package/dist/src/index.js.map +1 -1
  63. package/dist/src/line-reader.d.ts +4 -0
  64. package/dist/src/line-reader.d.ts.map +1 -0
  65. package/dist/src/line-reader.js +59 -0
  66. package/dist/src/line-reader.js.map +1 -0
  67. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  68. package/dist/src/mcpl/capability-mask.js +3 -2
  69. package/dist/src/mcpl/capability-mask.js.map +1 -1
  70. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  71. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  72. package/dist/src/mcpl/channel-registry.js +133 -54
  73. package/dist/src/mcpl/channel-registry.js.map +1 -1
  74. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  75. package/dist/src/mcpl/conversation-router.js +2 -1
  76. package/dist/src/mcpl/conversation-router.js.map +1 -1
  77. package/dist/src/mcpl/convert-block.d.ts +1 -0
  78. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  79. package/dist/src/mcpl/convert-block.js +16 -4
  80. package/dist/src/mcpl/convert-block.js.map +1 -1
  81. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  82. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  83. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  84. package/dist/src/mcpl/inbound-source.d.ts +29 -11
  85. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  86. package/dist/src/mcpl/inbound-source.js +43 -70
  87. package/dist/src/mcpl/inbound-source.js.map +1 -1
  88. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  89. package/dist/src/mcpl/modern-connection.js +11 -7
  90. package/dist/src/mcpl/modern-connection.js.map +1 -1
  91. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  92. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  93. package/dist/src/mcpl/protocol-family.js +25 -3
  94. package/dist/src/mcpl/protocol-family.js.map +1 -1
  95. package/dist/src/mcpl/push-handler.js +2 -2
  96. package/dist/src/mcpl/push-handler.js.map +1 -1
  97. package/dist/src/mcpl/references.d.ts +12 -40
  98. package/dist/src/mcpl/references.d.ts.map +1 -1
  99. package/dist/src/mcpl/references.js +29 -23
  100. package/dist/src/mcpl/references.js.map +1 -1
  101. package/dist/src/mcpl/server-connection.d.ts +18 -8
  102. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  103. package/dist/src/mcpl/server-connection.js +70 -30
  104. package/dist/src/mcpl/server-connection.js.map +1 -1
  105. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  106. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  107. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  108. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  109. package/dist/src/mcpl/tags.d.ts +9 -2
  110. package/dist/src/mcpl/tags.d.ts.map +1 -1
  111. package/dist/src/mcpl/tags.js +12 -2
  112. package/dist/src/mcpl/tags.js.map +1 -1
  113. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  114. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  115. package/dist/src/mcpl/tool-presence.js +150 -80
  116. package/dist/src/mcpl/tool-presence.js.map +1 -1
  117. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  118. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  119. package/dist/src/mcpl/tool-signature.js +60 -30
  120. package/dist/src/mcpl/tool-signature.js.map +1 -1
  121. package/dist/src/mcpl/toolset-host.d.ts +88 -55
  122. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  123. package/dist/src/mcpl/toolset-host.js +220 -111
  124. package/dist/src/mcpl/toolset-host.js.map +1 -1
  125. package/dist/src/mcpl/toolsets-module.d.ts +27 -35
  126. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  127. package/dist/src/mcpl/toolsets-module.js +313 -91
  128. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  129. package/dist/src/mcpl/transport.d.ts +1 -1
  130. package/dist/src/mcpl/transport.d.ts.map +1 -1
  131. package/dist/src/mcpl/transport.js +16 -16
  132. package/dist/src/mcpl/transport.js.map +1 -1
  133. package/dist/src/mcpl/types.d.ts +31 -13
  134. package/dist/src/mcpl/types.d.ts.map +1 -1
  135. package/dist/src/mcpl/types.js.map +1 -1
  136. package/dist/src/module-registry.d.ts +10 -8
  137. package/dist/src/module-registry.d.ts.map +1 -1
  138. package/dist/src/module-registry.js +15 -9
  139. package/dist/src/module-registry.js.map +1 -1
  140. package/dist/src/modules/api/index.d.ts.map +1 -1
  141. package/dist/src/modules/api/index.js +4 -2
  142. package/dist/src/modules/api/index.js.map +1 -1
  143. package/dist/src/modules/discord/index.d.ts.map +1 -1
  144. package/dist/src/modules/discord/index.js +33 -25
  145. package/dist/src/modules/discord/index.js.map +1 -1
  146. package/dist/src/modules/history/index.d.ts.map +1 -1
  147. package/dist/src/modules/history/index.js +30 -17
  148. package/dist/src/modules/history/index.js.map +1 -1
  149. package/dist/src/modules/sender-name.d.ts +7 -0
  150. package/dist/src/modules/sender-name.d.ts.map +1 -0
  151. package/dist/src/modules/sender-name.js +10 -0
  152. package/dist/src/modules/sender-name.js.map +1 -0
  153. package/dist/src/modules/workspace/answers.d.ts +26 -0
  154. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  155. package/dist/src/modules/workspace/answers.js +138 -0
  156. package/dist/src/modules/workspace/answers.js.map +1 -0
  157. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  158. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  159. package/dist/src/modules/workspace/branch-intent.js +6 -2
  160. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  161. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +59 -24
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  166. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  168. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  169. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  170. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/edit-text.js +4 -2
  172. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  173. package/dist/src/modules/workspace/effects.d.ts +4 -2
  174. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/effects.js +67 -50
  176. package/dist/src/modules/workspace/effects.js.map +1 -1
  177. package/dist/src/modules/workspace/index.d.ts +63 -51
  178. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/index.js +980 -547
  180. package/dist/src/modules/workspace/index.js.map +1 -1
  181. package/dist/src/modules/workspace/observe.d.ts +125 -82
  182. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  183. package/dist/src/modules/workspace/observe.js +465 -241
  184. package/dist/src/modules/workspace/observe.js.map +1 -1
  185. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  186. package/dist/src/modules/workspace/read-page.js +7 -1
  187. package/dist/src/modules/workspace/read-page.js.map +1 -1
  188. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  189. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  190. package/dist/src/modules/workspace/reconcile.js +156 -50
  191. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  192. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  193. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  194. package/dist/src/modules/workspace/result-text.js.map +1 -1
  195. package/dist/src/modules/workspace/sync.d.ts +9 -0
  196. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  197. package/dist/src/modules/workspace/sync.js +14 -0
  198. package/dist/src/modules/workspace/sync.js.map +1 -1
  199. package/dist/src/modules/workspace/types.d.ts +96 -43
  200. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  201. package/dist/src/modules/workspace/types.js.map +1 -1
  202. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  203. package/dist/src/modules/workspace/watcher.js +8 -3
  204. package/dist/src/modules/workspace/watcher.js.map +1 -1
  205. package/dist/src/notice-tags.d.ts +79 -0
  206. package/dist/src/notice-tags.d.ts.map +1 -0
  207. package/dist/src/notice-tags.js +83 -0
  208. package/dist/src/notice-tags.js.map +1 -0
  209. package/dist/src/prose-drafts.d.ts +4 -3
  210. package/dist/src/prose-drafts.d.ts.map +1 -1
  211. package/dist/src/prose-drafts.js +3 -2
  212. package/dist/src/prose-drafts.js.map +1 -1
  213. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  214. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  215. package/dist/src/reasoning-loss-notice.js +52 -0
  216. package/dist/src/reasoning-loss-notice.js.map +1 -0
  217. package/dist/src/reasoning-loss.d.ts +24 -0
  218. package/dist/src/reasoning-loss.d.ts.map +1 -0
  219. package/dist/src/reasoning-loss.js +102 -0
  220. package/dist/src/reasoning-loss.js.map +1 -0
  221. package/dist/src/send-tools.d.ts +3 -2
  222. package/dist/src/send-tools.d.ts.map +1 -1
  223. package/dist/src/send-tools.js +3 -2
  224. package/dist/src/send-tools.js.map +1 -1
  225. package/dist/src/sent-rows.js +1 -1
  226. package/dist/src/silent-heartbeat.d.ts +1 -53
  227. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  228. package/dist/src/silent-heartbeat.js +56 -3
  229. package/dist/src/silent-heartbeat.js.map +1 -1
  230. package/dist/src/supplied-text.d.ts +35 -0
  231. package/dist/src/supplied-text.d.ts.map +1 -0
  232. package/dist/src/supplied-text.js +141 -0
  233. package/dist/src/supplied-text.js.map +1 -0
  234. package/dist/src/thinking-drops.d.ts +4 -11
  235. package/dist/src/thinking-drops.d.ts.map +1 -1
  236. package/dist/src/thinking-drops.js +7 -14
  237. package/dist/src/thinking-drops.js.map +1 -1
  238. package/dist/src/tool-args.d.ts +12 -0
  239. package/dist/src/tool-args.d.ts.map +1 -1
  240. package/dist/src/tool-args.js +39 -4
  241. package/dist/src/tool-args.js.map +1 -1
  242. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  243. package/dist/src/tool-image-ledger.js +2 -1
  244. package/dist/src/tool-image-ledger.js.map +1 -1
  245. package/dist/src/tool-presentation.d.ts.map +1 -1
  246. package/dist/src/tool-presentation.js +5 -4
  247. package/dist/src/tool-presentation.js.map +1 -1
  248. package/dist/src/tool-result-guard.d.ts +8 -4
  249. package/dist/src/tool-result-guard.d.ts.map +1 -1
  250. package/dist/src/tool-result-guard.js +23 -11
  251. package/dist/src/tool-result-guard.js.map +1 -1
  252. package/dist/src/tune-out/coordinator.d.ts +3 -2
  253. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  254. package/dist/src/tune-out/coordinator.js +24 -26
  255. package/dist/src/tune-out/coordinator.js.map +1 -1
  256. package/dist/src/tune-out/tools.d.ts +4 -1
  257. package/dist/src/tune-out/tools.d.ts.map +1 -1
  258. package/dist/src/types/agent.d.ts +63 -18
  259. package/dist/src/types/agent.d.ts.map +1 -1
  260. package/dist/src/types/events.d.ts +18 -8
  261. package/dist/src/types/events.d.ts.map +1 -1
  262. package/dist/src/types/framework.d.ts +58 -0
  263. package/dist/src/types/framework.d.ts.map +1 -1
  264. package/dist/src/types/module.d.ts +27 -13
  265. package/dist/src/types/module.d.ts.map +1 -1
  266. package/dist/src/types/trace.d.ts +34 -1
  267. package/dist/src/types/trace.d.ts.map +1 -1
  268. package/package.json +3 -3
  269. package/src/agent.ts +76 -12
  270. package/src/api/mcp-server-core.ts +3 -14
  271. package/src/api/server.ts +11 -5
  272. package/src/auto-continue.ts +12 -5
  273. package/src/code-execution/isolate/runner.ts +7 -5
  274. package/src/code-execution/isolate/scripts.ts +3 -2
  275. package/src/cut-off-round.ts +98 -0
  276. package/src/framework.ts +3772 -1138
  277. package/src/gate/event-gate.ts +705 -60
  278. package/src/gate/index.ts +9 -1
  279. package/src/gate/reply-judge.ts +512 -0
  280. package/src/gate/types.ts +176 -3
  281. package/src/heartbeat/heartbeat.ts +843 -0
  282. package/src/heartbeat/reminder-time.ts +107 -0
  283. package/src/held-writes.ts +3 -3
  284. package/src/host-presentation.ts +605 -0
  285. package/src/index.ts +5 -1
  286. package/src/line-reader.ts +58 -0
  287. package/src/mcpl/capability-mask.ts +3 -2
  288. package/src/mcpl/channel-registry.ts +139 -53
  289. package/src/mcpl/conversation-router.ts +2 -1
  290. package/src/mcpl/convert-block.ts +17 -4
  291. package/src/mcpl/hook-orchestrator.ts +4 -3
  292. package/src/mcpl/inbound-source.ts +45 -70
  293. package/src/mcpl/modern-connection.ts +11 -7
  294. package/src/mcpl/protocol-family.ts +24 -3
  295. package/src/mcpl/push-handler.ts +2 -2
  296. package/src/mcpl/references.ts +31 -23
  297. package/src/mcpl/server-connection.ts +76 -34
  298. package/src/mcpl/skip-reply-wake.ts +35 -0
  299. package/src/mcpl/tags.ts +13 -2
  300. package/src/mcpl/tool-presence.ts +149 -82
  301. package/src/mcpl/tool-signature.ts +64 -32
  302. package/src/mcpl/toolset-host.ts +212 -110
  303. package/src/mcpl/toolsets-module.ts +277 -91
  304. package/src/mcpl/transport.ts +18 -14
  305. package/src/mcpl/types.ts +32 -13
  306. package/src/module-registry.ts +14 -9
  307. package/src/modules/api/index.ts +4 -2
  308. package/src/modules/discord/index.ts +37 -25
  309. package/src/modules/history/index.ts +30 -18
  310. package/src/modules/sender-name.ts +9 -0
  311. package/src/modules/workspace/answers.ts +151 -0
  312. package/src/modules/workspace/branch-intent.ts +10 -5
  313. package/src/modules/workspace/direct-write.ts +59 -23
  314. package/src/modules/workspace/disk-agreement.ts +52 -21
  315. package/src/modules/workspace/edit-text.ts +7 -5
  316. package/src/modules/workspace/effects.ts +69 -50
  317. package/src/modules/workspace/index.ts +966 -556
  318. package/src/modules/workspace/observe.ts +497 -242
  319. package/src/modules/workspace/read-page.ts +7 -1
  320. package/src/modules/workspace/reconcile.ts +184 -49
  321. package/src/modules/workspace/result-text.ts +2 -0
  322. package/src/modules/workspace/sync.ts +16 -0
  323. package/src/modules/workspace/types.ts +96 -43
  324. package/src/modules/workspace/watcher.ts +8 -3
  325. package/src/notice-tags.ts +84 -0
  326. package/src/prose-drafts.ts +4 -3
  327. package/src/reasoning-loss-notice.ts +64 -0
  328. package/src/reasoning-loss.ts +120 -0
  329. package/src/send-tools.ts +3 -2
  330. package/src/sent-rows.ts +1 -1
  331. package/src/silent-heartbeat.ts +4 -3
  332. package/src/supplied-text.ts +154 -0
  333. package/src/thinking-drops.ts +10 -16
  334. package/src/tool-args.ts +38 -4
  335. package/src/tool-image-ledger.ts +2 -1
  336. package/src/tool-presentation.ts +5 -4
  337. package/src/tool-result-guard.ts +26 -11
  338. package/src/tune-out/coordinator.ts +25 -27
  339. package/src/tune-out/tools.ts +4 -1
  340. package/src/types/agent.ts +63 -18
  341. package/src/types/events.ts +18 -8
  342. package/src/types/framework.ts +59 -0
  343. package/src/types/module.ts +28 -13
  344. package/src/types/trace.ts +36 -1
@@ -5,6 +5,7 @@
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
7
 
8
+ import { NOTICE_TAG } from '../../notice-tags.js';
8
9
  import { constants as fsConstants } from 'node:fs';
9
10
  import type { BigIntStats, Stats } from 'node:fs';
10
11
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
@@ -37,17 +38,17 @@ import type {
37
38
  } from './types.js';
38
39
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
39
40
  import { MountWatcher, type FsChange } from './watcher.js';
40
- import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
41
- import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
41
+ import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
42
+ import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
42
43
  import { type BranchIntent, BranchIntents } from './branch-intent.js';
43
- import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
44
+ import { CHANGED, type Cause, DIRECTORY, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
44
45
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
45
46
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
46
47
  import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
47
48
  import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
48
49
  import { type FileText, fileText } from './text.js';
49
50
  import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
50
- import { type GrepAnswer, grepText, textAnswer } from './result-text.js';
51
+ import { type DiskState, type GrepAnswer, grepText, textAnswer } from './result-text.js';
51
52
  import {
52
53
  type MountRuntime,
53
54
  type PassOptions,
@@ -60,11 +61,16 @@ import {
60
61
  NOT_PUSHED_IN_OTHER_MOUNT,
61
62
  WALK_CAP,
62
63
  entryMode,
64
+ explained,
63
65
  pushPaths,
64
66
  reconcilePass,
65
67
  settleAdoptionBeforeMutation,
68
+ wayOn,
66
69
  } from './reconcile.js';
67
70
  import { EffectFailed, unlinkContained } from './effects.js';
71
+ import { AWAITS_REOPEN, MOUNT_IT, ROOT_RECHECK, Refusal, UNFOLLOWED_STEP, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
72
+ import { unknownToolMessage } from '../unknown-tool.js';
73
+ import { closestNames } from '../../closest-names.js';
68
74
  import { RecordJournal } from '../../record-journal.js';
69
75
  import {
70
76
  type FileIdentity,
@@ -100,6 +106,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
100
106
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
101
107
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
102
108
  const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
109
+ const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
110
+ + 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
111
+ + 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
103
112
  /**
104
113
  * How many files a direct mount's grep reads, and its listings look at, at
105
114
  * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
@@ -134,30 +143,23 @@ export type {
134
143
 
135
144
  type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
136
145
 
137
- class WorkspaceImageReadError extends Error {
138
- constructor(
139
- readonly code:
140
- | 'mount_unavailable'
141
- | 'not_found'
142
- | 'directory'
143
- | 'symlink'
144
- | 'parent_symlink'
145
- | 'other_mount'
146
- | 'escape'
147
- | 'changed'
148
- | 'empty'
149
- | 'too_large'
150
- | 'truncated'
151
- | 'invalid'
152
- | 'unsupported'
153
- | 'blob_missing',
154
- message: string,
155
- ) {
156
- super(message);
146
+ class WorkspaceImageReadError extends Refusal {
147
+ constructor(readonly reason: string, wayOn: string | null) {
148
+ super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
157
149
  this.name = 'WorkspaceImageReadError';
158
150
  }
159
151
  }
160
152
 
153
+ const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
154
+
155
+ function truncatedImage(mountPrefixedPath: string): WorkspaceImageReadError {
156
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
157
+ }
158
+
159
+ function emptyImage(mountPrefixedPath: string): WorkspaceImageReadError {
160
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
161
+ }
162
+
161
163
  /**
162
164
  * Why a workspace-owned filesystem read was refused. Distinguishes the cases a
163
165
  * peer needs to react to differently: a path that never resolved inside the
@@ -175,10 +177,12 @@ class WorkspaceImageReadError extends Error {
175
177
  * directories leading out of it (`escape`), a file swapped underneath the read
176
178
  * (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
177
179
  * and a path in another mount's directory nested in the one named
178
- * (`other_mount`), whose message names the mount that holds it and the path
179
- * to use there. A symlink leading into such a directory is `other_mount`
180
- * only where its refusal names that mount: a directory, or a file that
181
- * mount's include covers. Anywhere else, on a mount that doesn't follow
180
+ * (`other_mount`), whose message names the mount that holds it, or may hold
181
+ * it, and the path to use there. A symlink leading into such a directory is
182
+ * `other_mount` only where its refusal names that mount: a directory, or a
183
+ * file that mount's include covers, and so does the include of each other
184
+ * nested mount whose directory may hold it, out to the first that surely
185
+ * does. Anywhere else, on a mount that doesn't follow
182
186
  * symlinks, it gets the code of a link where it stands, `symlink` at the
183
187
  * path itself and `parent_symlink` on its way, and on one that does,
184
188
  * `escape`, as a path that leaves the mount. On a mount with an include, a
@@ -220,7 +224,7 @@ export type WorkspaceReadStage =
220
224
  | 'read';
221
225
 
222
226
  /** A path the mount's `include` doesn't cover: outside the mount. */
223
- class OutsideIncludeError extends Error {
227
+ class OutsideIncludeError extends Refusal {
224
228
  constructor(shownPath: string, mount: MountConfig) {
225
229
  super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
226
230
  + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
@@ -232,9 +236,16 @@ class OutsideIncludeError extends Error {
232
236
  * mount it names. The message names the mount that holds it and the path to
233
237
  * use there.
234
238
  */
235
- class NestedMountError extends Error {
236
- constructor(path: string, mount: string, holder: string, use: string, how: 'lies in' | 'leads into' = 'lies in') {
237
- super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
239
+ class NestedMountError extends Refusal {
240
+ constructor(path: string, mount: string, holder: { name: string; use: string; guess?: NestedGuess }, how: 'lies in' | 'leads into' = 'lies in') {
241
+ const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
242
+ super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
243
+ }
244
+ }
245
+
246
+ class PathRefusal extends Refusal {
247
+ constructor(readonly code: 'unknown_mount' | 'traversal', message: string) {
248
+ super(message);
238
249
  }
239
250
  }
240
251
 
@@ -256,6 +267,24 @@ export class WorkspaceReadError extends Error {
256
267
  }
257
268
  }
258
269
 
270
+ class NotRegularFile extends WorkspaceReadError {
271
+ constructor(path: string, stage: WorkspaceReadStage) {
272
+ super('not_found', `Not a regular file: ${path}`, stage);
273
+ }
274
+ }
275
+
276
+ class LinkOnTheWay extends WorkspaceReadError {
277
+ constructor(code: WorkspaceReadErrorCode, message: string, readonly reason: string, errno?: string) {
278
+ super(code, message, 'readlink', errno);
279
+ }
280
+ }
281
+
282
+ class RootUnavailable extends WorkspaceReadError {
283
+ constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
284
+ super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
285
+ }
286
+ }
287
+
259
288
  /** Successful `readFileFromDisk()` result. */
260
289
  export interface WorkspaceDiskReadResult {
261
290
  /**
@@ -296,10 +325,10 @@ export interface ReadFileFromDiskOptions {
296
325
  * Why a pass decided nothing, or null where it decided its paths. A pass gives
297
326
  * up deciding, and reports no path, when the selected branch kept changing
298
327
  * while it gathered, or when a sync's branch was no longer selected; it then
299
- * carries no branchId, and the region it leaves incomplete says why.
328
+ * carries no branchId, and says why in `undecided`.
300
329
  */
301
330
  function undecidedOf(pass: PassResult): string | null {
302
- return pass.branchId === undefined ? (pass.incomplete[0]?.reason ?? 'the pass decided nothing') : null;
331
+ return pass.branchId === undefined ? (pass.undecided ?? 'the pass decided nothing') : null;
303
332
  }
304
333
 
305
334
  function errnoOf(err: unknown): string | undefined {
@@ -308,23 +337,10 @@ function errnoOf(err: unknown): string | undefined {
308
337
  : undefined;
309
338
  }
310
339
 
311
- /**
312
- * What is at a mount root's path now, followed as the root is: for a
313
- * directory, undefined; for anything else, ENOTDIR; and where the path
314
- * can't be followed to anything, the errno its stat failed with, ENOENT
315
- * when nothing is there.
316
- */
317
- async function rootAbsence(root: string): Promise<string | undefined> {
318
- try {
319
- return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
320
- } catch (err) {
321
- return errnoOf(err);
322
- }
323
- }
324
340
 
325
341
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
326
- function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
327
- return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
342
+ function imageTooLarge(maxSize: number, mountPrefixedPath: string, size: string): WorkspaceImageReadError {
343
+ return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
328
344
  }
329
345
 
330
346
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -342,10 +358,6 @@ const JPEG_EOI = 0xd9;
342
358
  const JPEG_SOS = 0xda;
343
359
  const JPEG_TEM = 0x01;
344
360
 
345
- function isErrnoCode(err: unknown, code: string): boolean {
346
- return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === code;
347
- }
348
-
349
361
  function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
350
362
  return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
351
363
  }
@@ -382,15 +394,16 @@ async function linkReadError(
382
394
  rootReal: string,
383
395
  ): Promise<WorkspaceReadError> {
384
396
  if (!('link' in found)) {
385
- return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
397
+ const reason = linkWords(mount.config.name, found, null);
398
+ return new LinkOnTheWay('not_found', `Unable to read file: ${mountPrefixedPath}: ${reason}`, reason, found.code);
386
399
  }
387
400
  const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
388
- const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
401
+ const reason = linkWords(mount.config.name, found, leads, nested, mount.config.include);
389
402
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
390
403
  const code = leads?.kind === 'outside' ? 'escape'
391
- : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
404
+ : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
392
405
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
393
- return new WorkspaceReadError(code, reason, 'readlink');
406
+ return new LinkOnTheWay(code, `${mountPrefixedPath}: ${reason}`, reason);
394
407
  }
395
408
 
396
409
  /**
@@ -420,32 +433,30 @@ function ancestorsOf(path: string): string[] {
420
433
  * directory as configured beneath the outer mount's configured path
421
434
  * (`outerPath`), beneath each directory above its configured path that is
422
435
  * the outer root on disk (`above`, each with where it lies on disk), and
423
- * where its real root (`real`) lies beneath the outer root (`outerReal`),
424
- * each also as disk spells it beneath `spellAt`. Its real root is claimed
425
- * where it doesn't hold the outer root, which it would otherwise claim
426
- * whole. Undefined where it's nested nowhere.
436
+ * where its real root (`real`) lies beneath the outer root (`outerReal`).
437
+ * Its real root is claimed where it doesn't hold the outer root, which it
438
+ * would otherwise claim whole. Undefined where it's nested nowhere.
427
439
  */
428
- async function placementOf(
440
+ function placementOf(
429
441
  outerPath: string,
430
442
  outerReal: string,
431
443
  innerPath: string,
432
444
  real: string,
433
445
  above: ReadonlyArray<{ dir: string; real: string }>,
434
- spellAt: string,
435
- ): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
446
+ ): { configured: string | undefined; fromRoots: NestedMountEntry['fromRoots']; claims: boolean } | undefined {
436
447
  const configured = nestedDir(outerPath, innerPath);
437
- const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
438
- const fromRoots: Array<{ dir: string; root: string }> = [];
439
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
440
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
441
- }
442
- if (fromRoots.length === 0) return undefined;
443
- const spellings = [...fromRoots];
444
- for (const found of fromRoots) {
445
- const dir = await diskSpelling(spellAt, found.dir);
446
- if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
447
- }
448
- return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
448
+ const dirs = new Map<string, boolean>();
449
+ const place = (dir: string | undefined, sure: boolean) => { if (dir !== undefined) dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
450
+ place(configured, true);
451
+ for (const a of above) {
452
+ if (sameRoot(a.real, outerReal)) place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
453
+ }
454
+ const below = beneathRoot(real, outerReal);
455
+ place(below, below !== undefined && surelyAtRoot(real, outerReal));
456
+ if (dirs.size === 0) return undefined;
457
+ const fromRoots: NestedMountEntry['fromRoots'] = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true as const }) }));
458
+ const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
459
+ return { configured, fromRoots, claims };
449
460
  }
450
461
 
451
462
  /** A mount nested in another, as the outer mount's otherMounts holds it. */
@@ -458,9 +469,7 @@ interface NestedMountEntry {
458
469
  */
459
470
  real?: string;
460
471
  /** Its directory as configured and as the real roots place it. */
461
- fromRoots: Array<{ dir: string; root: string }>;
462
- /** Those, and each as disk spells it where that differs. */
463
- spellings: Array<{ dir: string; root: string }>;
472
+ fromRoots: Array<{ dir: string; guessed?: true }>;
464
473
  }
465
474
 
466
475
  /**
@@ -539,7 +548,7 @@ function isContainedPath(root: string, candidate: string): boolean {
539
548
  }
540
549
 
541
550
  function invalidImage(format: string, mountPrefixedPath: string): never {
542
- throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
551
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
543
552
  }
544
553
 
545
554
  function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
@@ -915,7 +924,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
915
924
  return 'image/png';
916
925
  }
917
926
  if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
918
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
927
+ throw truncatedImage(mountPrefixedPath);
919
928
  }
920
929
 
921
930
  if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
@@ -923,7 +932,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
923
932
  return 'image/jpeg';
924
933
  }
925
934
  if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
926
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
935
+ throw truncatedImage(mountPrefixedPath);
927
936
  }
928
937
 
929
938
  if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
@@ -931,7 +940,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
931
940
  return 'image/gif';
932
941
  }
933
942
  if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
934
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
943
+ throw truncatedImage(mountPrefixedPath);
935
944
  }
936
945
 
937
946
  if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
@@ -942,10 +951,10 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
942
951
  (bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
943
952
  || (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
944
953
  ) {
945
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
954
+ throw truncatedImage(mountPrefixedPath);
946
955
  }
947
956
 
948
- throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
957
+ 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.");
949
958
  }
950
959
 
951
960
  export class WorkspaceModule implements Module {
@@ -973,14 +982,11 @@ export class WorkspaceModule implements Module {
973
982
  private ctimeTrust = new Map<string, string | null>();
974
983
  /**
975
984
  * Each mount's directories that are other mounts nested inside it, with
976
- * each mount's name and real root, in each spelling that names the
977
- * directory: as configured; as the real roots place it, where that differs
978
- * (`fromRoots`); and as disk spells each of those where that differs.
979
- * Each spelling is mount-relative and `/`-separated, with its root beneath
980
- * the mount's resolved root. The constructor nests by configured path,
981
- * with each root as resolved standing for its real one, and each start
982
- * reads it all again with the roots placed where disk has them
983
- * (readNesting).
985
+ * each mount's name and real root: as configured, and as the real roots
986
+ * place it, where that differs (`fromRoots`). Each is mount-relative and
987
+ * `/`-separated. The constructor nests by configured path, with each root
988
+ * as resolved standing for its real one, and each start reads it all again
989
+ * with the roots placed where disk has them (readNesting).
984
990
  */
985
991
  private otherMounts = new Map<string, NestedMountEntry[]>();
986
992
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
@@ -1045,24 +1051,18 @@ export class WorkspaceModule implements Module {
1045
1051
  if (refused.length > 0) {
1046
1052
  throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
1047
1053
  }
1048
- const include: unknown = mount.include;
1049
- if (include !== undefined) {
1050
- // A pattern the matcher can't read would match nothing, and take every
1051
- // file out of the mount unsaid (includePatternReadable).
1052
- if (!Array.isArray(include) || include.length === 0) {
1053
- throw new Error(
1054
- `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
1055
- );
1054
+ for (const list of ['ignore', 'include'] as const) {
1055
+ const patterns: unknown = mount[list];
1056
+ if (patterns === undefined) continue;
1057
+ if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
1058
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
1056
1059
  }
1057
- // By index: an entry that is itself undefined, or a hole, is unreadable too.
1058
- const at = include.findIndex((p) => !includePatternReadable(p));
1059
- if (at >= 0) {
1060
- const unreadable: unknown = include[at];
1061
- throw new Error(
1062
- `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
1063
- + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
1064
- + `${shownValue(unreadable)} is none of these`,
1065
- );
1060
+ for (let i = 0; i < patterns.length; i++) {
1061
+ const pattern: unknown = patterns[i];
1062
+ const why = patternRefusal(pattern, list);
1063
+ if (why !== undefined) {
1064
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
1065
+ }
1066
1066
  }
1067
1067
  }
1068
1068
  }
@@ -1081,8 +1081,8 @@ export class WorkspaceModule implements Module {
1081
1081
  const innerPath = resolve(inner.path);
1082
1082
  const dir = nestedDir(outerPath, innerPath);
1083
1083
  if (dir === undefined) continue;
1084
- const configured = [{ dir, root: innerPath }];
1085
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
1084
+ const configured = [{ dir }];
1085
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
1086
1086
  console.warn(
1087
1087
  `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
1088
1088
  `at ${dir}/ — passing over it as another mount's directory`,
@@ -1372,7 +1372,7 @@ export class WorkspaceModule implements Module {
1372
1372
  const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
1373
1373
  let start = base;
1374
1374
  for (const segment of beneath.split('/')) {
1375
- if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
1375
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined)) break;
1376
1376
  const here = await checkDir(view, rootReal, start);
1377
1377
  if (here.kind !== 'checked') break;
1378
1378
  const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
@@ -1425,11 +1425,10 @@ export class WorkspaceModule implements Module {
1425
1425
 
1426
1426
  async start(ctx: ModuleContext): Promise<void> {
1427
1427
  // Which mounts are nested where, with the roots placed where disk has
1428
- // them and each nested mount's directory as disk spells it, and whether
1429
- // each mount's root folds case, read where disk has it, before this start
1430
- // sets anything, so a restart takes no call and walks or watches nothing
1431
- // before they're known. A stop begun meanwhile stands, and this start then
1432
- // sets nothing.
1428
+ // them, and whether each mount's root folds case, read where disk has
1429
+ // it, before this start sets anything, so a restart takes no call
1430
+ // and walks or watches nothing before they're known. A stop begun
1431
+ // meanwhile stands, and this start then sets nothing.
1433
1432
  const stops = this.stopsBegun;
1434
1433
  const { nesting, reals, above } = await this.readNesting();
1435
1434
  const rootFoldsCase = new Map<string, boolean>();
@@ -1545,7 +1544,8 @@ export class WorkspaceModule implements Module {
1545
1544
  return (
1546
1545
  `current branch "${current.name}" has diverged from the branch last materialized to disk ` +
1547
1546
  `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
1548
- `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`
1547
+ `never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
1548
+ 'if the current branch is what disk should hold, pass force: true to overwrite disk from it.'
1549
1549
  );
1550
1550
  }
1551
1551
 
@@ -2184,7 +2184,13 @@ export class WorkspaceModule implements Module {
2184
2184
  return work;
2185
2185
  }
2186
2186
 
2187
- private async runToolCall(call: ToolCall): Promise<ToolResult> {
2187
+ private runToolCall(call: ToolCall): Promise<ToolResult> {
2188
+ const gate = { passed: false };
2189
+ return callGate.run(gate, () => this.answerToolCall(call, gate));
2190
+ }
2191
+
2192
+ private async answerToolCall(call: ToolCall, gate: { passed: boolean }): Promise<ToolResult> {
2193
+ const reopenAtStart = this.agreement?.awaitsReopen === true;
2188
2194
  try {
2189
2195
  // Every handler reads its input as its schema describes it; a call
2190
2196
  // that sends something else is refused here, before anything is read.
@@ -2199,12 +2205,12 @@ export class WorkspaceModule implements Module {
2199
2205
  // ResultTooLarge in its place. A call made in process carries none and
2200
2206
  // takes the data itself, so its read pages by its line limit alone.
2201
2207
  const resultMaxChars = call.resultMaxChars;
2202
- const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2208
+ const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2203
2209
  if (generated) {
2204
2210
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
2205
2211
  return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
2206
2212
  }
2207
- return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
2213
+ return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path as string, generated, call.callerAgentName)) };
2208
2214
  }
2209
2215
 
2210
2216
  switch (call.name) {
@@ -2221,13 +2227,41 @@ export class WorkspaceModule implements Module {
2221
2227
  case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
2222
2228
  case 'sync': return await this.handleSync(input as unknown as SyncInput);
2223
2229
  default:
2224
- return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
2230
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
2225
2231
  }
2226
2232
  } catch (err) {
2227
- return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
2233
+ return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
2234
+ }
2235
+ }
2236
+
2237
+ private failureAnswer(call: ToolCall, err: unknown, reopenAtStart: boolean, passedGate: boolean): string {
2238
+ const input = (typeof call.input === 'object' && call.input !== null ? call.input : {}) as { path?: unknown; mount?: unknown };
2239
+ const path = typeof input.path === 'string' ? input.path : undefined;
2240
+ const mountName = path !== undefined ? path.split('/')[0]! : typeof input.mount === 'string' ? input.mount : undefined;
2241
+ const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
2242
+ const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
2243
+ if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
2244
+ return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
2245
+ }
2246
+ if (err instanceof Refusal) return didNothing(call.name, err.message);
2247
+ if (err instanceof WorkspaceReadError) return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
2248
+ const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
2249
+ : mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
2250
+ : 'ls of each mount, with recursive: true, shows how each file stands against disk now';
2251
+ if (err instanceof NotStored || err instanceof AlreadyHeld) {
2252
+ console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
2253
+ return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
2254
+ }
2255
+ console.error(`[workspace] ${call.name} failed:`, err);
2256
+ if (err instanceof CommitUnknownError) {
2257
+ return unexpectedAnswer(call.name, subject, err.setCause, {
2258
+ said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
2259
+ });
2228
2260
  }
2261
+ return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
2229
2262
  }
2230
2263
 
2264
+
2231
2265
  async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
2232
2266
  // Wake agents on filesystem events when the originating mount opted in.
2233
2267
  // The EventGate still has final say — gate policies can further filter by
@@ -2266,13 +2300,13 @@ export class WorkspaceModule implements Module {
2266
2300
 
2267
2301
  let text: string;
2268
2302
  if (paths.length === 1) {
2269
- text = `[workspace event · ${op} · ${paths[0]}]`;
2303
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
2270
2304
  } else {
2271
2305
  const list = paths.map(p => `- ${p}`).join('\n');
2272
- text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2306
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2273
2307
  }
2274
2308
  if (conflicts && conflicts.length > 0) {
2275
- text += `\n[conflicts: ${conflicts.join(', ')}]`;
2309
+ text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
2276
2310
  }
2277
2311
 
2278
2312
  return {
@@ -2323,16 +2357,15 @@ export class WorkspaceModule implements Module {
2323
2357
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
2324
2358
  try {
2325
2359
  // Synchronous, so the path as given. The nested-mount check takes it
2326
- // in each spelling readNesting knows for the nested mount's directory,
2327
- // or in any spelling a disk that folds names may fold to one of them,
2328
- // erring toward that mount (holderOf's asGiven, beginsWithDir). The
2329
- // include judges it as given, so on a disk that
2360
+ // in any spelling a disk that folds names may fold to the nested
2361
+ // mount's directory, erring toward that mount (holderOf). The include
2362
+ // judges it as given, so on a disk that
2330
2363
  // folds names a covered name whose file disk spells otherwise returns
2331
2364
  // that file's path, which the tools refuse, and a covered file named in
2332
2365
  // another case is null, which the tools read. A path the mount doesn't
2333
2366
  // hold, outside its include or in another mount's directory nested in
2334
2367
  // it, is null, as for a path that leaves the mount's directory.
2335
- const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
2368
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
2336
2369
  return resolve(mount.config.path, relativePath);
2337
2370
  } catch {
2338
2371
  return null;
@@ -2360,6 +2393,9 @@ export class WorkspaceModule implements Module {
2360
2393
  * `agentName` names the agent whose write it is for the head-move notice
2361
2394
  * (a write naming none tells no agent).
2362
2395
  *
2396
+ * A success's `disk` says where the file stands on disk as the workspace
2397
+ * last saw it, as the write tool's answer does.
2398
+ *
2363
2399
  * A write whose tree entry is set but that then fails returns success
2364
2400
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
2365
2401
  * the store failed after recording the entry, which skips the push where
@@ -2396,11 +2432,12 @@ export class WorkspaceModule implements Module {
2396
2432
  isError: true,
2397
2433
  };
2398
2434
  }
2399
- const refusal = writeRefusal(mount);
2435
+ if (relativePath === '') return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
2436
+ const refusal = writeRefusal(mount, this.writableNames());
2400
2437
  if (refusal) return { success: false, error: refusal, isError: true };
2401
2438
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2402
2439
  if (data.byteLength > maxSize) {
2403
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2440
+ return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
2404
2441
  }
2405
2442
  const store = this.getStore();
2406
2443
  // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
@@ -2414,7 +2451,7 @@ export class WorkspaceModule implements Module {
2414
2451
  }
2415
2452
  const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2416
2453
  if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2417
- return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
2454
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
2418
2455
  }
2419
2456
  return this.withMount(mount, async () => {
2420
2457
  await this.settleBeforeMutation(mount, relativePath);
@@ -2429,20 +2466,24 @@ export class WorkspaceModule implements Module {
2429
2466
  let committed = false;
2430
2467
  let failure: string | null;
2431
2468
  let modeKept: string | null = null;
2469
+ let disk: DiskState | null = null;
2432
2470
  try {
2433
2471
  const answer = await this.commitToolChange(mount, relativePath,
2434
2472
  { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
2435
2473
  modeKept = answer.modeKept;
2474
+ disk = answer.disk;
2436
2475
  failure = answer.refused === null ? null
2437
2476
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
2438
2477
  } catch (error) {
2439
2478
  if (!committed) throw error;
2440
2479
  // As write words it: a store that failed after recording the file
2441
2480
  // skipped its push, so on a mount that pushes, disk lacks it.
2481
+ const reopen = this.agreement?.awaitsReopen === true;
2442
2482
  failure = error instanceof StoredThenFailed
2443
2483
  ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
2444
- + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2484
+ + (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 : ''}.` : '')
2445
2485
  : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2486
+ if (reopen) failure += ` ${AWAITS_REOPEN}`;
2446
2487
  }
2447
2488
  if (failure !== null) {
2448
2489
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -2452,7 +2493,7 @@ export class WorkspaceModule implements Module {
2452
2493
  }
2453
2494
  return {
2454
2495
  success: true,
2455
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
2496
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
2456
2497
  };
2457
2498
  });
2458
2499
  }
@@ -2491,10 +2532,12 @@ export class WorkspaceModule implements Module {
2491
2532
  return this.withMount(mount, async () => {
2492
2533
  const { report, undecided } = await this.ensureSynced(mount, relativePath);
2493
2534
  const entry = store.treeGet(mount.treeStateId, relativePath);
2494
- if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2495
- const blob = store.getBlob(entry.blobHash);
2496
- if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
2497
- return { data: blob };
2535
+ if (!entry) {
2536
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
2537
+ }
2538
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2539
+ if ('lost' in contents) return { error: nothingToRead(contents.lost) };
2540
+ return { data: contents.bytes };
2498
2541
  });
2499
2542
  }
2500
2543
 
@@ -2577,9 +2620,9 @@ export class WorkspaceModule implements Module {
2577
2620
  }
2578
2621
  }
2579
2622
 
2580
- /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
2623
+ /** The directories of the mounts nested inside a mount, mount-relative. */
2581
2624
  private othersOf(mountName: string): string[] {
2582
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
2625
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
2583
2626
  }
2584
2627
 
2585
2628
  /**
@@ -2595,13 +2638,8 @@ export class WorkspaceModule implements Module {
2595
2638
  * alone, with no real root, since through it it would claim the other's
2596
2639
  * every path.
2597
2640
  *
2598
- * Each way it lies there is a spelling of its directory (`fromRoots`), and
2599
- * so is the spelling disk lists each of those by, where that differs. On a
2600
- * disk that folds names, the outer mount's walks name the directory as
2601
- * disk does, and so does a path parsePath has respelled, while a path given
2602
- * as configured still names it as configured (resolveAbsolutePath). A
2603
- * directory not on disk keeps the spellings the roots give alone. Each
2604
- * mount's real root comes back beside the nesting (`reals`), for what
2641
+ * Each way it lies there is a spelling of its directory (`fromRoots`).
2642
+ * Each mount's real root comes back beside the nesting (`reals`), for what
2605
2643
  * start reads there, and each directory above each configured path with
2606
2644
  * where it lies on disk (`above`), for a direct mount whose root moves
2607
2645
  * (movedNesting).
@@ -2626,16 +2664,15 @@ export class WorkspaceModule implements Module {
2626
2664
  for (const inner of this.config.mounts) {
2627
2665
  if (outer === inner) continue;
2628
2666
  const real = reals.get(inner.name)!;
2629
- const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!, outerPath);
2667
+ const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!);
2630
2668
  if (at === undefined) continue;
2631
- const { configured, fromRoots, spellings, claims } = at;
2632
- nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2669
+ const { configured, fromRoots, claims } = at;
2670
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
2633
2671
  const placed = fromRoots.filter((s) => s.dir !== configured);
2634
2672
  if (placed.length > 0) {
2635
- console.warn(
2636
- `[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2637
- `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`,
2638
- );
2673
+ const at = (sure: boolean) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
2674
+ 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 ');
2675
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
2639
2676
  }
2640
2677
  }
2641
2678
  }
@@ -2644,11 +2681,11 @@ export class WorkspaceModule implements Module {
2644
2681
 
2645
2682
  /**
2646
2683
  * The mounts nested in `mountName`, by directory, name, real root where it
2647
- * has one, and include (MountView's `nested`), in each spelling known.
2684
+ * has one, and include (MountView's `nested`).
2648
2685
  */
2649
2686
  private nestedOf(mountName: string): NestedMount[] {
2650
2687
  return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
2651
- o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2688
+ 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 })));
2652
2689
  }
2653
2690
 
2654
2691
  /**
@@ -2747,10 +2784,12 @@ export class WorkspaceModule implements Module {
2747
2784
  if (real === undefined) continue;
2748
2785
  above = placedAbove.get(inner.name) ?? [];
2749
2786
  }
2750
- const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2787
+ const at = placementOf(outerPath, rootReal, innerPath, real, above);
2751
2788
  if (at === undefined) continue;
2752
2789
  const include = this.mounts.get(inner.name)?.config.include;
2753
- for (const s of at.spellings) nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2790
+ for (const s of at.fromRoots) {
2791
+ nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
2792
+ }
2754
2793
  }
2755
2794
  return nested;
2756
2795
  }
@@ -2760,33 +2799,22 @@ export class WorkspaceModule implements Module {
2760
2799
  * of that mount, judged lexically where the path lands (`.`, `..` and
2761
2800
  * repeated separators resolved, as for the traversal guard; a link of the
2762
2801
  * outer mount isn't followed), with the path to use there; undefined when
2763
- * no nested mount's directory holds it. A path parsePath has respelled is
2764
- * compared with the directory's spellings exactly. One judged as given
2765
- * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2766
- * folds names may compare it (beginsWithDir), so it can't reach that
2767
- * mount's directory by spelling a segment otherwise. On a disk that tells
2768
- * names apart, that takes a directory of this mount's own spelled like the
2769
- * nested one in another way as that mount's too.
2802
+ * no nested mount's directory holds it.
2770
2803
  */
2771
- private holderOf(mountName: string, relativePath: string, asGiven = false, moved: NestedMount[] | null = null): { name: string; use: string } | undefined {
2804
+ private holderOf(mountName: string, relativePath: string, moved: NestedMount[] | null = null): { name: string; use: string; guess?: NestedGuess } | undefined {
2772
2805
  const mount = this.mounts.get(mountName);
2773
2806
  if (!mount) return undefined;
2774
2807
  const root = resolve(mount.config.path);
2775
2808
  const resolved = resolve(root, relativePath);
2776
2809
  if (!isContainedPath(root, resolved)) return undefined;
2777
- const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2810
+ const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
2778
2811
  // A direct mount whose root a call found moved is judged by that call's
2779
2812
  // nesting (movedNesting), each directory beneath its configured path.
2780
- const dirs = moved !== null
2781
- ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2782
- : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2783
- const holder = dirs
2784
- .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2785
- .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2786
- .sort((a, b) => b.depth - a.depth)[0];
2813
+ const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
2787
2814
  if (!holder) return undefined;
2788
- const rest = segments.slice(holder.depth).join('/');
2789
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2815
+ const rest = restBeneath(path, holder);
2816
+ const guess = nestedGuess(mountName, path.split('/'), holder);
2817
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
2790
2818
  }
2791
2819
 
2792
2820
  /**
@@ -2795,10 +2823,10 @@ export class WorkspaceModule implements Module {
2795
2823
  * it; for a listing, whose the path is.
2796
2824
  */
2797
2825
  private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
2798
- const holder = this.holderOf(mountName, relativePath, false, moved);
2826
+ const holder = this.holderOf(mountName, relativePath, moved);
2799
2827
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2800
2828
  return holder
2801
- ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2829
+ ? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
2802
2830
  : `it lies in another mount's directory${what}`;
2803
2831
  }
2804
2832
 
@@ -2818,7 +2846,7 @@ export class WorkspaceModule implements Module {
2818
2846
  // Both checks judge by the nesting the parse found, and the second reads
2819
2847
  // disk beneath the root the parse resolved.
2820
2848
  const { mount, root, moved } = spelled;
2821
- const parsed = this.refuseNotHeld(spelled, path, false, moved);
2849
+ const parsed = this.refuseNotHeld(spelled, path, moved);
2822
2850
  // A path can also reach a nested mount's directory, or a file this
2823
2851
  // mount's own include leaves out, through a symlink on its way. No push
2824
2852
  // of this mount writes it there and no read reads it, so it's refused
@@ -2858,7 +2886,8 @@ export class WorkspaceModule implements Module {
2858
2886
  // operation, in the words a disk read gives a path it can't resolve,
2859
2887
  // whatever the place realpath stopped short of would say.
2860
2888
  if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2861
- const holder = nestedAtReal(real, nested);
2889
+ let holder = await nestedAtReal(real, nested);
2890
+ if (holder?.guess === null) holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
2862
2891
  if (!holder) {
2863
2892
  if (!judged) return null;
2864
2893
  const rootReal = await realBeneath(root);
@@ -2874,11 +2903,11 @@ export class WorkspaceModule implements Module {
2874
2903
  // on the place's way as that mount's, so a place beneath anything else
2875
2904
  // there, such as a file the include leaves out, isn't named; one beneath
2876
2905
  // a directory not there yet is.
2877
- const onItsWay = holder.include === undefined
2906
+ const onItsWay = !holder.judgedByInclude
2878
2907
  || await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
2879
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2880
- if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2881
- return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2908
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
2909
+ if (!onItsWay || !holder.names(directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2910
+ return new NestedMountError(path, mount.config.name, holder, 'leads into');
2882
2911
  }
2883
2912
 
2884
2913
  /**
@@ -2886,13 +2915,12 @@ export class WorkspaceModule implements Module {
2886
2915
  * mount's directory first, naming the mount that holds it, then outside
2887
2916
  * the mount's include. Each is judged on the path as the caller parsed it:
2888
2917
  * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2889
- * which is synchronous and says so (`asGiven`, so the nested-mount check
2890
- * folds where disk does). The include's refusal shows the path as given
2918
+ * which is synchronous. The include's refusal shows the path as given
2891
2919
  * (`given`), since it answers the path the caller asked about.
2892
2920
  */
2893
- private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, asGiven = false, moved: NestedMount[] | null = null): P {
2894
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2895
- if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2921
+ private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, moved: NestedMount[] | null = null): P {
2922
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
2923
+ if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
2896
2924
  if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2897
2925
  throw new OutsideIncludeError(given, parsed.mount.config);
2898
2926
  }
@@ -2942,7 +2970,7 @@ export class WorkspaceModule implements Module {
2942
2970
  * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2943
2971
  */
2944
2972
  private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
2945
- if (this.holderOf(mount.config.name, relativePath, false, moved)) return false;
2973
+ if (this.holderOf(mount.config.name, relativePath, moved)) return false;
2946
2974
  if (insideMount(relativePath, mount.config.include)) return false;
2947
2975
  const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
2948
2976
  if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
@@ -3015,18 +3043,12 @@ export class WorkspaceModule implements Module {
3015
3043
 
3016
3044
  /**
3017
3045
  * How many segments the innermost nested mount's directory that holds
3018
- * `relativePath` has, in a spelling it's known by (othersOf), or, for a
3019
- * direct mount whose root has moved, in the nesting there (`moved`);
3046
+ * `relativePath` has, or, for a direct mount whose root has moved, in the
3047
+ * nesting there (`moved`);
3020
3048
  * undefined when no nested mount's directory holds it.
3021
3049
  */
3022
3050
  private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
3023
- let depth: number | undefined;
3024
- for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
3025
- if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
3026
- const segments = dir.split('/').length;
3027
- if (depth === undefined || segments > depth) depth = segments;
3028
- }
3029
- return depth;
3051
+ return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
3030
3052
  }
3031
3053
 
3032
3054
  /**
@@ -3049,11 +3071,7 @@ export class WorkspaceModule implements Module {
3049
3071
  // Only an entry this mount's include covers spells a draft's path
3050
3072
  // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
3051
3073
  // from an earlier version, nor one the include leaves out, which would
3052
- // respell a covered draft into a name the include leaves out. An entry
3053
- // in a nested mount's directory that this mount's include covers
3054
- // spells one whatever that mount's include says, so on a disk that
3055
- // folds names, a draft naming the directory in another spelling takes
3056
- // the entry's spelling and is judged as in it.
3074
+ // respell a covered draft into a name the include leaves out.
3057
3075
  under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
3058
3076
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
3059
3077
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
@@ -3079,25 +3097,24 @@ export class WorkspaceModule implements Module {
3079
3097
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
3080
3098
  // store would keep one name and disk another.
3081
3099
  if (LONE_SURROGATE.test(path)) {
3082
- 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.`);
3100
+ 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.`);
3083
3101
  }
3084
3102
  const slashIdx = path.indexOf('/');
3085
3103
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
3086
3104
  const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
3087
3105
 
3088
3106
  const mount = this.mounts.get(mountName);
3089
- if (!mount) {
3090
- throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
3091
- }
3107
+ if (!mount) throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
3092
3108
  const segments = pathSegments(given);
3093
3109
  if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
3094
- 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.`);
3110
+ 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>".`);
3095
3111
  }
3096
3112
  const kept: string[] = [];
3097
3113
  for (const segment of segments) {
3098
3114
  if (segment !== '..') kept.push(segment);
3099
3115
  else if (kept.pop() === undefined) {
3100
- throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
3116
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
3117
+ + `Name a file from its own mount's name; ${this.mountList()}.`);
3101
3118
  }
3102
3119
  }
3103
3120
  const relativePath = kept.join('/');
@@ -3112,11 +3129,49 @@ export class WorkspaceModule implements Module {
3112
3129
  const root = resolve(mount.config.path);
3113
3130
  const resolved = resolve(root, relativePath);
3114
3131
  if (!isContainedPath(root, resolved)) {
3115
- throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
3132
+ 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>".`);
3116
3133
  }
3117
3134
  return { mount, relativePath };
3118
3135
  }
3119
3136
 
3137
+ private writableNames(): string[] {
3138
+ return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
3139
+ }
3140
+
3141
+ private mountList(): string {
3142
+ const names = [...this.mounts.keys()];
3143
+ return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
3144
+ }
3145
+
3146
+ private emptyPathField(mount: string | undefined, bare: string): string {
3147
+ const empty = '"path" is "", which names no file or directory.';
3148
+ if (mount === undefined) return `${empty} Leaving "path" out ${bare}.`;
3149
+ if (mount === '') return `${empty} "mount" is "" too; leaving both out ${bare}.`;
3150
+ if (this.mounts.has(mount)) return `${empty} Leaving "path" out covers mount ${JSON.stringify(mount)}.`;
3151
+ return `${empty} "mount" is ${JSON.stringify(mount)}, which is no mount's name; ${this.mountList()}. Leaving both out ${bare}.`;
3152
+ }
3153
+
3154
+ private noSuchMountField(given: string, bare: string): string {
3155
+ const [close] = closestNames(given, [...this.mounts.keys()], 1);
3156
+ return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
3157
+ + (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
3158
+ + ` Leaving "mount" out ${bare}.`;
3159
+ }
3160
+
3161
+ private noSuchMount(path: string, mountName: string): string {
3162
+ const names = [...this.mounts.keys()];
3163
+ const rest = path.slice(mountName.length);
3164
+ const unslashed = path.replace(/^[/\\]+/, '');
3165
+ if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
3166
+ return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
3167
+ }
3168
+ if (mountName === '') return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
3169
+ const [close] = closestNames(mountName, names, 1);
3170
+ const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
3171
+ : `${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()}.`;
3172
+ return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
3173
+ }
3174
+
3120
3175
  /**
3121
3176
  * Whether the module takes work: stop hasn't finished. Work admitted while
3122
3177
  * stop drains still runs; once stop has saved the pins and watermarks,
@@ -3140,31 +3195,26 @@ export class WorkspaceModule implements Module {
3140
3195
  maxSize: number,
3141
3196
  ): { bytes: Buffer; mimeType: SupportedImageMimeType } {
3142
3197
  if (bytes.byteLength === 0) {
3143
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3198
+ throw emptyImage(mountPrefixedPath);
3144
3199
  }
3145
- if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
3200
+ if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
3146
3201
  return {
3147
3202
  bytes,
3148
3203
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
3149
3204
  };
3150
3205
  }
3151
3206
 
3152
- private tryReadImageFromTree(
3207
+ private async readImageFromTree(
3153
3208
  mount: MountState,
3154
3209
  relativePath: string,
3155
3210
  mountPrefixedPath: string,
3156
3211
  maxSize: number,
3157
- ): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
3158
- const store = this.getStore();
3159
- const entry = store.treeGet(mount.treeStateId, relativePath);
3212
+ ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> {
3213
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
3160
3214
  if (!entry) return null;
3161
-
3162
- const blob = store.getBlob(entry.blobHash);
3163
- if (!blob) {
3164
- throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
3165
- }
3166
-
3167
- return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
3215
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
3216
+ if ('lost' in contents) return contents;
3217
+ return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
3168
3218
  }
3169
3219
 
3170
3220
  /**
@@ -3181,19 +3231,14 @@ export class WorkspaceModule implements Module {
3181
3231
  * another directory carries none. The identity is rootIdentityForRead's,
3182
3232
  * which answers while the evidence awaits reopening; where even it can't
3183
3233
  * be read, as when the evidence was never read back whole, the read is
3184
- * refused too, with an errno only where no directory is at the root's path.
3234
+ * refused before its root is resolved, with an errno only where no
3235
+ * directory is at the root's path.
3185
3236
  * A direct mount's root is checked against no identity, and its read reads
3186
3237
  * no evidence: it promises what's at its root now (diskView).
3187
3238
  */
3188
3239
  private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
3189
3240
  const name = mount.config.name;
3190
3241
  const root = resolve(mount.config.path);
3191
- let real: string;
3192
- try {
3193
- real = await realpath(root);
3194
- } catch (err) {
3195
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
3196
- }
3197
3242
  let rootIdentity: MountView['rootIdentity'];
3198
3243
  if (!mount.direct) {
3199
3244
  try {
@@ -3201,17 +3246,22 @@ export class WorkspaceModule implements Module {
3201
3246
  } catch (err) {
3202
3247
  // The evidence can't be read back, so neither can the check be made.
3203
3248
  // Where no directory is at the root's path, its errno still says so.
3204
- throw new WorkspaceReadError('mount_unavailable',
3205
- `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
3249
+ throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
3206
3250
  }
3207
3251
  }
3252
+ let real: string;
3253
+ try {
3254
+ real = await realpath(root);
3255
+ } catch (err) {
3256
+ throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
3257
+ }
3208
3258
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
3209
3259
  if ('dir' in checked) return { real, dir: checked.dir };
3210
3260
  // Another directory is no absence. For any other refusal, what is at the
3211
3261
  // root's path now decides: nothing, something that isn't a directory, or
3212
3262
  // a directory the path has come to name since it was resolved (no errno).
3213
3263
  const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
3214
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
3264
+ throw new RootUnavailable(name, checked.reason, 'root', errno);
3215
3265
  }
3216
3266
 
3217
3267
  /**
@@ -3226,7 +3276,7 @@ export class WorkspaceModule implements Module {
3226
3276
  private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
3227
3277
  const errno = await rootAbsence(resolve(mount.config.path));
3228
3278
  const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
3229
- return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
3279
+ return new RootUnavailable(mount.config.name, reason, 'root', errno);
3230
3280
  }
3231
3281
 
3232
3282
  /**
@@ -3328,7 +3378,7 @@ export class WorkspaceModule implements Module {
3328
3378
  }
3329
3379
  if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
3330
3380
  // A FIFO, socket or device is never opened.
3331
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
3381
+ throw new NotRegularFile(mountPrefixedPath, 'lstat');
3332
3382
  }
3333
3383
 
3334
3384
  let handle: Awaited<ReturnType<typeof open>>;
@@ -3364,7 +3414,7 @@ export class WorkspaceModule implements Module {
3364
3414
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
3365
3415
  }
3366
3416
  if (!fileStat.isFile()) {
3367
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
3417
+ throw new NotRegularFile(mountPrefixedPath, 'fstat');
3368
3418
  }
3369
3419
 
3370
3420
  if (!follow && !useNoFollow) {
@@ -3403,13 +3453,13 @@ export class WorkspaceModule implements Module {
3403
3453
  if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
3404
3454
 
3405
3455
  const inside = isContainedPath(realMountRoot, realFilePath);
3406
- const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3456
+ const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
3407
3457
  // A place in a nested mount's directory that its include leaves out is
3408
3458
  // no mount's, so the path leaves this one (namesNestedPlace), and so
3409
3459
  // does one reaching a file this mount's own include leaves out
3410
3460
  // (coversLanding).
3411
3461
  const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
3412
- if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
3462
+ if (!inside || own || (holder !== undefined && !holder.names(false))) {
3413
3463
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
3414
3464
  }
3415
3465
  if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
@@ -3477,7 +3527,9 @@ export class WorkspaceModule implements Module {
3477
3527
  * (`outside_include`), a path in another mount's directory nested in the one named
3478
3528
  * (`other_mount`), unavailable mount, missing or non-regular file,
3479
3529
  * directory, policy-denied symlink, outside-mount target, and
3480
- * file-changed-during-read.
3530
+ * file-changed-during-read. An error met while parsing the path that is
3531
+ * none of these refusals, such as the store's, is thrown as it is, and a
3532
+ * `maxBytes` that isn't a non-negative integer throws a `RangeError`.
3481
3533
  */
3482
3534
  async readFileFromDisk(
3483
3535
  mountPrefixedPath: string,
@@ -3493,11 +3545,11 @@ export class WorkspaceModule implements Module {
3493
3545
  } catch (err) {
3494
3546
  // A link's refusal (parseFilePath) carries its own code.
3495
3547
  if (err instanceof WorkspaceReadError) throw err;
3496
- const message = err instanceof Error ? err.message : String(err);
3497
- const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
3548
+ const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
3498
3549
  : err instanceof NestedMountError ? 'other_mount'
3499
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
3500
- throw new WorkspaceReadError(code, message, 'parse');
3550
+ : err instanceof PathRefusal ? err.code : null;
3551
+ if (code === null) throw err;
3552
+ throw new WorkspaceReadError(code, (err as Error).message, 'parse');
3501
3553
  }
3502
3554
  if (!relativePath) {
3503
3555
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
@@ -3525,92 +3577,25 @@ export class WorkspaceModule implements Module {
3525
3577
  }
3526
3578
  }
3527
3579
 
3528
- /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
3529
- private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
3530
- const p = mountPrefixedPath;
3531
- switch (err.code) {
3532
- case 'mount_unavailable':
3533
- // With why, where the root's check says, such as a directory that
3534
- // isn't the mount's.
3535
- return new WorkspaceImageReadError('mount_unavailable', err.message);
3536
- case 'directory':
3537
- return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
3538
- case 'symlink':
3539
- // The core reader's own words for a link at the path: "Symlinks are not
3540
- // allowed: <path>".
3541
- return new WorkspaceImageReadError('symlink', err.message);
3542
- case 'parent_symlink':
3543
- // The core reader's own words for a link on its way: which link and
3544
- // where it leads, with the setting for a link to a directory.
3545
- return new WorkspaceImageReadError('parent_symlink', err.message);
3546
- case 'other_mount':
3547
- // The core reader's own words for a path a link takes into a nested
3548
- // mount's directory: that mount and the path to use there. (#460's
3549
- // own other_mount, a path in that directory, is refused at parse,
3550
- // and so is a link found there before the read.)
3551
- return new WorkspaceImageReadError('other_mount', err.message);
3552
- case 'escape':
3553
- // The core reader's own words: "Symlink escape detected: <path>" for a
3554
- // followed link that resolves outside the mount, and for a link on the
3555
- // way that leads outside a mount that doesn't follow links, which link.
3556
- return new WorkspaceImageReadError('escape', err.message);
3557
- case 'changed':
3558
- return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
3559
- case 'not_found':
3560
- default:
3561
- // The core reader's own words for a file that isn't regular, and for
3562
- // a directory on the way it couldn't ask about.
3563
- if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
3564
- return new WorkspaceImageReadError('not_found', err.message);
3565
- }
3566
- if (err.message.startsWith('File not found')) {
3567
- return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
3568
- }
3569
- if (err.stage === 'realpath') {
3570
- return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
3571
- }
3572
- if (err.stage === 'stat') {
3573
- return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
3574
- }
3575
- return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
3576
- }
3577
- }
3578
-
3579
3580
  private async readImageFromFilesystem(
3580
3581
  mount: MountState,
3581
3582
  relativePath: string,
3582
3583
  mountPrefixedPath: string,
3583
3584
  maxSize: number,
3584
3585
  ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
3585
- let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3586
- try {
3587
- opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3588
- if (fileStat.size === 0) {
3589
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3590
- }
3591
- if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
3592
- });
3593
- } catch (err) {
3594
- if (err instanceof WorkspaceImageReadError) throw err;
3595
- if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
3596
- if (isErrnoCode(err, 'ENOENT')) {
3597
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3598
- }
3599
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3600
- }
3601
- const { handle, fileStat } = opened;
3586
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3587
+ if (fileStat.size === 0) throw emptyImage(mountPrefixedPath);
3588
+ if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
3589
+ });
3602
3590
  try {
3603
3591
  // No more than maxSize is loaded: a file grown past it since the fstat
3604
3592
  // above is refused here, as one already over it was there.
3605
3593
  const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
3606
- if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
3594
+ if (more) throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
3607
3595
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
3608
3596
  } catch (err) {
3609
3597
  if (err instanceof WorkspaceImageReadError) throw err;
3610
- if (isErrnoCode(err, 'ENOENT')) {
3611
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3612
- }
3613
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3598
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
3614
3599
  } finally {
3615
3600
  await handle.close();
3616
3601
  }
@@ -3643,18 +3628,19 @@ export class WorkspaceModule implements Module {
3643
3628
  * downstream agents. And, apart from it, why a write whose bytes reached
3644
3629
  * disk left the file's mode as disk had it (`modeKept`), and, for a
3645
3630
  * deletion that cleared a conflict naming a disk file the workspace never
3646
- * took in, that nothing was unlinked (`note`). The caller holds the mount's
3647
- * turn and has settled the path first (settleBeforeMutation, or a pass over
3648
- * the path, as an edit makes).
3631
+ * took in, that nothing was unlinked (`note`). And the answer's `disk` for
3632
+ * the path, unless it was refused. The caller holds the mount's turn and has
3633
+ * settled the path first (settleBeforeMutation, or a pass over the path, as
3634
+ * an edit makes).
3649
3635
  */
3650
3636
  private async commitToolChange(
3651
3637
  mount: MountState,
3652
3638
  relativePath: string,
3653
3639
  change: ToolChange,
3654
3640
  onCommitted?: () => void,
3655
- ): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
3656
- const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3657
- return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3641
+ ): Promise<{ refused: string | null; modeKept: string | null; note: string | null; disk: DiskState | null }> {
3642
+ const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3643
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
3658
3644
  }
3659
3645
 
3660
3646
  /**
@@ -3670,15 +3656,16 @@ export class WorkspaceModule implements Module {
3670
3656
  * (changesLanded).
3671
3657
  * Answers, by path, the reason for each that reached the workspace but not
3672
3658
  * disk (`refused`), and, apart from it, why one whose bytes reached disk
3673
- * left the file's mode as disk had it (`modeKept`), and that a deletion
3674
- * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3675
- * commitToolChange answers them for one path.
3659
+ * left the file's mode as disk had it (`modeKept`), that a deletion
3660
+ * clearing a conflict that named a disk file unlinked nothing (`notes`),
3661
+ * and the answer's `disk` for each path not refused, as commitToolChange
3662
+ * answers them for one path.
3676
3663
  */
3677
3664
  private async commitToolChanges(
3678
3665
  mount: MountState,
3679
3666
  changes: Array<{ path: string; change: ToolChange }>,
3680
3667
  onCommitted?: (path: string) => void,
3681
- ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
3668
+ ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string>; disk: Map<string, DiskState> }> {
3682
3669
  const store = this.getStore();
3683
3670
  const agreement = this.agreementOrThrow();
3684
3671
  const intents = this.intents.get(mount.config.name)!;
@@ -3817,7 +3804,7 @@ export class WorkspaceModule implements Module {
3817
3804
  // nothing.
3818
3805
  if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3819
3806
  putBack();
3820
- throw error;
3807
+ throw changing.length === 0 ? new AlreadyHeld(error) : error;
3821
3808
  }
3822
3809
  landedThenThrew = { error };
3823
3810
  }
@@ -3831,7 +3818,9 @@ export class WorkspaceModule implements Module {
3831
3818
  if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3832
3819
  const refused = new Map<string, string>();
3833
3820
  const modeKept = new Map<string, string>();
3834
- if (!this.pushesOnCommit(mount) || pushing.length === 0) return { refused, modeKept, notes };
3821
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) {
3822
+ return { refused, modeKept, notes, disk: new Map(changes.map(({ path }): [string, DiskState] => [path, this.diskOf(mount, path)])) };
3823
+ }
3835
3824
 
3836
3825
  const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3837
3826
  applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
@@ -3853,7 +3842,7 @@ export class WorkspaceModule implements Module {
3853
3842
  error: skipped.reason,
3854
3843
  } as ProcessEvent);
3855
3844
  }
3856
- return { refused, modeKept, notes };
3845
+ return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }): [string, DiskState] => [path, 'done'])) };
3857
3846
  }
3858
3847
 
3859
3848
  // ==========================================================================
@@ -3904,26 +3893,30 @@ export class WorkspaceModule implements Module {
3904
3893
  * call made again checks it.
3905
3894
  */
3906
3895
  private notCheckedYet(path: string, reason: string): string {
3907
- return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3896
+ 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.`;
3908
3897
  }
3909
3898
 
3910
3899
  /** Why the workspace holds no entry at a path, when disk explains it. */
3911
- private notInWorkspace(path: string, report: PathReport | undefined): string {
3900
+ private notInWorkspace(path: string, report: PathReport | undefined, mount: MountState, missing?: string): string {
3901
+ const shown = JSON.stringify(path);
3912
3902
  switch (report?.state) {
3913
3903
  case 'workspace-deleted':
3914
- return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3904
+ return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3915
3905
  case 'not-in-branch':
3916
- return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3917
- case 'disk-only':
3918
- return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
3919
- (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
3906
+ return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3907
+ case 'disk-only': {
3908
+ const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
3909
+ return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
3910
+ + 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
3911
+ + (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
3912
+ }
3920
3913
  case 'conflict':
3921
- return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
3914
+ return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3922
3915
  'Sync this path to take the disk version.';
3923
3916
  case 'unverified':
3924
- return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
3917
+ return uncheckedFile(shown, report.note, report.noteCause ?? {});
3925
3918
  default:
3926
- return `File not found: ${path}`;
3919
+ return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3927
3920
  }
3928
3921
  }
3929
3922
 
@@ -3966,14 +3959,14 @@ export class WorkspaceModule implements Module {
3966
3959
  // A pass also drops a copy disk made binary or too large, which
3967
3960
  // the disk-only answer describes; any other drop is disk's deletion.
3968
3961
  return {
3969
- error: took('deleted') && report?.state !== 'disk-only'
3970
- ? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3971
- : [this.notInWorkspace(path, report), ...again].join(' '),
3962
+ error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
3963
+ : undecided !== null ? this.notCheckedYet(path, undecided)
3964
+ : this.notInWorkspace(path, report, mount),
3972
3965
  };
3973
3966
  }
3974
- const blob = store.getBlob(entry.blobHash);
3975
- if (!blob) return { error: `Blob not found for: ${path}` };
3976
- return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3967
+ const contents = await this.contentsOf(mount, relativePath, path, entry);
3968
+ if ('lost' in contents) return { error: nothingToRead(contents.lost) };
3969
+ return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3977
3970
  });
3978
3971
  }
3979
3972
 
@@ -3987,33 +3980,144 @@ export class WorkspaceModule implements Module {
3987
3980
  */
3988
3981
  private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3989
3982
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3990
- const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3983
+ const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
3991
3984
  let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3992
3985
  try {
3993
3986
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3994
- if (fileStat.size > maxSize) throw new Error(over);
3987
+ if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
3995
3988
  });
3996
3989
  } catch (err) {
3997
- // A refusal that names only the path says why with its errno, as a
3998
- // failed read below does.
3999
- return { error: containedRefusal(err, path) };
3990
+ if (err instanceof Refusal) return { error: err.message };
3991
+ if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
3992
+ throw err;
4000
3993
  }
4001
3994
  try {
4002
3995
  // No more than the limit is loaded, as for a file over it at its fstat.
4003
3996
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4004
- if (more) return { error: over };
3997
+ if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4005
3998
  if (looksBinary(bytes)) {
4006
- return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3999
+ 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.'}` };
4007
4000
  }
4008
4001
  return { bytes };
4009
4002
  } catch (err) {
4010
- const errno = errnoOf(err);
4011
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4003
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4012
4004
  } finally {
4013
4005
  await opened.handle.close();
4014
4006
  }
4015
4007
  }
4016
4008
 
4009
+ private async contentsOf(
4010
+ mount: MountState,
4011
+ relativePath: string,
4012
+ path: string,
4013
+ version: { blobHash: string; size: number },
4014
+ what: 'saved' | 'conflict-disk' = 'saved',
4015
+ tell: 'now' | 'later' = 'now',
4016
+ ): Promise<{ bytes: Buffer } | { lost: LostContents }> {
4017
+ const store = this.getStore();
4018
+ const stored = storedBytes(store, version.blobHash);
4019
+ if ('bytes' in stored) return stored;
4020
+ const otherSize = new Error('other size');
4021
+ let found: { bytes: Buffer } | { disk: LostContents['disk'] };
4022
+ try {
4023
+ const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4024
+ if (fileStat.size !== version.size) throw otherSize;
4025
+ });
4026
+ try {
4027
+ const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
4028
+ found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
4029
+ } finally {
4030
+ await opened.handle.close();
4031
+ }
4032
+ } catch (err) {
4033
+ found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
4034
+ }
4035
+ const subject = contentsSubject(what, path);
4036
+ const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
4037
+ if ('disk' in found) {
4038
+ const now = storedBytes(store, version.blobHash);
4039
+ if ('bytes' in now) return now;
4040
+ const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
4041
+ if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost);
4042
+ return { lost };
4043
+ }
4044
+ const { bytes } = found;
4045
+ if ('bytes' in storedBytes(store, version.blobHash)) return { bytes };
4046
+ let thrown: string | null = null;
4047
+ try {
4048
+ store.storeBlob(bytes, 'application/octet-stream');
4049
+ } catch (error) {
4050
+ thrown = error instanceof Error ? error.message : String(error);
4051
+ }
4052
+ // Chronicle's storeBlob takes any file already at the hash's path as
4053
+ // stored, so whether the bytes took is what a read of them gives now.
4054
+ const now = storedBytes(store, version.blobHash);
4055
+ if ('bytes' in now) {
4056
+ this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace',
4057
+ `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
4058
+ } else {
4059
+ const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
4060
+ this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace',
4061
+ `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}. `
4062
+ + ('unreadable' in now
4063
+ ? "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."
4064
+ : 'Each read does this again until the store takes them.'),
4065
+ { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
4066
+ }
4067
+ return { bytes };
4068
+ }
4069
+
4070
+ private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void {
4071
+ this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace',
4072
+ `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)}. `
4073
+ + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.",
4074
+ { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
4075
+ }
4076
+
4077
+ private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
4078
+ const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
4079
+ if (!absent) return { unreadable: containedRefusal(err, path) };
4080
+ const root = resolve(mount.config.path);
4081
+ const rootReal = await realpath(root).catch(() => root);
4082
+ const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
4083
+ const proof = await confirmAbsent(view, rootReal, relativePath);
4084
+ if (!('reason' in proof)) return 'none';
4085
+ return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
4086
+ }
4087
+
4088
+ private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
4089
+ mount: MountState,
4090
+ versions: readonly T[],
4091
+ each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void,
4092
+ ): Promise<void> {
4093
+ const byHash = new Map<string, T[]>();
4094
+ for (const version of versions) {
4095
+ const sharing = byHash.get(version.hash);
4096
+ if (sharing) sharing.push(version);
4097
+ else byHash.set(version.hash, [version]);
4098
+ }
4099
+ for (const [hash, sharing] of byHash) {
4100
+ let bytes: Buffer | null = null;
4101
+ const lost: Array<[T, LostContents]> = [];
4102
+ for (const version of sharing) {
4103
+ const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
4104
+ if ('bytes' in contents) {
4105
+ bytes = contents.bytes;
4106
+ break;
4107
+ }
4108
+ lost.push([version, contents.lost]);
4109
+ }
4110
+ if (bytes === null) {
4111
+ for (const [version, found] of lost) {
4112
+ this.tellLost(mount.config.name, version.what, hash, found);
4113
+ each(version, { lost: found });
4114
+ }
4115
+ } else {
4116
+ for (const version of sharing) each(version, { bytes });
4117
+ }
4118
+ }
4119
+ }
4120
+
4017
4121
  // ==========================================================================
4018
4122
  // Tool Handlers
4019
4123
  // ==========================================================================
@@ -4029,15 +4133,16 @@ export class WorkspaceModule implements Module {
4029
4133
  // Each number is a whole number of at least its schema's minimum by now
4030
4134
  // (inputProblem).
4031
4135
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
4032
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
4136
+ 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.') };
4033
4137
  }
4034
4138
  let file: FileText;
4035
4139
  let notes: string[] = [];
4036
4140
  if (generatedContent === undefined) {
4037
4141
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
4142
+ if (relativePath === '') return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
4038
4143
  input = { ...input, path };
4039
4144
  const found = await this.bytesOf(mount, relativePath, path);
4040
- if ('error' in found) return { success: false, error: found.error, isError: true };
4145
+ if ('error' in found) return { success: false, error: didNothing('read', found.error), isError: true };
4041
4146
  file = fileText(found.bytes);
4042
4147
  notes = found.notes;
4043
4148
  } else {
@@ -4053,32 +4158,23 @@ export class WorkspaceModule implements Module {
4053
4158
  }
4054
4159
 
4055
4160
  private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
4056
- let mount: MountState;
4057
- let relativePath: string;
4058
- try {
4059
- let path: string;
4060
- ({ mount, relativePath, path } = await this.parseFilePath(input.path));
4061
- input = { ...input, path };
4062
- } catch (err) {
4063
- return {
4064
- success: false,
4065
- error: err instanceof Error ? err.message : String(err),
4066
- isError: true,
4067
- };
4068
- }
4069
- if (!relativePath) {
4070
- return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
4071
- }
4161
+ const given = input.path;
4162
+ const refused = (body: string): ToolResult => ({ success: false, isError: true, error: didNothing('read_image', body) });
4163
+ const { mount, relativePath, path } = await this.parseFilePath(given);
4164
+ if (!relativePath) return refused(namesRoot(given, mount.config.name));
4165
+ input = { ...input, path };
4072
4166
 
4073
4167
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4074
4168
  let treeError: WorkspaceImageReadError | null = null;
4169
+ let lost: LostContents | null = null;
4075
4170
 
4076
4171
  // The workspace's copy first, where it holds one, then disk's. A direct
4077
4172
  // mount holds none, so its image is disk's.
4078
4173
  if (!mount.direct) {
4079
4174
  try {
4080
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
4081
- if (image) return imageResult(input.path, image);
4175
+ const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
4176
+ if (image && 'lost' in image) lost = image.lost;
4177
+ else if (image) return imageResult(input.path, image);
4082
4178
  } catch (err) {
4083
4179
  if (err instanceof WorkspaceImageReadError) {
4084
4180
  treeError = err;
@@ -4089,31 +4185,37 @@ export class WorkspaceModule implements Module {
4089
4185
  }
4090
4186
 
4091
4187
  try {
4092
- return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
4188
+ const note = lost === null ? undefined
4189
+ : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
4190
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
4093
4191
  } catch (err) {
4094
- if (err instanceof WorkspaceImageReadError) {
4095
- if (err.code === 'not_found' && treeError) {
4096
- return { success: false, error: treeError.message, isError: true };
4097
- }
4098
- return { success: false, error: err.message, isError: true };
4192
+ if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError)) throw err;
4193
+ const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
4194
+ if (lost) {
4195
+ const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
4196
+ return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
4197
+ : `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
4099
4198
  }
4100
- throw err;
4199
+ if (noFile && treeError) return refused(treeError.message);
4200
+ return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
4101
4201
  }
4102
4202
  }
4103
4203
 
4104
4204
  private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
4105
4205
  if (LONE_SURROGATE.test(input.content)) {
4106
- return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
4206
+ 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.' };
4107
4207
  }
4108
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4208
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
4209
+ if (relativePath === '') return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
4109
4210
  input = { ...input, path };
4110
- const refusal = writeRefusal(mount);
4111
- if (refusal) return { success: false, error: refusal, isError: true };
4211
+ const refusal = writeRefusal(mount, this.writableNames());
4212
+ if (refusal) return { success: false, error: didNothing('write', refusal), isError: true };
4112
4213
 
4113
4214
  const store = this.getStore();
4114
4215
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4115
- if (Buffer.byteLength(input.content) > maxSize) {
4116
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
4216
+ const bytes = Buffer.byteLength(input.content);
4217
+ if (bytes > maxSize) {
4218
+ return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
4117
4219
  }
4118
4220
 
4119
4221
  const buffer = Buffer.from(input.content, 'utf-8');
@@ -4122,13 +4224,14 @@ export class WorkspaceModule implements Module {
4122
4224
  if (mount.direct) {
4123
4225
  const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
4124
4226
  if ('error' in written) return { success: false, isError: true, error: written.error };
4125
- return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
4227
+ return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
4126
4228
  }
4127
4229
  let committed = false;
4128
4230
  let refused: string | null;
4129
4231
  let modeKept: string | null;
4232
+ let disk: DiskState | null;
4130
4233
  try {
4131
- ({ refused, modeKept } = await this.withMount(mount, async () => {
4234
+ ({ refused, modeKept, disk } = await this.withMount(mount, async () => {
4132
4235
  await this.settleBeforeMutation(mount, relativePath);
4133
4236
  const blobHash = store.storeBlob(buffer, 'text/plain');
4134
4237
  // An overwrite keeps the file's mode, as an edit does.
@@ -4137,14 +4240,14 @@ export class WorkspaceModule implements Module {
4137
4240
  () => { committed = true; });
4138
4241
  }));
4139
4242
  } catch (error) {
4140
- if (!committed) throw error;
4243
+ if (!committed) throw notStored(error);
4141
4244
  return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
4142
4245
  ? { storeFailed: error.message, pushes: error.pushes }
4143
- : { threw: error instanceof Error ? error.message : String(error) });
4246
+ : { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
4144
4247
  }
4145
4248
  if (refused !== null) return storedButUnfinished('write', input.path, { refused });
4146
4249
 
4147
- return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
4250
+ return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
4148
4251
  }
4149
4252
 
4150
4253
  private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
@@ -4191,12 +4294,14 @@ export class WorkspaceModule implements Module {
4191
4294
  * stored where its record landed, and that it can't tell where they
4192
4295
  * couldn't be read back.
4193
4296
  * Under the mount's turn, one pass settles every path with disk, by the
4194
- * rule a listing uses, so the edits apply to what disk now holds. That pass
4195
- * is the only step that awaits before the commit: every file is then read,
4196
- * every edit planned, and every entry set (commitToolChanges) with no turn
4197
- * of the event loop between, so no pass, branch change or other call moves
4198
- * the tree between a file's read and its set. Disk then takes what it can
4199
- * of the stored change.
4297
+ * rule a listing uses, so the edits apply to what disk now holds. Each
4298
+ * file's stored contents are then read; where the store has lost some,
4299
+ * they're restored from disk's matching copy (contentsOf) and the pass runs
4300
+ * again, so the plan reads a pass taken after every await. From there
4301
+ * nothing awaits before the commit: every edit is planned and every entry
4302
+ * set (commitToolChanges) with no turn of the event loop between, so no
4303
+ * pass, branch change or other call moves the tree between a file's read
4304
+ * and its set. Disk then takes what it can of the stored change.
4200
4305
  *
4201
4306
  * The answer is held to `maxChars`, the length the call's result may have
4202
4307
  * (ToolCall.resultMaxChars), as edit-text.ts describes.
@@ -4237,7 +4342,7 @@ export class WorkspaceModule implements Module {
4237
4342
  }
4238
4343
  for (const field of ['oldString', 'newString'] as const) {
4239
4344
  if (LONE_SURROGATE.test(edit[field])) {
4240
- problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
4345
+ 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.` });
4241
4346
  }
4242
4347
  }
4243
4348
  if (problems.length === found) specs.set(index, spec);
@@ -4291,7 +4396,26 @@ export class WorkspaceModule implements Module {
4291
4396
  return await this.withMount(mount, async (): Promise<ToolResult> => {
4292
4397
  // As every file tool's pass does, it names the paths it acts on, so a
4293
4398
  // file the mount passes over by design is observed (see PassOptions.named).
4294
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4399
+ let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4400
+ let ops = pass.ops;
4401
+ const contents = new Map<string, Buffer>();
4402
+ const lost = new Map<string, { blobHash: string; lost: LostContents }>();
4403
+ const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = [];
4404
+ for (const target of targets.values()) {
4405
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4406
+ if (!entry || contents.has(entry.blobHash)) continue;
4407
+ const stored = storedBytes(store, entry.blobHash);
4408
+ if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes);
4409
+ else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
4410
+ }
4411
+ if (toRecover.length > 0) {
4412
+ await this.restoreEach(mount, toRecover, (version, found) => {
4413
+ if ('bytes' in found) contents.set(version.hash, found.bytes);
4414
+ else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
4415
+ });
4416
+ pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4417
+ ops = [...ops, ...pass.ops];
4418
+ }
4295
4419
  // A pass that decided nothing checked no file's disk copy.
4296
4420
  const undecided = undecidedOf(pass);
4297
4421
  // Nothing below awaits until the commit's push.
@@ -4310,7 +4434,7 @@ export class WorkspaceModule implements Module {
4310
4434
  // A mode taken in alone isn't a change to the text: it brings nothing
4311
4435
  // new to read, as it makes no event (emitFsEvents), and the edits
4312
4436
  // then write at the mode disk has, so it leaves nothing to say.
4313
- const took = (op: TreeOp) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4437
+ const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4314
4438
  // Which copy the edits used, when it isn't the one the workspace
4315
4439
  // last agreed on with disk: disk's newer text, or the workspace's
4316
4440
  // copy because disk's couldn't be read or disk has none. A file the
@@ -4375,18 +4499,28 @@ export class WorkspaceModule implements Module {
4375
4499
  // A pass also drops a copy disk made binary or too large, which
4376
4500
  // the disk-only answer describes; any other drop is disk's deletion.
4377
4501
  const text = took('deleted') && report?.state !== 'disk-only'
4378
- ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
4379
- : this.notInWorkspace(target.path, report);
4380
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
4502
+ ? deletedOnDisk(target.path)
4503
+ : this.notInWorkspace(target.path, report, mount);
4504
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
4381
4505
  continue;
4382
4506
  }
4383
- notes.push(...lookedIn);
4384
- const blob = store.getBlob(entry.blobHash);
4385
- if (!blob) {
4386
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4507
+ const known = contents.get(entry.blobHash);
4508
+ const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
4509
+ if (!('bytes' in stored)) {
4510
+ const found = lost.get(target.relativePath);
4511
+ problems.push({
4512
+ kind: 'file',
4513
+ edits: target.edits,
4514
+ path: target.path,
4515
+ text: found?.blobHash === entry.blobHash
4516
+ ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
4517
+ : `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.`,
4518
+ whole: true,
4519
+ });
4387
4520
  continue;
4388
4521
  }
4389
- const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
4522
+ notes.push(...lookedIn);
4523
+ const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
4390
4524
  if (plan === null) continue;
4391
4525
  plans.push({
4392
4526
  target,
@@ -4400,7 +4534,7 @@ export class WorkspaceModule implements Module {
4400
4534
  }
4401
4535
  if (problems.length) return refuse(problems, notes);
4402
4536
 
4403
- const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4537
+ const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4404
4538
  path: target.relativePath,
4405
4539
  change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4406
4540
  })), () => { committed = true; });
@@ -4410,19 +4544,19 @@ export class WorkspaceModule implements Module {
4410
4544
  plan,
4411
4545
  notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4412
4546
  modeKept: modeKept.get(target.relativePath) ?? null,
4413
- })), given.length, maxChars);
4547
+ })), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath)!)));
4414
4548
  });
4415
4549
  } catch (error) {
4416
4550
  const message = error instanceof Error ? error.message : String(error);
4417
4551
  if (committed) {
4418
4552
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
4419
4553
  ? { storeFailed: message, pushes: error.pushes }
4420
- : { threw: message }, maxChars);
4554
+ : { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
4421
4555
  }
4422
4556
  // A set that threw and couldn't be read back may have stored the edits,
4423
4557
  // and a second call could then apply them twice.
4424
4558
  if (error instanceof CommitUnknownError) {
4425
- const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4559
+ const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
4426
4560
  return {
4427
4561
  success: false,
4428
4562
  isError: true,
@@ -4435,9 +4569,8 @@ export class WorkspaceModule implements Module {
4435
4569
  }
4436
4570
  // The commit sets every entry in one record or none, and a set that
4437
4571
  // threw was read back, so nothing is stored; or every change was one
4438
- // the tree already held, which leaves the tree as it was either way.
4439
- if (tool === 'edit_many') return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
4440
- throw error;
4572
+ // the tree already held (AlreadyHeld).
4573
+ throw notStored(error);
4441
4574
  }
4442
4575
  }
4443
4576
 
@@ -4455,7 +4588,7 @@ export class WorkspaceModule implements Module {
4455
4588
  path: string,
4456
4589
  callerAgentName: string | undefined,
4457
4590
  ): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
4458
- const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: true });
4591
+ const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: false });
4459
4592
  try {
4460
4593
  // In the one spelling every file tool reads, as disk spells it, so two
4461
4594
  // spellings of a file are one entry and, in one call, one plan
@@ -4464,14 +4597,16 @@ export class WorkspaceModule implements Module {
4464
4597
  const where = await this.parseFilePath(path);
4465
4598
  // One that names the mount's root, its bare name included, names no
4466
4599
  // file.
4467
- if (where.relativePath === '') return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
4468
- const refusal = writeRefusal(where.mount);
4600
+ if (where.relativePath === '') return unusable(namesRoot(path, where.mount.config.name));
4601
+ const refusal = writeRefusal(where.mount, this.writableNames());
4469
4602
  if (refusal) return unusable(refusal);
4470
4603
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4471
- if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4604
+ if (generated) return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
4472
4605
  return where;
4473
4606
  } catch (error) {
4474
- return unusable(error instanceof Error ? error.message : String(error));
4607
+ if (error instanceof Refusal) return unusable(error.message);
4608
+ if (error instanceof WorkspaceReadError) return unusable(readRefusal(error, path));
4609
+ throw notStored(error);
4475
4610
  }
4476
4611
  }
4477
4612
 
@@ -4480,10 +4615,10 @@ export class WorkspaceModule implements Module {
4480
4615
  * and the agent's own is read-only, since it shows the agent's tool
4481
4616
  * presentation, which set_tool_visibility and set_tool_description change.
4482
4617
  */
4483
- private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
4618
+ private generatedFileRefusal(path: string, generated: { agentName: string }, callerAgentName: string | undefined): string {
4484
4619
  return callerAgentName !== generated.agentName
4485
- ? 'Generated file belongs to another agent'
4486
- : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4620
+ ? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
4621
+ : `${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.`;
4487
4622
  }
4488
4623
 
4489
4624
  /**
@@ -4507,12 +4642,13 @@ export class WorkspaceModule implements Module {
4507
4642
  conflicts: Map<EditTarget, string>,
4508
4643
  outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
4509
4644
  maxChars: number,
4645
+ reopen = false,
4510
4646
  ): ToolResult {
4511
4647
  const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4512
4648
  if (tool === 'edit') {
4513
4649
  const [target] = targets;
4514
4650
  const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
4515
- return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
4651
+ return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null, reopen);
4516
4652
  }
4517
4653
  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.";
4518
4654
  // The head is never cut, so what stands for the whole call goes in it,
@@ -4533,9 +4669,10 @@ export class WorkspaceModule implements Module {
4533
4669
  }
4534
4670
  } else if ('storeFailed' in outcome) {
4535
4671
  head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4536
- + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4672
+ + (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
4673
+ + (reopen ? ` ${AWAITS_REOPEN}` : '');
4537
4674
  } else {
4538
- head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4675
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
4539
4676
  }
4540
4677
  const kept = 'modeKept' in outcome
4541
4678
  ? targets.flatMap((target) => {
@@ -4577,7 +4714,7 @@ export class WorkspaceModule implements Module {
4577
4714
  try {
4578
4715
  staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4579
4716
  } catch (error) {
4580
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4717
+ return { error: directDidNothing('write', path, error) };
4581
4718
  }
4582
4719
  try {
4583
4720
  await directWriteTestSeam.afterStage?.([staged.path]);
@@ -4591,7 +4728,7 @@ export class WorkspaceModule implements Module {
4591
4728
  renameInto(staged);
4592
4729
  } catch (error) {
4593
4730
  await discard(staged);
4594
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4731
+ return { error: directDidNothing('write', path, error) };
4595
4732
  }
4596
4733
  this.recordDirectWrite(mount, agentName);
4597
4734
  return { note: linksNote(path, staged, 'write') };
@@ -4668,7 +4805,7 @@ export class WorkspaceModule implements Module {
4668
4805
  changedMeanwhile.add(target);
4669
4806
  return 'changed';
4670
4807
  }
4671
- if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
4808
+ if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
4672
4809
  else read.set(target, got);
4673
4810
  }
4674
4811
  // Two paths that reach one file, through a link this mount follows, or
@@ -4733,7 +4870,8 @@ export class WorkspaceModule implements Module {
4733
4870
  edits: target.edits,
4734
4871
  path: target.path,
4735
4872
  text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4736
- + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
4873
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
4874
+ + (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
4737
4875
  whole: true,
4738
4876
  }]);
4739
4877
  for (const { target, plan } of plans) {
@@ -4798,7 +4936,7 @@ export class WorkspaceModule implements Module {
4798
4936
  plan: plans.find((p) => p.target === target)!.plan,
4799
4937
  notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
4800
4938
  modeKept: null,
4801
- })), editCount, maxChars);
4939
+ })), editCount, maxChars, 'done');
4802
4940
  }
4803
4941
 
4804
4942
  /**
@@ -4811,7 +4949,8 @@ export class WorkspaceModule implements Module {
4811
4949
  private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
4812
4950
  // The failed file is named among the outcomes, so the head stays as
4813
4951
  // long as its reason, whatever the paths' lengths.
4814
- const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
4952
+ const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
4953
+ 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}.` : ''} `
4815
4954
  + "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:";
4816
4955
  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'}`);
4817
4956
  const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
@@ -4833,26 +4972,29 @@ export class WorkspaceModule implements Module {
4833
4972
  path: string,
4834
4973
  ): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
4835
4974
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4836
- const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
4975
+ const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
4837
4976
  let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
4838
4977
  try {
4839
4978
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4840
- if (fileStat.size > maxSize) throw new Error(over);
4979
+ if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
4841
4980
  });
4842
4981
  } catch (err) {
4843
4982
  if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
4844
- return { error: containedRefusal(err, path) };
4983
+ if (err instanceof Refusal) return { error: err.message };
4984
+ if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
4985
+ throw err;
4845
4986
  }
4846
4987
  try {
4847
4988
  const before = identityOf(await opened.handle.stat({ bigint: true }));
4848
4989
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4849
- if (more) return { error: over };
4850
- if (looksBinary(bytes)) return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
4990
+ if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4991
+ if (looksBinary(bytes)) {
4992
+ 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.` };
4993
+ }
4851
4994
  if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
4852
4995
  return { bytes, identity: before };
4853
4996
  } catch (err) {
4854
- const errno = errnoOf(err);
4855
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4997
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4856
4998
  } finally {
4857
4999
  await opened.handle.close();
4858
5000
  }
@@ -4880,11 +5022,9 @@ export class WorkspaceModule implements Module {
4880
5022
  } catch (err) {
4881
5023
  const code = errnoOf(err);
4882
5024
  const why = code === 'ENOENT' || code === 'ENOTDIR'
4883
- ? `the mount root is gone (${code}), and calling again is refused the same way until whoever runs the host puts a directory back at the mount's path`
4884
- : code === 'EACCES' || code === 'EPERM'
4885
- ? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
4886
- : `the mount root can't be read (${code ?? String(err)})`;
4887
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
5025
+ ? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
5026
+ : `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
5027
+ return { success: false, isError: true, error: didNothing('delete', why, path) };
4888
5028
  }
4889
5029
  const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4890
5030
  const { rootReal } = disk;
@@ -4893,10 +5033,11 @@ export class WorkspaceModule implements Module {
4893
5033
  await opened.handle.close();
4894
5034
  } catch (err) {
4895
5035
  // The open's own words for a replaced root speak of reading.
4896
- if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
4897
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
5036
+ if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
5037
+ return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
4898
5038
  }
4899
- return { success: false, isError: true, error: containedRefusal(err, path) };
5039
+ if (err instanceof WorkspaceReadError) return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
5040
+ throw err;
4900
5041
  }
4901
5042
  // The unlink is awaited, unlike a write's rename, so where the head
4902
5043
  // was just before it says whether the head moved while it ran.
@@ -4908,12 +5049,12 @@ export class WorkspaceModule implements Module {
4908
5049
  // whether the file is gone, so it's taken as the agent's write too.
4909
5050
  if (error instanceof EffectFailed && error.touched) {
4910
5051
  this.recordDirectWrite(mount, agentName, since);
4911
- 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.` };
5052
+ return { success: false, isError: true, error: `The delete of ${JSON.stringify(path)} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
4912
5053
  }
4913
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
5054
+ return { success: false, isError: true, error: directDidNothing('delete', path, error) };
4914
5055
  }
4915
5056
  this.recordDirectWrite(mount, agentName, since);
4916
- return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
5057
+ return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
4917
5058
  });
4918
5059
  }
4919
5060
 
@@ -5052,10 +5193,11 @@ export class WorkspaceModule implements Module {
5052
5193
  }
5053
5194
 
5054
5195
  private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
5055
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
5196
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
5197
+ if (relativePath === '') return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
5056
5198
  input = { ...input, path };
5057
- const refusal = writeRefusal(mount);
5058
- if (refusal) return { success: false, error: refusal, isError: true };
5199
+ const refusal = writeRefusal(mount, this.writableNames());
5200
+ if (refusal) return { success: false, error: didNothing('delete', refusal), isError: true };
5059
5201
  if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
5060
5202
 
5061
5203
  const store = this.getStore();
@@ -5066,11 +5208,11 @@ export class WorkspaceModule implements Module {
5066
5208
  await this.settleBeforeMutation(mount, relativePath);
5067
5209
  const entry = store.treeGet(mount.treeStateId, relativePath);
5068
5210
  if (!entry) {
5069
- const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
5070
- return { success: false, error, isError: true };
5211
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
5212
+ return { success: false, error: didNothing('delete', error), isError: true };
5071
5213
  }
5072
5214
 
5073
- const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
5215
+ const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
5074
5216
  () => { committed = true; });
5075
5217
  if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
5076
5218
 
@@ -5078,20 +5220,20 @@ export class WorkspaceModule implements Module {
5078
5220
  // withholds the result (agent-framework #277).
5079
5221
  return {
5080
5222
  success: true,
5081
- data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
5082
- keepWhenWithheld: ['path', 'deleted', 'note'],
5223
+ data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
5224
+ keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
5083
5225
  };
5084
5226
  });
5085
5227
  } catch (error) {
5086
- if (!committed) throw error;
5228
+ if (!committed) throw notStored(error);
5087
5229
  if (error instanceof StoredThenFailed) {
5088
5230
  // Its note, as the success answer's: a deletion that cleared a
5089
5231
  // conflict naming a disk file unlinked nothing.
5090
5232
  const note = error.notes.get(relativePath);
5091
5233
  return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
5092
- note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
5234
+ note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
5093
5235
  }
5094
- return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
5236
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
5095
5237
  }
5096
5238
  }
5097
5239
 
@@ -5117,7 +5259,7 @@ export class WorkspaceModule implements Module {
5117
5259
  // A path outside the mount's include that's no scope (refusesAsOutside)
5118
5260
  // is refused, as glob refuses it, and shown as given.
5119
5261
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5120
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5262
+ return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5121
5263
  }
5122
5264
  input = { ...input, path };
5123
5265
  const recursive = input.recursive === true;
@@ -5127,6 +5269,9 @@ export class WorkspaceModule implements Module {
5127
5269
  // shows disk's.
5128
5270
  const listing = await this.listingOf(mount, relativePath, recursive);
5129
5271
  const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
5272
+ const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
5273
+ const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
5274
+ const noted = note !== null ? { note } : {};
5130
5275
  if (recursive) {
5131
5276
  return {
5132
5277
  success: true,
@@ -5135,6 +5280,7 @@ export class WorkspaceModule implements Module {
5135
5280
  entries: listing.files,
5136
5281
  count: listing.files.length,
5137
5282
  ...incomplete,
5283
+ ...noted,
5138
5284
  },
5139
5285
  };
5140
5286
  }
@@ -5152,10 +5298,34 @@ export class WorkspaceModule implements Module {
5152
5298
  entries: children,
5153
5299
  count: children.length,
5154
5300
  ...incomplete,
5301
+ ...noted,
5155
5302
  },
5156
5303
  };
5157
5304
  }
5158
5305
 
5306
+ private async emptyScopeNote(mount: MountState, relativePath: string, path: string): Promise<string | null> {
5307
+ const shown = JSON.stringify(path);
5308
+ const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
5309
+ const { view, rootReal } = await this.diskView(mount);
5310
+ const seen = await observePath(view, rootReal, relativePath);
5311
+ if (seen.kind === 'file') return isFile;
5312
+ if (seen.kind !== 'absent') return null;
5313
+ if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) return isFile;
5314
+ return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
5315
+ }
5316
+
5317
+ private async absentOnDisk(mount: MountState, relativePath: string): Promise<boolean> {
5318
+ const { view, rootReal } = await this.diskView(mount);
5319
+ return (await observePath(view, rootReal, relativePath)).kind === 'absent';
5320
+ }
5321
+
5322
+ private holdsAnythingAt(mount: MountState, relativePath: string): boolean {
5323
+ if (mount.direct) return false;
5324
+ const store = this.getStore();
5325
+ const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree): tree is string => tree !== undefined);
5326
+ return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
5327
+ }
5328
+
5159
5329
  /**
5160
5330
  * What a listing shows of a directory scope, from where the mount keeps
5161
5331
  * what it reads: the store-backed kind's pass (listingFromStore), or, on a
@@ -5232,7 +5402,8 @@ export class WorkspaceModule implements Module {
5232
5402
  .filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
5233
5403
  .map((region) => ({
5234
5404
  path: region.path,
5235
- reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved) : region.reason,
5405
+ reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
5406
+ : explained(region.reason, region.errno !== undefined ? { errno: region.errno } : {}, 'list'),
5236
5407
  }));
5237
5408
  return {
5238
5409
  view,
@@ -5259,7 +5430,7 @@ export class WorkspaceModule implements Module {
5259
5430
  if (input.path) {
5260
5431
  const { mount, relativePath, moved, root } = mountsToSearch[0]!;
5261
5432
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5262
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5433
+ return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5263
5434
  }
5264
5435
  }
5265
5436
 
@@ -5281,7 +5452,7 @@ export class WorkspaceModule implements Module {
5281
5452
  if (prefixed) {
5282
5453
  const dir = deeperOf(relativePath, prefixed.dir);
5283
5454
  if (dir === null) {
5284
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5455
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5285
5456
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5286
5457
  continue;
5287
5458
  }
@@ -5316,8 +5487,16 @@ export class WorkspaceModule implements Module {
5316
5487
  try {
5317
5488
  regex = new RegExp(input.pattern);
5318
5489
  } catch (e) {
5319
- // The parser's reason says where the pattern goes wrong.
5320
- return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
5490
+ // V8's message ends with the parser's reason, after the pattern: "Invalid
5491
+ // regular expression: /foo(/: Unterminated group".
5492
+ const message = e instanceof Error ? e.message : String(e);
5493
+ const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
5494
+ return {
5495
+ success: false,
5496
+ isError: true,
5497
+ error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
5498
+ + 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
5499
+ };
5321
5500
  }
5322
5501
 
5323
5502
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
@@ -5345,7 +5524,7 @@ export class WorkspaceModule implements Module {
5345
5524
  if (input.path) {
5346
5525
  const { mount, relativePath, moved, root } = mountsToSearch[0]!;
5347
5526
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5348
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5527
+ return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5349
5528
  }
5350
5529
  }
5351
5530
 
@@ -5369,11 +5548,9 @@ export class WorkspaceModule implements Module {
5369
5548
  // read doesn't show it.
5370
5549
  const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
5371
5550
  const bomFiles = new Map<string, GrepText['version']>();
5372
- const search = (text: GrepText): Searched => {
5551
+ const search = (text: GrepText): Searched & { bom: boolean } => {
5373
5552
  const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5374
5553
  const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
5375
- if (lineEndings !== undefined && !crlfFiles.has(text.file)) crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
5376
- if (bom && !bomFiles.has(text.file)) bomFiles.set(text.file, text.version);
5377
5554
  const found: GrepMatch[] = [];
5378
5555
  for (let i = 0; i < lines.length; i++) {
5379
5556
  if (regex.test(lines[i])) {
@@ -5386,7 +5563,7 @@ export class WorkspaceModule implements Module {
5386
5563
  found.push(match);
5387
5564
  }
5388
5565
  }
5389
- return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
5566
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
5390
5567
  };
5391
5568
 
5392
5569
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
@@ -5394,7 +5571,7 @@ export class WorkspaceModule implements Module {
5394
5571
  // is that mount's: grep searches nothing of this mount's there, and
5395
5572
  // says whose it is. A direct mount's is judged by the nesting its parse
5396
5573
  // found where its root lies now (movedNesting).
5397
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5574
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5398
5575
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5399
5576
  continue;
5400
5577
  }
@@ -5412,16 +5589,21 @@ export class WorkspaceModule implements Module {
5412
5589
  return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
5413
5590
  }
5414
5591
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
5415
- const searched = (text: GrepText): void => {
5416
- const hits = search(text);
5417
- if (hits.matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
5592
+ const searched = (text: GrepText): (() => void) => {
5593
+ const { bom, ...hits } = search(text);
5594
+ const { file, state, version } = text;
5595
+ return () => {
5596
+ if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version });
5597
+ if (bom && !bomFiles.has(file)) bomFiles.set(file, version);
5598
+ if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
5599
+ };
5418
5600
  };
5419
5601
  const kept = prefixed
5420
5602
  ? (path: string): boolean => prefixed.regex.test(path)
5421
5603
  : (path: string): boolean => fileGlob === null || fileGlob.test(path)
5422
5604
  || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
5423
5605
  const found = mount.direct
5424
- ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
5606
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
5425
5607
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
5426
5608
  skipped.push(...found.skipped);
5427
5609
  incomplete.push(...found.incomplete);
@@ -5471,8 +5653,9 @@ export class WorkspaceModule implements Module {
5471
5653
  /**
5472
5654
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
5473
5655
  * single file `relativePath` names, those `keep` lets through, each handed
5474
- * to `each` in path order: a pass over the scope, then each path's
5475
- * workspace copy, labelled with its state, and a conflict's disk side.
5656
+ * to `search`, whose answers are recorded in path order: a pass over the
5657
+ * scope, then each path's workspace copy, labelled with its state, and a
5658
+ * conflict's disk side.
5476
5659
  * What it doesn't search is in `skipped`, and what it couldn't see in
5477
5660
  * `incomplete`, each mount-prefixed.
5478
5661
  */
@@ -5481,7 +5664,7 @@ export class WorkspaceModule implements Module {
5481
5664
  relativePath: string,
5482
5665
  start: string,
5483
5666
  keep: (path: string) => boolean,
5484
- each: (text: GrepText) => void,
5667
+ search: (text: GrepText) => () => void,
5485
5668
  ): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
5486
5669
  const store = this.getStore();
5487
5670
  const skipped: GrepSkip[] = [];
@@ -5534,16 +5717,18 @@ export class WorkspaceModule implements Module {
5534
5717
  }
5535
5718
 
5536
5719
  const intents = this.intents.get(mount.config.name)!;
5720
+ type Lost = Omit<GrepText, 'bytes'> & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' };
5721
+ const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = [];
5537
5722
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
5538
5723
  if (!keep(report.path)) continue;
5539
5724
  const file = `${mount.config.name}/${report.path}`;
5540
5725
  if (report.state === 'disk-only') {
5541
- skipped.push({
5726
+ found.push({ skip: {
5542
5727
  file,
5543
5728
  reason: 'disk-only: binary or over the size limit, never stored',
5544
5729
  ...(report.size !== undefined ? { size: report.size } : {}),
5545
5730
  ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5546
- });
5731
+ } });
5547
5732
  continue;
5548
5733
  }
5549
5734
  // A path in another mount's directory is that mount's: grep gives
@@ -5552,16 +5737,16 @@ export class WorkspaceModule implements Module {
5552
5737
  if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
5553
5738
  const refusal = refused.get(report.path);
5554
5739
  if (refusal !== undefined) {
5555
- skipped.push({ file, reason: refusal });
5740
+ found.push({ skip: { file, reason: refusal } });
5556
5741
  continue;
5557
5742
  }
5558
5743
  // The workspace's version, labelled with the path's state.
5559
5744
  const entry = store.treeGet(mount.treeStateId, report.path);
5560
- const blob = entry ? store.getBlob(entry.blobHash) : null;
5561
- if (blob) {
5562
- each({ file, state: report.state, version: 'workspace', bytes: blob });
5563
- } else if (entry) {
5564
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
5745
+ if (entry) {
5746
+ const stored = storedBytes(store, entry.blobHash);
5747
+ found.push('bytes' in stored
5748
+ ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
5749
+ : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
5565
5750
  }
5566
5751
  if (report.state !== 'conflict') continue;
5567
5752
  // A conflict's disk side. The version recorded with the conflict is
@@ -5571,21 +5756,37 @@ export class WorkspaceModule implements Module {
5571
5756
  // disk version was never stored either.
5572
5757
  const disk = intents.get(report.path)?.conflict?.disk;
5573
5758
  const changed = report.conflict?.diskChangedSinceRecorded === true;
5574
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
5575
- if (diskBlob) {
5576
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
5577
- }
5578
- if (disk?.stored && !diskBlob) {
5579
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
5759
+ if (disk?.stored) {
5760
+ const version = changed ? 'recorded-disk' : 'conflicting-disk';
5761
+ const stored = storedBytes(store, disk.stored);
5762
+ found.push('bytes' in stored
5763
+ ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
5764
+ : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
5580
5765
  }
5581
5766
  if (changed) {
5582
5767
  if (report.diskNow === 'file') {
5583
- 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' });
5768
+ 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' } });
5584
5769
  }
5585
5770
  } else if (disk && !disk.stored) {
5586
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
5771
+ 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 } });
5587
5772
  }
5588
5773
  }
5774
+ const answers = new Map<Lost, { searched: () => void } | { skip: GrepSkip }>();
5775
+ await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
5776
+ const { file, state, version, what } = lost;
5777
+ answers.set(lost, 'bytes' in contents
5778
+ ? { searched: search({ file, state, version, bytes: contents.bytes }) }
5779
+ : { skip: {
5780
+ file,
5781
+ reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
5782
+ + `and ${lostOnDisk(contents.lost)}, so it was not searched`,
5783
+ } });
5784
+ });
5785
+ for (const item of found) {
5786
+ const answer = 'lost' in item ? answers.get(item.lost)! : item;
5787
+ if ('searched' in answer) answer.searched();
5788
+ else skipped.push(answer.skip);
5789
+ }
5589
5790
  });
5590
5791
  return { skipped, incomplete };
5591
5792
  }
@@ -5635,7 +5836,7 @@ export class WorkspaceModule implements Module {
5635
5836
  const store = this.getStore();
5636
5837
  const status: Record<string, unknown> = {};
5637
5838
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
5638
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5839
+ return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5639
5840
  }
5640
5841
 
5641
5842
  for (const [name, mount] of this.mounts) {
@@ -5778,9 +5979,6 @@ export class WorkspaceModule implements Module {
5778
5979
  */
5779
5980
  private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
5780
5981
  const store = this.getStore();
5781
- const agreement = this.agreementOrThrow();
5782
- const name = mount.config.name;
5783
- const intents = this.intents.get(name)!;
5784
5982
  const owed: string[] = [];
5785
5983
  const unchecked: string[] = [];
5786
5984
  let agreed = 0;
@@ -5789,17 +5987,32 @@ export class WorkspaceModule implements Module {
5789
5987
  // Outside the mount's include, or in another mount's directory nested
5790
5988
  // in it: neither owed to disk nor counted as on it.
5791
5989
  if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
5792
- const p = agreement.get(name, e.path);
5793
- if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
5794
- else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
5795
- // A mode the store records, other than the one disk last agreed with,
5796
- // where modes are observed: elsewhere no push applies one.
5797
- else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
5990
+ const kind = this.evidenceOf(mount, e);
5991
+ if (kind === 'owed') owed.push(e.path);
5992
+ else if (kind === 'unchecked') unchecked.push(e.path);
5798
5993
  else agreed++;
5799
5994
  }
5800
5995
  return { owed, unchecked, agreed };
5801
5996
  }
5802
5997
 
5998
+ private evidenceOf(mount: MountState, e: { path: string; blobHash: string; mode: number }): 'owed' | 'unchecked' | 'agreed' {
5999
+ const p = this.agreementOrThrow().get(mount.config.name, e.path);
6000
+ if (p === undefined) return this.intents.get(mount.config.name)!.get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
6001
+ if (p.kind !== 'content' || p.hash !== e.blobHash) return 'owed';
6002
+ // A mode the store records, other than the one disk last agreed with,
6003
+ // where modes are observed: elsewhere no push applies one.
6004
+ if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) return 'owed';
6005
+ return 'agreed';
6006
+ }
6007
+
6008
+ private diskOf(mount: MountState, relativePath: string): DiskState {
6009
+ const intent = this.intents.get(mount.config.name)!.get(relativePath);
6010
+ if (intent?.conflict) return 'in conflict with disk';
6011
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
6012
+ if (!entry) return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
6013
+ return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
6014
+ }
6015
+
5803
6016
  private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
5804
6017
  const store = this.getStore();
5805
6018
 
@@ -5809,24 +6022,27 @@ export class WorkspaceModule implements Module {
5809
6022
  const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
5810
6023
 
5811
6024
  let explicit: { mount: MountState; relativePath: string } | null = null;
5812
- if (input.path) {
6025
+ if (input.path !== undefined) {
6026
+ if (input.path === '') {
6027
+ return { success: false, error: didNothing('materialize', this.emptyPathField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
6028
+ }
5813
6029
  const parsed = await this.parsePath(input.path);
5814
6030
  explicit = parsed;
5815
6031
  // A path outside the mount's include that's no scope (refusesAsOutside)
5816
6032
  // is refused; beneath a directory, the push leaves out what the include
5817
6033
  // doesn't cover.
5818
6034
  if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
5819
- return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
6035
+ return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
5820
6036
  }
5821
6037
  }
5822
6038
 
5823
6039
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
5824
6040
  if (explicit) {
5825
6041
  mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
5826
- } else if (input.mount) {
6042
+ } else if (input.mount !== undefined) {
5827
6043
  const m = this.mounts.get(input.mount);
5828
6044
  if (!m) {
5829
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
6045
+ 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 };
5830
6046
  }
5831
6047
  mountsToMaterialize = [{ name: input.mount, mount: m }];
5832
6048
  } else {
@@ -5934,16 +6150,21 @@ export class WorkspaceModule implements Module {
5934
6150
  if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
5935
6151
  return {
5936
6152
  success: false,
5937
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
6153
+ error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
5938
6154
  isError: true,
5939
6155
  };
5940
6156
  }
5941
6157
 
6158
+ const held = explicit;
6159
+ const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
6160
+ && allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
6161
+ && !this.holdsAnythingAt(held.mount, held.relativePath);
5942
6162
  return {
5943
6163
  success: true,
5944
6164
  data: {
5945
6165
  materialized: allWritten,
5946
6166
  count: allWritten.length,
6167
+ ...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
5947
6168
  ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
5948
6169
  ...(pendingDeletions.length > 0
5949
6170
  ? {
@@ -6014,24 +6235,31 @@ export class WorkspaceModule implements Module {
6014
6235
  const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
6015
6236
  const rootsAccepted: string[] = [];
6016
6237
  if (input.acceptRoot && input.path) {
6017
- return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
6238
+ const named = input.path.split('/')[0]!;
6239
+ return {
6240
+ success: false,
6241
+ isError: true,
6242
+ error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
6243
+ + `${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.`),
6244
+ };
6018
6245
  }
6019
6246
 
6020
6247
  // A path names its own mount; otherwise the given mount, or every mount.
6021
6248
  let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
6022
- if (input.path) {
6249
+ if (input.path !== undefined) {
6250
+ if (input.path === '') return { success: false, error: didNothing('sync', this.emptyPathField(input.mount, 'covers every mount')), isError: true };
6023
6251
  const { mount, relativePath, moved, root } = await this.parsePath(input.path);
6024
6252
  // A path outside the mount's include that's no scope (refusesAsOutside)
6025
6253
  // is refused; beneath a directory, the walk passes over what the include
6026
6254
  // doesn't cover.
6027
6255
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
6028
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
6256
+ return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
6029
6257
  }
6030
6258
  targets = [{ name: mount.config.name, mount, relativePath }];
6031
- } else if (input.mount) {
6259
+ } else if (input.mount !== undefined) {
6032
6260
  const m = this.mounts.get(input.mount);
6033
6261
  if (!m) {
6034
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
6262
+ return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
6035
6263
  }
6036
6264
  targets = [{ name: input.mount, mount: m, relativePath: '' }];
6037
6265
  } else {
@@ -6045,6 +6273,7 @@ export class WorkspaceModule implements Module {
6045
6273
  allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
6046
6274
  continue;
6047
6275
  }
6276
+ let namedDirectory = false;
6048
6277
  const pass = await this.withMount(mount, async () => {
6049
6278
  if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
6050
6279
  if (!relativePath) {
@@ -6059,6 +6288,7 @@ export class WorkspaceModule implements Module {
6059
6288
  store.treeList(mount.treeStateId, prefix).length > 0 ||
6060
6289
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
6061
6290
  this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
6291
+ namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
6062
6292
  // named: ignore patterns covering the path asked for don't apply
6063
6293
  // beneath it (a directory scope's walk; a path scope doesn't walk).
6064
6294
  const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
@@ -6105,7 +6335,7 @@ export class WorkspaceModule implements Module {
6105
6335
  // refused" don't look identical from the outside.
6106
6336
  for (const r of pass.reports.values()) {
6107
6337
  if (r.state === 'unverified') {
6108
- allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
6338
+ allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${explained(r.note ?? 'not observed', r.noteCause ?? {}, 'sync')}` });
6109
6339
  } else if (r.state === 'disk-only') {
6110
6340
  allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
6111
6341
  }
@@ -6117,7 +6347,7 @@ export class WorkspaceModule implements Module {
6117
6347
  // without a path passes over it, so nothing else keeps it current
6118
6348
  // (agent-framework #276). A path in another mount's directory isn't
6119
6349
  // synced at all, and the note says so.
6120
- const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
6350
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
6121
6351
  if (passedOver !== undefined) {
6122
6352
  allIgnored.push({
6123
6353
  mount: name,
@@ -6131,10 +6361,17 @@ export class WorkspaceModule implements Module {
6131
6361
  }
6132
6362
  }
6133
6363
 
6364
+ const named = input.path !== undefined && input.path !== '' ? targets[0]! : null;
6365
+ const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
6366
+ && allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
6367
+ && allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
6368
+ && !this.holdsAnythingAt(named.mount, named.relativePath)
6369
+ && await this.absentOnDisk(named.mount, named.relativePath);
6134
6370
  return {
6135
6371
  success: true,
6136
6372
  data: {
6137
6373
  results: allResults,
6374
+ ...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
6138
6375
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
6139
6376
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
6140
6377
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
@@ -6408,13 +6645,13 @@ export async function inOrder<T, R>(items: readonly T[], width: number, work: (i
6408
6645
  }
6409
6646
 
6410
6647
  /** read_image's answer for an image, wherever it was read from. */
6411
- function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
6648
+ function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult {
6412
6649
  return {
6413
6650
  success: true,
6414
6651
  data: [
6415
6652
  {
6416
6653
  type: 'text',
6417
- text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
6654
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
6418
6655
  },
6419
6656
  {
6420
6657
  type: 'image',
@@ -6475,13 +6712,16 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
6475
6712
  * add to what keeps it off direct; refused, what's held only shrinks, and
6476
6713
  * the next start once none is held runs it direct.
6477
6714
  */
6478
- function writeRefusal(mount: MountState): string | null {
6715
+ function writeRefusal(mount: MountState, writable: readonly string[] = []): string | null {
6479
6716
  const name = mount.config.name;
6480
- if (mount.config.mode === 'read-only') return `Mount "${name}" is read-only`;
6717
+ if (mount.config.mode === 'read-only') {
6718
+ return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
6719
+ + (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
6720
+ }
6481
6721
  if (mount.config.backend === 'direct' && !mount.direct) {
6482
- return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6722
+ return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6483
6723
  + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6484
- + `(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`;
6724
+ + `(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.`;
6485
6725
  }
6486
6726
  return null;
6487
6727
  }
@@ -6581,6 +6821,21 @@ function directReason(error: unknown): string {
6581
6821
  return error instanceof Error ? error.message : String(error);
6582
6822
  }
6583
6823
 
6824
+ function directWayOn(error: unknown): string | null {
6825
+ if (error instanceof FileChanged) return 'Calling again reads it afresh.';
6826
+ if (error instanceof WriteRefused) return error.wayOn;
6827
+ if (error instanceof EffectFailed) {
6828
+ if (error.detail.errno !== undefined) return errnoWayOn(error.detail.errno);
6829
+ if (error.detail.again === true) return 'Calling again checks it afresh.';
6830
+ }
6831
+ return null;
6832
+ }
6833
+
6834
+ function directDidNothing(tool: string, path: string, error: unknown): string {
6835
+ const on = directWayOn(error);
6836
+ return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
6837
+ }
6838
+
6584
6839
  /** "a", "a and b", or "a, b and c". */
6585
6840
  function listed(items: readonly string[]): string {
6586
6841
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
@@ -6604,7 +6859,130 @@ function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): stri
6604
6859
  */
6605
6860
  function containedRefusal(err: unknown, path: string): string {
6606
6861
  const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6607
- return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
6862
+ return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
6863
+ }
6864
+
6865
+ function deletedOnDisk(path: string): string {
6866
+ 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.`;
6867
+ }
6868
+
6869
+ function uncheckedFile(shown: string, note: string | undefined, cause: Cause): string {
6870
+ if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6871
+ const on = note === ROOT_REPLACED || cause.ownStep === true ? null
6872
+ : (note === undefined ? null : wayOn(note, cause, 'read')) ?? 'calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk';
6873
+ return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on[0]!.toUpperCase()}${sentence(on.slice(1))}` : ''}`;
6874
+ }
6875
+
6876
+ function namesRoot(path: string, mount: string): string {
6877
+ return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
6878
+ }
6879
+
6880
+ function tooLargeToWrite(path: string, bytes: number, maxSize: number): string {
6881
+ 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.');
6882
+ }
6883
+
6884
+ function pastSizeLimit(what: string, maxSize: number, outcome: string, within: string | null): string {
6885
+ return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
6886
+ + "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
6887
+ }
6888
+
6889
+ function sentence(text: string): string {
6890
+ return /[.!?]$/.test(text) ? text : `${text}.`;
6891
+ }
6892
+
6893
+ function unreadable(shown: string, errno: string | undefined): string {
6894
+ return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
6895
+ }
6896
+
6897
+ function readRefusal(err: WorkspaceReadError, path: string): string {
6898
+ const shown = JSON.stringify(path);
6899
+ if (err instanceof RootUnavailable) {
6900
+ if (err.refusal) return err.refusal.message;
6901
+ const mount = JSON.stringify(err.mount);
6902
+ if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
6903
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
6904
+ 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")}`;
6905
+ }
6906
+ if (err.errno !== undefined) {
6907
+ return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
6908
+ + errnoWayOn(err.errno, ROOT_RECHECK, true);
6909
+ }
6910
+ return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
6911
+ }
6912
+ return `${shown} can't be read: ${sentence(err.reason)}`;
6913
+ }
6914
+ if (err instanceof LinkOnTheWay) return `${shown} can't be reached: ${sentence(err.reason)}`;
6915
+ switch (err.code) {
6916
+ case 'directory':
6917
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6918
+ case 'symlink':
6919
+ return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6920
+ + `Calling again won't change that; ${UNFOLLOWED_STEP}.`;
6921
+ case 'escape':
6922
+ return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6923
+ + `Calling again won't change that; ${MOUNT_IT}.`;
6924
+ case 'changed':
6925
+ return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6926
+ case 'not_found':
6927
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
6928
+ if (err instanceof NotRegularFile) {
6929
+ 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')}`;
6930
+ }
6931
+ if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
6932
+ return unreadable(shown, err.errno);
6933
+ default:
6934
+ return sentence(err.message);
6935
+ }
6936
+ }
6937
+
6938
+ type LostContents = {
6939
+ path: string;
6940
+ store: Exclude<StoredBytes, { bytes: Buffer }>;
6941
+ disk: 'other' | 'none' | { unreadable: string };
6942
+ writable: boolean;
6943
+ reopen: boolean;
6944
+ };
6945
+
6946
+ function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string {
6947
+ return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
6948
+ }
6949
+
6950
+ function inTheStore(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
6951
+ return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
6952
+ }
6953
+
6954
+ function lostInStore(lost: Pick<LostContents, 'store'>): string {
6955
+ return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
6956
+ }
6957
+
6958
+ function lostOnDisk(lost: LostContents): string {
6959
+ return lost.disk === 'other' ? "disk's copy holds other contents"
6960
+ : lost.disk === 'none' ? 'disk has no copy'
6961
+ : `disk's copy can't be read (${lost.disk.unreadable})`;
6962
+ }
6963
+
6964
+ function lostText(lost: LostContents, the: 'The' | 'the' = 'The'): string {
6965
+ return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
6966
+ }
6967
+
6968
+ function nothingToRead(lost: LostContents): string {
6969
+ return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
6970
+ }
6971
+
6972
+ function lostRemedy(lost: LostContents): string {
6973
+ const after = remedyOfLost(lost);
6974
+ if (!lost.reopen) return after;
6975
+ 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. "
6976
+ + `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
6977
+ }
6978
+
6979
+ function remedyOfLost(lost: LostContents): string {
6980
+ const rewrite = lost.writable && 'missing' in lost.store;
6981
+ if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
6982
+ if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
6983
+ return lost.disk === 'none'
6984
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy."
6985
+ : "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.";
6608
6986
  }
6609
6987
 
6610
6988
  /**
@@ -6616,7 +6994,7 @@ function containedRefusal(err: unknown, path: string): string {
6616
6994
  */
6617
6995
  function headMoveText(mounts: readonly string[]): string {
6618
6996
  const one = mounts.length === 1;
6619
- return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6997
+ return `[${NOTICE_TAG.workspace}] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6620
6998
  + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6621
6999
  + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6622
7000
  }
@@ -6683,9 +7061,8 @@ function planTargetEdits(
6683
7061
  kind: 'file',
6684
7062
  edits: target.edits,
6685
7063
  path: target.path,
6686
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
6687
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
6688
- + "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.",
7064
+ text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize,
7065
+ "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
6689
7066
  whole: false,
6690
7067
  });
6691
7068
  return null;
@@ -6728,17 +7105,36 @@ function changesLanded(
6728
7105
  * change is stored isn't known. Its message says so after both errors.
6729
7106
  */
6730
7107
  class CommitUnknownError extends Error {
6731
- readonly setError: string;
6732
- readonly readError: string;
6733
- constructor(setError: unknown, readError: unknown) {
7108
+ constructor(readonly setCause: unknown, readonly readCause: unknown) {
6734
7109
  const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
6735
- super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
7110
+ super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
6736
7111
  this.name = 'CommitUnknownError';
6737
- this.setError = text(setError);
6738
- this.readError = text(readError);
6739
7112
  }
6740
7113
  }
6741
7114
 
7115
+ function storeWords(error: unknown): string {
7116
+ const code = errnoCode(error);
7117
+ return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
7118
+ }
7119
+
7120
+ class NotStored extends Error {
7121
+ constructor(readonly thrown: unknown) {
7122
+ super('stopped before the change was stored');
7123
+ }
7124
+ }
7125
+
7126
+ class AlreadyHeld extends Error {
7127
+ constructor(readonly thrown: unknown) {
7128
+ super(thrown instanceof Error ? thrown.message : String(thrown));
7129
+ }
7130
+ }
7131
+
7132
+ function notStored(error: unknown): unknown {
7133
+ return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
7134
+ ? error : new NotStored(error);
7135
+ }
7136
+
7137
+
6742
7138
  /**
6743
7139
  * A store's set that threw after recording the change (changesLanded): the
6744
7140
  * entries are set and their intents follow, and the store's error goes on in
@@ -6752,6 +7148,14 @@ class StoredThenFailed extends Error {
6752
7148
  }
6753
7149
  }
6754
7150
 
7151
+ const ONCE_REOPENED = ', once the store is reopened';
7152
+
7153
+ const DISK_ORDER: DiskState[] = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
7154
+
7155
+ function furthestFromDisk(values: DiskState[]): DiskState {
7156
+ return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
7157
+ }
7158
+
6755
7159
  /**
6756
7160
  * The answer to a write, edit or delete that the workspace stored but
6757
7161
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -6768,6 +7172,7 @@ function storedButUnfinished(
6768
7172
  path: string,
6769
7173
  outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
6770
7174
  note: string | null = null,
7175
+ reopen = false,
6771
7176
  ): ToolResult {
6772
7177
  const change = tool === 'delete' ? 'deletion' : tool;
6773
7178
  const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
@@ -6777,10 +7182,10 @@ function storedButUnfinished(
6777
7182
  : 'storeFailed' in outcome
6778
7183
  ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
6779
7184
  : tool === 'delete'
6780
- ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
6781
- : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
6782
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
6783
- return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
7185
+ ? ` 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 : ''}.`
7186
+ : ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
7187
+ : ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
7188
+ return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
6784
7189
  }
6785
7190
 
6786
7191
  /**
@@ -6810,19 +7215,24 @@ function whichCopyRead(report: PathReport | undefined, tookChange: boolean, read
6810
7215
  // can hold it (not binary or too large). A read-only mount can hold a
6811
7216
  // copy disk doesn't, such as one a branch switch selected, but a
6812
7217
  // materialize, forced or not, writes nothing to it.
6813
- const sync = report.diskNow === 'absent'
6814
- ? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6815
- : 'Sync the path to keep disk\'s copy and give up the workspace\'s';
6816
- const force = readOnly
6817
- ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6818
- : report.diskNow === 'absent'
6819
- ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6820
- : ', or materialize it with force to write the workspace\'s over disk\'s.';
6821
- return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
7218
+ const settle = settleConflict(report.diskNow, readOnly);
7219
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0]!.toUpperCase()}${settle.slice(1)}`];
6822
7220
  }
6823
7221
  return [];
6824
7222
  }
6825
7223
 
7224
+ function settleConflict(diskNow: PathReport['diskNow'], readOnly: boolean): string {
7225
+ const sync = diskNow === 'absent'
7226
+ ? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
7227
+ : 'sync the path to keep disk\'s copy and give up the workspace\'s';
7228
+ const force = readOnly
7229
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
7230
+ : diskNow === 'absent'
7231
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
7232
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
7233
+ return `${sync}${force}`;
7234
+ }
7235
+
6826
7236
  /**
6827
7237
  * The least each tool's whole-number fields may be, beside their schemas,
6828
7238
  * whose type (membrane's ToolParameter) has no `minimum`.
@@ -7020,13 +7430,13 @@ function globToRegex(pattern: string): RegExp {
7020
7430
  if (pattern[j] === '{') depth++;
7021
7431
  else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
7022
7432
  }
7023
- if (closeIdx < 0) throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
7433
+ if (closeIdx < 0) throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
7024
7434
  const group = pattern.slice(i + 1, closeIdx);
7025
7435
  if (group.includes('{')) {
7026
- throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
7436
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
7027
7437
  }
7028
7438
  if (!group.includes(',')) {
7029
- throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
7439
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
7030
7440
  }
7031
7441
  // A `/` right after the group goes into each alternative, as `{a,b}/x`
7032
7442
  // and `{a/,b/}x` name the same paths, so an alternative's own `**`