@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
@@ -4,15 +4,16 @@
4
4
  * Provides a unified workspace with mount-based filesystem access,
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
+ import { NOTICE_TAG } from '../../notice-tags.js';
7
8
  import { constants as fsConstants } from 'node:fs';
8
9
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
9
10
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
10
11
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
11
12
  import { MountWatcher } from './watcher.js';
12
- import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
13
- import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
13
+ import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE } from './sync.js';
14
+ import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
14
15
  import { BranchIntents } from './branch-intent.js';
15
- import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes } from './observe.js';
16
+ import { CHANGED, 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, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes } from './observe.js';
16
17
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
17
18
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
18
19
  import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
@@ -20,10 +21,13 @@ import { cutLine, editList, editReceipt, fitted, refusalText } from './edit-text
20
21
  import { fileText } from './text.js';
21
22
  import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
22
23
  import { grepText, textAnswer } from './result-text.js';
23
- import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
24
+ import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, explained, pushPaths, reconcilePass, settleAdoptionBeforeMutation, wayOn, } from './reconcile.js';
24
25
  import { EffectFailed, unlinkContained } from './effects.js';
26
+ import { AWAITS_REOPEN, MOUNT_IT, ROOT_RECHECK, Refusal, UNFOLLOWED_STEP, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
27
+ import { unknownToolMessage } from '../unknown-tool.js';
28
+ import { closestNames } from '../../closest-names.js';
25
29
  import { RecordJournal } from '../../record-journal.js';
26
- import { FileChanged, ROOT_MOVED, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
30
+ import { FileChanged, ROOT_MOVED, WriteRefused, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
27
31
  /** Default for how long the after-batch scan may hold the agent's next inference. */
28
32
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
29
33
  /**
@@ -42,6 +46,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
42
46
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
43
47
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
44
48
  const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
49
+ const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
50
+ + 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
51
+ + 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
45
52
  /**
46
53
  * How many files a direct mount's grep reads, and its listings look at, at
47
54
  * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
@@ -54,16 +61,23 @@ const DIRECT_READS_AT_ONCE = 16;
54
61
  const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
55
62
  /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
56
63
  const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
57
- class WorkspaceImageReadError extends Error {
58
- code;
59
- constructor(code, message) {
60
- super(message);
61
- this.code = code;
64
+ class WorkspaceImageReadError extends Refusal {
65
+ reason;
66
+ constructor(reason, wayOn) {
67
+ super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
68
+ this.reason = reason;
62
69
  this.name = 'WorkspaceImageReadError';
63
70
  }
64
71
  }
72
+ const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
73
+ function truncatedImage(mountPrefixedPath) {
74
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
75
+ }
76
+ function emptyImage(mountPrefixedPath) {
77
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
78
+ }
65
79
  /** A path the mount's `include` doesn't cover: outside the mount. */
66
- class OutsideIncludeError extends Error {
80
+ class OutsideIncludeError extends Refusal {
67
81
  constructor(shownPath, mount) {
68
82
  super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
69
83
  + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
@@ -74,9 +88,17 @@ class OutsideIncludeError extends Error {
74
88
  * mount it names. The message names the mount that holds it and the path to
75
89
  * use there.
76
90
  */
77
- class NestedMountError extends Error {
78
- constructor(path, mount, holder, use, how = 'lies in') {
79
- super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
91
+ class NestedMountError extends Refusal {
92
+ constructor(path, mount, holder, how = 'lies in') {
93
+ const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
94
+ super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
95
+ }
96
+ }
97
+ class PathRefusal extends Refusal {
98
+ code;
99
+ constructor(code, message) {
100
+ super(message);
101
+ this.code = code;
80
102
  }
81
103
  }
82
104
  /**
@@ -98,37 +120,46 @@ export class WorkspaceReadError extends Error {
98
120
  this.name = 'WorkspaceReadError';
99
121
  }
100
122
  }
123
+ class NotRegularFile extends WorkspaceReadError {
124
+ constructor(path, stage) {
125
+ super('not_found', `Not a regular file: ${path}`, stage);
126
+ }
127
+ }
128
+ class LinkOnTheWay extends WorkspaceReadError {
129
+ reason;
130
+ constructor(code, message, reason, errno) {
131
+ super(code, message, 'readlink', errno);
132
+ this.reason = reason;
133
+ }
134
+ }
135
+ class RootUnavailable extends WorkspaceReadError {
136
+ mount;
137
+ reason;
138
+ refusal;
139
+ constructor(mount, reason, stage, errno, refusal) {
140
+ super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
141
+ this.mount = mount;
142
+ this.reason = reason;
143
+ this.refusal = refusal;
144
+ }
145
+ }
101
146
  /**
102
147
  * Why a pass decided nothing, or null where it decided its paths. A pass gives
103
148
  * up deciding, and reports no path, when the selected branch kept changing
104
149
  * while it gathered, or when a sync's branch was no longer selected; it then
105
- * carries no branchId, and the region it leaves incomplete says why.
150
+ * carries no branchId, and says why in `undecided`.
106
151
  */
107
152
  function undecidedOf(pass) {
108
- return pass.branchId === undefined ? (pass.incomplete[0]?.reason ?? 'the pass decided nothing') : null;
153
+ return pass.branchId === undefined ? (pass.undecided ?? 'the pass decided nothing') : null;
109
154
  }
110
155
  function errnoOf(err) {
111
156
  return typeof err === 'object' && err !== null && 'code' in err && typeof err.code === 'string'
112
157
  ? err.code
113
158
  : undefined;
114
159
  }
115
- /**
116
- * What is at a mount root's path now, followed as the root is: for a
117
- * directory, undefined; for anything else, ENOTDIR; and where the path
118
- * can't be followed to anything, the errno its stat failed with, ENOENT
119
- * when nothing is there.
120
- */
121
- async function rootAbsence(root) {
122
- try {
123
- return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
124
- }
125
- catch (err) {
126
- return errnoOf(err);
127
- }
128
- }
129
160
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
130
- function imageTooLarge(maxSize, mountPrefixedPath) {
131
- return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
161
+ function imageTooLarge(maxSize, mountPrefixedPath, size) {
162
+ return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
132
163
  }
133
164
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
134
165
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
@@ -144,9 +175,6 @@ const JPEG_SOI = 0xd8;
144
175
  const JPEG_EOI = 0xd9;
145
176
  const JPEG_SOS = 0xda;
146
177
  const JPEG_TEM = 0x01;
147
- function isErrnoCode(err, code) {
148
- return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
149
- }
150
178
  function startsWithBytes(buffer, prefix) {
151
179
  return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
152
180
  }
@@ -174,15 +202,16 @@ function matchesPartialPrefix(buffer, prefix) {
174
202
  */
175
203
  async function linkReadError(mount, found, mountPrefixedPath, relativePath, nested, rootReal) {
176
204
  if (!('link' in found)) {
177
- return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
205
+ const reason = linkWords(mount.config.name, found, null);
206
+ return new LinkOnTheWay('not_found', `Unable to read file: ${mountPrefixedPath}: ${reason}`, reason, found.code);
178
207
  }
179
208
  const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
180
- const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
209
+ const reason = linkWords(mount.config.name, found, leads, nested, mount.config.include);
181
210
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
182
211
  const code = leads?.kind === 'outside' ? 'escape'
183
- : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
212
+ : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
184
213
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
185
- return new WorkspaceReadError(code, reason, 'readlink');
214
+ return new LinkOnTheWay(code, `${mountPrefixedPath}: ${reason}`, reason);
186
215
  }
187
216
  /**
188
217
  * `to` beneath `from`, mount-relative and `/`-separated, whatever the
@@ -211,28 +240,27 @@ function ancestorsOf(path) {
211
240
  * directory as configured beneath the outer mount's configured path
212
241
  * (`outerPath`), beneath each directory above its configured path that is
213
242
  * the outer root on disk (`above`, each with where it lies on disk), and
214
- * where its real root (`real`) lies beneath the outer root (`outerReal`),
215
- * each also as disk spells it beneath `spellAt`. Its real root is claimed
216
- * where it doesn't hold the outer root, which it would otherwise claim
217
- * whole. Undefined where it's nested nowhere.
243
+ * where its real root (`real`) lies beneath the outer root (`outerReal`).
244
+ * Its real root is claimed where it doesn't hold the outer root, which it
245
+ * would otherwise claim whole. Undefined where it's nested nowhere.
218
246
  */
219
- async function placementOf(outerPath, outerReal, innerPath, real, above, spellAt) {
247
+ function placementOf(outerPath, outerReal, innerPath, real, above) {
220
248
  const configured = nestedDir(outerPath, innerPath);
221
- const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
222
- const fromRoots = [];
223
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
224
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
225
- fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
226
- }
227
- if (fromRoots.length === 0)
249
+ const dirs = new Map();
250
+ const place = (dir, sure) => { if (dir !== undefined)
251
+ dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
252
+ place(configured, true);
253
+ for (const a of above) {
254
+ if (sameRoot(a.real, outerReal))
255
+ place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
256
+ }
257
+ const below = beneathRoot(real, outerReal);
258
+ place(below, below !== undefined && surelyAtRoot(real, outerReal));
259
+ if (dirs.size === 0)
228
260
  return undefined;
229
- const spellings = [...fromRoots];
230
- for (const found of fromRoots) {
231
- const dir = await diskSpelling(spellAt, found.dir);
232
- if (!spellings.some((s) => s.dir === dir))
233
- spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
234
- }
235
- return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
261
+ const fromRoots = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true }) }));
262
+ const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
263
+ return { configured, fromRoots, claims };
236
264
  }
237
265
  /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
238
266
  const LINKS_FOLLOWED = 40;
@@ -298,7 +326,7 @@ function isContainedPath(root, candidate) {
298
326
  return candidate.startsWith(normalizedRoot);
299
327
  }
300
328
  function invalidImage(format, mountPrefixedPath) {
301
- throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
329
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
302
330
  }
303
331
  function requireBufferRange(bytes, start, length, format, mountPrefixedPath) {
304
332
  if (start < 0 || length < 0 || start + length > bytes.length) {
@@ -634,21 +662,21 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
634
662
  return 'image/png';
635
663
  }
636
664
  if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
637
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
665
+ throw truncatedImage(mountPrefixedPath);
638
666
  }
639
667
  if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
640
668
  validateJpeg(bytes, mountPrefixedPath);
641
669
  return 'image/jpeg';
642
670
  }
643
671
  if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
644
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
672
+ throw truncatedImage(mountPrefixedPath);
645
673
  }
646
674
  if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
647
675
  validateGif(bytes, mountPrefixedPath);
648
676
  return 'image/gif';
649
677
  }
650
678
  if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
651
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
679
+ throw truncatedImage(mountPrefixedPath);
652
680
  }
653
681
  if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
654
682
  validateWebp(bytes, mountPrefixedPath);
@@ -656,9 +684,9 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
656
684
  }
657
685
  if ((bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
658
686
  || (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)) {
659
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
687
+ throw truncatedImage(mountPrefixedPath);
660
688
  }
661
- throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
689
+ 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.");
662
690
  }
663
691
  export class WorkspaceModule {
664
692
  name = 'workspace';
@@ -684,14 +712,11 @@ export class WorkspaceModule {
684
712
  ctimeTrust = new Map();
685
713
  /**
686
714
  * Each mount's directories that are other mounts nested inside it, with
687
- * each mount's name and real root, in each spelling that names the
688
- * directory: as configured; as the real roots place it, where that differs
689
- * (`fromRoots`); and as disk spells each of those where that differs.
690
- * Each spelling is mount-relative and `/`-separated, with its root beneath
691
- * the mount's resolved root. The constructor nests by configured path,
692
- * with each root as resolved standing for its real one, and each start
693
- * reads it all again with the roots placed where disk has them
694
- * (readNesting).
715
+ * each mount's name and real root: as configured, and as the real roots
716
+ * place it, where that differs (`fromRoots`). Each is mount-relative and
717
+ * `/`-separated. The constructor nests by configured path, with each root
718
+ * as resolved standing for its real one, and each start reads it all again
719
+ * with the roots placed where disk has them (readNesting).
695
720
  */
696
721
  otherMounts = new Map();
697
722
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
@@ -748,20 +773,19 @@ export class WorkspaceModule {
748
773
  if (refused.length > 0) {
749
774
  throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
750
775
  }
751
- const include = mount.include;
752
- if (include !== undefined) {
753
- // A pattern the matcher can't read would match nothing, and take every
754
- // file out of the mount unsaid (includePatternReadable).
755
- if (!Array.isArray(include) || include.length === 0) {
756
- throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`);
776
+ for (const list of ['ignore', 'include']) {
777
+ const patterns = mount[list];
778
+ if (patterns === undefined)
779
+ continue;
780
+ if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
781
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
757
782
  }
758
- // By index: an entry that is itself undefined, or a hole, is unreadable too.
759
- const at = include.findIndex((p) => !includePatternReadable(p));
760
- if (at >= 0) {
761
- const unreadable = include[at];
762
- throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
763
- + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
764
- + `${shownValue(unreadable)} is none of these`);
783
+ for (let i = 0; i < patterns.length; i++) {
784
+ const pattern = patterns[i];
785
+ const why = patternRefusal(pattern, list);
786
+ if (why !== undefined) {
787
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
788
+ }
765
789
  }
766
790
  }
767
791
  }
@@ -782,8 +806,8 @@ export class WorkspaceModule {
782
806
  const dir = nestedDir(outerPath, innerPath);
783
807
  if (dir === undefined)
784
808
  continue;
785
- const configured = [{ dir, root: innerPath }];
786
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
809
+ const configured = [{ dir }];
810
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
787
811
  console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
788
812
  `at ${dir}/ — passing over it as another mount's directory`);
789
813
  }
@@ -1067,7 +1091,7 @@ export class WorkspaceModule {
1067
1091
  const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
1068
1092
  let start = base;
1069
1093
  for (const segment of beneath.split('/')) {
1070
- if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
1094
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined))
1071
1095
  break;
1072
1096
  const here = await checkDir(view, rootReal, start);
1073
1097
  if (here.kind !== 'checked')
@@ -1125,11 +1149,10 @@ export class WorkspaceModule {
1125
1149
  }
1126
1150
  async start(ctx) {
1127
1151
  // Which mounts are nested where, with the roots placed where disk has
1128
- // them and each nested mount's directory as disk spells it, and whether
1129
- // each mount's root folds case, read where disk has it, before this start
1130
- // sets anything, so a restart takes no call and walks or watches nothing
1131
- // before they're known. A stop begun meanwhile stands, and this start then
1132
- // sets nothing.
1152
+ // them, and whether each mount's root folds case, read where disk has
1153
+ // it, before this start sets anything, so a restart takes no call
1154
+ // and walks or watches nothing before they're known. A stop begun
1155
+ // meanwhile stands, and this start then sets nothing.
1133
1156
  const stops = this.stopsBegun;
1134
1157
  const { nesting, reals, above } = await this.readNesting();
1135
1158
  const rootFoldsCase = new Map();
@@ -1242,7 +1265,8 @@ export class WorkspaceModule {
1242
1265
  const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
1243
1266
  return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
1244
1267
  `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
1245
- `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`);
1268
+ `never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
1269
+ 'if the current branch is what disk should hold, pass force: true to overwrite disk from it.');
1246
1270
  }
1247
1271
  /**
1248
1272
  * Re-pin mounts whose last-materialized branch is a proven ancestor of the
@@ -1866,7 +1890,12 @@ export class WorkspaceModule {
1866
1890
  work.then(done, done);
1867
1891
  return work;
1868
1892
  }
1869
- async runToolCall(call) {
1893
+ runToolCall(call) {
1894
+ const gate = { passed: false };
1895
+ return callGate.run(gate, () => this.answerToolCall(call, gate));
1896
+ }
1897
+ async answerToolCall(call, gate) {
1898
+ const reopenAtStart = this.agreement?.awaitsReopen === true;
1870
1899
  try {
1871
1900
  // Every handler reads its input as its schema describes it; a call
1872
1901
  // that sends something else is refused here, before anything is read.
@@ -1881,12 +1910,12 @@ export class WorkspaceModule {
1881
1910
  // ResultTooLarge in its place. A call made in process carries none and
1882
1911
  // takes the data itself, so its read pages by its line limit alone.
1883
1912
  const resultMaxChars = call.resultMaxChars;
1884
- const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1913
+ const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1885
1914
  if (generated) {
1886
1915
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
1887
1916
  return await this.handleRead(input, resultMaxChars, generated.read());
1888
1917
  }
1889
- return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
1918
+ return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path, generated, call.callerAgentName)) };
1890
1919
  }
1891
1920
  switch (call.name) {
1892
1921
  case 'read': return await this.handleRead(input, resultMaxChars);
@@ -1902,12 +1931,40 @@ export class WorkspaceModule {
1902
1931
  case 'materialize': return await this.handleMaterialize(input);
1903
1932
  case 'sync': return await this.handleSync(input);
1904
1933
  default:
1905
- return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
1934
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
1906
1935
  }
1907
1936
  }
1908
1937
  catch (err) {
1909
- return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
1938
+ return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
1939
+ }
1940
+ }
1941
+ failureAnswer(call, err, reopenAtStart, passedGate) {
1942
+ const input = (typeof call.input === 'object' && call.input !== null ? call.input : {});
1943
+ const path = typeof input.path === 'string' ? input.path : undefined;
1944
+ const mountName = path !== undefined ? path.split('/')[0] : typeof input.mount === 'string' ? input.mount : undefined;
1945
+ const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
1946
+ const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
1947
+ if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
1948
+ return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
1949
+ }
1950
+ if (err instanceof Refusal)
1951
+ return didNothing(call.name, err.message);
1952
+ if (err instanceof WorkspaceReadError)
1953
+ return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
1954
+ const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
1955
+ : mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
1956
+ : 'ls of each mount, with recursive: true, shows how each file stands against disk now';
1957
+ if (err instanceof NotStored || err instanceof AlreadyHeld) {
1958
+ console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
1959
+ return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
1960
+ }
1961
+ console.error(`[workspace] ${call.name} failed:`, err);
1962
+ if (err instanceof CommitUnknownError) {
1963
+ return unexpectedAnswer(call.name, subject, err.setCause, {
1964
+ said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
1965
+ });
1910
1966
  }
1967
+ return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
1911
1968
  }
1912
1969
  async onProcess(event, _state) {
1913
1970
  // Wake agents on filesystem events when the originating mount opted in.
@@ -1948,14 +2005,14 @@ export class WorkspaceModule {
1948
2005
  return { requestInference: true };
1949
2006
  let text;
1950
2007
  if (paths.length === 1) {
1951
- text = `[workspace event · ${op} · ${paths[0]}]`;
2008
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
1952
2009
  }
1953
2010
  else {
1954
2011
  const list = paths.map(p => `- ${p}`).join('\n');
1955
- text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2012
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
1956
2013
  }
1957
2014
  if (conflicts && conflicts.length > 0) {
1958
- text += `\n[conflicts: ${conflicts.join(', ')}]`;
2015
+ text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
1959
2016
  }
1960
2017
  return {
1961
2018
  requestInference: true,
@@ -2003,16 +2060,15 @@ export class WorkspaceModule {
2003
2060
  resolveAbsolutePath(mountPrefixedPath) {
2004
2061
  try {
2005
2062
  // Synchronous, so the path as given. The nested-mount check takes it
2006
- // in each spelling readNesting knows for the nested mount's directory,
2007
- // or in any spelling a disk that folds names may fold to one of them,
2008
- // erring toward that mount (holderOf's asGiven, beginsWithDir). The
2009
- // include judges it as given, so on a disk that
2063
+ // in any spelling a disk that folds names may fold to the nested
2064
+ // mount's directory, erring toward that mount (holderOf). The include
2065
+ // judges it as given, so on a disk that
2010
2066
  // folds names a covered name whose file disk spells otherwise returns
2011
2067
  // that file's path, which the tools refuse, and a covered file named in
2012
2068
  // another case is null, which the tools read. A path the mount doesn't
2013
2069
  // hold, outside its include or in another mount's directory nested in
2014
2070
  // it, is null, as for a path that leaves the mount's directory.
2015
- const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
2071
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
2016
2072
  return resolve(mount.config.path, relativePath);
2017
2073
  }
2018
2074
  catch {
@@ -2040,6 +2096,9 @@ export class WorkspaceModule {
2040
2096
  * `agentName` names the agent whose write it is for the head-move notice
2041
2097
  * (a write naming none tells no agent).
2042
2098
  *
2099
+ * A success's `disk` says where the file stands on disk as the workspace
2100
+ * last saw it, as the write tool's answer does.
2101
+ *
2043
2102
  * A write whose tree entry is set but that then fails returns success
2044
2103
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
2045
2104
  * the store failed after recording the entry, which skips the push where
@@ -2067,12 +2126,14 @@ export class WorkspaceModule {
2067
2126
  isError: true,
2068
2127
  };
2069
2128
  }
2070
- const refusal = writeRefusal(mount);
2129
+ if (relativePath === '')
2130
+ return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
2131
+ const refusal = writeRefusal(mount, this.writableNames());
2071
2132
  if (refusal)
2072
2133
  return { success: false, error: refusal, isError: true };
2073
2134
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2074
2135
  if (data.byteLength > maxSize) {
2075
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2136
+ return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
2076
2137
  }
2077
2138
  const store = this.getStore();
2078
2139
  // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
@@ -2087,7 +2148,7 @@ export class WorkspaceModule {
2087
2148
  const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2088
2149
  if ('error' in written)
2089
2150
  return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2090
- return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
2151
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
2091
2152
  }
2092
2153
  return this.withMount(mount, async () => {
2093
2154
  await this.settleBeforeMutation(mount, relativePath);
@@ -2102,9 +2163,11 @@ export class WorkspaceModule {
2102
2163
  let committed = false;
2103
2164
  let failure;
2104
2165
  let modeKept = null;
2166
+ let disk = null;
2105
2167
  try {
2106
2168
  const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
2107
2169
  modeKept = answer.modeKept;
2170
+ disk = answer.disk;
2108
2171
  failure = answer.refused === null ? null
2109
2172
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
2110
2173
  }
@@ -2113,10 +2176,13 @@ export class WorkspaceModule {
2113
2176
  throw error;
2114
2177
  // As write words it: a store that failed after recording the file
2115
2178
  // skipped its push, so on a mount that pushes, disk lacks it.
2179
+ const reopen = this.agreement?.awaitsReopen === true;
2116
2180
  failure = error instanceof StoredThenFailed
2117
2181
  ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
2118
- + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2182
+ + (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 : ''}.` : '')
2119
2183
  : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2184
+ if (reopen)
2185
+ failure += ` ${AWAITS_REOPEN}`;
2120
2186
  }
2121
2187
  if (failure !== null) {
2122
2188
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -2126,7 +2192,7 @@ export class WorkspaceModule {
2126
2192
  }
2127
2193
  return {
2128
2194
  success: true,
2129
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
2195
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
2130
2196
  };
2131
2197
  });
2132
2198
  }
@@ -2162,12 +2228,13 @@ export class WorkspaceModule {
2162
2228
  return this.withMount(mount, async () => {
2163
2229
  const { report, undecided } = await this.ensureSynced(mount, relativePath);
2164
2230
  const entry = store.treeGet(mount.treeStateId, relativePath);
2165
- if (!entry)
2166
- return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2167
- const blob = store.getBlob(entry.blobHash);
2168
- if (!blob)
2169
- return { error: `Blob not found for: ${mountPrefixedPath}` };
2170
- return { data: blob };
2231
+ if (!entry) {
2232
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
2233
+ }
2234
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2235
+ if ('lost' in contents)
2236
+ return { error: nothingToRead(contents.lost) };
2237
+ return { data: contents.bytes };
2171
2238
  });
2172
2239
  }
2173
2240
  /**
@@ -2245,9 +2312,9 @@ export class WorkspaceModule {
2245
2312
  return { error: error instanceof Error ? error.message : String(error) };
2246
2313
  }
2247
2314
  }
2248
- /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
2315
+ /** The directories of the mounts nested inside a mount, mount-relative. */
2249
2316
  othersOf(mountName) {
2250
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
2317
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
2251
2318
  }
2252
2319
  /**
2253
2320
  * Which mounts are nested in which, read afresh at each start. A mount is
@@ -2262,13 +2329,8 @@ export class WorkspaceModule {
2262
2329
  * alone, with no real root, since through it it would claim the other's
2263
2330
  * every path.
2264
2331
  *
2265
- * Each way it lies there is a spelling of its directory (`fromRoots`), and
2266
- * so is the spelling disk lists each of those by, where that differs. On a
2267
- * disk that folds names, the outer mount's walks name the directory as
2268
- * disk does, and so does a path parsePath has respelled, while a path given
2269
- * as configured still names it as configured (resolveAbsolutePath). A
2270
- * directory not on disk keeps the spellings the roots give alone. Each
2271
- * mount's real root comes back beside the nesting (`reals`), for what
2332
+ * Each way it lies there is a spelling of its directory (`fromRoots`).
2333
+ * Each mount's real root comes back beside the nesting (`reals`), for what
2272
2334
  * start reads there, and each directory above each configured path with
2273
2335
  * where it lies on disk (`above`), for a direct mount whose root moves
2274
2336
  * (movedNesting).
@@ -2295,15 +2357,16 @@ export class WorkspaceModule {
2295
2357
  if (outer === inner)
2296
2358
  continue;
2297
2359
  const real = reals.get(inner.name);
2298
- const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name), outerPath);
2360
+ const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name));
2299
2361
  if (at === undefined)
2300
2362
  continue;
2301
- const { configured, fromRoots, spellings, claims } = at;
2302
- nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2363
+ const { configured, fromRoots, claims } = at;
2364
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
2303
2365
  const placed = fromRoots.filter((s) => s.dir !== configured);
2304
2366
  if (placed.length > 0) {
2305
- console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2306
- `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`);
2367
+ const at = (sure) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
2368
+ 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 ');
2369
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
2307
2370
  }
2308
2371
  }
2309
2372
  }
@@ -2311,10 +2374,10 @@ export class WorkspaceModule {
2311
2374
  }
2312
2375
  /**
2313
2376
  * The mounts nested in `mountName`, by directory, name, real root where it
2314
- * has one, and include (MountView's `nested`), in each spelling known.
2377
+ * has one, and include (MountView's `nested`).
2315
2378
  */
2316
2379
  nestedOf(mountName) {
2317
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2380
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2318
2381
  }
2319
2382
  /**
2320
2383
  * The mounts nested in `mount` as a call of it that resolved its root to
@@ -2416,12 +2479,13 @@ export class WorkspaceModule {
2416
2479
  continue;
2417
2480
  above = placedAbove.get(inner.name) ?? [];
2418
2481
  }
2419
- const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2482
+ const at = placementOf(outerPath, rootReal, innerPath, real, above);
2420
2483
  if (at === undefined)
2421
2484
  continue;
2422
2485
  const include = this.mounts.get(inner.name)?.config.include;
2423
- for (const s of at.spellings)
2424
- nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2486
+ for (const s of at.fromRoots) {
2487
+ nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
2488
+ }
2425
2489
  }
2426
2490
  return nested;
2427
2491
  }
@@ -2430,15 +2494,9 @@ export class WorkspaceModule {
2430
2494
  * of that mount, judged lexically where the path lands (`.`, `..` and
2431
2495
  * repeated separators resolved, as for the traversal guard; a link of the
2432
2496
  * outer mount isn't followed), with the path to use there; undefined when
2433
- * no nested mount's directory holds it. A path parsePath has respelled is
2434
- * compared with the directory's spellings exactly. One judged as given
2435
- * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2436
- * folds names may compare it (beginsWithDir), so it can't reach that
2437
- * mount's directory by spelling a segment otherwise. On a disk that tells
2438
- * names apart, that takes a directory of this mount's own spelled like the
2439
- * nested one in another way as that mount's too.
2497
+ * no nested mount's directory holds it.
2440
2498
  */
2441
- holderOf(mountName, relativePath, asGiven = false, moved = null) {
2499
+ holderOf(mountName, relativePath, moved = null) {
2442
2500
  const mount = this.mounts.get(mountName);
2443
2501
  if (!mount)
2444
2502
  return undefined;
@@ -2446,20 +2504,15 @@ export class WorkspaceModule {
2446
2504
  const resolved = resolve(root, relativePath);
2447
2505
  if (!isContainedPath(root, resolved))
2448
2506
  return undefined;
2449
- const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2507
+ const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
2450
2508
  // A direct mount whose root a call found moved is judged by that call's
2451
2509
  // nesting (movedNesting), each directory beneath its configured path.
2452
- const dirs = moved !== null
2453
- ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2454
- : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2455
- const holder = dirs
2456
- .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2457
- .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2458
- .sort((a, b) => b.depth - a.depth)[0];
2510
+ const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
2459
2511
  if (!holder)
2460
2512
  return undefined;
2461
- const rest = segments.slice(holder.depth).join('/');
2462
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2513
+ const rest = restBeneath(path, holder);
2514
+ const guess = nestedGuess(mountName, path.split('/'), holder);
2515
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
2463
2516
  }
2464
2517
  /**
2465
2518
  * Why a mount leaves a path in a nested mount's directory, naming that
@@ -2467,10 +2520,10 @@ export class WorkspaceModule {
2467
2520
  * it; for a listing, whose the path is.
2468
2521
  */
2469
2522
  otherMountNote(mountName, relativePath, by, moved = null) {
2470
- const holder = this.holderOf(mountName, relativePath, false, moved);
2523
+ const holder = this.holderOf(mountName, relativePath, moved);
2471
2524
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2472
2525
  return holder
2473
- ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2526
+ ? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
2474
2527
  : `it lies in another mount's directory${what}`;
2475
2528
  }
2476
2529
  /**
@@ -2489,7 +2542,7 @@ export class WorkspaceModule {
2489
2542
  // Both checks judge by the nesting the parse found, and the second reads
2490
2543
  // disk beneath the root the parse resolved.
2491
2544
  const { mount, root, moved } = spelled;
2492
- const parsed = this.refuseNotHeld(spelled, path, false, moved);
2545
+ const parsed = this.refuseNotHeld(spelled, path, moved);
2493
2546
  // A path can also reach a nested mount's directory, or a file this
2494
2547
  // mount's own include leaves out, through a symlink on its way. No push
2495
2548
  // of this mount writes it there and no read reads it, so it's refused
@@ -2531,7 +2584,9 @@ export class WorkspaceModule {
2531
2584
  // whatever the place realpath stopped short of would say.
2532
2585
  if (judged && unresolved.code !== undefined)
2533
2586
  return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2534
- const holder = nestedAtReal(real, nested);
2587
+ let holder = await nestedAtReal(real, nested);
2588
+ if (holder?.guess === null)
2589
+ holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
2535
2590
  if (!holder) {
2536
2591
  if (!judged)
2537
2592
  return null;
@@ -2550,26 +2605,25 @@ export class WorkspaceModule {
2550
2605
  // on the place's way as that mount's, so a place beneath anything else
2551
2606
  // there, such as a file the include leaves out, isn't named; one beneath
2552
2607
  // a directory not there yet is.
2553
- const onItsWay = holder.include === undefined
2608
+ const onItsWay = !holder.judgedByInclude
2554
2609
  || await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
2555
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2556
- if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory))
2610
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
2611
+ if (!onItsWay || !holder.names(directory))
2557
2612
  return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2558
- return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2613
+ return new NestedMountError(path, mount.config.name, holder, 'leads into');
2559
2614
  }
2560
2615
  /**
2561
2616
  * A parsed path, refused where the mount doesn't hold it: in a nested
2562
2617
  * mount's directory first, naming the mount that holds it, then outside
2563
2618
  * the mount's include. Each is judged on the path as the caller parsed it:
2564
2619
  * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2565
- * which is synchronous and says so (`asGiven`, so the nested-mount check
2566
- * folds where disk does). The include's refusal shows the path as given
2620
+ * which is synchronous. The include's refusal shows the path as given
2567
2621
  * (`given`), since it answers the path the caller asked about.
2568
2622
  */
2569
- refuseNotHeld(parsed, given, asGiven = false, moved = null) {
2570
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2623
+ refuseNotHeld(parsed, given, moved = null) {
2624
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
2571
2625
  if (holder)
2572
- throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2626
+ throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
2573
2627
  if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2574
2628
  throw new OutsideIncludeError(given, parsed.mount.config);
2575
2629
  }
@@ -2615,7 +2669,7 @@ export class WorkspaceModule {
2615
2669
  * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2616
2670
  */
2617
2671
  async refusesAsOutside(mount, relativePath, moved, root) {
2618
- if (this.holderOf(mount.config.name, relativePath, false, moved))
2672
+ if (this.holderOf(mount.config.name, relativePath, moved))
2619
2673
  return false;
2620
2674
  if (insideMount(relativePath, mount.config.include))
2621
2675
  return false;
@@ -2690,20 +2744,12 @@ export class WorkspaceModule {
2690
2744
  }
2691
2745
  /**
2692
2746
  * How many segments the innermost nested mount's directory that holds
2693
- * `relativePath` has, in a spelling it's known by (othersOf), or, for a
2694
- * direct mount whose root has moved, in the nesting there (`moved`);
2747
+ * `relativePath` has, or, for a direct mount whose root has moved, in the
2748
+ * nesting there (`moved`);
2695
2749
  * undefined when no nested mount's directory holds it.
2696
2750
  */
2697
2751
  nestedDepth(mountName, relativePath, moved = null) {
2698
- let depth;
2699
- for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
2700
- if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
2701
- continue;
2702
- const segments = dir.split('/').length;
2703
- if (depth === undefined || segments > depth)
2704
- depth = segments;
2705
- }
2706
- return depth;
2752
+ return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
2707
2753
  }
2708
2754
  /**
2709
2755
  * A mount's entries on the current branch, and the paths it records an
@@ -2725,11 +2771,7 @@ export class WorkspaceModule {
2725
2771
  // Only an entry this mount's include covers spells a draft's path
2726
2772
  // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
2727
2773
  // from an earlier version, nor one the include leaves out, which would
2728
- // respell a covered draft into a name the include leaves out. An entry
2729
- // in a nested mount's directory that this mount's include covers
2730
- // spells one whatever that mount's include says, so on a disk that
2731
- // folds names, a draft naming the directory in another spelling takes
2732
- // the entry's spelling and is judged as in it.
2774
+ // respell a covered draft into a name the include leaves out.
2733
2775
  under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
2734
2776
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
2735
2777
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
@@ -2754,25 +2796,25 @@ export class WorkspaceModule {
2754
2796
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
2755
2797
  // store would keep one name and disk another.
2756
2798
  if (LONE_SURROGATE.test(path)) {
2757
- 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.`);
2799
+ 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.`);
2758
2800
  }
2759
2801
  const slashIdx = path.indexOf('/');
2760
2802
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
2761
2803
  const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2762
2804
  const mount = this.mounts.get(mountName);
2763
- if (!mount) {
2764
- throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
2765
- }
2805
+ if (!mount)
2806
+ throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
2766
2807
  const segments = pathSegments(given);
2767
2808
  if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
2768
- 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.`);
2809
+ 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>".`);
2769
2810
  }
2770
2811
  const kept = [];
2771
2812
  for (const segment of segments) {
2772
2813
  if (segment !== '..')
2773
2814
  kept.push(segment);
2774
2815
  else if (kept.pop() === undefined) {
2775
- throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
2816
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
2817
+ + `Name a file from its own mount's name; ${this.mountList()}.`);
2776
2818
  }
2777
2819
  }
2778
2820
  const relativePath = kept.join('/');
@@ -2786,10 +2828,47 @@ export class WorkspaceModule {
2786
2828
  const root = resolve(mount.config.path);
2787
2829
  const resolved = resolve(root, relativePath);
2788
2830
  if (!isContainedPath(root, resolved)) {
2789
- throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
2831
+ 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>".`);
2790
2832
  }
2791
2833
  return { mount, relativePath };
2792
2834
  }
2835
+ writableNames() {
2836
+ return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
2837
+ }
2838
+ mountList() {
2839
+ const names = [...this.mounts.keys()];
2840
+ return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
2841
+ }
2842
+ emptyPathField(mount, bare) {
2843
+ const empty = '"path" is "", which names no file or directory.';
2844
+ if (mount === undefined)
2845
+ return `${empty} Leaving "path" out ${bare}.`;
2846
+ if (mount === '')
2847
+ return `${empty} "mount" is "" too; leaving both out ${bare}.`;
2848
+ if (this.mounts.has(mount))
2849
+ return `${empty} Leaving "path" out covers mount ${JSON.stringify(mount)}.`;
2850
+ return `${empty} "mount" is ${JSON.stringify(mount)}, which is no mount's name; ${this.mountList()}. Leaving both out ${bare}.`;
2851
+ }
2852
+ noSuchMountField(given, bare) {
2853
+ const [close] = closestNames(given, [...this.mounts.keys()], 1);
2854
+ return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
2855
+ + (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
2856
+ + ` Leaving "mount" out ${bare}.`;
2857
+ }
2858
+ noSuchMount(path, mountName) {
2859
+ const names = [...this.mounts.keys()];
2860
+ const rest = path.slice(mountName.length);
2861
+ const unslashed = path.replace(/^[/\\]+/, '');
2862
+ if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
2863
+ return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
2864
+ }
2865
+ if (mountName === '')
2866
+ return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
2867
+ const [close] = closestNames(mountName, names, 1);
2868
+ const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
2869
+ : `${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()}.`;
2870
+ return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
2871
+ }
2793
2872
  /**
2794
2873
  * Whether the module takes work: stop hasn't finished. Work admitted while
2795
2874
  * stop drains still runs; once stop has saved the pins and watermarks,
@@ -2808,25 +2887,23 @@ export class WorkspaceModule {
2808
2887
  }
2809
2888
  validateImageBytes(bytes, mountPrefixedPath, maxSize) {
2810
2889
  if (bytes.byteLength === 0) {
2811
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
2890
+ throw emptyImage(mountPrefixedPath);
2812
2891
  }
2813
2892
  if (bytes.byteLength > maxSize)
2814
- throw imageTooLarge(maxSize, mountPrefixedPath);
2893
+ throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
2815
2894
  return {
2816
2895
  bytes,
2817
2896
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
2818
2897
  };
2819
2898
  }
2820
- tryReadImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
2821
- const store = this.getStore();
2822
- const entry = store.treeGet(mount.treeStateId, relativePath);
2899
+ async readImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
2900
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
2823
2901
  if (!entry)
2824
2902
  return null;
2825
- const blob = store.getBlob(entry.blobHash);
2826
- if (!blob) {
2827
- throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
2828
- }
2829
- return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
2903
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2904
+ if ('lost' in contents)
2905
+ return contents;
2906
+ return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
2830
2907
  }
2831
2908
  /**
2832
2909
  * The mount root a disk read reads beneath: resolved, then checked as
@@ -2842,20 +2919,14 @@ export class WorkspaceModule {
2842
2919
  * another directory carries none. The identity is rootIdentityForRead's,
2843
2920
  * which answers while the evidence awaits reopening; where even it can't
2844
2921
  * be read, as when the evidence was never read back whole, the read is
2845
- * refused too, with an errno only where no directory is at the root's path.
2922
+ * refused before its root is resolved, with an errno only where no
2923
+ * directory is at the root's path.
2846
2924
  * A direct mount's root is checked against no identity, and its read reads
2847
2925
  * no evidence: it promises what's at its root now (diskView).
2848
2926
  */
2849
2927
  async readRoot(mount) {
2850
2928
  const name = mount.config.name;
2851
2929
  const root = resolve(mount.config.path);
2852
- let real;
2853
- try {
2854
- real = await realpath(root);
2855
- }
2856
- catch (err) {
2857
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
2858
- }
2859
2930
  let rootIdentity;
2860
2931
  if (!mount.direct) {
2861
2932
  try {
@@ -2864,9 +2935,16 @@ export class WorkspaceModule {
2864
2935
  catch (err) {
2865
2936
  // The evidence can't be read back, so neither can the check be made.
2866
2937
  // Where no directory is at the root's path, its errno still says so.
2867
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2938
+ throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
2868
2939
  }
2869
2940
  }
2941
+ let real;
2942
+ try {
2943
+ real = await realpath(root);
2944
+ }
2945
+ catch (err) {
2946
+ throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
2947
+ }
2870
2948
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2871
2949
  if ('dir' in checked)
2872
2950
  return { real, dir: checked.dir };
@@ -2874,7 +2952,7 @@ export class WorkspaceModule {
2874
2952
  // root's path now decides: nothing, something that isn't a directory, or
2875
2953
  // a directory the path has come to name since it was resolved (no errno).
2876
2954
  const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2877
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2955
+ throw new RootUnavailable(name, checked.reason, 'root', errno);
2878
2956
  }
2879
2957
  /**
2880
2958
  * The refusal for a root that stopped being the directory a read checked,
@@ -2888,7 +2966,7 @@ export class WorkspaceModule {
2888
2966
  async rootMoved(mount) {
2889
2967
  const errno = await rootAbsence(resolve(mount.config.path));
2890
2968
  const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
2891
- return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2969
+ return new RootUnavailable(mount.config.name, reason, 'root', errno);
2892
2970
  }
2893
2971
  /**
2894
2972
  * Open a mount-relative file for reading with the mount boundary enforced on
@@ -2978,7 +3056,7 @@ export class WorkspaceModule {
2978
3056
  }
2979
3057
  if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2980
3058
  // A FIFO, socket or device is never opened.
2981
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
3059
+ throw new NotRegularFile(mountPrefixedPath, 'lstat');
2982
3060
  }
2983
3061
  let handle;
2984
3062
  try {
@@ -3009,7 +3087,7 @@ export class WorkspaceModule {
3009
3087
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
3010
3088
  }
3011
3089
  if (!fileStat.isFile()) {
3012
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
3090
+ throw new NotRegularFile(mountPrefixedPath, 'fstat');
3013
3091
  }
3014
3092
  if (!follow && !useNoFollow) {
3015
3093
  let postOpenInfo;
@@ -3047,13 +3125,13 @@ export class WorkspaceModule {
3047
3125
  if (resolvedThrough !== null)
3048
3126
  throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
3049
3127
  const inside = isContainedPath(realMountRoot, realFilePath);
3050
- const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3128
+ const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
3051
3129
  // A place in a nested mount's directory that its include leaves out is
3052
3130
  // no mount's, so the path leaves this one (namesNestedPlace), and so
3053
3131
  // does one reaching a file this mount's own include leaves out
3054
3132
  // (coversLanding).
3055
3133
  const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
3056
- if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
3134
+ if (!inside || own || (holder !== undefined && !holder.names(false))) {
3057
3135
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
3058
3136
  }
3059
3137
  if (holder)
@@ -3122,7 +3200,9 @@ export class WorkspaceModule {
3122
3200
  * (`outside_include`), a path in another mount's directory nested in the one named
3123
3201
  * (`other_mount`), unavailable mount, missing or non-regular file,
3124
3202
  * directory, policy-denied symlink, outside-mount target, and
3125
- * file-changed-during-read.
3203
+ * file-changed-during-read. An error met while parsing the path that is
3204
+ * none of these refusals, such as the store's, is thrown as it is, and a
3205
+ * `maxBytes` that isn't a non-negative integer throws a `RangeError`.
3126
3206
  */
3127
3207
  async readFileFromDisk(mountPrefixedPath, options = {}) {
3128
3208
  if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
@@ -3137,11 +3217,12 @@ export class WorkspaceModule {
3137
3217
  // A link's refusal (parseFilePath) carries its own code.
3138
3218
  if (err instanceof WorkspaceReadError)
3139
3219
  throw err;
3140
- const message = err instanceof Error ? err.message : String(err);
3141
3220
  const code = err instanceof OutsideIncludeError ? 'outside_include'
3142
3221
  : err instanceof NestedMountError ? 'other_mount'
3143
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
3144
- throw new WorkspaceReadError(code, message, 'parse');
3222
+ : err instanceof PathRefusal ? err.code : null;
3223
+ if (code === null)
3224
+ throw err;
3225
+ throw new WorkspaceReadError(code, err.message, 'parse');
3145
3226
  }
3146
3227
  if (!relativePath) {
3147
3228
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
@@ -3171,93 +3252,25 @@ export class WorkspaceModule {
3171
3252
  await handle.close();
3172
3253
  }
3173
3254
  }
3174
- /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
3175
- imageErrorFromRead(err, mount, mountPrefixedPath) {
3176
- const p = mountPrefixedPath;
3177
- switch (err.code) {
3178
- case 'mount_unavailable':
3179
- // With why, where the root's check says, such as a directory that
3180
- // isn't the mount's.
3181
- return new WorkspaceImageReadError('mount_unavailable', err.message);
3182
- case 'directory':
3183
- return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
3184
- case 'symlink':
3185
- // The core reader's own words for a link at the path: "Symlinks are not
3186
- // allowed: <path>".
3187
- return new WorkspaceImageReadError('symlink', err.message);
3188
- case 'parent_symlink':
3189
- // The core reader's own words for a link on its way: which link and
3190
- // where it leads, with the setting for a link to a directory.
3191
- return new WorkspaceImageReadError('parent_symlink', err.message);
3192
- case 'other_mount':
3193
- // The core reader's own words for a path a link takes into a nested
3194
- // mount's directory: that mount and the path to use there. (#460's
3195
- // own other_mount, a path in that directory, is refused at parse,
3196
- // and so is a link found there before the read.)
3197
- return new WorkspaceImageReadError('other_mount', err.message);
3198
- case 'escape':
3199
- // The core reader's own words: "Symlink escape detected: <path>" for a
3200
- // followed link that resolves outside the mount, and for a link on the
3201
- // way that leads outside a mount that doesn't follow links, which link.
3202
- return new WorkspaceImageReadError('escape', err.message);
3203
- case 'changed':
3204
- return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
3205
- case 'not_found':
3206
- default:
3207
- // The core reader's own words for a file that isn't regular, and for
3208
- // a directory on the way it couldn't ask about.
3209
- if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
3210
- return new WorkspaceImageReadError('not_found', err.message);
3211
- }
3212
- if (err.message.startsWith('File not found')) {
3213
- return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
3214
- }
3215
- if (err.stage === 'realpath') {
3216
- return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
3217
- }
3218
- if (err.stage === 'stat') {
3219
- return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
3220
- }
3221
- return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
3222
- }
3223
- }
3224
3255
  async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
3225
- let opened;
3226
- try {
3227
- opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3228
- if (fileStat.size === 0) {
3229
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3230
- }
3231
- if (fileStat.size > maxSize)
3232
- throw imageTooLarge(maxSize, mountPrefixedPath);
3233
- });
3234
- }
3235
- catch (err) {
3236
- if (err instanceof WorkspaceImageReadError)
3237
- throw err;
3238
- if (err instanceof WorkspaceReadError)
3239
- throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
3240
- if (isErrnoCode(err, 'ENOENT')) {
3241
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3242
- }
3243
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3244
- }
3245
- const { handle, fileStat } = opened;
3256
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3257
+ if (fileStat.size === 0)
3258
+ throw emptyImage(mountPrefixedPath);
3259
+ if (fileStat.size > maxSize)
3260
+ throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
3261
+ });
3246
3262
  try {
3247
3263
  // No more than maxSize is loaded: a file grown past it since the fstat
3248
3264
  // above is refused here, as one already over it was there.
3249
3265
  const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
3250
3266
  if (more)
3251
- throw imageTooLarge(maxSize, mountPrefixedPath);
3267
+ throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
3252
3268
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
3253
3269
  }
3254
3270
  catch (err) {
3255
3271
  if (err instanceof WorkspaceImageReadError)
3256
3272
  throw err;
3257
- if (isErrnoCode(err, 'ENOENT')) {
3258
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3259
- }
3260
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3273
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
3261
3274
  }
3262
3275
  finally {
3263
3276
  await handle.close();
@@ -3289,13 +3302,14 @@ export class WorkspaceModule {
3289
3302
  * downstream agents. And, apart from it, why a write whose bytes reached
3290
3303
  * disk left the file's mode as disk had it (`modeKept`), and, for a
3291
3304
  * deletion that cleared a conflict naming a disk file the workspace never
3292
- * took in, that nothing was unlinked (`note`). The caller holds the mount's
3293
- * turn and has settled the path first (settleBeforeMutation, or a pass over
3294
- * the path, as an edit makes).
3305
+ * took in, that nothing was unlinked (`note`). And the answer's `disk` for
3306
+ * the path, unless it was refused. The caller holds the mount's turn and has
3307
+ * settled the path first (settleBeforeMutation, or a pass over the path, as
3308
+ * an edit makes).
3295
3309
  */
3296
3310
  async commitToolChange(mount, relativePath, change, onCommitted) {
3297
- const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3298
- return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3311
+ const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3312
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
3299
3313
  }
3300
3314
  /**
3301
3315
  * commitToolChange for several paths of one mount at once, as edit_many
@@ -3310,9 +3324,10 @@ export class WorkspaceModule {
3310
3324
  * (changesLanded).
3311
3325
  * Answers, by path, the reason for each that reached the workspace but not
3312
3326
  * disk (`refused`), and, apart from it, why one whose bytes reached disk
3313
- * left the file's mode as disk had it (`modeKept`), and that a deletion
3314
- * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3315
- * commitToolChange answers them for one path.
3327
+ * left the file's mode as disk had it (`modeKept`), that a deletion
3328
+ * clearing a conflict that named a disk file unlinked nothing (`notes`),
3329
+ * and the answer's `disk` for each path not refused, as commitToolChange
3330
+ * answers them for one path.
3316
3331
  */
3317
3332
  async commitToolChanges(mount, changes, onCommitted) {
3318
3333
  const store = this.getStore();
@@ -3461,7 +3476,7 @@ export class WorkspaceModule {
3461
3476
  // nothing.
3462
3477
  if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3463
3478
  putBack();
3464
- throw error;
3479
+ throw changing.length === 0 ? new AlreadyHeld(error) : error;
3465
3480
  }
3466
3481
  landedThenThrew = { error };
3467
3482
  }
@@ -3479,8 +3494,9 @@ export class WorkspaceModule {
3479
3494
  throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3480
3495
  const refused = new Map();
3481
3496
  const modeKept = new Map();
3482
- if (!this.pushesOnCommit(mount) || pushing.length === 0)
3483
- return { refused, modeKept, notes };
3497
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) {
3498
+ return { refused, modeKept, notes, disk: new Map(changes.map(({ path }) => [path, this.diskOf(mount, path)])) };
3499
+ }
3484
3500
  const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3485
3501
  applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
3486
3502
  branchId: store.currentBranch().id,
@@ -3502,7 +3518,7 @@ export class WorkspaceModule {
3502
3518
  error: skipped.reason,
3503
3519
  });
3504
3520
  }
3505
- return { refused, modeKept, notes };
3521
+ return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }) => [path, 'done'])) };
3506
3522
  }
3507
3523
  // ==========================================================================
3508
3524
  // Lazy Sync
@@ -3551,25 +3567,29 @@ export class WorkspaceModule {
3551
3567
  * call made again checks it.
3552
3568
  */
3553
3569
  notCheckedYet(path, reason) {
3554
- return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3570
+ 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.`;
3555
3571
  }
3556
3572
  /** Why the workspace holds no entry at a path, when disk explains it. */
3557
- notInWorkspace(path, report) {
3573
+ notInWorkspace(path, report, mount, missing) {
3574
+ const shown = JSON.stringify(path);
3558
3575
  switch (report?.state) {
3559
3576
  case 'workspace-deleted':
3560
- return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3577
+ return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3561
3578
  case 'not-in-branch':
3562
- return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3563
- case 'disk-only':
3564
- return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
3565
- (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
3579
+ return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3580
+ case 'disk-only': {
3581
+ const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
3582
+ return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
3583
+ + 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
3584
+ + (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
3585
+ }
3566
3586
  case 'conflict':
3567
- return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
3587
+ return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3568
3588
  'Sync this path to take the disk version.';
3569
3589
  case 'unverified':
3570
- return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
3590
+ return uncheckedFile(shown, report.note, report.noteCause ?? {});
3571
3591
  default:
3572
- return `File not found: ${path}`;
3592
+ return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3573
3593
  }
3574
3594
  }
3575
3595
  /**
@@ -3611,15 +3631,15 @@ export class WorkspaceModule {
3611
3631
  // A pass also drops a copy disk made binary or too large, which
3612
3632
  // the disk-only answer describes; any other drop is disk's deletion.
3613
3633
  return {
3614
- error: took('deleted') && report?.state !== 'disk-only'
3615
- ? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3616
- : [this.notInWorkspace(path, report), ...again].join(' '),
3634
+ error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
3635
+ : undecided !== null ? this.notCheckedYet(path, undecided)
3636
+ : this.notInWorkspace(path, report, mount),
3617
3637
  };
3618
3638
  }
3619
- const blob = store.getBlob(entry.blobHash);
3620
- if (!blob)
3621
- return { error: `Blob not found for: ${path}` };
3622
- return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3639
+ const contents = await this.contentsOf(mount, relativePath, path, entry);
3640
+ if ('lost' in contents)
3641
+ return { error: nothingToRead(contents.lost) };
3642
+ return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3623
3643
  });
3624
3644
  }
3625
3645
  /**
@@ -3632,37 +3652,145 @@ export class WorkspaceModule {
3632
3652
  */
3633
3653
  async bytesOnDisk(mount, relativePath, path) {
3634
3654
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3635
- const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3655
+ const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
3636
3656
  let opened;
3637
3657
  try {
3638
3658
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3639
3659
  if (fileStat.size > maxSize)
3640
- throw new Error(over);
3660
+ throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
3641
3661
  });
3642
3662
  }
3643
3663
  catch (err) {
3644
- // A refusal that names only the path says why with its errno, as a
3645
- // failed read below does.
3646
- return { error: containedRefusal(err, path) };
3664
+ if (err instanceof Refusal)
3665
+ return { error: err.message };
3666
+ if (err instanceof WorkspaceReadError)
3667
+ return { error: readRefusal(err, path) };
3668
+ throw err;
3647
3669
  }
3648
3670
  try {
3649
3671
  // No more than the limit is loaded, as for a file over it at its fstat.
3650
3672
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
3651
3673
  if (more)
3652
- return { error: over };
3674
+ return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
3653
3675
  if (looksBinary(bytes)) {
3654
- return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3676
+ 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.'}` };
3655
3677
  }
3656
3678
  return { bytes };
3657
3679
  }
3658
3680
  catch (err) {
3659
- const errno = errnoOf(err);
3660
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3681
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
3661
3682
  }
3662
3683
  finally {
3663
3684
  await opened.handle.close();
3664
3685
  }
3665
3686
  }
3687
+ async contentsOf(mount, relativePath, path, version, what = 'saved', tell = 'now') {
3688
+ const store = this.getStore();
3689
+ const stored = storedBytes(store, version.blobHash);
3690
+ if ('bytes' in stored)
3691
+ return stored;
3692
+ const otherSize = new Error('other size');
3693
+ let found;
3694
+ try {
3695
+ const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3696
+ if (fileStat.size !== version.size)
3697
+ throw otherSize;
3698
+ });
3699
+ try {
3700
+ const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
3701
+ found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
3702
+ }
3703
+ finally {
3704
+ await opened.handle.close();
3705
+ }
3706
+ }
3707
+ catch (err) {
3708
+ found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
3709
+ }
3710
+ const subject = contentsSubject(what, path);
3711
+ const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
3712
+ if ('disk' in found) {
3713
+ const now = storedBytes(store, version.blobHash);
3714
+ if ('bytes' in now)
3715
+ return now;
3716
+ const lost = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
3717
+ if (tell === 'now')
3718
+ this.tellLost(mount.config.name, what, version.blobHash, lost);
3719
+ return { lost };
3720
+ }
3721
+ const { bytes } = found;
3722
+ if ('bytes' in storedBytes(store, version.blobHash))
3723
+ return { bytes };
3724
+ let thrown = null;
3725
+ try {
3726
+ store.storeBlob(bytes, 'application/octet-stream');
3727
+ }
3728
+ catch (error) {
3729
+ thrown = error instanceof Error ? error.message : String(error);
3730
+ }
3731
+ // Chronicle's storeBlob takes any file already at the hash's path as
3732
+ // stored, so whether the bytes took is what a read of them gives now.
3733
+ const now = storedBytes(store, version.blobHash);
3734
+ if ('bytes' in now) {
3735
+ this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
3736
+ }
3737
+ else {
3738
+ const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
3739
+ this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', `Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
3740
+ + ('unreadable' in now
3741
+ ? "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."
3742
+ : 'Each read does this again until the store takes them.'), { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
3743
+ }
3744
+ return { bytes };
3745
+ }
3746
+ tellLost(mountName, what, blobHash, lost) {
3747
+ this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', `Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
3748
+ + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
3749
+ }
3750
+ async diskLacks(mount, relativePath, path, err) {
3751
+ const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
3752
+ if (!absent)
3753
+ return { unreadable: containedRefusal(err, path) };
3754
+ const root = resolve(mount.config.path);
3755
+ const rootReal = await realpath(root).catch(() => root);
3756
+ const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
3757
+ const proof = await confirmAbsent(view, rootReal, relativePath);
3758
+ if (!('reason' in proof))
3759
+ return 'none';
3760
+ return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
3761
+ }
3762
+ async restoreEach(mount, versions, each) {
3763
+ const byHash = new Map();
3764
+ for (const version of versions) {
3765
+ const sharing = byHash.get(version.hash);
3766
+ if (sharing)
3767
+ sharing.push(version);
3768
+ else
3769
+ byHash.set(version.hash, [version]);
3770
+ }
3771
+ for (const [hash, sharing] of byHash) {
3772
+ let bytes = null;
3773
+ const lost = [];
3774
+ for (const version of sharing) {
3775
+ const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
3776
+ if ('bytes' in contents) {
3777
+ bytes = contents.bytes;
3778
+ break;
3779
+ }
3780
+ lost.push([version, contents.lost]);
3781
+ }
3782
+ if (bytes === null) {
3783
+ for (const [version, found] of lost) {
3784
+ this.tellLost(mount.config.name, version.what, hash, found);
3785
+ each(version, { lost: found });
3786
+ }
3787
+ }
3788
+ else {
3789
+ for (const version of sharing)
3790
+ each(version, { bytes });
3791
+ }
3792
+ }
3793
+ }
3666
3794
  // ==========================================================================
3667
3795
  // Tool Handlers
3668
3796
  // ==========================================================================
@@ -3677,16 +3805,18 @@ export class WorkspaceModule {
3677
3805
  // Each number is a whole number of at least its schema's minimum by now
3678
3806
  // (inputProblem).
3679
3807
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
3680
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
3808
+ 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.') };
3681
3809
  }
3682
3810
  let file;
3683
3811
  let notes = [];
3684
3812
  if (generatedContent === undefined) {
3685
3813
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3814
+ if (relativePath === '')
3815
+ return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
3686
3816
  input = { ...input, path };
3687
3817
  const found = await this.bytesOf(mount, relativePath, path);
3688
3818
  if ('error' in found)
3689
- return { success: false, error: found.error, isError: true };
3819
+ return { success: false, error: didNothing('read', found.error), isError: true };
3690
3820
  file = fileText(found.bytes);
3691
3821
  notes = found.notes;
3692
3822
  }
@@ -3702,31 +3832,23 @@ export class WorkspaceModule {
3702
3832
  : linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
3703
3833
  }
3704
3834
  async handleReadImage(input) {
3705
- let mount;
3706
- let relativePath;
3707
- try {
3708
- let path;
3709
- ({ mount, relativePath, path } = await this.parseFilePath(input.path));
3710
- input = { ...input, path };
3711
- }
3712
- catch (err) {
3713
- return {
3714
- success: false,
3715
- error: err instanceof Error ? err.message : String(err),
3716
- isError: true,
3717
- };
3718
- }
3719
- if (!relativePath) {
3720
- return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
3721
- }
3835
+ const given = input.path;
3836
+ const refused = (body) => ({ success: false, isError: true, error: didNothing('read_image', body) });
3837
+ const { mount, relativePath, path } = await this.parseFilePath(given);
3838
+ if (!relativePath)
3839
+ return refused(namesRoot(given, mount.config.name));
3840
+ input = { ...input, path };
3722
3841
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3723
3842
  let treeError = null;
3843
+ let lost = null;
3724
3844
  // The workspace's copy first, where it holds one, then disk's. A direct
3725
3845
  // mount holds none, so its image is disk's.
3726
3846
  if (!mount.direct) {
3727
3847
  try {
3728
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
3729
- if (image)
3848
+ const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
3849
+ if (image && 'lost' in image)
3850
+ lost = image.lost;
3851
+ else if (image)
3730
3852
  return imageResult(input.path, image);
3731
3853
  }
3732
3854
  catch (err) {
@@ -3739,31 +3861,40 @@ export class WorkspaceModule {
3739
3861
  }
3740
3862
  }
3741
3863
  try {
3742
- return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
3864
+ const note = lost === null ? undefined
3865
+ : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
3866
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
3743
3867
  }
3744
3868
  catch (err) {
3745
- if (err instanceof WorkspaceImageReadError) {
3746
- if (err.code === 'not_found' && treeError) {
3747
- return { success: false, error: treeError.message, isError: true };
3748
- }
3749
- return { success: false, error: err.message, isError: true };
3869
+ if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError))
3870
+ throw err;
3871
+ const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
3872
+ if (lost) {
3873
+ const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
3874
+ return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
3875
+ : `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
3750
3876
  }
3751
- throw err;
3877
+ if (noFile && treeError)
3878
+ return refused(treeError.message);
3879
+ return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
3752
3880
  }
3753
3881
  }
3754
3882
  async handleWrite(input, callerAgentName) {
3755
3883
  if (LONE_SURROGATE.test(input.content)) {
3756
- return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
3884
+ 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.' };
3757
3885
  }
3758
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
3886
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
3887
+ if (relativePath === '')
3888
+ return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
3759
3889
  input = { ...input, path };
3760
- const refusal = writeRefusal(mount);
3890
+ const refusal = writeRefusal(mount, this.writableNames());
3761
3891
  if (refusal)
3762
- return { success: false, error: refusal, isError: true };
3892
+ return { success: false, error: didNothing('write', refusal), isError: true };
3763
3893
  const store = this.getStore();
3764
3894
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3765
- if (Buffer.byteLength(input.content) > maxSize) {
3766
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
3895
+ const bytes = Buffer.byteLength(input.content);
3896
+ if (bytes > maxSize) {
3897
+ return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
3767
3898
  }
3768
3899
  const buffer = Buffer.from(input.content, 'utf-8');
3769
3900
  const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
@@ -3772,13 +3903,14 @@ export class WorkspaceModule {
3772
3903
  const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
3773
3904
  if ('error' in written)
3774
3905
  return { success: false, isError: true, error: written.error };
3775
- return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
3906
+ return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
3776
3907
  }
3777
3908
  let committed = false;
3778
3909
  let refused;
3779
3910
  let modeKept;
3911
+ let disk;
3780
3912
  try {
3781
- ({ refused, modeKept } = await this.withMount(mount, async () => {
3913
+ ({ refused, modeKept, disk } = await this.withMount(mount, async () => {
3782
3914
  await this.settleBeforeMutation(mount, relativePath);
3783
3915
  const blobHash = store.storeBlob(buffer, 'text/plain');
3784
3916
  // An overwrite keeps the file's mode, as an edit does.
@@ -3788,14 +3920,14 @@ export class WorkspaceModule {
3788
3920
  }
3789
3921
  catch (error) {
3790
3922
  if (!committed)
3791
- throw error;
3923
+ throw notStored(error);
3792
3924
  return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
3793
3925
  ? { storeFailed: error.message, pushes: error.pushes }
3794
- : { threw: error instanceof Error ? error.message : String(error) });
3926
+ : { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
3795
3927
  }
3796
3928
  if (refused !== null)
3797
3929
  return storedButUnfinished('write', input.path, { refused });
3798
- return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
3930
+ return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
3799
3931
  }
3800
3932
  async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3801
3933
  return this.applyEdits('edit', [{
@@ -3835,12 +3967,14 @@ export class WorkspaceModule {
3835
3967
  * stored where its record landed, and that it can't tell where they
3836
3968
  * couldn't be read back.
3837
3969
  * Under the mount's turn, one pass settles every path with disk, by the
3838
- * rule a listing uses, so the edits apply to what disk now holds. That pass
3839
- * is the only step that awaits before the commit: every file is then read,
3840
- * every edit planned, and every entry set (commitToolChanges) with no turn
3841
- * of the event loop between, so no pass, branch change or other call moves
3842
- * the tree between a file's read and its set. Disk then takes what it can
3843
- * of the stored change.
3970
+ * rule a listing uses, so the edits apply to what disk now holds. Each
3971
+ * file's stored contents are then read; where the store has lost some,
3972
+ * they're restored from disk's matching copy (contentsOf) and the pass runs
3973
+ * again, so the plan reads a pass taken after every await. From there
3974
+ * nothing awaits before the commit: every edit is planned and every entry
3975
+ * set (commitToolChanges) with no turn of the event loop between, so no
3976
+ * pass, branch change or other call moves the tree between a file's read
3977
+ * and its set. Disk then takes what it can of the stored change.
3844
3978
  *
3845
3979
  * The answer is held to `maxChars`, the length the call's result may have
3846
3980
  * (ToolCall.resultMaxChars), as edit-text.ts describes.
@@ -3875,7 +4009,7 @@ export class WorkspaceModule {
3875
4009
  }
3876
4010
  for (const field of ['oldString', 'newString']) {
3877
4011
  if (LONE_SURROGATE.test(edit[field])) {
3878
- problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
4012
+ 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.` });
3879
4013
  }
3880
4014
  }
3881
4015
  if (problems.length === found)
@@ -3937,7 +4071,31 @@ export class WorkspaceModule {
3937
4071
  return await this.withMount(mount, async () => {
3938
4072
  // As every file tool's pass does, it names the paths it acts on, so a
3939
4073
  // file the mount passes over by design is observed (see PassOptions.named).
3940
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4074
+ let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4075
+ let ops = pass.ops;
4076
+ const contents = new Map();
4077
+ const lost = new Map();
4078
+ const toRecover = [];
4079
+ for (const target of targets.values()) {
4080
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4081
+ if (!entry || contents.has(entry.blobHash))
4082
+ continue;
4083
+ const stored = storedBytes(store, entry.blobHash);
4084
+ if ('bytes' in stored)
4085
+ contents.set(entry.blobHash, stored.bytes);
4086
+ else
4087
+ toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
4088
+ }
4089
+ if (toRecover.length > 0) {
4090
+ await this.restoreEach(mount, toRecover, (version, found) => {
4091
+ if ('bytes' in found)
4092
+ contents.set(version.hash, found.bytes);
4093
+ else
4094
+ lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
4095
+ });
4096
+ pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4097
+ ops = [...ops, ...pass.ops];
4098
+ }
3941
4099
  // A pass that decided nothing checked no file's disk copy.
3942
4100
  const undecided = undecidedOf(pass);
3943
4101
  // Nothing below awaits until the commit's push.
@@ -3957,7 +4115,7 @@ export class WorkspaceModule {
3957
4115
  // A mode taken in alone isn't a change to the text: it brings nothing
3958
4116
  // new to read, as it makes no event (emitFsEvents), and the edits
3959
4117
  // then write at the mode disk has, so it leaves nothing to say.
3960
- const took = (op) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4118
+ const took = (op) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
3961
4119
  // Which copy the edits used, when it isn't the one the workspace
3962
4120
  // last agreed on with disk: disk's newer text, or the workspace's
3963
4121
  // copy because disk's couldn't be read or disk has none. A file the
@@ -4021,18 +4179,28 @@ export class WorkspaceModule {
4021
4179
  // A pass also drops a copy disk made binary or too large, which
4022
4180
  // the disk-only answer describes; any other drop is disk's deletion.
4023
4181
  const text = took('deleted') && report?.state !== 'disk-only'
4024
- ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
4025
- : this.notInWorkspace(target.path, report);
4026
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
4182
+ ? deletedOnDisk(target.path)
4183
+ : this.notInWorkspace(target.path, report, mount);
4184
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
4027
4185
  continue;
4028
4186
  }
4029
- notes.push(...lookedIn);
4030
- const blob = store.getBlob(entry.blobHash);
4031
- if (!blob) {
4032
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4187
+ const known = contents.get(entry.blobHash);
4188
+ const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
4189
+ if (!('bytes' in stored)) {
4190
+ const found = lost.get(target.relativePath);
4191
+ problems.push({
4192
+ kind: 'file',
4193
+ edits: target.edits,
4194
+ path: target.path,
4195
+ text: found?.blobHash === entry.blobHash
4196
+ ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
4197
+ : `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.`,
4198
+ whole: true,
4199
+ });
4033
4200
  continue;
4034
4201
  }
4035
- const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
4202
+ notes.push(...lookedIn);
4203
+ const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
4036
4204
  if (plan === null)
4037
4205
  continue;
4038
4206
  plans.push({
@@ -4048,7 +4216,7 @@ export class WorkspaceModule {
4048
4216
  }
4049
4217
  if (problems.length)
4050
4218
  return refuse(problems, notes);
4051
- const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4219
+ const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4052
4220
  path: target.relativePath,
4053
4221
  change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4054
4222
  })), () => { committed = true; });
@@ -4059,7 +4227,7 @@ export class WorkspaceModule {
4059
4227
  plan,
4060
4228
  notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
4061
4229
  modeKept: modeKept.get(target.relativePath) ?? null,
4062
- })), given.length, maxChars);
4230
+ })), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath))));
4063
4231
  });
4064
4232
  }
4065
4233
  catch (error) {
@@ -4067,12 +4235,12 @@ export class WorkspaceModule {
4067
4235
  if (committed) {
4068
4236
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
4069
4237
  ? { storeFailed: message, pushes: error.pushes }
4070
- : { threw: message }, maxChars);
4238
+ : { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
4071
4239
  }
4072
4240
  // A set that threw and couldn't be read back may have stored the edits,
4073
4241
  // and a second call could then apply them twice.
4074
4242
  if (error instanceof CommitUnknownError) {
4075
- const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4243
+ const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
4076
4244
  return {
4077
4245
  success: false,
4078
4246
  isError: true,
@@ -4085,10 +4253,8 @@ export class WorkspaceModule {
4085
4253
  }
4086
4254
  // The commit sets every entry in one record or none, and a set that
4087
4255
  // threw was read back, so nothing is stored; or every change was one
4088
- // the tree already held, which leaves the tree as it was either way.
4089
- if (tool === 'edit_many')
4090
- return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
4091
- throw error;
4256
+ // the tree already held (AlreadyHeld).
4257
+ throw notStored(error);
4092
4258
  }
4093
4259
  }
4094
4260
  /**
@@ -4101,7 +4267,7 @@ export class WorkspaceModule {
4101
4267
  * (handleToolCall checks a call's `path`, which edit_many's input lacks).
4102
4268
  */
4103
4269
  async editablePath(tool, path, callerAgentName) {
4104
- const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: true });
4270
+ const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: false });
4105
4271
  try {
4106
4272
  // In the one spelling every file tool reads, as disk spells it, so two
4107
4273
  // spellings of a file are one entry and, in one call, one plan
@@ -4111,17 +4277,21 @@ export class WorkspaceModule {
4111
4277
  // One that names the mount's root, its bare name included, names no
4112
4278
  // file.
4113
4279
  if (where.relativePath === '')
4114
- 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.`);
4115
- const refusal = writeRefusal(where.mount);
4280
+ return unusable(namesRoot(path, where.mount.config.name));
4281
+ const refusal = writeRefusal(where.mount, this.writableNames());
4116
4282
  if (refusal)
4117
4283
  return unusable(refusal);
4118
4284
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4119
4285
  if (generated)
4120
- return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4286
+ return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
4121
4287
  return where;
4122
4288
  }
4123
4289
  catch (error) {
4124
- return unusable(error instanceof Error ? error.message : String(error));
4290
+ if (error instanceof Refusal)
4291
+ return unusable(error.message);
4292
+ if (error instanceof WorkspaceReadError)
4293
+ return unusable(readRefusal(error, path));
4294
+ throw notStored(error);
4125
4295
  }
4126
4296
  }
4127
4297
  /**
@@ -4129,10 +4299,10 @@ export class WorkspaceModule {
4129
4299
  * and the agent's own is read-only, since it shows the agent's tool
4130
4300
  * presentation, which set_tool_visibility and set_tool_description change.
4131
4301
  */
4132
- generatedFileRefusal(generated, callerAgentName) {
4302
+ generatedFileRefusal(path, generated, callerAgentName) {
4133
4303
  return callerAgentName !== generated.agentName
4134
- ? 'Generated file belongs to another agent'
4135
- : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4304
+ ? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
4305
+ : `${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.`;
4136
4306
  }
4137
4307
  /**
4138
4308
  * The answer to edits stored but not finished: the commit set every file's
@@ -4148,12 +4318,12 @@ export class WorkspaceModule {
4148
4318
  * which stand as on a success, it names each conflict the success answer
4149
4319
  * would (`conflicts`, found only where the mount doesn't push).
4150
4320
  */
4151
- editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
4321
+ editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars, reopen = false) {
4152
4322
  const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4153
4323
  if (tool === 'edit') {
4154
4324
  const [target] = targets;
4155
4325
  const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
4156
- return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
4326
+ return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null, reopen);
4157
4327
  }
4158
4328
  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.";
4159
4329
  // The head is never cut, so what stands for the whole call goes in it,
@@ -4176,10 +4346,11 @@ export class WorkspaceModule {
4176
4346
  }
4177
4347
  else if ('storeFailed' in outcome) {
4178
4348
  head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4179
- + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4349
+ + (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
4350
+ + (reopen ? ` ${AWAITS_REOPEN}` : '');
4180
4351
  }
4181
4352
  else {
4182
- head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4353
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
4183
4354
  }
4184
4355
  const kept = 'modeKept' in outcome
4185
4356
  ? targets.flatMap((target) => {
@@ -4213,7 +4384,7 @@ export class WorkspaceModule {
4213
4384
  staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4214
4385
  }
4215
4386
  catch (error) {
4216
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4387
+ return { error: directDidNothing('write', path, error) };
4217
4388
  }
4218
4389
  try {
4219
4390
  await directWriteTestSeam.afterStage?.([staged.path]);
@@ -4228,7 +4399,7 @@ export class WorkspaceModule {
4228
4399
  }
4229
4400
  catch (error) {
4230
4401
  await discard(staged);
4231
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4402
+ return { error: directDidNothing('write', path, error) };
4232
4403
  }
4233
4404
  this.recordDirectWrite(mount, agentName);
4234
4405
  return { note: linksNote(path, staged, 'write') };
@@ -4288,7 +4459,7 @@ export class WorkspaceModule {
4288
4459
  return 'changed';
4289
4460
  }
4290
4461
  if ('error' in got)
4291
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
4462
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
4292
4463
  else
4293
4464
  read.set(target, got);
4294
4465
  }
@@ -4358,7 +4529,8 @@ export class WorkspaceModule {
4358
4529
  edits: target.edits,
4359
4530
  path: target.path,
4360
4531
  text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4361
- + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
4532
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
4533
+ + (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
4362
4534
  whole: true,
4363
4535
  }]);
4364
4536
  for (const { target, plan } of plans) {
@@ -4428,7 +4600,7 @@ export class WorkspaceModule {
4428
4600
  plan: plans.find((p) => p.target === target).plan,
4429
4601
  notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
4430
4602
  modeKept: null,
4431
- })), editCount, maxChars);
4603
+ })), editCount, maxChars, 'done');
4432
4604
  }
4433
4605
  /**
4434
4606
  * The answer to an edit_many call on a direct mount whose rename of its
@@ -4440,7 +4612,8 @@ export class WorkspaceModule {
4440
4612
  directEditsPartway(staged, failed, error, maxChars) {
4441
4613
  // The failed file is named among the outcomes, so the head stays as
4442
4614
  // long as its reason, whatever the paths' lengths.
4443
- const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
4615
+ const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
4616
+ 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}.` : ''} `
4444
4617
  + "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:";
4445
4618
  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'}`);
4446
4619
  const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
@@ -4457,33 +4630,37 @@ export class WorkspaceModule {
4457
4630
  */
4458
4631
  async directEditText(mount, relativePath, path) {
4459
4632
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4460
- const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
4633
+ const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
4461
4634
  let opened;
4462
4635
  try {
4463
4636
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4464
4637
  if (fileStat.size > maxSize)
4465
- throw new Error(over);
4638
+ throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
4466
4639
  });
4467
4640
  }
4468
4641
  catch (err) {
4469
4642
  if (err instanceof WorkspaceReadError && err.code === 'changed')
4470
4643
  return { changed: true };
4471
- return { error: containedRefusal(err, path) };
4644
+ if (err instanceof Refusal)
4645
+ return { error: err.message };
4646
+ if (err instanceof WorkspaceReadError)
4647
+ return { error: readRefusal(err, path) };
4648
+ throw err;
4472
4649
  }
4473
4650
  try {
4474
4651
  const before = identityOf(await opened.handle.stat({ bigint: true }));
4475
4652
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4476
4653
  if (more)
4477
- return { error: over };
4478
- if (looksBinary(bytes))
4479
- return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
4654
+ return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4655
+ if (looksBinary(bytes)) {
4656
+ 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.` };
4657
+ }
4480
4658
  if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
4481
4659
  return { changed: true };
4482
4660
  return { bytes, identity: before };
4483
4661
  }
4484
4662
  catch (err) {
4485
- const errno = errnoOf(err);
4486
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4663
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4487
4664
  }
4488
4665
  finally {
4489
4666
  await opened.handle.close();
@@ -4512,11 +4689,9 @@ export class WorkspaceModule {
4512
4689
  catch (err) {
4513
4690
  const code = errnoOf(err);
4514
4691
  const why = code === 'ENOENT' || code === 'ENOTDIR'
4515
- ? `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`
4516
- : code === 'EACCES' || code === 'EPERM'
4517
- ? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
4518
- : `the mount root can't be read (${code ?? String(err)})`;
4519
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
4692
+ ? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
4693
+ : `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
4694
+ return { success: false, isError: true, error: didNothing('delete', why, path) };
4520
4695
  }
4521
4696
  const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4522
4697
  const { rootReal } = disk;
@@ -4526,10 +4701,12 @@ export class WorkspaceModule {
4526
4701
  }
4527
4702
  catch (err) {
4528
4703
  // The open's own words for a replaced root speak of reading.
4529
- if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
4530
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
4704
+ if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
4705
+ return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
4531
4706
  }
4532
- return { success: false, isError: true, error: containedRefusal(err, path) };
4707
+ if (err instanceof WorkspaceReadError)
4708
+ return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
4709
+ throw err;
4533
4710
  }
4534
4711
  // The unlink is awaited, unlike a write's rename, so where the head
4535
4712
  // was just before it says whether the head moved while it ran.
@@ -4542,12 +4719,12 @@ export class WorkspaceModule {
4542
4719
  // whether the file is gone, so it's taken as the agent's write too.
4543
4720
  if (error instanceof EffectFailed && error.touched) {
4544
4721
  this.recordDirectWrite(mount, agentName, since);
4545
- 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.` };
4722
+ 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.` };
4546
4723
  }
4547
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
4724
+ return { success: false, isError: true, error: directDidNothing('delete', path, error) };
4548
4725
  }
4549
4726
  this.recordDirectWrite(mount, agentName, since);
4550
- return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
4727
+ return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
4551
4728
  });
4552
4729
  }
4553
4730
  /**
@@ -4695,11 +4872,13 @@ export class WorkspaceModule {
4695
4872
  this.historyLookup = lookup;
4696
4873
  }
4697
4874
  async handleDelete(input, callerAgentName) {
4698
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4875
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
4876
+ if (relativePath === '')
4877
+ return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
4699
4878
  input = { ...input, path };
4700
- const refusal = writeRefusal(mount);
4879
+ const refusal = writeRefusal(mount, this.writableNames());
4701
4880
  if (refusal)
4702
- return { success: false, error: refusal, isError: true };
4881
+ return { success: false, error: didNothing('delete', refusal), isError: true };
4703
4882
  if (mount.direct)
4704
4883
  return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
4705
4884
  const store = this.getStore();
@@ -4710,31 +4889,31 @@ export class WorkspaceModule {
4710
4889
  await this.settleBeforeMutation(mount, relativePath);
4711
4890
  const entry = store.treeGet(mount.treeStateId, relativePath);
4712
4891
  if (!entry) {
4713
- const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4714
- return { success: false, error, isError: true };
4892
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
4893
+ return { success: false, error: didNothing('delete', error), isError: true };
4715
4894
  }
4716
- const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
4895
+ const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
4717
4896
  if (refused !== null)
4718
4897
  return storedButUnfinished('delete', input.path, { refused });
4719
4898
  // Its whole receipt is what it changed: kept in the stub if the guard
4720
4899
  // withholds the result (agent-framework #277).
4721
4900
  return {
4722
4901
  success: true,
4723
- data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
4724
- keepWhenWithheld: ['path', 'deleted', 'note'],
4902
+ data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
4903
+ keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
4725
4904
  };
4726
4905
  });
4727
4906
  }
4728
4907
  catch (error) {
4729
4908
  if (!committed)
4730
- throw error;
4909
+ throw notStored(error);
4731
4910
  if (error instanceof StoredThenFailed) {
4732
4911
  // Its note, as the success answer's: a deletion that cleared a
4733
4912
  // conflict naming a disk file unlinked nothing.
4734
4913
  const note = error.notes.get(relativePath);
4735
- return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
4914
+ return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
4736
4915
  }
4737
- return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
4916
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
4738
4917
  }
4739
4918
  }
4740
4919
  async handleLs(input) {
@@ -4758,7 +4937,7 @@ export class WorkspaceModule {
4758
4937
  // A path outside the mount's include that's no scope (refusesAsOutside)
4759
4938
  // is refused, as glob refuses it, and shown as given.
4760
4939
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4761
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4940
+ return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4762
4941
  }
4763
4942
  input = { ...input, path };
4764
4943
  const recursive = input.recursive === true;
@@ -4767,6 +4946,9 @@ export class WorkspaceModule {
4767
4946
  // shows disk's.
4768
4947
  const listing = await this.listingOf(mount, relativePath, recursive);
4769
4948
  const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
4949
+ const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
4950
+ const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
4951
+ const noted = note !== null ? { note } : {};
4770
4952
  if (recursive) {
4771
4953
  return {
4772
4954
  success: true,
@@ -4775,6 +4957,7 @@ export class WorkspaceModule {
4775
4957
  entries: listing.files,
4776
4958
  count: listing.files.length,
4777
4959
  ...incomplete,
4960
+ ...noted,
4778
4961
  },
4779
4962
  };
4780
4963
  }
@@ -4791,9 +4974,34 @@ export class WorkspaceModule {
4791
4974
  entries: children,
4792
4975
  count: children.length,
4793
4976
  ...incomplete,
4977
+ ...noted,
4794
4978
  },
4795
4979
  };
4796
4980
  }
4981
+ async emptyScopeNote(mount, relativePath, path) {
4982
+ const shown = JSON.stringify(path);
4983
+ const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
4984
+ const { view, rootReal } = await this.diskView(mount);
4985
+ const seen = await observePath(view, rootReal, relativePath);
4986
+ if (seen.kind === 'file')
4987
+ return isFile;
4988
+ if (seen.kind !== 'absent')
4989
+ return null;
4990
+ if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null)
4991
+ return isFile;
4992
+ return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
4993
+ }
4994
+ async absentOnDisk(mount, relativePath) {
4995
+ const { view, rootReal } = await this.diskView(mount);
4996
+ return (await observePath(view, rootReal, relativePath)).kind === 'absent';
4997
+ }
4998
+ holdsAnythingAt(mount, relativePath) {
4999
+ if (mount.direct)
5000
+ return false;
5001
+ const store = this.getStore();
5002
+ const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree) => tree !== undefined);
5003
+ return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
5004
+ }
4797
5005
  /**
4798
5006
  * What a listing shows of a directory scope, from where the mount keeps
4799
5007
  * what it reads: the store-backed kind's pass (listingFromStore), or, on a
@@ -4870,7 +5078,8 @@ export class WorkspaceModule {
4870
5078
  .filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
4871
5079
  .map((region) => ({
4872
5080
  path: region.path,
4873
- reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved) : region.reason,
5081
+ reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
5082
+ : explained(region.reason, region.errno !== undefined ? { errno: region.errno } : {}, 'list'),
4874
5083
  }));
4875
5084
  return {
4876
5085
  view,
@@ -4895,7 +5104,7 @@ export class WorkspaceModule {
4895
5104
  if (input.path) {
4896
5105
  const { mount, relativePath, moved, root } = mountsToSearch[0];
4897
5106
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4898
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5107
+ return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4899
5108
  }
4900
5109
  }
4901
5110
  // Every match lies beneath the pattern's literal directory, so the walk
@@ -4916,7 +5125,7 @@ export class WorkspaceModule {
4916
5125
  if (prefixed) {
4917
5126
  const dir = deeperOf(relativePath, prefixed.dir);
4918
5127
  if (dir === null) {
4919
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5128
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
4920
5129
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4921
5130
  continue;
4922
5131
  }
@@ -4952,8 +5161,16 @@ export class WorkspaceModule {
4952
5161
  regex = new RegExp(input.pattern);
4953
5162
  }
4954
5163
  catch (e) {
4955
- // The parser's reason says where the pattern goes wrong.
4956
- return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
5164
+ // V8's message ends with the parser's reason, after the pattern: "Invalid
5165
+ // regular expression: /foo(/: Unterminated group".
5166
+ const message = e instanceof Error ? e.message : String(e);
5167
+ const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
5168
+ return {
5169
+ success: false,
5170
+ isError: true,
5171
+ error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
5172
+ + 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
5173
+ };
4957
5174
  }
4958
5175
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4959
5176
  // The filter matches mount-relative paths, from the mount's root whatever
@@ -4979,7 +5196,7 @@ export class WorkspaceModule {
4979
5196
  if (input.path) {
4980
5197
  const { mount, relativePath, moved, root } = mountsToSearch[0];
4981
5198
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4982
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5199
+ return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4983
5200
  }
4984
5201
  }
4985
5202
  const results = [];
@@ -5002,10 +5219,6 @@ export class WorkspaceModule {
5002
5219
  const search = (text) => {
5003
5220
  const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5004
5221
  const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
5005
- if (lineEndings !== undefined && !crlfFiles.has(text.file))
5006
- crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
5007
- if (bom && !bomFiles.has(text.file))
5008
- bomFiles.set(text.file, text.version);
5009
5222
  const found = [];
5010
5223
  for (let i = 0; i < lines.length; i++) {
5011
5224
  if (regex.test(lines[i])) {
@@ -5018,14 +5231,14 @@ export class WorkspaceModule {
5018
5231
  found.push(match);
5019
5232
  }
5020
5233
  }
5021
- return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
5234
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
5022
5235
  };
5023
5236
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
5024
5237
  // A path named in another mount's directory, as holderOf resolves it,
5025
5238
  // is that mount's: grep searches nothing of this mount's there, and
5026
5239
  // says whose it is. A direct mount's is judged by the nesting its parse
5027
5240
  // found where its root lies now (movedNesting).
5028
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5241
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5029
5242
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5030
5243
  continue;
5031
5244
  }
@@ -5044,16 +5257,23 @@ export class WorkspaceModule {
5044
5257
  }
5045
5258
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
5046
5259
  const searched = (text) => {
5047
- const hits = search(text);
5048
- if (hits.matches.length > 0)
5049
- results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
5260
+ const { bom, ...hits } = search(text);
5261
+ const { file, state, version } = text;
5262
+ return () => {
5263
+ if (hits.lineEndings !== undefined && !crlfFiles.has(file))
5264
+ crlfFiles.set(file, { ends: hits.lineEndings, version });
5265
+ if (bom && !bomFiles.has(file))
5266
+ bomFiles.set(file, version);
5267
+ if (hits.matches.length > 0)
5268
+ results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
5269
+ };
5050
5270
  };
5051
5271
  const kept = prefixed
5052
5272
  ? (path) => prefixed.regex.test(path)
5053
5273
  : (path) => fileGlob === null || fileGlob.test(path)
5054
5274
  || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
5055
5275
  const found = mount.direct
5056
- ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
5276
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
5057
5277
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
5058
5278
  skipped.push(...found.skipped);
5059
5279
  incomplete.push(...found.incomplete);
@@ -5102,12 +5322,13 @@ export class WorkspaceModule {
5102
5322
  /**
5103
5323
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
5104
5324
  * single file `relativePath` names, those `keep` lets through, each handed
5105
- * to `each` in path order: a pass over the scope, then each path's
5106
- * workspace copy, labelled with its state, and a conflict's disk side.
5325
+ * to `search`, whose answers are recorded in path order: a pass over the
5326
+ * scope, then each path's workspace copy, labelled with its state, and a
5327
+ * conflict's disk side.
5107
5328
  * What it doesn't search is in `skipped`, and what it couldn't see in
5108
5329
  * `incomplete`, each mount-prefixed.
5109
5330
  */
5110
- async textsFromStore(mount, relativePath, start, keep, each) {
5331
+ async textsFromStore(mount, relativePath, start, keep, search) {
5111
5332
  const store = this.getStore();
5112
5333
  const skipped = [];
5113
5334
  const incomplete = [];
@@ -5160,17 +5381,18 @@ export class WorkspaceModule {
5160
5381
  incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
5161
5382
  }
5162
5383
  const intents = this.intents.get(mount.config.name);
5384
+ const found = [];
5163
5385
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
5164
5386
  if (!keep(report.path))
5165
5387
  continue;
5166
5388
  const file = `${mount.config.name}/${report.path}`;
5167
5389
  if (report.state === 'disk-only') {
5168
- skipped.push({
5169
- file,
5170
- reason: 'disk-only: binary or over the size limit, never stored',
5171
- ...(report.size !== undefined ? { size: report.size } : {}),
5172
- ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5173
- });
5390
+ found.push({ skip: {
5391
+ file,
5392
+ reason: 'disk-only: binary or over the size limit, never stored',
5393
+ ...(report.size !== undefined ? { size: report.size } : {}),
5394
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5395
+ } });
5174
5396
  continue;
5175
5397
  }
5176
5398
  // A path in another mount's directory is that mount's: grep gives
@@ -5180,17 +5402,16 @@ export class WorkspaceModule {
5180
5402
  continue;
5181
5403
  const refusal = refused.get(report.path);
5182
5404
  if (refusal !== undefined) {
5183
- skipped.push({ file, reason: refusal });
5405
+ found.push({ skip: { file, reason: refusal } });
5184
5406
  continue;
5185
5407
  }
5186
5408
  // The workspace's version, labelled with the path's state.
5187
5409
  const entry = store.treeGet(mount.treeStateId, report.path);
5188
- const blob = entry ? store.getBlob(entry.blobHash) : null;
5189
- if (blob) {
5190
- each({ file, state: report.state, version: 'workspace', bytes: blob });
5191
- }
5192
- else if (entry) {
5193
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
5410
+ if (entry) {
5411
+ const stored = storedBytes(store, entry.blobHash);
5412
+ found.push('bytes' in stored
5413
+ ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
5414
+ : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
5194
5415
  }
5195
5416
  if (report.state !== 'conflict')
5196
5417
  continue;
@@ -5201,22 +5422,40 @@ export class WorkspaceModule {
5201
5422
  // disk version was never stored either.
5202
5423
  const disk = intents.get(report.path)?.conflict?.disk;
5203
5424
  const changed = report.conflict?.diskChangedSinceRecorded === true;
5204
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
5205
- if (diskBlob) {
5206
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
5207
- }
5208
- if (disk?.stored && !diskBlob) {
5209
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
5425
+ if (disk?.stored) {
5426
+ const version = changed ? 'recorded-disk' : 'conflicting-disk';
5427
+ const stored = storedBytes(store, disk.stored);
5428
+ found.push('bytes' in stored
5429
+ ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
5430
+ : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
5210
5431
  }
5211
5432
  if (changed) {
5212
5433
  if (report.diskNow === 'file') {
5213
- 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' });
5434
+ 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' } });
5214
5435
  }
5215
5436
  }
5216
5437
  else if (disk && !disk.stored) {
5217
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
5438
+ 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 } });
5218
5439
  }
5219
5440
  }
5441
+ const answers = new Map();
5442
+ await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
5443
+ const { file, state, version, what } = lost;
5444
+ answers.set(lost, 'bytes' in contents
5445
+ ? { searched: search({ file, state, version, bytes: contents.bytes }) }
5446
+ : { skip: {
5447
+ file,
5448
+ reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
5449
+ + `and ${lostOnDisk(contents.lost)}, so it was not searched`,
5450
+ } });
5451
+ });
5452
+ for (const item of found) {
5453
+ const answer = 'lost' in item ? answers.get(item.lost) : item;
5454
+ if ('searched' in answer)
5455
+ answer.searched();
5456
+ else
5457
+ skipped.push(answer.skip);
5458
+ }
5220
5459
  });
5221
5460
  return { skipped, incomplete };
5222
5461
  }
@@ -5261,7 +5500,7 @@ export class WorkspaceModule {
5261
5500
  const store = this.getStore();
5262
5501
  const status = {};
5263
5502
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
5264
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5503
+ return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5265
5504
  }
5266
5505
  for (const [name, mount] of this.mounts) {
5267
5506
  if (input.mount !== undefined && name !== input.mount)
@@ -5403,9 +5642,6 @@ export class WorkspaceModule {
5403
5642
  */
5404
5643
  entriesByEvidence(mount) {
5405
5644
  const store = this.getStore();
5406
- const agreement = this.agreementOrThrow();
5407
- const name = mount.config.name;
5408
- const intents = this.intents.get(name);
5409
5645
  const owed = [];
5410
5646
  const unchecked = [];
5411
5647
  let agreed = 0;
@@ -5415,20 +5651,37 @@ export class WorkspaceModule {
5415
5651
  // in it: neither owed to disk nor counted as on it.
5416
5652
  if (!insideMount(e.path, mount.config.include) || !own(e.path))
5417
5653
  continue;
5418
- const p = agreement.get(name, e.path);
5419
- if (p === undefined)
5420
- (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
5421
- else if (p.kind !== 'content' || p.hash !== e.blobHash)
5422
- owed.push(e.path);
5423
- // A mode the store records, other than the one disk last agreed with,
5424
- // where modes are observed: elsewhere no push applies one.
5425
- else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
5654
+ const kind = this.evidenceOf(mount, e);
5655
+ if (kind === 'owed')
5426
5656
  owed.push(e.path);
5657
+ else if (kind === 'unchecked')
5658
+ unchecked.push(e.path);
5427
5659
  else
5428
5660
  agreed++;
5429
5661
  }
5430
5662
  return { owed, unchecked, agreed };
5431
5663
  }
5664
+ evidenceOf(mount, e) {
5665
+ const p = this.agreementOrThrow().get(mount.config.name, e.path);
5666
+ if (p === undefined)
5667
+ return this.intents.get(mount.config.name).get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
5668
+ if (p.kind !== 'content' || p.hash !== e.blobHash)
5669
+ return 'owed';
5670
+ // A mode the store records, other than the one disk last agreed with,
5671
+ // where modes are observed: elsewhere no push applies one.
5672
+ if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
5673
+ return 'owed';
5674
+ return 'agreed';
5675
+ }
5676
+ diskOf(mount, relativePath) {
5677
+ const intent = this.intents.get(mount.config.name).get(relativePath);
5678
+ if (intent?.conflict)
5679
+ return 'in conflict with disk';
5680
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
5681
+ if (!entry)
5682
+ return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
5683
+ return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
5684
+ }
5432
5685
  async handleMaterialize(input) {
5433
5686
  const store = this.getStore();
5434
5687
  const allWritten = [];
@@ -5436,24 +5689,27 @@ export class WorkspaceModule {
5436
5689
  const pendingDeletions = [];
5437
5690
  const modesKept = [];
5438
5691
  let explicit = null;
5439
- if (input.path) {
5692
+ if (input.path !== undefined) {
5693
+ if (input.path === '') {
5694
+ 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 };
5695
+ }
5440
5696
  const parsed = await this.parsePath(input.path);
5441
5697
  explicit = parsed;
5442
5698
  // A path outside the mount's include that's no scope (refusesAsOutside)
5443
5699
  // is refused; beneath a directory, the push leaves out what the include
5444
5700
  // doesn't cover.
5445
5701
  if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
5446
- return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
5702
+ return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
5447
5703
  }
5448
5704
  }
5449
5705
  let mountsToMaterialize;
5450
5706
  if (explicit) {
5451
5707
  mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
5452
5708
  }
5453
- else if (input.mount) {
5709
+ else if (input.mount !== undefined) {
5454
5710
  const m = this.mounts.get(input.mount);
5455
5711
  if (!m) {
5456
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5712
+ 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 };
5457
5713
  }
5458
5714
  mountsToMaterialize = [{ name: input.mount, mount: m }];
5459
5715
  }
@@ -5567,15 +5823,20 @@ export class WorkspaceModule {
5567
5823
  if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
5568
5824
  return {
5569
5825
  success: false,
5570
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
5826
+ error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
5571
5827
  isError: true,
5572
5828
  };
5573
5829
  }
5830
+ const held = explicit;
5831
+ const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
5832
+ && allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
5833
+ && !this.holdsAnythingAt(held.mount, held.relativePath);
5574
5834
  return {
5575
5835
  success: true,
5576
5836
  data: {
5577
5837
  materialized: allWritten,
5578
5838
  count: allWritten.length,
5839
+ ...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
5579
5840
  ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
5580
5841
  ...(pendingDeletions.length > 0
5581
5842
  ? {
@@ -5637,24 +5898,32 @@ export class WorkspaceModule {
5637
5898
  const allIgnored = [];
5638
5899
  const rootsAccepted = [];
5639
5900
  if (input.acceptRoot && input.path) {
5640
- return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
5901
+ const named = input.path.split('/')[0];
5902
+ return {
5903
+ success: false,
5904
+ isError: true,
5905
+ error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
5906
+ + `${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.`),
5907
+ };
5641
5908
  }
5642
5909
  // A path names its own mount; otherwise the given mount, or every mount.
5643
5910
  let targets;
5644
- if (input.path) {
5911
+ if (input.path !== undefined) {
5912
+ if (input.path === '')
5913
+ return { success: false, error: didNothing('sync', this.emptyPathField(input.mount, 'covers every mount')), isError: true };
5645
5914
  const { mount, relativePath, moved, root } = await this.parsePath(input.path);
5646
5915
  // A path outside the mount's include that's no scope (refusesAsOutside)
5647
5916
  // is refused; beneath a directory, the walk passes over what the include
5648
5917
  // doesn't cover.
5649
5918
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5650
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5919
+ return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5651
5920
  }
5652
5921
  targets = [{ name: mount.config.name, mount, relativePath }];
5653
5922
  }
5654
- else if (input.mount) {
5923
+ else if (input.mount !== undefined) {
5655
5924
  const m = this.mounts.get(input.mount);
5656
5925
  if (!m) {
5657
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5926
+ return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5658
5927
  }
5659
5928
  targets = [{ name: input.mount, mount: m, relativePath: '' }];
5660
5929
  }
@@ -5668,6 +5937,7 @@ export class WorkspaceModule {
5668
5937
  allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
5669
5938
  continue;
5670
5939
  }
5940
+ let namedDirectory = false;
5671
5941
  const pass = await this.withMount(mount, async () => {
5672
5942
  if (input.acceptRoot && await this.acceptRootUnlocked(mount))
5673
5943
  rootsAccepted.push(name);
@@ -5682,6 +5952,7 @@ export class WorkspaceModule {
5682
5952
  store.treeList(mount.treeStateId, prefix).length > 0 ||
5683
5953
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
5684
5954
  this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
5955
+ namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
5685
5956
  // named: ignore patterns covering the path asked for don't apply
5686
5957
  // beneath it (a directory scope's walk; a path scope doesn't walk).
5687
5958
  const opts = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
@@ -5730,7 +6001,7 @@ export class WorkspaceModule {
5730
6001
  // refused" don't look identical from the outside.
5731
6002
  for (const r of pass.reports.values()) {
5732
6003
  if (r.state === 'unverified') {
5733
- allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
6004
+ allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${explained(r.note ?? 'not observed', r.noteCause ?? {}, 'sync')}` });
5734
6005
  }
5735
6006
  else if (r.state === 'disk-only') {
5736
6007
  allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
@@ -5745,7 +6016,7 @@ export class WorkspaceModule {
5745
6016
  // without a path passes over it, so nothing else keeps it current
5746
6017
  // (agent-framework #276). A path in another mount's directory isn't
5747
6018
  // synced at all, and the note says so.
5748
- const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
6019
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
5749
6020
  if (passedOver !== undefined) {
5750
6021
  allIgnored.push({
5751
6022
  mount: name,
@@ -5758,10 +6029,17 @@ export class WorkspaceModule {
5758
6029
  });
5759
6030
  }
5760
6031
  }
6032
+ const named = input.path !== undefined && input.path !== '' ? targets[0] : null;
6033
+ const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
6034
+ && allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
6035
+ && allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
6036
+ && !this.holdsAnythingAt(named.mount, named.relativePath)
6037
+ && await this.absentOnDisk(named.mount, named.relativePath);
5761
6038
  return {
5762
6039
  success: true,
5763
6040
  data: {
5764
6041
  results: allResults,
6042
+ ...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
5765
6043
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
5766
6044
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
5767
6045
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
@@ -6007,13 +6285,13 @@ export async function inOrder(items, width, work, each) {
6007
6285
  }
6008
6286
  }
6009
6287
  /** read_image's answer for an image, wherever it was read from. */
6010
- function imageResult(path, image) {
6288
+ function imageResult(path, image, note) {
6011
6289
  return {
6012
6290
  success: true,
6013
6291
  data: [
6014
6292
  {
6015
6293
  type: 'text',
6016
- text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
6294
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
6017
6295
  },
6018
6296
  {
6019
6297
  type: 'image',
@@ -6067,14 +6345,16 @@ function watchOf(config) {
6067
6345
  * add to what keeps it off direct; refused, what's held only shrinks, and
6068
6346
  * the next start once none is held runs it direct.
6069
6347
  */
6070
- function writeRefusal(mount) {
6348
+ function writeRefusal(mount, writable = []) {
6071
6349
  const name = mount.config.name;
6072
- if (mount.config.mode === 'read-only')
6073
- return `Mount "${name}" is read-only`;
6350
+ if (mount.config.mode === 'read-only') {
6351
+ return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
6352
+ + (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
6353
+ }
6074
6354
  if (mount.config.backend === 'direct' && !mount.direct) {
6075
- return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6355
+ return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6076
6356
  + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6077
- + `(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`;
6357
+ + `(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.`;
6078
6358
  }
6079
6359
  return null;
6080
6360
  }
@@ -6150,6 +6430,23 @@ function directReason(error) {
6150
6430
  return ROOT_MOVED;
6151
6431
  return error instanceof Error ? error.message : String(error);
6152
6432
  }
6433
+ function directWayOn(error) {
6434
+ if (error instanceof FileChanged)
6435
+ return 'Calling again reads it afresh.';
6436
+ if (error instanceof WriteRefused)
6437
+ return error.wayOn;
6438
+ if (error instanceof EffectFailed) {
6439
+ if (error.detail.errno !== undefined)
6440
+ return errnoWayOn(error.detail.errno);
6441
+ if (error.detail.again === true)
6442
+ return 'Calling again checks it afresh.';
6443
+ }
6444
+ return null;
6445
+ }
6446
+ function directDidNothing(tool, path, error) {
6447
+ const on = directWayOn(error);
6448
+ return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
6449
+ }
6153
6450
  /** "a", "a and b", or "a, b and c". */
6154
6451
  function listed(items) {
6155
6452
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
@@ -6172,7 +6469,114 @@ function linksNote(path, staged, tool) {
6172
6469
  */
6173
6470
  function containedRefusal(err, path) {
6174
6471
  const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6175
- return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
6472
+ return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
6473
+ }
6474
+ function deletedOnDisk(path) {
6475
+ 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.`;
6476
+ }
6477
+ function uncheckedFile(shown, note, cause) {
6478
+ if (note === DIRECTORY)
6479
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6480
+ const on = note === ROOT_REPLACED || cause.ownStep === true ? null
6481
+ : (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';
6482
+ 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))}` : ''}`;
6483
+ }
6484
+ function namesRoot(path, mount) {
6485
+ return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
6486
+ }
6487
+ function tooLargeToWrite(path, bytes, maxSize) {
6488
+ 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.');
6489
+ }
6490
+ function pastSizeLimit(what, maxSize, outcome, within) {
6491
+ return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
6492
+ + "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
6493
+ }
6494
+ function sentence(text) {
6495
+ return /[.!?]$/.test(text) ? text : `${text}.`;
6496
+ }
6497
+ function unreadable(shown, errno) {
6498
+ return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
6499
+ }
6500
+ function readRefusal(err, path) {
6501
+ const shown = JSON.stringify(path);
6502
+ if (err instanceof RootUnavailable) {
6503
+ if (err.refusal)
6504
+ return err.refusal.message;
6505
+ const mount = JSON.stringify(err.mount);
6506
+ if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
6507
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
6508
+ 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")}`;
6509
+ }
6510
+ if (err.errno !== undefined) {
6511
+ return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
6512
+ + errnoWayOn(err.errno, ROOT_RECHECK, true);
6513
+ }
6514
+ return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
6515
+ }
6516
+ return `${shown} can't be read: ${sentence(err.reason)}`;
6517
+ }
6518
+ if (err instanceof LinkOnTheWay)
6519
+ return `${shown} can't be reached: ${sentence(err.reason)}`;
6520
+ switch (err.code) {
6521
+ case 'directory':
6522
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6523
+ case 'symlink':
6524
+ return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6525
+ + `Calling again won't change that; ${UNFOLLOWED_STEP}.`;
6526
+ case 'escape':
6527
+ return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6528
+ + `Calling again won't change that; ${MOUNT_IT}.`;
6529
+ case 'changed':
6530
+ return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6531
+ case 'not_found':
6532
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR')
6533
+ return `there's no file at ${shown}. ls or glob lists the files that exist.`;
6534
+ if (err instanceof NotRegularFile) {
6535
+ 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')}`;
6536
+ }
6537
+ if (err.stage === 'realpath')
6538
+ return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
6539
+ return unreadable(shown, err.errno);
6540
+ default:
6541
+ return sentence(err.message);
6542
+ }
6543
+ }
6544
+ function contentsSubject(what, path) {
6545
+ return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
6546
+ }
6547
+ function inTheStore(stored) {
6548
+ return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
6549
+ }
6550
+ function lostInStore(lost) {
6551
+ return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
6552
+ }
6553
+ function lostOnDisk(lost) {
6554
+ return lost.disk === 'other' ? "disk's copy holds other contents"
6555
+ : lost.disk === 'none' ? 'disk has no copy'
6556
+ : `disk's copy can't be read (${lost.disk.unreadable})`;
6557
+ }
6558
+ function lostText(lost, the = 'The') {
6559
+ return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
6560
+ }
6561
+ function nothingToRead(lost) {
6562
+ return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
6563
+ }
6564
+ function lostRemedy(lost) {
6565
+ const after = remedyOfLost(lost);
6566
+ if (!lost.reopen)
6567
+ return after;
6568
+ 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. "
6569
+ + `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
6570
+ }
6571
+ function remedyOfLost(lost) {
6572
+ const rewrite = lost.writable && 'missing' in lost.store;
6573
+ if (lost.disk === 'other')
6574
+ return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
6575
+ if (lost.writable)
6576
+ return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
6577
+ return lost.disk === 'none'
6578
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy."
6579
+ : "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.";
6176
6580
  }
6177
6581
  /**
6178
6582
  * The notice that writes an agent made direct remain on disk from
@@ -6183,7 +6587,7 @@ function containedRefusal(err, path) {
6183
6587
  */
6184
6588
  function headMoveText(mounts) {
6185
6589
  const one = mounts.length === 1;
6186
- return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6590
+ return `[${NOTICE_TAG.workspace}] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6187
6591
  + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6188
6592
  + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6189
6593
  }
@@ -6229,9 +6633,7 @@ function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems)
6229
6633
  kind: 'file',
6230
6634
  edits: target.edits,
6231
6635
  path: target.path,
6232
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
6233
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
6234
- + "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.",
6636
+ text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize, "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
6235
6637
  whole: false,
6236
6638
  });
6237
6639
  return null;
@@ -6267,16 +6669,38 @@ function changesLanded(store, treeStateId, changes, setError) {
6267
6669
  * change is stored isn't known. Its message says so after both errors.
6268
6670
  */
6269
6671
  class CommitUnknownError extends Error {
6270
- setError;
6271
- readError;
6272
- constructor(setError, readError) {
6672
+ setCause;
6673
+ readCause;
6674
+ constructor(setCause, readCause) {
6273
6675
  const text = (error) => (error instanceof Error ? error.message : String(error));
6274
- super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
6676
+ super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
6677
+ this.setCause = setCause;
6678
+ this.readCause = readCause;
6275
6679
  this.name = 'CommitUnknownError';
6276
- this.setError = text(setError);
6277
- this.readError = text(readError);
6278
6680
  }
6279
6681
  }
6682
+ function storeWords(error) {
6683
+ const code = errnoCode(error);
6684
+ return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
6685
+ }
6686
+ class NotStored extends Error {
6687
+ thrown;
6688
+ constructor(thrown) {
6689
+ super('stopped before the change was stored');
6690
+ this.thrown = thrown;
6691
+ }
6692
+ }
6693
+ class AlreadyHeld extends Error {
6694
+ thrown;
6695
+ constructor(thrown) {
6696
+ super(thrown instanceof Error ? thrown.message : String(thrown));
6697
+ this.thrown = thrown;
6698
+ }
6699
+ }
6700
+ function notStored(error) {
6701
+ return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
6702
+ ? error : new NotStored(error);
6703
+ }
6280
6704
  /**
6281
6705
  * A store's set that threw after recording the change (changesLanded): the
6282
6706
  * entries are set and their intents follow, and the store's error goes on in
@@ -6293,6 +6717,11 @@ class StoredThenFailed extends Error {
6293
6717
  this.name = 'StoredThenFailed';
6294
6718
  }
6295
6719
  }
6720
+ const ONCE_REOPENED = ', once the store is reopened';
6721
+ const DISK_ORDER = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
6722
+ function furthestFromDisk(values) {
6723
+ return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
6724
+ }
6296
6725
  /**
6297
6726
  * The answer to a write, edit or delete that the workspace stored but
6298
6727
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -6304,7 +6733,7 @@ class StoredThenFailed extends Error {
6304
6733
  * script. A note comes last: an edit's on which copy it applied to, or a
6305
6734
  * deletion's that it unlinked nothing.
6306
6735
  */
6307
- function storedButUnfinished(tool, path, outcome, note = null) {
6736
+ function storedButUnfinished(tool, path, outcome, note = null, reopen = false) {
6308
6737
  const change = tool === 'delete' ? 'deletion' : tool;
6309
6738
  const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
6310
6739
  + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
@@ -6313,10 +6742,10 @@ function storedButUnfinished(tool, path, outcome, note = null) {
6313
6742
  : 'storeFailed' in outcome
6314
6743
  ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
6315
6744
  : tool === 'delete'
6316
- ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
6317
- : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
6318
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
6319
- return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
6745
+ ? ` 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 : ''}.`
6746
+ : ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
6747
+ : ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
6748
+ return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
6320
6749
  }
6321
6750
  /**
6322
6751
  * What a read says about the copy it shows, when that isn't simply the one the
@@ -6345,18 +6774,22 @@ function whichCopyRead(report, tookChange, readOnly) {
6345
6774
  // can hold it (not binary or too large). A read-only mount can hold a
6346
6775
  // copy disk doesn't, such as one a branch switch selected, but a
6347
6776
  // materialize, forced or not, writes nothing to it.
6348
- const sync = report.diskNow === 'absent'
6349
- ? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6350
- : 'Sync the path to keep disk\'s copy and give up the workspace\'s';
6351
- const force = readOnly
6352
- ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6353
- : report.diskNow === 'absent'
6354
- ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6355
- : ', or materialize it with force to write the workspace\'s over disk\'s.';
6356
- return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
6777
+ const settle = settleConflict(report.diskNow, readOnly);
6778
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0].toUpperCase()}${settle.slice(1)}`];
6357
6779
  }
6358
6780
  return [];
6359
6781
  }
6782
+ function settleConflict(diskNow, readOnly) {
6783
+ const sync = diskNow === 'absent'
6784
+ ? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6785
+ : 'sync the path to keep disk\'s copy and give up the workspace\'s';
6786
+ const force = readOnly
6787
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6788
+ : diskNow === 'absent'
6789
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6790
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
6791
+ return `${sync}${force}`;
6792
+ }
6360
6793
  /**
6361
6794
  * The least each tool's whole-number fields may be, beside their schemas,
6362
6795
  * whose type (membrane's ToolParameter) has no `minimum`.
@@ -6548,13 +6981,13 @@ function globToRegex(pattern) {
6548
6981
  closeIdx = j;
6549
6982
  }
6550
6983
  if (closeIdx < 0)
6551
- throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
6984
+ throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
6552
6985
  const group = pattern.slice(i + 1, closeIdx);
6553
6986
  if (group.includes('{')) {
6554
- throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
6987
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
6555
6988
  }
6556
6989
  if (!group.includes(',')) {
6557
- throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
6990
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
6558
6991
  }
6559
6992
  // A `/` right after the group goes into each alternative, as `{a,b}/x`
6560
6993
  // and `{a/,b/}x` name the same paths, so an alternative's own `**`