@animalabs/agent-framework 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +56 -15
  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 +69 -11
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +4 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +3 -2
  10. package/dist/src/auto-continue.d.ts.map +1 -1
  11. package/dist/src/auto-continue.js +4 -3
  12. package/dist/src/auto-continue.js.map +1 -1
  13. package/dist/src/cut-off-round.d.ts +24 -0
  14. package/dist/src/cut-off-round.d.ts.map +1 -0
  15. package/dist/src/cut-off-round.js +87 -0
  16. package/dist/src/cut-off-round.js.map +1 -0
  17. package/dist/src/framework.d.ts +304 -89
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +2577 -678
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +99 -3
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +538 -13
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -1
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -0
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/reply-judge.d.ts +116 -0
  30. package/dist/src/gate/reply-judge.d.ts.map +1 -0
  31. package/dist/src/gate/reply-judge.js +467 -0
  32. package/dist/src/gate/reply-judge.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +161 -0
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/heartbeat/heartbeat.d.ts +184 -0
  36. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
  37. package/dist/src/heartbeat/heartbeat.js +752 -0
  38. package/dist/src/heartbeat/heartbeat.js.map +1 -0
  39. package/dist/src/heartbeat/reminder-time.d.ts +5 -0
  40. package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
  41. package/dist/src/heartbeat/reminder-time.js +107 -0
  42. package/dist/src/heartbeat/reminder-time.js.map +1 -0
  43. package/dist/src/index.d.ts +4 -0
  44. package/dist/src/index.d.ts.map +1 -1
  45. package/dist/src/index.js +2 -0
  46. package/dist/src/index.js.map +1 -1
  47. package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
  48. package/dist/src/mcpl/capability-mask.js +3 -2
  49. package/dist/src/mcpl/capability-mask.js.map +1 -1
  50. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  51. package/dist/src/mcpl/channel-registry.js +2 -1
  52. package/dist/src/mcpl/channel-registry.js.map +1 -1
  53. package/dist/src/mcpl/inbound-source.d.ts +26 -10
  54. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  55. package/dist/src/mcpl/inbound-source.js +40 -69
  56. package/dist/src/mcpl/inbound-source.js.map +1 -1
  57. package/dist/src/mcpl/server-connection.d.ts +18 -8
  58. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-connection.js +50 -10
  60. package/dist/src/mcpl/server-connection.js.map +1 -1
  61. package/dist/src/mcpl/tags.d.ts +9 -2
  62. package/dist/src/mcpl/tags.d.ts.map +1 -1
  63. package/dist/src/mcpl/tags.js +12 -2
  64. package/dist/src/mcpl/tags.js.map +1 -1
  65. package/dist/src/mcpl/tool-presence.d.ts +66 -14
  66. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  67. package/dist/src/mcpl/tool-presence.js +134 -65
  68. package/dist/src/mcpl/tool-presence.js.map +1 -1
  69. package/dist/src/mcpl/tool-signature.d.ts +10 -3
  70. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  71. package/dist/src/mcpl/tool-signature.js +47 -18
  72. package/dist/src/mcpl/tool-signature.js.map +1 -1
  73. package/dist/src/mcpl/toolset-host.d.ts +88 -37
  74. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  75. package/dist/src/mcpl/toolset-host.js +207 -108
  76. package/dist/src/mcpl/toolset-host.js.map +1 -1
  77. package/dist/src/mcpl/toolsets-module.d.ts +14 -11
  78. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  79. package/dist/src/mcpl/toolsets-module.js +141 -49
  80. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  81. package/dist/src/mcpl/types.d.ts +3 -1
  82. package/dist/src/mcpl/types.d.ts.map +1 -1
  83. package/dist/src/mcpl/types.js.map +1 -1
  84. package/dist/src/module-registry.d.ts +4 -1
  85. package/dist/src/module-registry.d.ts.map +1 -1
  86. package/dist/src/module-registry.js +9 -2
  87. package/dist/src/module-registry.js.map +1 -1
  88. package/dist/src/modules/history/index.d.ts.map +1 -1
  89. package/dist/src/modules/history/index.js +22 -11
  90. package/dist/src/modules/history/index.js.map +1 -1
  91. package/dist/src/modules/workspace/answers.d.ts +22 -0
  92. package/dist/src/modules/workspace/answers.d.ts.map +1 -0
  93. package/dist/src/modules/workspace/answers.js +123 -0
  94. package/dist/src/modules/workspace/answers.js.map +1 -0
  95. package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
  96. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  97. package/dist/src/modules/workspace/branch-intent.js +6 -2
  98. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  99. package/dist/src/modules/workspace/direct-write.d.ts +3 -0
  100. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  101. package/dist/src/modules/workspace/direct-write.js +57 -24
  102. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  103. package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
  104. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  105. package/dist/src/modules/workspace/disk-agreement.js +48 -17
  106. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  107. package/dist/src/modules/workspace/edit-text.d.ts +2 -1
  108. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/edit-text.js +4 -2
  110. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/effects.js +20 -13
  113. package/dist/src/modules/workspace/effects.js.map +1 -1
  114. package/dist/src/modules/workspace/index.d.ts +62 -51
  115. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  116. package/dist/src/modules/workspace/index.js +934 -531
  117. package/dist/src/modules/workspace/index.js.map +1 -1
  118. package/dist/src/modules/workspace/observe.d.ts +75 -63
  119. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  120. package/dist/src/modules/workspace/observe.js +366 -180
  121. package/dist/src/modules/workspace/observe.js.map +1 -1
  122. package/dist/src/modules/workspace/read-page.d.ts.map +1 -1
  123. package/dist/src/modules/workspace/read-page.js +7 -1
  124. package/dist/src/modules/workspace/read-page.js.map +1 -1
  125. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/reconcile.js +26 -16
  127. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  128. package/dist/src/modules/workspace/result-text.d.ts +1 -0
  129. package/dist/src/modules/workspace/result-text.d.ts.map +1 -1
  130. package/dist/src/modules/workspace/result-text.js.map +1 -1
  131. package/dist/src/modules/workspace/sync.d.ts +9 -0
  132. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  133. package/dist/src/modules/workspace/sync.js +14 -0
  134. package/dist/src/modules/workspace/sync.js.map +1 -1
  135. package/dist/src/modules/workspace/types.d.ts +96 -43
  136. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  137. package/dist/src/modules/workspace/types.js.map +1 -1
  138. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/watcher.js +8 -3
  140. package/dist/src/modules/workspace/watcher.js.map +1 -1
  141. package/dist/src/prose-drafts.d.ts +4 -3
  142. package/dist/src/prose-drafts.d.ts.map +1 -1
  143. package/dist/src/prose-drafts.js +3 -2
  144. package/dist/src/prose-drafts.js.map +1 -1
  145. package/dist/src/reasoning-loss-notice.d.ts +23 -0
  146. package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
  147. package/dist/src/reasoning-loss-notice.js +50 -0
  148. package/dist/src/reasoning-loss-notice.js.map +1 -0
  149. package/dist/src/reasoning-loss.d.ts +24 -0
  150. package/dist/src/reasoning-loss.d.ts.map +1 -0
  151. package/dist/src/reasoning-loss.js +102 -0
  152. package/dist/src/reasoning-loss.js.map +1 -0
  153. package/dist/src/send-tools.d.ts +3 -2
  154. package/dist/src/send-tools.d.ts.map +1 -1
  155. package/dist/src/send-tools.js +3 -2
  156. package/dist/src/send-tools.js.map +1 -1
  157. package/dist/src/sent-rows.js +1 -1
  158. package/dist/src/thinking-drops.d.ts +4 -11
  159. package/dist/src/thinking-drops.d.ts.map +1 -1
  160. package/dist/src/thinking-drops.js +7 -14
  161. package/dist/src/thinking-drops.js.map +1 -1
  162. package/dist/src/tool-args.d.ts +12 -0
  163. package/dist/src/tool-args.d.ts.map +1 -1
  164. package/dist/src/tool-args.js +35 -1
  165. package/dist/src/tool-args.js.map +1 -1
  166. package/dist/src/tool-result-guard.d.ts +8 -4
  167. package/dist/src/tool-result-guard.d.ts.map +1 -1
  168. package/dist/src/tool-result-guard.js +23 -11
  169. package/dist/src/tool-result-guard.js.map +1 -1
  170. package/dist/src/tune-out/tools.d.ts +4 -1
  171. package/dist/src/tune-out/tools.d.ts.map +1 -1
  172. package/dist/src/types/agent.d.ts +40 -9
  173. package/dist/src/types/agent.d.ts.map +1 -1
  174. package/dist/src/types/events.d.ts +18 -8
  175. package/dist/src/types/events.d.ts.map +1 -1
  176. package/dist/src/types/framework.d.ts +15 -0
  177. package/dist/src/types/framework.d.ts.map +1 -1
  178. package/dist/src/types/module.d.ts +5 -2
  179. package/dist/src/types/module.d.ts.map +1 -1
  180. package/dist/src/types/trace.d.ts +34 -1
  181. package/dist/src/types/trace.d.ts.map +1 -1
  182. package/package.json +3 -3
  183. package/src/agent.ts +72 -11
  184. package/src/api/server.ts +4 -2
  185. package/src/auto-continue.ts +4 -3
  186. package/src/cut-off-round.ts +98 -0
  187. package/src/framework.ts +2694 -700
  188. package/src/gate/event-gate.ts +579 -15
  189. package/src/gate/index.ts +8 -0
  190. package/src/gate/reply-judge.ts +512 -0
  191. package/src/gate/types.ts +164 -1
  192. package/src/heartbeat/heartbeat.ts +804 -0
  193. package/src/heartbeat/reminder-time.ts +107 -0
  194. package/src/index.ts +4 -0
  195. package/src/mcpl/capability-mask.ts +3 -2
  196. package/src/mcpl/channel-registry.ts +2 -1
  197. package/src/mcpl/inbound-source.ts +42 -69
  198. package/src/mcpl/server-connection.ts +56 -16
  199. package/src/mcpl/tags.ts +13 -2
  200. package/src/mcpl/tool-presence.ts +133 -67
  201. package/src/mcpl/tool-signature.ts +51 -20
  202. package/src/mcpl/toolset-host.ts +197 -107
  203. package/src/mcpl/toolsets-module.ts +143 -49
  204. package/src/mcpl/types.ts +3 -1
  205. package/src/module-registry.ts +8 -2
  206. package/src/modules/history/index.ts +22 -12
  207. package/src/modules/workspace/answers.ts +133 -0
  208. package/src/modules/workspace/branch-intent.ts +10 -5
  209. package/src/modules/workspace/direct-write.ts +58 -23
  210. package/src/modules/workspace/disk-agreement.ts +52 -21
  211. package/src/modules/workspace/edit-text.ts +7 -5
  212. package/src/modules/workspace/effects.ts +21 -12
  213. package/src/modules/workspace/index.ts +927 -541
  214. package/src/modules/workspace/observe.ts +381 -177
  215. package/src/modules/workspace/read-page.ts +7 -1
  216. package/src/modules/workspace/reconcile.ts +27 -15
  217. package/src/modules/workspace/result-text.ts +2 -0
  218. package/src/modules/workspace/sync.ts +16 -0
  219. package/src/modules/workspace/types.ts +96 -43
  220. package/src/modules/workspace/watcher.ts +8 -3
  221. package/src/prose-drafts.ts +4 -3
  222. package/src/reasoning-loss-notice.ts +62 -0
  223. package/src/reasoning-loss.ts +120 -0
  224. package/src/send-tools.ts +3 -2
  225. package/src/sent-rows.ts +1 -1
  226. package/src/thinking-drops.ts +10 -16
  227. package/src/tool-args.ts +34 -1
  228. package/src/tool-result-guard.ts +26 -11
  229. package/src/tune-out/tools.ts +4 -1
  230. package/src/types/agent.ts +40 -9
  231. package/src/types/events.ts +18 -8
  232. package/src/types/framework.ts +15 -0
  233. package/src/types/module.ts +5 -2
  234. package/src/types/trace.ts +36 -1
@@ -37,17 +37,17 @@ import type {
37
37
  } from './types.js';
38
38
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
39
39
  import { MountWatcher, type FsChange } from './watcher.js';
40
- import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
41
- import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
40
+ import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
41
+ import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
42
42
  import { type BranchIntent, BranchIntents } from './branch-intent.js';
43
- import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
43
+ import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
44
44
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
45
45
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
46
46
  import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
47
47
  import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
48
48
  import { type FileText, fileText } from './text.js';
49
49
  import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
50
- import { type GrepAnswer, grepText, textAnswer } from './result-text.js';
50
+ import { type DiskState, type GrepAnswer, grepText, textAnswer } from './result-text.js';
51
51
  import {
52
52
  type MountRuntime,
53
53
  type PassOptions,
@@ -65,6 +65,9 @@ import {
65
65
  settleAdoptionBeforeMutation,
66
66
  } from './reconcile.js';
67
67
  import { EffectFailed, unlinkContained } from './effects.js';
68
+ import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
69
+ import { unknownToolMessage } from '../unknown-tool.js';
70
+ import { closestNames } from '../../closest-names.js';
68
71
  import { RecordJournal } from '../../record-journal.js';
69
72
  import {
70
73
  type FileIdentity,
@@ -100,6 +103,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
100
103
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
101
104
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
102
105
  const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
106
+ const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
107
+ + 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
108
+ + 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
103
109
  /**
104
110
  * How many files a direct mount's grep reads, and its listings look at, at
105
111
  * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
@@ -134,30 +140,23 @@ export type {
134
140
 
135
141
  type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
136
142
 
137
- class WorkspaceImageReadError extends Error {
138
- constructor(
139
- readonly code:
140
- | 'mount_unavailable'
141
- | 'not_found'
142
- | 'directory'
143
- | 'symlink'
144
- | 'parent_symlink'
145
- | 'other_mount'
146
- | 'escape'
147
- | 'changed'
148
- | 'empty'
149
- | 'too_large'
150
- | 'truncated'
151
- | 'invalid'
152
- | 'unsupported'
153
- | 'blob_missing',
154
- message: string,
155
- ) {
156
- super(message);
143
+ class WorkspaceImageReadError extends Refusal {
144
+ constructor(readonly reason: string, wayOn: string | null) {
145
+ super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
157
146
  this.name = 'WorkspaceImageReadError';
158
147
  }
159
148
  }
160
149
 
150
+ const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
151
+
152
+ function truncatedImage(mountPrefixedPath: string): WorkspaceImageReadError {
153
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
154
+ }
155
+
156
+ function emptyImage(mountPrefixedPath: string): WorkspaceImageReadError {
157
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
158
+ }
159
+
161
160
  /**
162
161
  * Why a workspace-owned filesystem read was refused. Distinguishes the cases a
163
162
  * peer needs to react to differently: a path that never resolved inside the
@@ -175,10 +174,12 @@ class WorkspaceImageReadError extends Error {
175
174
  * directories leading out of it (`escape`), a file swapped underneath the read
176
175
  * (`changed`), a path the mount's `include` doesn't cover (`outside_include`),
177
176
  * and a path in another mount's directory nested in the one named
178
- * (`other_mount`), whose message names the mount that holds it and the path
179
- * to use there. A symlink leading into such a directory is `other_mount`
180
- * only where its refusal names that mount: a directory, or a file that
181
- * mount's include covers. Anywhere else, on a mount that doesn't follow
177
+ * (`other_mount`), whose message names the mount that holds it, or may hold
178
+ * it, and the path to use there. A symlink leading into such a directory is
179
+ * `other_mount` only where its refusal names that mount: a directory, or a
180
+ * file that mount's include covers, and so does the include of each other
181
+ * nested mount whose directory may hold it, out to the first that surely
182
+ * does. Anywhere else, on a mount that doesn't follow
182
183
  * symlinks, it gets the code of a link where it stands, `symlink` at the
183
184
  * path itself and `parent_symlink` on its way, and on one that does,
184
185
  * `escape`, as a path that leaves the mount. On a mount with an include, a
@@ -220,7 +221,7 @@ export type WorkspaceReadStage =
220
221
  | 'read';
221
222
 
222
223
  /** A path the mount's `include` doesn't cover: outside the mount. */
223
- class OutsideIncludeError extends Error {
224
+ class OutsideIncludeError extends Refusal {
224
225
  constructor(shownPath: string, mount: MountConfig) {
225
226
  super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
226
227
  + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
@@ -232,9 +233,16 @@ class OutsideIncludeError extends Error {
232
233
  * mount it names. The message names the mount that holds it and the path to
233
234
  * use there.
234
235
  */
235
- class NestedMountError extends Error {
236
- constructor(path: string, mount: string, holder: string, use: string, how: 'lies in' | 'leads into' = 'lies in') {
237
- super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
236
+ class NestedMountError extends Refusal {
237
+ constructor(path: string, mount: string, holder: { name: string; use: string; guess?: NestedGuess }, how: 'lies in' | 'leads into' = 'lies in') {
238
+ const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
239
+ super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
240
+ }
241
+ }
242
+
243
+ class PathRefusal extends Refusal {
244
+ constructor(readonly code: 'unknown_mount' | 'traversal', message: string) {
245
+ super(message);
238
246
  }
239
247
  }
240
248
 
@@ -256,6 +264,18 @@ export class WorkspaceReadError extends Error {
256
264
  }
257
265
  }
258
266
 
267
+ class NotRegularFile extends WorkspaceReadError {
268
+ constructor(path: string, stage: WorkspaceReadStage) {
269
+ super('not_found', `Not a regular file: ${path}`, stage);
270
+ }
271
+ }
272
+
273
+ class RootUnavailable extends WorkspaceReadError {
274
+ constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
275
+ super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
276
+ }
277
+ }
278
+
259
279
  /** Successful `readFileFromDisk()` result. */
260
280
  export interface WorkspaceDiskReadResult {
261
281
  /**
@@ -308,23 +328,10 @@ function errnoOf(err: unknown): string | undefined {
308
328
  : undefined;
309
329
  }
310
330
 
311
- /**
312
- * What is at a mount root's path now, followed as the root is: for a
313
- * directory, undefined; for anything else, ENOTDIR; and where the path
314
- * can't be followed to anything, the errno its stat failed with, ENOENT
315
- * when nothing is there.
316
- */
317
- async function rootAbsence(root: string): Promise<string | undefined> {
318
- try {
319
- return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
320
- } catch (err) {
321
- return errnoOf(err);
322
- }
323
- }
324
331
 
325
332
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
326
- function imageTooLarge(maxSize: number, mountPrefixedPath: string): WorkspaceImageReadError {
327
- return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
333
+ function imageTooLarge(maxSize: number, mountPrefixedPath: string, size: string): WorkspaceImageReadError {
334
+ return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
328
335
  }
329
336
 
330
337
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -342,10 +349,6 @@ const JPEG_EOI = 0xd9;
342
349
  const JPEG_SOS = 0xda;
343
350
  const JPEG_TEM = 0x01;
344
351
 
345
- function isErrnoCode(err: unknown, code: string): boolean {
346
- return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === code;
347
- }
348
-
349
352
  function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
350
353
  return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
351
354
  }
@@ -388,7 +391,7 @@ async function linkReadError(
388
391
  const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
389
392
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
390
393
  const code = leads?.kind === 'outside' ? 'escape'
391
- : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
394
+ : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
392
395
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
393
396
  return new WorkspaceReadError(code, reason, 'readlink');
394
397
  }
@@ -420,32 +423,30 @@ function ancestorsOf(path: string): string[] {
420
423
  * directory as configured beneath the outer mount's configured path
421
424
  * (`outerPath`), beneath each directory above its configured path that is
422
425
  * the outer root on disk (`above`, each with where it lies on disk), and
423
- * where its real root (`real`) lies beneath the outer root (`outerReal`),
424
- * each also as disk spells it beneath `spellAt`. Its real root is claimed
425
- * where it doesn't hold the outer root, which it would otherwise claim
426
- * whole. Undefined where it's nested nowhere.
426
+ * where its real root (`real`) lies beneath the outer root (`outerReal`).
427
+ * Its real root is claimed where it doesn't hold the outer root, which it
428
+ * would otherwise claim whole. Undefined where it's nested nowhere.
427
429
  */
428
- async function placementOf(
430
+ function placementOf(
429
431
  outerPath: string,
430
432
  outerReal: string,
431
433
  innerPath: string,
432
434
  real: string,
433
435
  above: ReadonlyArray<{ dir: string; real: string }>,
434
- spellAt: string,
435
- ): Promise<{ configured: string | undefined; fromRoots: Array<{ dir: string; root: string }>; spellings: Array<{ dir: string; root: string }>; claims: boolean } | undefined> {
436
+ ): { configured: string | undefined; fromRoots: NestedMountEntry['fromRoots']; claims: boolean } | undefined {
436
437
  const configured = nestedDir(outerPath, innerPath);
437
- const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
438
- const fromRoots: Array<{ dir: string; root: string }> = [];
439
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
440
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
441
- }
442
- if (fromRoots.length === 0) return undefined;
443
- const spellings = [...fromRoots];
444
- for (const found of fromRoots) {
445
- const dir = await diskSpelling(spellAt, found.dir);
446
- if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
447
- }
448
- return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
438
+ const dirs = new Map<string, boolean>();
439
+ const place = (dir: string | undefined, sure: boolean) => { if (dir !== undefined) dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
440
+ place(configured, true);
441
+ for (const a of above) {
442
+ if (sameRoot(a.real, outerReal)) place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
443
+ }
444
+ const below = beneathRoot(real, outerReal);
445
+ place(below, below !== undefined && surelyAtRoot(real, outerReal));
446
+ if (dirs.size === 0) return undefined;
447
+ const fromRoots: NestedMountEntry['fromRoots'] = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true as const }) }));
448
+ const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
449
+ return { configured, fromRoots, claims };
449
450
  }
450
451
 
451
452
  /** A mount nested in another, as the outer mount's otherMounts holds it. */
@@ -458,9 +459,7 @@ interface NestedMountEntry {
458
459
  */
459
460
  real?: string;
460
461
  /** Its directory as configured and as the real roots place it. */
461
- fromRoots: Array<{ dir: string; root: string }>;
462
- /** Those, and each as disk spells it where that differs. */
463
- spellings: Array<{ dir: string; root: string }>;
462
+ fromRoots: Array<{ dir: string; guessed?: true }>;
464
463
  }
465
464
 
466
465
  /**
@@ -539,7 +538,7 @@ function isContainedPath(root: string, candidate: string): boolean {
539
538
  }
540
539
 
541
540
  function invalidImage(format: string, mountPrefixedPath: string): never {
542
- throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
541
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
543
542
  }
544
543
 
545
544
  function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
@@ -915,7 +914,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
915
914
  return 'image/png';
916
915
  }
917
916
  if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
918
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
917
+ throw truncatedImage(mountPrefixedPath);
919
918
  }
920
919
 
921
920
  if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
@@ -923,7 +922,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
923
922
  return 'image/jpeg';
924
923
  }
925
924
  if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
926
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
925
+ throw truncatedImage(mountPrefixedPath);
927
926
  }
928
927
 
929
928
  if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
@@ -931,7 +930,7 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
931
930
  return 'image/gif';
932
931
  }
933
932
  if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
934
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
933
+ throw truncatedImage(mountPrefixedPath);
935
934
  }
936
935
 
937
936
  if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
@@ -942,10 +941,10 @@ function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): Supporte
942
941
  (bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
943
942
  || (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
944
943
  ) {
945
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
944
+ throw truncatedImage(mountPrefixedPath);
946
945
  }
947
946
 
948
- throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
947
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows`, "read shows it, if it's text.");
949
948
  }
950
949
 
951
950
  export class WorkspaceModule implements Module {
@@ -973,14 +972,11 @@ export class WorkspaceModule implements Module {
973
972
  private ctimeTrust = new Map<string, string | null>();
974
973
  /**
975
974
  * Each mount's directories that are other mounts nested inside it, with
976
- * each mount's name and real root, in each spelling that names the
977
- * directory: as configured; as the real roots place it, where that differs
978
- * (`fromRoots`); and as disk spells each of those where that differs.
979
- * Each spelling is mount-relative and `/`-separated, with its root beneath
980
- * the mount's resolved root. The constructor nests by configured path,
981
- * with each root as resolved standing for its real one, and each start
982
- * reads it all again with the roots placed where disk has them
983
- * (readNesting).
975
+ * each mount's name and real root: as configured, and as the real roots
976
+ * place it, where that differs (`fromRoots`). Each is mount-relative and
977
+ * `/`-separated. The constructor nests by configured path, with each root
978
+ * as resolved standing for its real one, and each start reads it all again
979
+ * with the roots placed where disk has them (readNesting).
984
980
  */
985
981
  private otherMounts = new Map<string, NestedMountEntry[]>();
986
982
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
@@ -1045,24 +1041,18 @@ export class WorkspaceModule implements Module {
1045
1041
  if (refused.length > 0) {
1046
1042
  throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
1047
1043
  }
1048
- const include: unknown = mount.include;
1049
- if (include !== undefined) {
1050
- // A pattern the matcher can't read would match nothing, and take every
1051
- // file out of the mount unsaid (includePatternReadable).
1052
- if (!Array.isArray(include) || include.length === 0) {
1053
- throw new Error(
1054
- `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`,
1055
- );
1044
+ for (const list of ['ignore', 'include'] as const) {
1045
+ const patterns: unknown = mount[list];
1046
+ if (patterns === undefined) continue;
1047
+ if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
1048
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
1056
1049
  }
1057
- // By index: an entry that is itself undefined, or a hole, is unreadable too.
1058
- const at = include.findIndex((p) => !includePatternReadable(p));
1059
- if (at >= 0) {
1060
- const unreadable: unknown = include[at];
1061
- throw new Error(
1062
- `[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
1063
- + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
1064
- + `${shownValue(unreadable)} is none of these`,
1065
- );
1050
+ for (let i = 0; i < patterns.length; i++) {
1051
+ const pattern: unknown = patterns[i];
1052
+ const why = patternRefusal(pattern, list);
1053
+ if (why !== undefined) {
1054
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
1055
+ }
1066
1056
  }
1067
1057
  }
1068
1058
  }
@@ -1081,8 +1071,8 @@ export class WorkspaceModule implements Module {
1081
1071
  const innerPath = resolve(inner.path);
1082
1072
  const dir = nestedDir(outerPath, innerPath);
1083
1073
  if (dir === undefined) continue;
1084
- const configured = [{ dir, root: innerPath }];
1085
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
1074
+ const configured = [{ dir }];
1075
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
1086
1076
  console.warn(
1087
1077
  `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
1088
1078
  `at ${dir}/ — passing over it as another mount's directory`,
@@ -1372,7 +1362,7 @@ export class WorkspaceModule implements Module {
1372
1362
  const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
1373
1363
  let start = base;
1374
1364
  for (const segment of beneath.split('/')) {
1375
- if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined)) break;
1365
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined)) break;
1376
1366
  const here = await checkDir(view, rootReal, start);
1377
1367
  if (here.kind !== 'checked') break;
1378
1368
  const entry = (await readdir(here.dir.real, { withFileTypes: true }).catch(() => [])).find((e) => e.name === segment);
@@ -1425,11 +1415,10 @@ export class WorkspaceModule implements Module {
1425
1415
 
1426
1416
  async start(ctx: ModuleContext): Promise<void> {
1427
1417
  // Which mounts are nested where, with the roots placed where disk has
1428
- // them and each nested mount's directory as disk spells it, and whether
1429
- // each mount's root folds case, read where disk has it, before this start
1430
- // sets anything, so a restart takes no call and walks or watches nothing
1431
- // before they're known. A stop begun meanwhile stands, and this start then
1432
- // sets nothing.
1418
+ // them, and whether each mount's root folds case, read where disk has
1419
+ // it, before this start sets anything, so a restart takes no call
1420
+ // and walks or watches nothing before they're known. A stop begun
1421
+ // meanwhile stands, and this start then sets nothing.
1433
1422
  const stops = this.stopsBegun;
1434
1423
  const { nesting, reals, above } = await this.readNesting();
1435
1424
  const rootFoldsCase = new Map<string, boolean>();
@@ -1545,7 +1534,8 @@ export class WorkspaceModule implements Module {
1545
1534
  return (
1546
1535
  `current branch "${current.name}" has diverged from the branch last materialized to disk ` +
1547
1536
  `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
1548
- `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`
1537
+ `never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
1538
+ 'if the current branch is what disk should hold, pass force: true to overwrite disk from it.'
1549
1539
  );
1550
1540
  }
1551
1541
 
@@ -2184,7 +2174,13 @@ export class WorkspaceModule implements Module {
2184
2174
  return work;
2185
2175
  }
2186
2176
 
2187
- private async runToolCall(call: ToolCall): Promise<ToolResult> {
2177
+ private runToolCall(call: ToolCall): Promise<ToolResult> {
2178
+ const gate = { passed: false };
2179
+ return callGate.run(gate, () => this.answerToolCall(call, gate));
2180
+ }
2181
+
2182
+ private async answerToolCall(call: ToolCall, gate: { passed: boolean }): Promise<ToolResult> {
2183
+ const reopenAtStart = this.agreement?.awaitsReopen === true;
2188
2184
  try {
2189
2185
  // Every handler reads its input as its schema describes it; a call
2190
2186
  // that sends something else is refused here, before anything is read.
@@ -2199,12 +2195,12 @@ export class WorkspaceModule implements Module {
2199
2195
  // ResultTooLarge in its place. A call made in process carries none and
2200
2196
  // takes the data itself, so its read pages by its line limit alone.
2201
2197
  const resultMaxChars = call.resultMaxChars;
2202
- const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2198
+ const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2203
2199
  if (generated) {
2204
2200
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
2205
2201
  return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
2206
2202
  }
2207
- return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
2203
+ return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path as string, generated, call.callerAgentName)) };
2208
2204
  }
2209
2205
 
2210
2206
  switch (call.name) {
@@ -2221,13 +2217,41 @@ export class WorkspaceModule implements Module {
2221
2217
  case 'materialize': return await this.handleMaterialize(input as unknown as MaterializeInput);
2222
2218
  case 'sync': return await this.handleSync(input as unknown as SyncInput);
2223
2219
  default:
2224
- return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
2220
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
2225
2221
  }
2226
2222
  } catch (err) {
2227
- return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
2223
+ return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
2224
+ }
2225
+ }
2226
+
2227
+ private failureAnswer(call: ToolCall, err: unknown, reopenAtStart: boolean, passedGate: boolean): string {
2228
+ const input = (typeof call.input === 'object' && call.input !== null ? call.input : {}) as { path?: unknown; mount?: unknown };
2229
+ const path = typeof input.path === 'string' ? input.path : undefined;
2230
+ const mountName = path !== undefined ? path.split('/')[0]! : typeof input.mount === 'string' ? input.mount : undefined;
2231
+ const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
2232
+ const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
2233
+ if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
2234
+ return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
2235
+ }
2236
+ if (err instanceof Refusal) return didNothing(call.name, err.message);
2237
+ if (err instanceof WorkspaceReadError) return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
2238
+ const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
2239
+ : mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
2240
+ : 'ls of each mount, with recursive: true, shows how each file stands against disk now';
2241
+ if (err instanceof NotStored || err instanceof AlreadyHeld) {
2242
+ console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
2243
+ return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
2244
+ }
2245
+ console.error(`[workspace] ${call.name} failed:`, err);
2246
+ if (err instanceof CommitUnknownError) {
2247
+ return unexpectedAnswer(call.name, subject, err.setCause, {
2248
+ said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
2249
+ });
2228
2250
  }
2251
+ return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
2229
2252
  }
2230
2253
 
2254
+
2231
2255
  async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
2232
2256
  // Wake agents on filesystem events when the originating mount opted in.
2233
2257
  // The EventGate still has final say — gate policies can further filter by
@@ -2272,7 +2296,7 @@ export class WorkspaceModule implements Module {
2272
2296
  text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2273
2297
  }
2274
2298
  if (conflicts && conflicts.length > 0) {
2275
- text += `\n[conflicts: ${conflicts.join(', ')}]`;
2299
+ text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
2276
2300
  }
2277
2301
 
2278
2302
  return {
@@ -2323,16 +2347,15 @@ export class WorkspaceModule implements Module {
2323
2347
  resolveAbsolutePath(mountPrefixedPath: string): string | null {
2324
2348
  try {
2325
2349
  // Synchronous, so the path as given. The nested-mount check takes it
2326
- // in each spelling readNesting knows for the nested mount's directory,
2327
- // or in any spelling a disk that folds names may fold to one of them,
2328
- // erring toward that mount (holderOf's asGiven, beginsWithDir). The
2329
- // include judges it as given, so on a disk that
2350
+ // in any spelling a disk that folds names may fold to the nested
2351
+ // mount's directory, erring toward that mount (holderOf). The include
2352
+ // judges it as given, so on a disk that
2330
2353
  // folds names a covered name whose file disk spells otherwise returns
2331
2354
  // that file's path, which the tools refuse, and a covered file named in
2332
2355
  // another case is null, which the tools read. A path the mount doesn't
2333
2356
  // hold, outside its include or in another mount's directory nested in
2334
2357
  // it, is null, as for a path that leaves the mount's directory.
2335
- const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
2358
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
2336
2359
  return resolve(mount.config.path, relativePath);
2337
2360
  } catch {
2338
2361
  return null;
@@ -2360,6 +2383,9 @@ export class WorkspaceModule implements Module {
2360
2383
  * `agentName` names the agent whose write it is for the head-move notice
2361
2384
  * (a write naming none tells no agent).
2362
2385
  *
2386
+ * A success's `disk` says where the file stands on disk as the workspace
2387
+ * last saw it, as the write tool's answer does.
2388
+ *
2363
2389
  * A write whose tree entry is set but that then fails returns success
2364
2390
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
2365
2391
  * the store failed after recording the entry, which skips the push where
@@ -2396,11 +2422,12 @@ export class WorkspaceModule implements Module {
2396
2422
  isError: true,
2397
2423
  };
2398
2424
  }
2399
- const refusal = writeRefusal(mount);
2425
+ if (relativePath === '') return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
2426
+ const refusal = writeRefusal(mount, this.writableNames());
2400
2427
  if (refusal) return { success: false, error: refusal, isError: true };
2401
2428
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2402
2429
  if (data.byteLength > maxSize) {
2403
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2430
+ return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
2404
2431
  }
2405
2432
  const store = this.getStore();
2406
2433
  // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
@@ -2414,7 +2441,7 @@ export class WorkspaceModule implements Module {
2414
2441
  }
2415
2442
  const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2416
2443
  if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2417
- return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
2444
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
2418
2445
  }
2419
2446
  return this.withMount(mount, async () => {
2420
2447
  await this.settleBeforeMutation(mount, relativePath);
@@ -2429,20 +2456,24 @@ export class WorkspaceModule implements Module {
2429
2456
  let committed = false;
2430
2457
  let failure: string | null;
2431
2458
  let modeKept: string | null = null;
2459
+ let disk: DiskState | null = null;
2432
2460
  try {
2433
2461
  const answer = await this.commitToolChange(mount, relativePath,
2434
2462
  { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
2435
2463
  modeKept = answer.modeKept;
2464
+ disk = answer.disk;
2436
2465
  failure = answer.refused === null ? null
2437
2466
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
2438
2467
  } catch (error) {
2439
2468
  if (!committed) throw error;
2440
2469
  // As write words it: a store that failed after recording the file
2441
2470
  // skipped its push, so on a mount that pushes, disk lacks it.
2471
+ const reopen = this.agreement?.awaitsReopen === true;
2442
2472
  failure = error instanceof StoredThenFailed
2443
2473
  ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
2444
- + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2474
+ + (error.pushes ? ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.` : '')
2445
2475
  : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2476
+ if (reopen) failure += ` ${AWAITS_REOPEN}`;
2446
2477
  }
2447
2478
  if (failure !== null) {
2448
2479
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -2452,7 +2483,7 @@ export class WorkspaceModule implements Module {
2452
2483
  }
2453
2484
  return {
2454
2485
  success: true,
2455
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
2486
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
2456
2487
  };
2457
2488
  });
2458
2489
  }
@@ -2491,10 +2522,12 @@ export class WorkspaceModule implements Module {
2491
2522
  return this.withMount(mount, async () => {
2492
2523
  const { report, undecided } = await this.ensureSynced(mount, relativePath);
2493
2524
  const entry = store.treeGet(mount.treeStateId, relativePath);
2494
- if (!entry) return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2495
- const blob = store.getBlob(entry.blobHash);
2496
- if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
2497
- return { data: blob };
2525
+ if (!entry) {
2526
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
2527
+ }
2528
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2529
+ if ('lost' in contents) return { error: nothingToRead(contents.lost) };
2530
+ return { data: contents.bytes };
2498
2531
  });
2499
2532
  }
2500
2533
 
@@ -2577,9 +2610,9 @@ export class WorkspaceModule implements Module {
2577
2610
  }
2578
2611
  }
2579
2612
 
2580
- /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
2613
+ /** The directories of the mounts nested inside a mount, mount-relative. */
2581
2614
  private othersOf(mountName: string): string[] {
2582
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
2615
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
2583
2616
  }
2584
2617
 
2585
2618
  /**
@@ -2595,13 +2628,8 @@ export class WorkspaceModule implements Module {
2595
2628
  * alone, with no real root, since through it it would claim the other's
2596
2629
  * every path.
2597
2630
  *
2598
- * Each way it lies there is a spelling of its directory (`fromRoots`), and
2599
- * so is the spelling disk lists each of those by, where that differs. On a
2600
- * disk that folds names, the outer mount's walks name the directory as
2601
- * disk does, and so does a path parsePath has respelled, while a path given
2602
- * as configured still names it as configured (resolveAbsolutePath). A
2603
- * directory not on disk keeps the spellings the roots give alone. Each
2604
- * mount's real root comes back beside the nesting (`reals`), for what
2631
+ * Each way it lies there is a spelling of its directory (`fromRoots`).
2632
+ * Each mount's real root comes back beside the nesting (`reals`), for what
2605
2633
  * start reads there, and each directory above each configured path with
2606
2634
  * where it lies on disk (`above`), for a direct mount whose root moves
2607
2635
  * (movedNesting).
@@ -2626,16 +2654,15 @@ export class WorkspaceModule implements Module {
2626
2654
  for (const inner of this.config.mounts) {
2627
2655
  if (outer === inner) continue;
2628
2656
  const real = reals.get(inner.name)!;
2629
- const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!, outerPath);
2657
+ const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name)!);
2630
2658
  if (at === undefined) continue;
2631
- const { configured, fromRoots, spellings, claims } = at;
2632
- nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2659
+ const { configured, fromRoots, claims } = at;
2660
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
2633
2661
  const placed = fromRoots.filter((s) => s.dir !== configured);
2634
2662
  if (placed.length > 0) {
2635
- console.warn(
2636
- `[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2637
- `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`,
2638
- );
2663
+ const at = (sure: boolean) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
2664
+ const where = [at(true) && `where it lies on disk, at ${at(true)}`, at(false) && `where it may lie on disk, at ${at(false)}`].filter(Boolean).join(', and ');
2665
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
2639
2666
  }
2640
2667
  }
2641
2668
  }
@@ -2644,11 +2671,11 @@ export class WorkspaceModule implements Module {
2644
2671
 
2645
2672
  /**
2646
2673
  * The mounts nested in `mountName`, by directory, name, real root where it
2647
- * has one, and include (MountView's `nested`), in each spelling known.
2674
+ * has one, and include (MountView's `nested`).
2648
2675
  */
2649
2676
  private nestedOf(mountName: string): NestedMount[] {
2650
2677
  return (this.otherMounts.get(mountName) ?? []).flatMap((o) =>
2651
- o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2678
+ o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2652
2679
  }
2653
2680
 
2654
2681
  /**
@@ -2747,10 +2774,12 @@ export class WorkspaceModule implements Module {
2747
2774
  if (real === undefined) continue;
2748
2775
  above = placedAbove.get(inner.name) ?? [];
2749
2776
  }
2750
- const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2777
+ const at = placementOf(outerPath, rootReal, innerPath, real, above);
2751
2778
  if (at === undefined) continue;
2752
2779
  const include = this.mounts.get(inner.name)?.config.include;
2753
- for (const s of at.spellings) nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2780
+ for (const s of at.fromRoots) {
2781
+ nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
2782
+ }
2754
2783
  }
2755
2784
  return nested;
2756
2785
  }
@@ -2760,33 +2789,22 @@ export class WorkspaceModule implements Module {
2760
2789
  * of that mount, judged lexically where the path lands (`.`, `..` and
2761
2790
  * repeated separators resolved, as for the traversal guard; a link of the
2762
2791
  * outer mount isn't followed), with the path to use there; undefined when
2763
- * no nested mount's directory holds it. A path parsePath has respelled is
2764
- * compared with the directory's spellings exactly. One judged as given
2765
- * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2766
- * folds names may compare it (beginsWithDir), so it can't reach that
2767
- * mount's directory by spelling a segment otherwise. On a disk that tells
2768
- * names apart, that takes a directory of this mount's own spelled like the
2769
- * nested one in another way as that mount's too.
2792
+ * no nested mount's directory holds it.
2770
2793
  */
2771
- private holderOf(mountName: string, relativePath: string, asGiven = false, moved: NestedMount[] | null = null): { name: string; use: string } | undefined {
2794
+ private holderOf(mountName: string, relativePath: string, moved: NestedMount[] | null = null): { name: string; use: string; guess?: NestedGuess } | undefined {
2772
2795
  const mount = this.mounts.get(mountName);
2773
2796
  if (!mount) return undefined;
2774
2797
  const root = resolve(mount.config.path);
2775
2798
  const resolved = resolve(root, relativePath);
2776
2799
  if (!isContainedPath(root, resolved)) return undefined;
2777
- const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2800
+ const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
2778
2801
  // A direct mount whose root a call found moved is judged by that call's
2779
2802
  // nesting (movedNesting), each directory beneath its configured path.
2780
- const dirs = moved !== null
2781
- ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2782
- : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2783
- const holder = dirs
2784
- .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2785
- .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2786
- .sort((a, b) => b.depth - a.depth)[0];
2803
+ const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
2787
2804
  if (!holder) return undefined;
2788
- const rest = segments.slice(holder.depth).join('/');
2789
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2805
+ const rest = restBeneath(path, holder);
2806
+ const guess = nestedGuess(mountName, path.split('/'), holder);
2807
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
2790
2808
  }
2791
2809
 
2792
2810
  /**
@@ -2795,10 +2813,10 @@ export class WorkspaceModule implements Module {
2795
2813
  * it; for a listing, whose the path is.
2796
2814
  */
2797
2815
  private otherMountNote(mountName: string, relativePath: string, by: 'sync' | 'push' | 'list', moved: NestedMount[] | null = null): string {
2798
- const holder = this.holderOf(mountName, relativePath, false, moved);
2816
+ const holder = this.holderOf(mountName, relativePath, moved);
2799
2817
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2800
2818
  return holder
2801
- ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2819
+ ? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
2802
2820
  : `it lies in another mount's directory${what}`;
2803
2821
  }
2804
2822
 
@@ -2818,7 +2836,7 @@ export class WorkspaceModule implements Module {
2818
2836
  // Both checks judge by the nesting the parse found, and the second reads
2819
2837
  // disk beneath the root the parse resolved.
2820
2838
  const { mount, root, moved } = spelled;
2821
- const parsed = this.refuseNotHeld(spelled, path, false, moved);
2839
+ const parsed = this.refuseNotHeld(spelled, path, moved);
2822
2840
  // A path can also reach a nested mount's directory, or a file this
2823
2841
  // mount's own include leaves out, through a symlink on its way. No push
2824
2842
  // of this mount writes it there and no read reads it, so it's refused
@@ -2858,7 +2876,8 @@ export class WorkspaceModule implements Module {
2858
2876
  // operation, in the words a disk read gives a path it can't resolve,
2859
2877
  // whatever the place realpath stopped short of would say.
2860
2878
  if (judged && unresolved.code !== undefined) return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2861
- const holder = nestedAtReal(real, nested);
2879
+ let holder = await nestedAtReal(real, nested);
2880
+ if (holder?.guess === null) holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
2862
2881
  if (!holder) {
2863
2882
  if (!judged) return null;
2864
2883
  const rootReal = await realBeneath(root);
@@ -2874,11 +2893,11 @@ export class WorkspaceModule implements Module {
2874
2893
  // on the place's way as that mount's, so a place beneath anything else
2875
2894
  // there, such as a file the include leaves out, isn't named; one beneath
2876
2895
  // a directory not there yet is.
2877
- const onItsWay = holder.include === undefined
2896
+ const onItsWay = !holder.judgedByInclude
2878
2897
  || await stat(dirname(real)).then((info) => info.isDirectory(), (err: unknown) => errnoOf(err) === 'ENOENT');
2879
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2880
- if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2881
- return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2898
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
2899
+ if (!onItsWay || !holder.names(directory)) return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2900
+ return new NestedMountError(path, mount.config.name, holder, 'leads into');
2882
2901
  }
2883
2902
 
2884
2903
  /**
@@ -2886,13 +2905,12 @@ export class WorkspaceModule implements Module {
2886
2905
  * mount's directory first, naming the mount that holds it, then outside
2887
2906
  * the mount's include. Each is judged on the path as the caller parsed it:
2888
2907
  * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2889
- * which is synchronous and says so (`asGiven`, so the nested-mount check
2890
- * folds where disk does). The include's refusal shows the path as given
2908
+ * which is synchronous. The include's refusal shows the path as given
2891
2909
  * (`given`), since it answers the path the caller asked about.
2892
2910
  */
2893
- private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, asGiven = false, moved: NestedMount[] | null = null): P {
2894
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2895
- if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2911
+ private refuseNotHeld<P extends { mount: MountState; relativePath: string; path: string }>(parsed: P, given: string, moved: NestedMount[] | null = null): P {
2912
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
2913
+ if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
2896
2914
  if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2897
2915
  throw new OutsideIncludeError(given, parsed.mount.config);
2898
2916
  }
@@ -2942,7 +2960,7 @@ export class WorkspaceModule implements Module {
2942
2960
  * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2943
2961
  */
2944
2962
  private async refusesAsOutside(mount: MountState, relativePath: string, moved: NestedMount[] | null, root: string): Promise<boolean> {
2945
- if (this.holderOf(mount.config.name, relativePath, false, moved)) return false;
2963
+ if (this.holderOf(mount.config.name, relativePath, moved)) return false;
2946
2964
  if (insideMount(relativePath, mount.config.include)) return false;
2947
2965
  const own = { include: mount.config.include, nested: this.nestedOf(mount.config.name) };
2948
2966
  if (!mount.direct && this.getStore().treeList(mount.treeStateId, relativePath + '/')
@@ -3015,18 +3033,12 @@ export class WorkspaceModule implements Module {
3015
3033
 
3016
3034
  /**
3017
3035
  * How many segments the innermost nested mount's directory that holds
3018
- * `relativePath` has, in a spelling it's known by (othersOf), or, for a
3019
- * direct mount whose root has moved, in the nesting there (`moved`);
3036
+ * `relativePath` has, or, for a direct mount whose root has moved, in the
3037
+ * nesting there (`moved`);
3020
3038
  * undefined when no nested mount's directory holds it.
3021
3039
  */
3022
3040
  private nestedDepth(mountName: string, relativePath: string, moved: NestedMount[] | null = null): number | undefined {
3023
- let depth: number | undefined;
3024
- for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
3025
- if (relativePath !== dir && !relativePath.startsWith(`${dir}/`)) continue;
3026
- const segments = dir.split('/').length;
3027
- if (depth === undefined || segments > depth) depth = segments;
3028
- }
3029
- return depth;
3041
+ return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
3030
3042
  }
3031
3043
 
3032
3044
  /**
@@ -3049,11 +3061,7 @@ export class WorkspaceModule implements Module {
3049
3061
  // Only an entry this mount's include covers spells a draft's path
3050
3062
  // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
3051
3063
  // from an earlier version, nor one the include leaves out, which would
3052
- // respell a covered draft into a name the include leaves out. An entry
3053
- // in a nested mount's directory that this mount's include covers
3054
- // spells one whatever that mount's include says, so on a disk that
3055
- // folds names, a draft naming the directory in another spelling takes
3056
- // the entry's spelling and is judged as in it.
3064
+ // respell a covered draft into a name the include leaves out.
3057
3065
  under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
3058
3066
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
3059
3067
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
@@ -3079,25 +3087,24 @@ export class WorkspaceModule implements Module {
3079
3087
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
3080
3088
  // store would keep one name and disk another.
3081
3089
  if (LONE_SURROGATE.test(path)) {
3082
- throw new Error(`The path ${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Nothing was done.`);
3090
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.`);
3083
3091
  }
3084
3092
  const slashIdx = path.indexOf('/');
3085
3093
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
3086
3094
  const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
3087
3095
 
3088
3096
  const mount = this.mounts.get(mountName);
3089
- if (!mount) {
3090
- throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
3091
- }
3097
+ if (!mount) throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
3092
3098
  const segments = pathSegments(given);
3093
3099
  if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
3094
- throw new Error(`Path traversal detected: "${path}" has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root.`);
3100
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root, as "${mountName}/<path>".`);
3095
3101
  }
3096
3102
  const kept: string[] = [];
3097
3103
  for (const segment of segments) {
3098
3104
  if (segment !== '..') kept.push(segment);
3099
3105
  else if (kept.pop() === undefined) {
3100
- throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
3106
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
3107
+ + `Name a file from its own mount's name; ${this.mountList()}.`);
3101
3108
  }
3102
3109
  }
3103
3110
  const relativePath = kept.join('/');
@@ -3112,11 +3119,41 @@ export class WorkspaceModule implements Module {
3112
3119
  const root = resolve(mount.config.path);
3113
3120
  const resolved = resolve(root, relativePath);
3114
3121
  if (!isContainedPath(root, resolved)) {
3115
- throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
3122
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} resolves outside mount "${mountName}", as a drive or root named in it does: give the path from the mount's root, as "${mountName}/<path>".`);
3116
3123
  }
3117
3124
  return { mount, relativePath };
3118
3125
  }
3119
3126
 
3127
+ private writableNames(): string[] {
3128
+ return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
3129
+ }
3130
+
3131
+ private mountList(): string {
3132
+ const names = [...this.mounts.keys()];
3133
+ return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
3134
+ }
3135
+
3136
+ private noSuchMountField(given: string, bare: string): string {
3137
+ const [close] = closestNames(given, [...this.mounts.keys()], 1);
3138
+ return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
3139
+ + (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
3140
+ + ` Leaving "mount" out ${bare}.`;
3141
+ }
3142
+
3143
+ private noSuchMount(path: string, mountName: string): string {
3144
+ const names = [...this.mounts.keys()];
3145
+ const rest = path.slice(mountName.length);
3146
+ const unslashed = path.replace(/^[/\\]+/, '');
3147
+ if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
3148
+ return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
3149
+ }
3150
+ if (mountName === '') return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
3151
+ const [close] = closestNames(mountName, names, 1);
3152
+ const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
3153
+ : `${JSON.stringify(path)} starts with ${JSON.stringify(mountName)}, which is no mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
3154
+ return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
3155
+ }
3156
+
3120
3157
  /**
3121
3158
  * Whether the module takes work: stop hasn't finished. Work admitted while
3122
3159
  * stop drains still runs; once stop has saved the pins and watermarks,
@@ -3140,31 +3177,26 @@ export class WorkspaceModule implements Module {
3140
3177
  maxSize: number,
3141
3178
  ): { bytes: Buffer; mimeType: SupportedImageMimeType } {
3142
3179
  if (bytes.byteLength === 0) {
3143
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3180
+ throw emptyImage(mountPrefixedPath);
3144
3181
  }
3145
- if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
3182
+ if (bytes.byteLength > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
3146
3183
  return {
3147
3184
  bytes,
3148
3185
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
3149
3186
  };
3150
3187
  }
3151
3188
 
3152
- private tryReadImageFromTree(
3189
+ private async readImageFromTree(
3153
3190
  mount: MountState,
3154
3191
  relativePath: string,
3155
3192
  mountPrefixedPath: string,
3156
3193
  maxSize: number,
3157
- ): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
3158
- const store = this.getStore();
3159
- const entry = store.treeGet(mount.treeStateId, relativePath);
3194
+ ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType } | { lost: LostContents } | null> {
3195
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
3160
3196
  if (!entry) return null;
3161
-
3162
- const blob = store.getBlob(entry.blobHash);
3163
- if (!blob) {
3164
- throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
3165
- }
3166
-
3167
- return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
3197
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
3198
+ if ('lost' in contents) return contents;
3199
+ return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
3168
3200
  }
3169
3201
 
3170
3202
  /**
@@ -3181,19 +3213,14 @@ export class WorkspaceModule implements Module {
3181
3213
  * another directory carries none. The identity is rootIdentityForRead's,
3182
3214
  * which answers while the evidence awaits reopening; where even it can't
3183
3215
  * be read, as when the evidence was never read back whole, the read is
3184
- * refused too, with an errno only where no directory is at the root's path.
3216
+ * refused before its root is resolved, with an errno only where no
3217
+ * directory is at the root's path.
3185
3218
  * A direct mount's root is checked against no identity, and its read reads
3186
3219
  * no evidence: it promises what's at its root now (diskView).
3187
3220
  */
3188
3221
  private async readRoot(mount: MountState): Promise<{ real: string; dir: CheckedDir }> {
3189
3222
  const name = mount.config.name;
3190
3223
  const root = resolve(mount.config.path);
3191
- let real: string;
3192
- try {
3193
- real = await realpath(root);
3194
- } catch (err) {
3195
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
3196
- }
3197
3224
  let rootIdentity: MountView['rootIdentity'];
3198
3225
  if (!mount.direct) {
3199
3226
  try {
@@ -3201,17 +3228,22 @@ export class WorkspaceModule implements Module {
3201
3228
  } catch (err) {
3202
3229
  // The evidence can't be read back, so neither can the check be made.
3203
3230
  // Where no directory is at the root's path, its errno still says so.
3204
- throw new WorkspaceReadError('mount_unavailable',
3205
- `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
3231
+ throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
3206
3232
  }
3207
3233
  }
3234
+ let real: string;
3235
+ try {
3236
+ real = await realpath(root);
3237
+ } catch (err) {
3238
+ throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
3239
+ }
3208
3240
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
3209
3241
  if ('dir' in checked) return { real, dir: checked.dir };
3210
3242
  // Another directory is no absence. For any other refusal, what is at the
3211
3243
  // root's path now decides: nothing, something that isn't a directory, or
3212
3244
  // a directory the path has come to name since it was resolved (no errno).
3213
3245
  const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
3214
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
3246
+ throw new RootUnavailable(name, checked.reason, 'root', errno);
3215
3247
  }
3216
3248
 
3217
3249
  /**
@@ -3226,7 +3258,7 @@ export class WorkspaceModule implements Module {
3226
3258
  private async rootMoved(mount: MountState): Promise<WorkspaceReadError> {
3227
3259
  const errno = await rootAbsence(resolve(mount.config.path));
3228
3260
  const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
3229
- return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
3261
+ return new RootUnavailable(mount.config.name, reason, 'root', errno);
3230
3262
  }
3231
3263
 
3232
3264
  /**
@@ -3328,7 +3360,7 @@ export class WorkspaceModule implements Module {
3328
3360
  }
3329
3361
  if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
3330
3362
  // A FIFO, socket or device is never opened.
3331
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
3363
+ throw new NotRegularFile(mountPrefixedPath, 'lstat');
3332
3364
  }
3333
3365
 
3334
3366
  let handle: Awaited<ReturnType<typeof open>>;
@@ -3364,7 +3396,7 @@ export class WorkspaceModule implements Module {
3364
3396
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
3365
3397
  }
3366
3398
  if (!fileStat.isFile()) {
3367
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
3399
+ throw new NotRegularFile(mountPrefixedPath, 'fstat');
3368
3400
  }
3369
3401
 
3370
3402
  if (!follow && !useNoFollow) {
@@ -3403,13 +3435,13 @@ export class WorkspaceModule implements Module {
3403
3435
  if (resolvedThrough !== null) throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
3404
3436
 
3405
3437
  const inside = isContainedPath(realMountRoot, realFilePath);
3406
- const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3438
+ const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
3407
3439
  // A place in a nested mount's directory that its include leaves out is
3408
3440
  // no mount's, so the path leaves this one (namesNestedPlace), and so
3409
3441
  // does one reaching a file this mount's own include leaves out
3410
3442
  // (coversLanding).
3411
3443
  const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
3412
- if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
3444
+ if (!inside || own || (holder !== undefined && !holder.names(false))) {
3413
3445
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
3414
3446
  }
3415
3447
  if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
@@ -3477,7 +3509,9 @@ export class WorkspaceModule implements Module {
3477
3509
  * (`outside_include`), a path in another mount's directory nested in the one named
3478
3510
  * (`other_mount`), unavailable mount, missing or non-regular file,
3479
3511
  * directory, policy-denied symlink, outside-mount target, and
3480
- * file-changed-during-read.
3512
+ * file-changed-during-read. An error met while parsing the path that is
3513
+ * none of these refusals, such as the store's, is thrown as it is, and a
3514
+ * `maxBytes` that isn't a non-negative integer throws a `RangeError`.
3481
3515
  */
3482
3516
  async readFileFromDisk(
3483
3517
  mountPrefixedPath: string,
@@ -3493,11 +3527,11 @@ export class WorkspaceModule implements Module {
3493
3527
  } catch (err) {
3494
3528
  // A link's refusal (parseFilePath) carries its own code.
3495
3529
  if (err instanceof WorkspaceReadError) throw err;
3496
- const message = err instanceof Error ? err.message : String(err);
3497
- const code: WorkspaceReadErrorCode = err instanceof OutsideIncludeError ? 'outside_include'
3530
+ const code: WorkspaceReadErrorCode | null = err instanceof OutsideIncludeError ? 'outside_include'
3498
3531
  : err instanceof NestedMountError ? 'other_mount'
3499
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
3500
- throw new WorkspaceReadError(code, message, 'parse');
3532
+ : err instanceof PathRefusal ? err.code : null;
3533
+ if (code === null) throw err;
3534
+ throw new WorkspaceReadError(code, (err as Error).message, 'parse');
3501
3535
  }
3502
3536
  if (!relativePath) {
3503
3537
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
@@ -3525,92 +3559,25 @@ export class WorkspaceModule implements Module {
3525
3559
  }
3526
3560
  }
3527
3561
 
3528
- /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
3529
- private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
3530
- const p = mountPrefixedPath;
3531
- switch (err.code) {
3532
- case 'mount_unavailable':
3533
- // With why, where the root's check says, such as a directory that
3534
- // isn't the mount's.
3535
- return new WorkspaceImageReadError('mount_unavailable', err.message);
3536
- case 'directory':
3537
- return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
3538
- case 'symlink':
3539
- // The core reader's own words for a link at the path: "Symlinks are not
3540
- // allowed: <path>".
3541
- return new WorkspaceImageReadError('symlink', err.message);
3542
- case 'parent_symlink':
3543
- // The core reader's own words for a link on its way: which link and
3544
- // where it leads, with the setting for a link to a directory.
3545
- return new WorkspaceImageReadError('parent_symlink', err.message);
3546
- case 'other_mount':
3547
- // The core reader's own words for a path a link takes into a nested
3548
- // mount's directory: that mount and the path to use there. (#460's
3549
- // own other_mount, a path in that directory, is refused at parse,
3550
- // and so is a link found there before the read.)
3551
- return new WorkspaceImageReadError('other_mount', err.message);
3552
- case 'escape':
3553
- // The core reader's own words: "Symlink escape detected: <path>" for a
3554
- // followed link that resolves outside the mount, and for a link on the
3555
- // way that leads outside a mount that doesn't follow links, which link.
3556
- return new WorkspaceImageReadError('escape', err.message);
3557
- case 'changed':
3558
- return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
3559
- case 'not_found':
3560
- default:
3561
- // The core reader's own words for a file that isn't regular, and for
3562
- // a directory on the way it couldn't ask about.
3563
- if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
3564
- return new WorkspaceImageReadError('not_found', err.message);
3565
- }
3566
- if (err.message.startsWith('File not found')) {
3567
- return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
3568
- }
3569
- if (err.stage === 'realpath') {
3570
- return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
3571
- }
3572
- if (err.stage === 'stat') {
3573
- return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
3574
- }
3575
- return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
3576
- }
3577
- }
3578
-
3579
3562
  private async readImageFromFilesystem(
3580
3563
  mount: MountState,
3581
3564
  relativePath: string,
3582
3565
  mountPrefixedPath: string,
3583
3566
  maxSize: number,
3584
3567
  ): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
3585
- let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3586
- try {
3587
- opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3588
- if (fileStat.size === 0) {
3589
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3590
- }
3591
- if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath);
3592
- });
3593
- } catch (err) {
3594
- if (err instanceof WorkspaceImageReadError) throw err;
3595
- if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
3596
- if (isErrnoCode(err, 'ENOENT')) {
3597
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3598
- }
3599
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3600
- }
3601
- const { handle, fileStat } = opened;
3568
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3569
+ if (fileStat.size === 0) throw emptyImage(mountPrefixedPath);
3570
+ if (fileStat.size > maxSize) throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
3571
+ });
3602
3572
  try {
3603
3573
  // No more than maxSize is loaded: a file grown past it since the fstat
3604
3574
  // above is refused here, as one already over it was there.
3605
3575
  const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
3606
- if (more) throw imageTooLarge(maxSize, mountPrefixedPath);
3576
+ if (more) throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
3607
3577
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
3608
3578
  } catch (err) {
3609
3579
  if (err instanceof WorkspaceImageReadError) throw err;
3610
- if (isErrnoCode(err, 'ENOENT')) {
3611
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3612
- }
3613
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3580
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
3614
3581
  } finally {
3615
3582
  await handle.close();
3616
3583
  }
@@ -3643,18 +3610,19 @@ export class WorkspaceModule implements Module {
3643
3610
  * downstream agents. And, apart from it, why a write whose bytes reached
3644
3611
  * disk left the file's mode as disk had it (`modeKept`), and, for a
3645
3612
  * deletion that cleared a conflict naming a disk file the workspace never
3646
- * took in, that nothing was unlinked (`note`). The caller holds the mount's
3647
- * turn and has settled the path first (settleBeforeMutation, or a pass over
3648
- * the path, as an edit makes).
3613
+ * took in, that nothing was unlinked (`note`). And the answer's `disk` for
3614
+ * the path, unless it was refused. The caller holds the mount's turn and has
3615
+ * settled the path first (settleBeforeMutation, or a pass over the path, as
3616
+ * an edit makes).
3649
3617
  */
3650
3618
  private async commitToolChange(
3651
3619
  mount: MountState,
3652
3620
  relativePath: string,
3653
3621
  change: ToolChange,
3654
3622
  onCommitted?: () => void,
3655
- ): Promise<{ refused: string | null; modeKept: string | null; note: string | null }> {
3656
- const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3657
- return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3623
+ ): Promise<{ refused: string | null; modeKept: string | null; note: string | null; disk: DiskState | null }> {
3624
+ const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3625
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
3658
3626
  }
3659
3627
 
3660
3628
  /**
@@ -3670,15 +3638,16 @@ export class WorkspaceModule implements Module {
3670
3638
  * (changesLanded).
3671
3639
  * Answers, by path, the reason for each that reached the workspace but not
3672
3640
  * disk (`refused`), and, apart from it, why one whose bytes reached disk
3673
- * left the file's mode as disk had it (`modeKept`), and that a deletion
3674
- * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3675
- * commitToolChange answers them for one path.
3641
+ * left the file's mode as disk had it (`modeKept`), that a deletion
3642
+ * clearing a conflict that named a disk file unlinked nothing (`notes`),
3643
+ * and the answer's `disk` for each path not refused, as commitToolChange
3644
+ * answers them for one path.
3676
3645
  */
3677
3646
  private async commitToolChanges(
3678
3647
  mount: MountState,
3679
3648
  changes: Array<{ path: string; change: ToolChange }>,
3680
3649
  onCommitted?: (path: string) => void,
3681
- ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string> }> {
3650
+ ): Promise<{ refused: Map<string, string>; modeKept: Map<string, string>; notes: Map<string, string>; disk: Map<string, DiskState> }> {
3682
3651
  const store = this.getStore();
3683
3652
  const agreement = this.agreementOrThrow();
3684
3653
  const intents = this.intents.get(mount.config.name)!;
@@ -3817,7 +3786,7 @@ export class WorkspaceModule implements Module {
3817
3786
  // nothing.
3818
3787
  if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3819
3788
  putBack();
3820
- throw error;
3789
+ throw changing.length === 0 ? new AlreadyHeld(error) : error;
3821
3790
  }
3822
3791
  landedThenThrew = { error };
3823
3792
  }
@@ -3831,7 +3800,9 @@ export class WorkspaceModule implements Module {
3831
3800
  if (landedThenThrew) throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3832
3801
  const refused = new Map<string, string>();
3833
3802
  const modeKept = new Map<string, string>();
3834
- if (!this.pushesOnCommit(mount) || pushing.length === 0) return { refused, modeKept, notes };
3803
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) {
3804
+ return { refused, modeKept, notes, disk: new Map(changes.map(({ path }): [string, DiskState] => [path, this.diskOf(mount, path)])) };
3805
+ }
3835
3806
 
3836
3807
  const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3837
3808
  applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
@@ -3853,7 +3824,7 @@ export class WorkspaceModule implements Module {
3853
3824
  error: skipped.reason,
3854
3825
  } as ProcessEvent);
3855
3826
  }
3856
- return { refused, modeKept, notes };
3827
+ return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }): [string, DiskState] => [path, 'done'])) };
3857
3828
  }
3858
3829
 
3859
3830
  // ==========================================================================
@@ -3904,26 +3875,30 @@ export class WorkspaceModule implements Module {
3904
3875
  * call made again checks it.
3905
3876
  */
3906
3877
  private notCheckedYet(path: string, reason: string): string {
3907
- return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3878
+ return `${JSON.stringify(path)} isn't in the workspace, and disk couldn't be checked: ${reason}. Make the same call again, which checks disk first.`;
3908
3879
  }
3909
3880
 
3910
3881
  /** Why the workspace holds no entry at a path, when disk explains it. */
3911
- private notInWorkspace(path: string, report: PathReport | undefined): string {
3882
+ private notInWorkspace(path: string, report: PathReport | undefined, mount: MountState, missing?: string): string {
3883
+ const shown = JSON.stringify(path);
3912
3884
  switch (report?.state) {
3913
3885
  case 'workspace-deleted':
3914
- return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3886
+ return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3915
3887
  case 'not-in-branch':
3916
- return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3917
- case 'disk-only':
3918
- return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
3919
- (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
3888
+ return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3889
+ case 'disk-only': {
3890
+ const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
3891
+ return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
3892
+ + 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
3893
+ + (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
3894
+ }
3920
3895
  case 'conflict':
3921
- return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
3896
+ return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3922
3897
  'Sync this path to take the disk version.';
3923
3898
  case 'unverified':
3924
- return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
3899
+ return uncheckedFile(shown, report.note);
3925
3900
  default:
3926
- return `File not found: ${path}`;
3901
+ return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3927
3902
  }
3928
3903
  }
3929
3904
 
@@ -3966,14 +3941,14 @@ export class WorkspaceModule implements Module {
3966
3941
  // A pass also drops a copy disk made binary or too large, which
3967
3942
  // the disk-only answer describes; any other drop is disk's deletion.
3968
3943
  return {
3969
- error: took('deleted') && report?.state !== 'disk-only'
3970
- ? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3971
- : [this.notInWorkspace(path, report), ...again].join(' '),
3944
+ error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
3945
+ : undecided !== null ? this.notCheckedYet(path, undecided)
3946
+ : this.notInWorkspace(path, report, mount),
3972
3947
  };
3973
3948
  }
3974
- const blob = store.getBlob(entry.blobHash);
3975
- if (!blob) return { error: `Blob not found for: ${path}` };
3976
- return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3949
+ const contents = await this.contentsOf(mount, relativePath, path, entry);
3950
+ if ('lost' in contents) return { error: nothingToRead(contents.lost) };
3951
+ return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3977
3952
  });
3978
3953
  }
3979
3954
 
@@ -3987,33 +3962,144 @@ export class WorkspaceModule implements Module {
3987
3962
  */
3988
3963
  private async bytesOnDisk(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3989
3964
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3990
- const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3965
+ const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
3991
3966
  let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
3992
3967
  try {
3993
3968
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3994
- if (fileStat.size > maxSize) throw new Error(over);
3969
+ if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
3995
3970
  });
3996
3971
  } catch (err) {
3997
- // A refusal that names only the path says why with its errno, as a
3998
- // failed read below does.
3999
- return { error: containedRefusal(err, path) };
3972
+ if (err instanceof Refusal) return { error: err.message };
3973
+ if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
3974
+ throw err;
4000
3975
  }
4001
3976
  try {
4002
3977
  // No more than the limit is loaded, as for a file over it at its fstat.
4003
3978
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4004
- if (more) return { error: over };
3979
+ if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4005
3980
  if (looksBinary(bytes)) {
4006
- return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3981
+ return { error: `${JSON.stringify(path)} is binary, so read doesn't show it as text. ${sniffImageMime(bytes) ? 'read_image shows it, if its data holds together as an image.' : 'No tool shows it as text, so calling again won\'t change that.'}` };
4007
3982
  }
4008
3983
  return { bytes };
4009
3984
  } catch (err) {
4010
- const errno = errnoOf(err);
4011
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3985
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4012
3986
  } finally {
4013
3987
  await opened.handle.close();
4014
3988
  }
4015
3989
  }
4016
3990
 
3991
+ private async contentsOf(
3992
+ mount: MountState,
3993
+ relativePath: string,
3994
+ path: string,
3995
+ version: { blobHash: string; size: number },
3996
+ what: 'saved' | 'conflict-disk' = 'saved',
3997
+ tell: 'now' | 'later' = 'now',
3998
+ ): Promise<{ bytes: Buffer } | { lost: LostContents }> {
3999
+ const store = this.getStore();
4000
+ const stored = storedBytes(store, version.blobHash);
4001
+ if ('bytes' in stored) return stored;
4002
+ const otherSize = new Error('other size');
4003
+ let found: { bytes: Buffer } | { disk: LostContents['disk'] };
4004
+ try {
4005
+ const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4006
+ if (fileStat.size !== version.size) throw otherSize;
4007
+ });
4008
+ try {
4009
+ const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
4010
+ found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
4011
+ } finally {
4012
+ await opened.handle.close();
4013
+ }
4014
+ } catch (err) {
4015
+ found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
4016
+ }
4017
+ const subject = contentsSubject(what, path);
4018
+ const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
4019
+ if ('disk' in found) {
4020
+ const now = storedBytes(store, version.blobHash);
4021
+ if ('bytes' in now) return now;
4022
+ const lost: LostContents = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
4023
+ if (tell === 'now') this.tellLost(mount.config.name, what, version.blobHash, lost);
4024
+ return { lost };
4025
+ }
4026
+ const { bytes } = found;
4027
+ if ('bytes' in storedBytes(store, version.blobHash)) return { bytes };
4028
+ let thrown: string | null = null;
4029
+ try {
4030
+ store.storeBlob(bytes, 'application/octet-stream');
4031
+ } catch (error) {
4032
+ thrown = error instanceof Error ? error.message : String(error);
4033
+ }
4034
+ // Chronicle's storeBlob takes any file already at the hash's path as
4035
+ // stored, so whether the bytes took is what a read of them gives now.
4036
+ const now = storedBytes(store, version.blobHash);
4037
+ if ('bytes' in now) {
4038
+ this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace',
4039
+ `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
4040
+ } else {
4041
+ const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
4042
+ this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace',
4043
+ `Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
4044
+ + ('unreadable' in now
4045
+ ? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them."
4046
+ : 'Each read does this again until the store takes them.'),
4047
+ { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
4048
+ }
4049
+ return { bytes };
4050
+ }
4051
+
4052
+ private tellLost(mountName: string, what: 'saved' | 'conflict-disk', blobHash: string, lost: LostContents): void {
4053
+ this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace',
4054
+ `Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
4055
+ + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.",
4056
+ { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
4057
+ }
4058
+
4059
+ private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
4060
+ const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
4061
+ if (!absent) return { unreadable: containedRefusal(err, path) };
4062
+ const root = resolve(mount.config.path);
4063
+ const rootReal = await realpath(root).catch(() => root);
4064
+ const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
4065
+ const proof = await confirmAbsent(view, rootReal, relativePath);
4066
+ if (typeof proof !== 'string') return 'none';
4067
+ return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
4068
+ }
4069
+
4070
+ private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
4071
+ mount: MountState,
4072
+ versions: readonly T[],
4073
+ each: (version: T, contents: { bytes: Buffer } | { lost: LostContents }) => void,
4074
+ ): Promise<void> {
4075
+ const byHash = new Map<string, T[]>();
4076
+ for (const version of versions) {
4077
+ const sharing = byHash.get(version.hash);
4078
+ if (sharing) sharing.push(version);
4079
+ else byHash.set(version.hash, [version]);
4080
+ }
4081
+ for (const [hash, sharing] of byHash) {
4082
+ let bytes: Buffer | null = null;
4083
+ const lost: Array<[T, LostContents]> = [];
4084
+ for (const version of sharing) {
4085
+ const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
4086
+ if ('bytes' in contents) {
4087
+ bytes = contents.bytes;
4088
+ break;
4089
+ }
4090
+ lost.push([version, contents.lost]);
4091
+ }
4092
+ if (bytes === null) {
4093
+ for (const [version, found] of lost) {
4094
+ this.tellLost(mount.config.name, version.what, hash, found);
4095
+ each(version, { lost: found });
4096
+ }
4097
+ } else {
4098
+ for (const version of sharing) each(version, { bytes });
4099
+ }
4100
+ }
4101
+ }
4102
+
4017
4103
  // ==========================================================================
4018
4104
  // Tool Handlers
4019
4105
  // ==========================================================================
@@ -4029,15 +4115,16 @@ export class WorkspaceModule implements Module {
4029
4115
  // Each number is a whole number of at least its schema's minimum by now
4030
4116
  // (inputProblem).
4031
4117
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
4032
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
4118
+ return { success: false, isError: true, error: didNothing('read', 'it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.') };
4033
4119
  }
4034
4120
  let file: FileText;
4035
4121
  let notes: string[] = [];
4036
4122
  if (generatedContent === undefined) {
4037
4123
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
4124
+ if (relativePath === '') return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
4038
4125
  input = { ...input, path };
4039
4126
  const found = await this.bytesOf(mount, relativePath, path);
4040
- if ('error' in found) return { success: false, error: found.error, isError: true };
4127
+ if ('error' in found) return { success: false, error: didNothing('read', found.error), isError: true };
4041
4128
  file = fileText(found.bytes);
4042
4129
  notes = found.notes;
4043
4130
  } else {
@@ -4053,32 +4140,23 @@ export class WorkspaceModule implements Module {
4053
4140
  }
4054
4141
 
4055
4142
  private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
4056
- let mount: MountState;
4057
- let relativePath: string;
4058
- try {
4059
- let path: string;
4060
- ({ mount, relativePath, path } = await this.parseFilePath(input.path));
4061
- input = { ...input, path };
4062
- } catch (err) {
4063
- return {
4064
- success: false,
4065
- error: err instanceof Error ? err.message : String(err),
4066
- isError: true,
4067
- };
4068
- }
4069
- if (!relativePath) {
4070
- return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
4071
- }
4143
+ const given = input.path;
4144
+ const refused = (body: string): ToolResult => ({ success: false, isError: true, error: didNothing('read_image', body) });
4145
+ const { mount, relativePath, path } = await this.parseFilePath(given);
4146
+ if (!relativePath) return refused(namesRoot(given, mount.config.name));
4147
+ input = { ...input, path };
4072
4148
 
4073
4149
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4074
4150
  let treeError: WorkspaceImageReadError | null = null;
4151
+ let lost: LostContents | null = null;
4075
4152
 
4076
4153
  // The workspace's copy first, where it holds one, then disk's. A direct
4077
4154
  // mount holds none, so its image is disk's.
4078
4155
  if (!mount.direct) {
4079
4156
  try {
4080
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
4081
- if (image) return imageResult(input.path, image);
4157
+ const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
4158
+ if (image && 'lost' in image) lost = image.lost;
4159
+ else if (image) return imageResult(input.path, image);
4082
4160
  } catch (err) {
4083
4161
  if (err instanceof WorkspaceImageReadError) {
4084
4162
  treeError = err;
@@ -4089,31 +4167,37 @@ export class WorkspaceModule implements Module {
4089
4167
  }
4090
4168
 
4091
4169
  try {
4092
- return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
4170
+ const note = lost === null ? undefined
4171
+ : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
4172
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
4093
4173
  } catch (err) {
4094
- if (err instanceof WorkspaceImageReadError) {
4095
- if (err.code === 'not_found' && treeError) {
4096
- return { success: false, error: treeError.message, isError: true };
4097
- }
4098
- return { success: false, error: err.message, isError: true };
4174
+ if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError)) throw err;
4175
+ const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
4176
+ if (lost) {
4177
+ const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
4178
+ return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
4179
+ : `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
4099
4180
  }
4100
- throw err;
4181
+ if (noFile && treeError) return refused(treeError.message);
4182
+ return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
4101
4183
  }
4102
4184
  }
4103
4185
 
4104
4186
  private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
4105
4187
  if (LONE_SURROGATE.test(input.content)) {
4106
- return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
4188
+ return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store. Give it without the surrogate, or with the other half of its pair.' };
4107
4189
  }
4108
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4190
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
4191
+ if (relativePath === '') return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
4109
4192
  input = { ...input, path };
4110
- const refusal = writeRefusal(mount);
4111
- if (refusal) return { success: false, error: refusal, isError: true };
4193
+ const refusal = writeRefusal(mount, this.writableNames());
4194
+ if (refusal) return { success: false, error: didNothing('write', refusal), isError: true };
4112
4195
 
4113
4196
  const store = this.getStore();
4114
4197
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4115
- if (Buffer.byteLength(input.content) > maxSize) {
4116
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
4198
+ const bytes = Buffer.byteLength(input.content);
4199
+ if (bytes > maxSize) {
4200
+ return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
4117
4201
  }
4118
4202
 
4119
4203
  const buffer = Buffer.from(input.content, 'utf-8');
@@ -4122,13 +4206,14 @@ export class WorkspaceModule implements Module {
4122
4206
  if (mount.direct) {
4123
4207
  const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
4124
4208
  if ('error' in written) return { success: false, isError: true, error: written.error };
4125
- return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
4209
+ return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
4126
4210
  }
4127
4211
  let committed = false;
4128
4212
  let refused: string | null;
4129
4213
  let modeKept: string | null;
4214
+ let disk: DiskState | null;
4130
4215
  try {
4131
- ({ refused, modeKept } = await this.withMount(mount, async () => {
4216
+ ({ refused, modeKept, disk } = await this.withMount(mount, async () => {
4132
4217
  await this.settleBeforeMutation(mount, relativePath);
4133
4218
  const blobHash = store.storeBlob(buffer, 'text/plain');
4134
4219
  // An overwrite keeps the file's mode, as an edit does.
@@ -4137,14 +4222,14 @@ export class WorkspaceModule implements Module {
4137
4222
  () => { committed = true; });
4138
4223
  }));
4139
4224
  } catch (error) {
4140
- if (!committed) throw error;
4225
+ if (!committed) throw notStored(error);
4141
4226
  return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
4142
4227
  ? { storeFailed: error.message, pushes: error.pushes }
4143
- : { threw: error instanceof Error ? error.message : String(error) });
4228
+ : { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
4144
4229
  }
4145
4230
  if (refused !== null) return storedButUnfinished('write', input.path, { refused });
4146
4231
 
4147
- return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
4232
+ return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
4148
4233
  }
4149
4234
 
4150
4235
  private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
@@ -4191,12 +4276,14 @@ export class WorkspaceModule implements Module {
4191
4276
  * stored where its record landed, and that it can't tell where they
4192
4277
  * couldn't be read back.
4193
4278
  * Under the mount's turn, one pass settles every path with disk, by the
4194
- * rule a listing uses, so the edits apply to what disk now holds. That pass
4195
- * is the only step that awaits before the commit: every file is then read,
4196
- * every edit planned, and every entry set (commitToolChanges) with no turn
4197
- * of the event loop between, so no pass, branch change or other call moves
4198
- * the tree between a file's read and its set. Disk then takes what it can
4199
- * of the stored change.
4279
+ * rule a listing uses, so the edits apply to what disk now holds. Each
4280
+ * file's stored contents are then read; where the store has lost some,
4281
+ * they're restored from disk's matching copy (contentsOf) and the pass runs
4282
+ * again, so the plan reads a pass taken after every await. From there
4283
+ * nothing awaits before the commit: every edit is planned and every entry
4284
+ * set (commitToolChanges) with no turn of the event loop between, so no
4285
+ * pass, branch change or other call moves the tree between a file's read
4286
+ * and its set. Disk then takes what it can of the stored change.
4200
4287
  *
4201
4288
  * The answer is held to `maxChars`, the length the call's result may have
4202
4289
  * (ToolCall.resultMaxChars), as edit-text.ts describes.
@@ -4237,7 +4324,7 @@ export class WorkspaceModule implements Module {
4237
4324
  }
4238
4325
  for (const field of ['oldString', 'newString'] as const) {
4239
4326
  if (LONE_SURROGATE.test(edit[field])) {
4240
- problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
4327
+ problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.` });
4241
4328
  }
4242
4329
  }
4243
4330
  if (problems.length === found) specs.set(index, spec);
@@ -4291,7 +4378,26 @@ export class WorkspaceModule implements Module {
4291
4378
  return await this.withMount(mount, async (): Promise<ToolResult> => {
4292
4379
  // As every file tool's pass does, it names the paths it acts on, so a
4293
4380
  // file the mount passes over by design is observed (see PassOptions.named).
4294
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4381
+ let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4382
+ let ops = pass.ops;
4383
+ const contents = new Map<string, Buffer>();
4384
+ const lost = new Map<string, { blobHash: string; lost: LostContents }>();
4385
+ const toRecover: Array<{ relativePath: string; file: string; hash: string; size: number; what: 'saved' }> = [];
4386
+ for (const target of targets.values()) {
4387
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4388
+ if (!entry || contents.has(entry.blobHash)) continue;
4389
+ const stored = storedBytes(store, entry.blobHash);
4390
+ if ('bytes' in stored) contents.set(entry.blobHash, stored.bytes);
4391
+ else toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
4392
+ }
4393
+ if (toRecover.length > 0) {
4394
+ await this.restoreEach(mount, toRecover, (version, found) => {
4395
+ if ('bytes' in found) contents.set(version.hash, found.bytes);
4396
+ else lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
4397
+ });
4398
+ pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4399
+ ops = [...ops, ...pass.ops];
4400
+ }
4295
4401
  // A pass that decided nothing checked no file's disk copy.
4296
4402
  const undecided = undecidedOf(pass);
4297
4403
  // Nothing below awaits until the commit's push.
@@ -4310,7 +4416,7 @@ export class WorkspaceModule implements Module {
4310
4416
  // A mode taken in alone isn't a change to the text: it brings nothing
4311
4417
  // new to read, as it makes no event (emitFsEvents), and the edits
4312
4418
  // then write at the mode disk has, so it leaves nothing to say.
4313
- const took = (op: TreeOp) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4419
+ const took = (op: TreeOp) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4314
4420
  // Which copy the edits used, when it isn't the one the workspace
4315
4421
  // last agreed on with disk: disk's newer text, or the workspace's
4316
4422
  // copy because disk's couldn't be read or disk has none. A file the
@@ -4375,18 +4481,28 @@ export class WorkspaceModule implements Module {
4375
4481
  // A pass also drops a copy disk made binary or too large, which
4376
4482
  // the disk-only answer describes; any other drop is disk's deletion.
4377
4483
  const text = took('deleted') && report?.state !== 'disk-only'
4378
- ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
4379
- : this.notInWorkspace(target.path, report);
4380
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
4484
+ ? deletedOnDisk(target.path)
4485
+ : this.notInWorkspace(target.path, report, mount);
4486
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
4381
4487
  continue;
4382
4488
  }
4383
- notes.push(...lookedIn);
4384
- const blob = store.getBlob(entry.blobHash);
4385
- if (!blob) {
4386
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4489
+ const known = contents.get(entry.blobHash);
4490
+ const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
4491
+ if (!('bytes' in stored)) {
4492
+ const found = lost.get(target.relativePath);
4493
+ problems.push({
4494
+ kind: 'file',
4495
+ edits: target.edits,
4496
+ path: target.path,
4497
+ text: found?.blobHash === entry.blobHash
4498
+ ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
4499
+ : `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`,
4500
+ whole: true,
4501
+ });
4387
4502
  continue;
4388
4503
  }
4389
- const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
4504
+ notes.push(...lookedIn);
4505
+ const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
4390
4506
  if (plan === null) continue;
4391
4507
  plans.push({
4392
4508
  target,
@@ -4400,7 +4516,7 @@ export class WorkspaceModule implements Module {
4400
4516
  }
4401
4517
  if (problems.length) return refuse(problems, notes);
4402
4518
 
4403
- const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4519
+ const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4404
4520
  path: target.relativePath,
4405
4521
  change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4406
4522
  })), () => { committed = true; });
@@ -4410,19 +4526,19 @@ export class WorkspaceModule implements Module {
4410
4526
  plan,
4411
4527
  notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4412
4528
  modeKept: modeKept.get(target.relativePath) ?? null,
4413
- })), given.length, maxChars);
4529
+ })), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath)!)));
4414
4530
  });
4415
4531
  } catch (error) {
4416
4532
  const message = error instanceof Error ? error.message : String(error);
4417
4533
  if (committed) {
4418
4534
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
4419
4535
  ? { storeFailed: message, pushes: error.pushes }
4420
- : { threw: message }, maxChars);
4536
+ : { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
4421
4537
  }
4422
4538
  // A set that threw and couldn't be read back may have stored the edits,
4423
4539
  // and a second call could then apply them twice.
4424
4540
  if (error instanceof CommitUnknownError) {
4425
- const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4541
+ const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
4426
4542
  return {
4427
4543
  success: false,
4428
4544
  isError: true,
@@ -4435,9 +4551,8 @@ export class WorkspaceModule implements Module {
4435
4551
  }
4436
4552
  // The commit sets every entry in one record or none, and a set that
4437
4553
  // threw was read back, so nothing is stored; or every change was one
4438
- // the tree already held, which leaves the tree as it was either way.
4439
- if (tool === 'edit_many') return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
4440
- throw error;
4554
+ // the tree already held (AlreadyHeld).
4555
+ throw notStored(error);
4441
4556
  }
4442
4557
  }
4443
4558
 
@@ -4455,7 +4570,7 @@ export class WorkspaceModule implements Module {
4455
4570
  path: string,
4456
4571
  callerAgentName: string | undefined,
4457
4572
  ): Promise<{ mount: MountState; relativePath: string; path: string } | Extract<EditProblem, { kind: 'file' }>> {
4458
- const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: true });
4573
+ const unusable = (text: string) => ({ kind: 'file' as const, edits: [], path, text, whole: false });
4459
4574
  try {
4460
4575
  // In the one spelling every file tool reads, as disk spells it, so two
4461
4576
  // spellings of a file are one entry and, in one call, one plan
@@ -4464,14 +4579,16 @@ export class WorkspaceModule implements Module {
4464
4579
  const where = await this.parseFilePath(path);
4465
4580
  // One that names the mount's root, its bare name included, names no
4466
4581
  // file.
4467
- if (where.relativePath === '') return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
4468
- const refusal = writeRefusal(where.mount);
4582
+ if (where.relativePath === '') return unusable(namesRoot(path, where.mount.config.name));
4583
+ const refusal = writeRefusal(where.mount, this.writableNames());
4469
4584
  if (refusal) return unusable(refusal);
4470
4585
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4471
- if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4586
+ if (generated) return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
4472
4587
  return where;
4473
4588
  } catch (error) {
4474
- return unusable(error instanceof Error ? error.message : String(error));
4589
+ if (error instanceof Refusal) return unusable(error.message);
4590
+ if (error instanceof WorkspaceReadError) return unusable(readRefusal(error, path));
4591
+ throw notStored(error);
4475
4592
  }
4476
4593
  }
4477
4594
 
@@ -4480,10 +4597,10 @@ export class WorkspaceModule implements Module {
4480
4597
  * and the agent's own is read-only, since it shows the agent's tool
4481
4598
  * presentation, which set_tool_visibility and set_tool_description change.
4482
4599
  */
4483
- private generatedFileRefusal(generated: { agentName: string }, callerAgentName: string | undefined): string {
4600
+ private generatedFileRefusal(path: string, generated: { agentName: string }, callerAgentName: string | undefined): string {
4484
4601
  return callerAgentName !== generated.agentName
4485
- ? 'Generated file belongs to another agent'
4486
- : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4602
+ ? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
4603
+ : `${JSON.stringify(path)} is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.`;
4487
4604
  }
4488
4605
 
4489
4606
  /**
@@ -4507,12 +4624,13 @@ export class WorkspaceModule implements Module {
4507
4624
  conflicts: Map<EditTarget, string>,
4508
4625
  outcome: { refused: Map<string, string>; modeKept: Map<string, string> } | { threw: string } | { storeFailed: string; pushes: boolean },
4509
4626
  maxChars: number,
4627
+ reopen = false,
4510
4628
  ): ToolResult {
4511
4629
  const conflictOf = (target: EditTarget) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4512
4630
  if (tool === 'edit') {
4513
4631
  const [target] = targets;
4514
4632
  const note = [applied.get(target!) ?? null, conflictOf(target!)].filter((text): text is string => text !== null).join(' ');
4515
- return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null);
4633
+ return storedButUnfinished('edit', target!.path, 'refused' in outcome ? { refused: outcome.refused.get(target!.relativePath)! } : outcome, note || null, reopen);
4516
4634
  }
4517
4635
  const stored = "Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.";
4518
4636
  // The head is never cut, so what stands for the whole call goes in it,
@@ -4533,9 +4651,10 @@ export class WorkspaceModule implements Module {
4533
4651
  }
4534
4652
  } else if ('storeFailed' in outcome) {
4535
4653
  head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4536
- + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4654
+ + (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
4655
+ + (reopen ? ` ${AWAITS_REOPEN}` : '');
4537
4656
  } else {
4538
- head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4657
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
4539
4658
  }
4540
4659
  const kept = 'modeKept' in outcome
4541
4660
  ? targets.flatMap((target) => {
@@ -4577,7 +4696,7 @@ export class WorkspaceModule implements Module {
4577
4696
  try {
4578
4697
  staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4579
4698
  } catch (error) {
4580
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4699
+ return { error: directDidNothing('write', path, error) };
4581
4700
  }
4582
4701
  try {
4583
4702
  await directWriteTestSeam.afterStage?.([staged.path]);
@@ -4591,7 +4710,7 @@ export class WorkspaceModule implements Module {
4591
4710
  renameInto(staged);
4592
4711
  } catch (error) {
4593
4712
  await discard(staged);
4594
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4713
+ return { error: directDidNothing('write', path, error) };
4595
4714
  }
4596
4715
  this.recordDirectWrite(mount, agentName);
4597
4716
  return { note: linksNote(path, staged, 'write') };
@@ -4668,7 +4787,7 @@ export class WorkspaceModule implements Module {
4668
4787
  changedMeanwhile.add(target);
4669
4788
  return 'changed';
4670
4789
  }
4671
- if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
4790
+ if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
4672
4791
  else read.set(target, got);
4673
4792
  }
4674
4793
  // Two paths that reach one file, through a link this mount follows, or
@@ -4733,7 +4852,8 @@ export class WorkspaceModule implements Module {
4733
4852
  edits: target.edits,
4734
4853
  path: target.path,
4735
4854
  text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4736
- + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
4855
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
4856
+ + (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
4737
4857
  whole: true,
4738
4858
  }]);
4739
4859
  for (const { target, plan } of plans) {
@@ -4798,7 +4918,7 @@ export class WorkspaceModule implements Module {
4798
4918
  plan: plans.find((p) => p.target === target)!.plan,
4799
4919
  notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
4800
4920
  modeKept: null,
4801
- })), editCount, maxChars);
4921
+ })), editCount, maxChars, 'done');
4802
4922
  }
4803
4923
 
4804
4924
  /**
@@ -4811,7 +4931,8 @@ export class WorkspaceModule implements Module {
4811
4931
  private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
4812
4932
  // The failed file is named among the outcomes, so the head stays as
4813
4933
  // long as its reason, whatever the paths' lengths.
4814
- const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
4934
+ const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
4935
+ const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}.${can !== undefined ? ` Whoever runs this host can ${can}.` : ''} `
4815
4936
  + "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
4816
4937
  const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
4817
4938
  const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
@@ -4833,26 +4954,29 @@ export class WorkspaceModule implements Module {
4833
4954
  path: string,
4834
4955
  ): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
4835
4956
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4836
- const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
4957
+ const over = (size: string) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
4837
4958
  let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
4838
4959
  try {
4839
4960
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4840
- if (fileStat.size > maxSize) throw new Error(over);
4961
+ if (fileStat.size > maxSize) throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
4841
4962
  });
4842
4963
  } catch (err) {
4843
4964
  if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
4844
- return { error: containedRefusal(err, path) };
4965
+ if (err instanceof Refusal) return { error: err.message };
4966
+ if (err instanceof WorkspaceReadError) return { error: readRefusal(err, path) };
4967
+ throw err;
4845
4968
  }
4846
4969
  try {
4847
4970
  const before = identityOf(await opened.handle.stat({ bigint: true }));
4848
4971
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4849
- if (more) return { error: over };
4850
- if (looksBinary(bytes)) return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
4972
+ if (more) return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4973
+ if (looksBinary(bytes)) {
4974
+ return { error: `${JSON.stringify(path)} is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.` };
4975
+ }
4851
4976
  if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
4852
4977
  return { bytes, identity: before };
4853
4978
  } catch (err) {
4854
- const errno = errnoOf(err);
4855
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4979
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4856
4980
  } finally {
4857
4981
  await opened.handle.close();
4858
4982
  }
@@ -4880,11 +5004,9 @@ export class WorkspaceModule implements Module {
4880
5004
  } catch (err) {
4881
5005
  const code = errnoOf(err);
4882
5006
  const why = code === 'ENOENT' || code === 'ENOTDIR'
4883
- ? `the mount root is gone (${code}), and calling again is refused the same way until whoever runs the host puts a directory back at the mount's path`
4884
- : code === 'EACCES' || code === 'EPERM'
4885
- ? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
4886
- : `the mount root can't be read (${code ?? String(err)})`;
4887
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
5007
+ ? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
5008
+ : `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
5009
+ return { success: false, isError: true, error: didNothing('delete', why, path) };
4888
5010
  }
4889
5011
  const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4890
5012
  const { rootReal } = disk;
@@ -4893,10 +5015,11 @@ export class WorkspaceModule implements Module {
4893
5015
  await opened.handle.close();
4894
5016
  } catch (err) {
4895
5017
  // The open's own words for a replaced root speak of reading.
4896
- if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
4897
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
5018
+ if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
5019
+ return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
4898
5020
  }
4899
- return { success: false, isError: true, error: containedRefusal(err, path) };
5021
+ if (err instanceof WorkspaceReadError) return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
5022
+ throw err;
4900
5023
  }
4901
5024
  // The unlink is awaited, unlike a write's rename, so where the head
4902
5025
  // was just before it says whether the head moved while it ran.
@@ -4910,10 +5033,10 @@ export class WorkspaceModule implements Module {
4910
5033
  this.recordDirectWrite(mount, agentName, since);
4911
5034
  return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
4912
5035
  }
4913
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
5036
+ return { success: false, isError: true, error: directDidNothing('delete', path, error) };
4914
5037
  }
4915
5038
  this.recordDirectWrite(mount, agentName, since);
4916
- return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
5039
+ return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
4917
5040
  });
4918
5041
  }
4919
5042
 
@@ -5052,10 +5175,11 @@ export class WorkspaceModule implements Module {
5052
5175
  }
5053
5176
 
5054
5177
  private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
5055
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
5178
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error: unknown) => { throw notStored(error); });
5179
+ if (relativePath === '') return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
5056
5180
  input = { ...input, path };
5057
- const refusal = writeRefusal(mount);
5058
- if (refusal) return { success: false, error: refusal, isError: true };
5181
+ const refusal = writeRefusal(mount, this.writableNames());
5182
+ if (refusal) return { success: false, error: didNothing('delete', refusal), isError: true };
5059
5183
  if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
5060
5184
 
5061
5185
  const store = this.getStore();
@@ -5066,11 +5190,11 @@ export class WorkspaceModule implements Module {
5066
5190
  await this.settleBeforeMutation(mount, relativePath);
5067
5191
  const entry = store.treeGet(mount.treeStateId, relativePath);
5068
5192
  if (!entry) {
5069
- const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
5070
- return { success: false, error, isError: true };
5193
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
5194
+ return { success: false, error: didNothing('delete', error), isError: true };
5071
5195
  }
5072
5196
 
5073
- const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
5197
+ const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash },
5074
5198
  () => { committed = true; });
5075
5199
  if (refused !== null) return storedButUnfinished('delete', input.path, { refused });
5076
5200
 
@@ -5078,20 +5202,20 @@ export class WorkspaceModule implements Module {
5078
5202
  // withholds the result (agent-framework #277).
5079
5203
  return {
5080
5204
  success: true,
5081
- data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
5082
- keepWhenWithheld: ['path', 'deleted', 'note'],
5205
+ data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
5206
+ keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
5083
5207
  };
5084
5208
  });
5085
5209
  } catch (error) {
5086
- if (!committed) throw error;
5210
+ if (!committed) throw notStored(error);
5087
5211
  if (error instanceof StoredThenFailed) {
5088
5212
  // Its note, as the success answer's: a deletion that cleared a
5089
5213
  // conflict naming a disk file unlinked nothing.
5090
5214
  const note = error.notes.get(relativePath);
5091
5215
  return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes },
5092
- note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
5216
+ note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
5093
5217
  }
5094
- return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
5218
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
5095
5219
  }
5096
5220
  }
5097
5221
 
@@ -5117,7 +5241,7 @@ export class WorkspaceModule implements Module {
5117
5241
  // A path outside the mount's include that's no scope (refusesAsOutside)
5118
5242
  // is refused, as glob refuses it, and shown as given.
5119
5243
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5120
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5244
+ return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5121
5245
  }
5122
5246
  input = { ...input, path };
5123
5247
  const recursive = input.recursive === true;
@@ -5127,6 +5251,9 @@ export class WorkspaceModule implements Module {
5127
5251
  // shows disk's.
5128
5252
  const listing = await this.listingOf(mount, relativePath, recursive);
5129
5253
  const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
5254
+ const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
5255
+ const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
5256
+ const noted = note !== null ? { note } : {};
5130
5257
  if (recursive) {
5131
5258
  return {
5132
5259
  success: true,
@@ -5135,6 +5262,7 @@ export class WorkspaceModule implements Module {
5135
5262
  entries: listing.files,
5136
5263
  count: listing.files.length,
5137
5264
  ...incomplete,
5265
+ ...noted,
5138
5266
  },
5139
5267
  };
5140
5268
  }
@@ -5152,10 +5280,34 @@ export class WorkspaceModule implements Module {
5152
5280
  entries: children,
5153
5281
  count: children.length,
5154
5282
  ...incomplete,
5283
+ ...noted,
5155
5284
  },
5156
5285
  };
5157
5286
  }
5158
5287
 
5288
+ private async emptyScopeNote(mount: MountState, relativePath: string, path: string): Promise<string | null> {
5289
+ const shown = JSON.stringify(path);
5290
+ const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
5291
+ const { view, rootReal } = await this.diskView(mount);
5292
+ const seen = await observePath(view, rootReal, relativePath);
5293
+ if (seen.kind === 'file') return isFile;
5294
+ if (seen.kind !== 'absent') return null;
5295
+ if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null) return isFile;
5296
+ return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
5297
+ }
5298
+
5299
+ private async absentOnDisk(mount: MountState, relativePath: string): Promise<boolean> {
5300
+ const { view, rootReal } = await this.diskView(mount);
5301
+ return (await observePath(view, rootReal, relativePath)).kind === 'absent';
5302
+ }
5303
+
5304
+ private holdsAnythingAt(mount: MountState, relativePath: string): boolean {
5305
+ if (mount.direct) return false;
5306
+ const store = this.getStore();
5307
+ const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree): tree is string => tree !== undefined);
5308
+ return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
5309
+ }
5310
+
5159
5311
  /**
5160
5312
  * What a listing shows of a directory scope, from where the mount keeps
5161
5313
  * what it reads: the store-backed kind's pass (listingFromStore), or, on a
@@ -5259,7 +5411,7 @@ export class WorkspaceModule implements Module {
5259
5411
  if (input.path) {
5260
5412
  const { mount, relativePath, moved, root } = mountsToSearch[0]!;
5261
5413
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5262
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5414
+ return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5263
5415
  }
5264
5416
  }
5265
5417
 
@@ -5281,7 +5433,7 @@ export class WorkspaceModule implements Module {
5281
5433
  if (prefixed) {
5282
5434
  const dir = deeperOf(relativePath, prefixed.dir);
5283
5435
  if (dir === null) {
5284
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5436
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5285
5437
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5286
5438
  continue;
5287
5439
  }
@@ -5316,8 +5468,16 @@ export class WorkspaceModule implements Module {
5316
5468
  try {
5317
5469
  regex = new RegExp(input.pattern);
5318
5470
  } catch (e) {
5319
- // The parser's reason says where the pattern goes wrong.
5320
- return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
5471
+ // V8's message ends with the parser's reason, after the pattern: "Invalid
5472
+ // regular expression: /foo(/: Unterminated group".
5473
+ const message = e instanceof Error ? e.message : String(e);
5474
+ const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
5475
+ return {
5476
+ success: false,
5477
+ isError: true,
5478
+ error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
5479
+ + 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
5480
+ };
5321
5481
  }
5322
5482
 
5323
5483
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
@@ -5345,7 +5505,7 @@ export class WorkspaceModule implements Module {
5345
5505
  if (input.path) {
5346
5506
  const { mount, relativePath, moved, root } = mountsToSearch[0]!;
5347
5507
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5348
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5508
+ return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5349
5509
  }
5350
5510
  }
5351
5511
 
@@ -5369,11 +5529,9 @@ export class WorkspaceModule implements Module {
5369
5529
  // read doesn't show it.
5370
5530
  const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
5371
5531
  const bomFiles = new Map<string, GrepText['version']>();
5372
- const search = (text: GrepText): Searched => {
5532
+ const search = (text: GrepText): Searched & { bom: boolean } => {
5373
5533
  const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5374
5534
  const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
5375
- if (lineEndings !== undefined && !crlfFiles.has(text.file)) crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
5376
- if (bom && !bomFiles.has(text.file)) bomFiles.set(text.file, text.version);
5377
5535
  const found: GrepMatch[] = [];
5378
5536
  for (let i = 0; i < lines.length; i++) {
5379
5537
  if (regex.test(lines[i])) {
@@ -5386,7 +5544,7 @@ export class WorkspaceModule implements Module {
5386
5544
  found.push(match);
5387
5545
  }
5388
5546
  }
5389
- return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
5547
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
5390
5548
  };
5391
5549
 
5392
5550
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
@@ -5394,7 +5552,7 @@ export class WorkspaceModule implements Module {
5394
5552
  // is that mount's: grep searches nothing of this mount's there, and
5395
5553
  // says whose it is. A direct mount's is judged by the nesting its parse
5396
5554
  // found where its root lies now (movedNesting).
5397
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5555
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5398
5556
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5399
5557
  continue;
5400
5558
  }
@@ -5412,16 +5570,21 @@ export class WorkspaceModule implements Module {
5412
5570
  return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
5413
5571
  }
5414
5572
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
5415
- const searched = (text: GrepText): void => {
5416
- const hits = search(text);
5417
- if (hits.matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
5573
+ const searched = (text: GrepText): (() => void) => {
5574
+ const { bom, ...hits } = search(text);
5575
+ const { file, state, version } = text;
5576
+ return () => {
5577
+ if (hits.lineEndings !== undefined && !crlfFiles.has(file)) crlfFiles.set(file, { ends: hits.lineEndings, version });
5578
+ if (bom && !bomFiles.has(file)) bomFiles.set(file, version);
5579
+ if (hits.matches.length > 0) results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
5580
+ };
5418
5581
  };
5419
5582
  const kept = prefixed
5420
5583
  ? (path: string): boolean => prefixed.regex.test(path)
5421
5584
  : (path: string): boolean => fileGlob === null || fileGlob.test(path)
5422
5585
  || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
5423
5586
  const found = mount.direct
5424
- ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
5587
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
5425
5588
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
5426
5589
  skipped.push(...found.skipped);
5427
5590
  incomplete.push(...found.incomplete);
@@ -5471,8 +5634,9 @@ export class WorkspaceModule implements Module {
5471
5634
  /**
5472
5635
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
5473
5636
  * single file `relativePath` names, those `keep` lets through, each handed
5474
- * to `each` in path order: a pass over the scope, then each path's
5475
- * workspace copy, labelled with its state, and a conflict's disk side.
5637
+ * to `search`, whose answers are recorded in path order: a pass over the
5638
+ * scope, then each path's workspace copy, labelled with its state, and a
5639
+ * conflict's disk side.
5476
5640
  * What it doesn't search is in `skipped`, and what it couldn't see in
5477
5641
  * `incomplete`, each mount-prefixed.
5478
5642
  */
@@ -5481,7 +5645,7 @@ export class WorkspaceModule implements Module {
5481
5645
  relativePath: string,
5482
5646
  start: string,
5483
5647
  keep: (path: string) => boolean,
5484
- each: (text: GrepText) => void,
5648
+ search: (text: GrepText) => () => void,
5485
5649
  ): Promise<{ skipped: GrepSkip[]; incomplete: Array<{ path: string; reason: string }> }> {
5486
5650
  const store = this.getStore();
5487
5651
  const skipped: GrepSkip[] = [];
@@ -5534,16 +5698,18 @@ export class WorkspaceModule implements Module {
5534
5698
  }
5535
5699
 
5536
5700
  const intents = this.intents.get(mount.config.name)!;
5701
+ type Lost = Omit<GrepText, 'bytes'> & { relativePath: string; hash: string; size: number; what: 'saved' | 'conflict-disk' };
5702
+ const found: Array<{ searched: () => void } | { skip: GrepSkip } | { lost: Lost }> = [];
5537
5703
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
5538
5704
  if (!keep(report.path)) continue;
5539
5705
  const file = `${mount.config.name}/${report.path}`;
5540
5706
  if (report.state === 'disk-only') {
5541
- skipped.push({
5707
+ found.push({ skip: {
5542
5708
  file,
5543
5709
  reason: 'disk-only: binary or over the size limit, never stored',
5544
5710
  ...(report.size !== undefined ? { size: report.size } : {}),
5545
5711
  ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5546
- });
5712
+ } });
5547
5713
  continue;
5548
5714
  }
5549
5715
  // A path in another mount's directory is that mount's: grep gives
@@ -5552,16 +5718,16 @@ export class WorkspaceModule implements Module {
5552
5718
  if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue;
5553
5719
  const refusal = refused.get(report.path);
5554
5720
  if (refusal !== undefined) {
5555
- skipped.push({ file, reason: refusal });
5721
+ found.push({ skip: { file, reason: refusal } });
5556
5722
  continue;
5557
5723
  }
5558
5724
  // The workspace's version, labelled with the path's state.
5559
5725
  const entry = store.treeGet(mount.treeStateId, report.path);
5560
- const blob = entry ? store.getBlob(entry.blobHash) : null;
5561
- if (blob) {
5562
- each({ file, state: report.state, version: 'workspace', bytes: blob });
5563
- } else if (entry) {
5564
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
5726
+ if (entry) {
5727
+ const stored = storedBytes(store, entry.blobHash);
5728
+ found.push('bytes' in stored
5729
+ ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
5730
+ : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
5565
5731
  }
5566
5732
  if (report.state !== 'conflict') continue;
5567
5733
  // A conflict's disk side. The version recorded with the conflict is
@@ -5571,21 +5737,37 @@ export class WorkspaceModule implements Module {
5571
5737
  // disk version was never stored either.
5572
5738
  const disk = intents.get(report.path)?.conflict?.disk;
5573
5739
  const changed = report.conflict?.diskChangedSinceRecorded === true;
5574
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
5575
- if (diskBlob) {
5576
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
5577
- }
5578
- if (disk?.stored && !diskBlob) {
5579
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
5740
+ if (disk?.stored) {
5741
+ const version = changed ? 'recorded-disk' : 'conflicting-disk';
5742
+ const stored = storedBytes(store, disk.stored);
5743
+ found.push('bytes' in stored
5744
+ ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
5745
+ : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
5580
5746
  }
5581
5747
  if (changed) {
5582
5748
  if (report.diskNow === 'file') {
5583
- skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
5749
+ found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } });
5584
5750
  }
5585
5751
  } else if (disk && !disk.stored) {
5586
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
5752
+ found.push({ skip: { file, reason: `conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; ${settleConflict('file', mount.config.mode === 'read-only').replace(/\.$/, '')}`, size: disk.size } });
5587
5753
  }
5588
5754
  }
5755
+ const answers = new Map<Lost, { searched: () => void } | { skip: GrepSkip }>();
5756
+ await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
5757
+ const { file, state, version, what } = lost;
5758
+ answers.set(lost, 'bytes' in contents
5759
+ ? { searched: search({ file, state, version, bytes: contents.bytes }) }
5760
+ : { skip: {
5761
+ file,
5762
+ reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
5763
+ + `and ${lostOnDisk(contents.lost)}, so it was not searched`,
5764
+ } });
5765
+ });
5766
+ for (const item of found) {
5767
+ const answer = 'lost' in item ? answers.get(item.lost)! : item;
5768
+ if ('searched' in answer) answer.searched();
5769
+ else skipped.push(answer.skip);
5770
+ }
5589
5771
  });
5590
5772
  return { skipped, incomplete };
5591
5773
  }
@@ -5635,7 +5817,7 @@ export class WorkspaceModule implements Module {
5635
5817
  const store = this.getStore();
5636
5818
  const status: Record<string, unknown> = {};
5637
5819
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
5638
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5820
+ return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5639
5821
  }
5640
5822
 
5641
5823
  for (const [name, mount] of this.mounts) {
@@ -5778,9 +5960,6 @@ export class WorkspaceModule implements Module {
5778
5960
  */
5779
5961
  private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
5780
5962
  const store = this.getStore();
5781
- const agreement = this.agreementOrThrow();
5782
- const name = mount.config.name;
5783
- const intents = this.intents.get(name)!;
5784
5963
  const owed: string[] = [];
5785
5964
  const unchecked: string[] = [];
5786
5965
  let agreed = 0;
@@ -5789,17 +5968,32 @@ export class WorkspaceModule implements Module {
5789
5968
  // Outside the mount's include, or in another mount's directory nested
5790
5969
  // in it: neither owed to disk nor counted as on it.
5791
5970
  if (!insideMount(e.path, mount.config.include) || !own(e.path)) continue;
5792
- const p = agreement.get(name, e.path);
5793
- if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
5794
- else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
5795
- // A mode the store records, other than the one disk last agreed with,
5796
- // where modes are observed: elsewhere no push applies one.
5797
- else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) owed.push(e.path);
5971
+ const kind = this.evidenceOf(mount, e);
5972
+ if (kind === 'owed') owed.push(e.path);
5973
+ else if (kind === 'unchecked') unchecked.push(e.path);
5798
5974
  else agreed++;
5799
5975
  }
5800
5976
  return { owed, unchecked, agreed };
5801
5977
  }
5802
5978
 
5979
+ private evidenceOf(mount: MountState, e: { path: string; blobHash: string; mode: number }): 'owed' | 'unchecked' | 'agreed' {
5980
+ const p = this.agreementOrThrow().get(mount.config.name, e.path);
5981
+ if (p === undefined) return this.intents.get(mount.config.name)!.get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
5982
+ if (p.kind !== 'content' || p.hash !== e.blobHash) return 'owed';
5983
+ // A mode the store records, other than the one disk last agreed with,
5984
+ // where modes are observed: elsewhere no push applies one.
5985
+ if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode) return 'owed';
5986
+ return 'agreed';
5987
+ }
5988
+
5989
+ private diskOf(mount: MountState, relativePath: string): DiskState {
5990
+ const intent = this.intents.get(mount.config.name)!.get(relativePath);
5991
+ if (intent?.conflict) return 'in conflict with disk';
5992
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
5993
+ if (!entry) return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
5994
+ return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
5995
+ }
5996
+
5803
5997
  private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
5804
5998
  const store = this.getStore();
5805
5999
 
@@ -5816,7 +6010,7 @@ export class WorkspaceModule implements Module {
5816
6010
  // is refused; beneath a directory, the push leaves out what the include
5817
6011
  // doesn't cover.
5818
6012
  if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
5819
- return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
6013
+ return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
5820
6014
  }
5821
6015
  }
5822
6016
 
@@ -5826,7 +6020,7 @@ export class WorkspaceModule implements Module {
5826
6020
  } else if (input.mount) {
5827
6021
  const m = this.mounts.get(input.mount);
5828
6022
  if (!m) {
5829
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
6023
+ return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
5830
6024
  }
5831
6025
  mountsToMaterialize = [{ name: input.mount, mount: m }];
5832
6026
  } else {
@@ -5934,16 +6128,21 @@ export class WorkspaceModule implements Module {
5934
6128
  if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
5935
6129
  return {
5936
6130
  success: false,
5937
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
6131
+ error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
5938
6132
  isError: true,
5939
6133
  };
5940
6134
  }
5941
6135
 
6136
+ const held = explicit;
6137
+ const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
6138
+ && allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
6139
+ && !this.holdsAnythingAt(held.mount, held.relativePath);
5942
6140
  return {
5943
6141
  success: true,
5944
6142
  data: {
5945
6143
  materialized: allWritten,
5946
6144
  count: allWritten.length,
6145
+ ...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
5947
6146
  ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
5948
6147
  ...(pendingDeletions.length > 0
5949
6148
  ? {
@@ -6014,7 +6213,13 @@ export class WorkspaceModule implements Module {
6014
6213
  const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
6015
6214
  const rootsAccepted: string[] = [];
6016
6215
  if (input.acceptRoot && input.path) {
6017
- return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
6216
+ const named = input.path.split('/')[0]!;
6217
+ return {
6218
+ success: false,
6219
+ isError: true,
6220
+ error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
6221
+ + `${this.mounts.has(named) ? `Give "mount" (${JSON.stringify(named)}) with acceptRoot to take that mount's root` : 'Give "mount", naming the mount whose root to take, with acceptRoot'}, or leave acceptRoot out to sync the path.`),
6222
+ };
6018
6223
  }
6019
6224
 
6020
6225
  // A path names its own mount; otherwise the given mount, or every mount.
@@ -6025,13 +6230,13 @@ export class WorkspaceModule implements Module {
6025
6230
  // is refused; beneath a directory, the walk passes over what the include
6026
6231
  // doesn't cover.
6027
6232
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
6028
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
6233
+ return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
6029
6234
  }
6030
6235
  targets = [{ name: mount.config.name, mount, relativePath }];
6031
6236
  } else if (input.mount) {
6032
6237
  const m = this.mounts.get(input.mount);
6033
6238
  if (!m) {
6034
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
6239
+ return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
6035
6240
  }
6036
6241
  targets = [{ name: input.mount, mount: m, relativePath: '' }];
6037
6242
  } else {
@@ -6045,6 +6250,7 @@ export class WorkspaceModule implements Module {
6045
6250
  allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
6046
6251
  continue;
6047
6252
  }
6253
+ let namedDirectory = false;
6048
6254
  const pass = await this.withMount(mount, async () => {
6049
6255
  if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
6050
6256
  if (!relativePath) {
@@ -6059,6 +6265,7 @@ export class WorkspaceModule implements Module {
6059
6265
  store.treeList(mount.treeStateId, prefix).length > 0 ||
6060
6266
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
6061
6267
  this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
6268
+ namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
6062
6269
  // named: ignore patterns covering the path asked for don't apply
6063
6270
  // beneath it (a directory scope's walk; a path scope doesn't walk).
6064
6271
  const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
@@ -6117,7 +6324,7 @@ export class WorkspaceModule implements Module {
6117
6324
  // without a path passes over it, so nothing else keeps it current
6118
6325
  // (agent-framework #276). A path in another mount's directory isn't
6119
6326
  // synced at all, and the note says so.
6120
- const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
6327
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
6121
6328
  if (passedOver !== undefined) {
6122
6329
  allIgnored.push({
6123
6330
  mount: name,
@@ -6131,10 +6338,17 @@ export class WorkspaceModule implements Module {
6131
6338
  }
6132
6339
  }
6133
6340
 
6341
+ const named = input.path !== undefined && input.path !== '' ? targets[0]! : null;
6342
+ const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
6343
+ && allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
6344
+ && allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
6345
+ && !this.holdsAnythingAt(named.mount, named.relativePath)
6346
+ && await this.absentOnDisk(named.mount, named.relativePath);
6134
6347
  return {
6135
6348
  success: true,
6136
6349
  data: {
6137
6350
  results: allResults,
6351
+ ...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
6138
6352
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
6139
6353
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
6140
6354
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
@@ -6408,13 +6622,13 @@ export async function inOrder<T, R>(items: readonly T[], width: number, work: (i
6408
6622
  }
6409
6623
 
6410
6624
  /** read_image's answer for an image, wherever it was read from. */
6411
- function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }): ToolResult {
6625
+ function imageResult(path: string, image: { bytes: Buffer; mimeType: SupportedImageMimeType }, note?: string): ToolResult {
6412
6626
  return {
6413
6627
  success: true,
6414
6628
  data: [
6415
6629
  {
6416
6630
  type: 'text',
6417
- text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
6631
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
6418
6632
  },
6419
6633
  {
6420
6634
  type: 'image',
@@ -6475,13 +6689,16 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
6475
6689
  * add to what keeps it off direct; refused, what's held only shrinks, and
6476
6690
  * the next start once none is held runs it direct.
6477
6691
  */
6478
- function writeRefusal(mount: MountState): string | null {
6692
+ function writeRefusal(mount: MountState, writable: readonly string[] = []): string | null {
6479
6693
  const name = mount.config.name;
6480
- if (mount.config.mode === 'read-only') return `Mount "${name}" is read-only`;
6694
+ if (mount.config.mode === 'read-only') {
6695
+ return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
6696
+ + (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
6697
+ }
6481
6698
  if (mount.config.backend === 'direct' && !mount.direct) {
6482
- return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6699
+ return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6483
6700
  + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6484
- + `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct`;
6701
+ + `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct.`;
6485
6702
  }
6486
6703
  return null;
6487
6704
  }
@@ -6581,6 +6798,15 @@ function directReason(error: unknown): string {
6581
6798
  return error instanceof Error ? error.message : String(error);
6582
6799
  }
6583
6800
 
6801
+ function directWayOn(error: unknown): string | null {
6802
+ return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
6803
+ }
6804
+
6805
+ function directDidNothing(tool: string, path: string, error: unknown): string {
6806
+ const on = directWayOn(error);
6807
+ return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
6808
+ }
6809
+
6584
6810
  /** "a", "a and b", or "a, b and c". */
6585
6811
  function listed(items: readonly string[]): string {
6586
6812
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
@@ -6604,7 +6830,135 @@ function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): stri
6604
6830
  */
6605
6831
  function containedRefusal(err: unknown, path: string): string {
6606
6832
  const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6607
- return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
6833
+ return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
6834
+ }
6835
+
6836
+ function deletedOnDisk(path: string): string {
6837
+ return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
6838
+ }
6839
+
6840
+ function uncheckedFile(shown: string, note: string | undefined): string {
6841
+ if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6842
+ const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
6843
+ : note === ROOT_REPLACED ? null
6844
+ : note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
6845
+ : note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
6846
+ : note === NOT_REGULAR ? onlyTheHost('put a regular file there')
6847
+ : note === CHANGED ? 'Calling again checks it afresh.'
6848
+ : 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
6849
+ return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
6850
+ }
6851
+
6852
+ function namesRoot(path: string, mount: string): string {
6853
+ return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
6854
+ }
6855
+
6856
+ function tooLargeToWrite(path: string, bytes: number, maxSize: number): string {
6857
+ return pastSizeLimit(`${JSON.stringify(path)} would be ${bytes.toLocaleString('en-US')} bytes`, maxSize, "the workspace couldn't keep it", 'Content within the limit is written.');
6858
+ }
6859
+
6860
+ function pastSizeLimit(what: string, maxSize: number, outcome: string, within: string | null): string {
6861
+ return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
6862
+ + "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
6863
+ }
6864
+
6865
+ function sentence(text: string): string {
6866
+ return /[.!?]$/.test(text) ? text : `${text}.`;
6867
+ }
6868
+
6869
+ function unreadable(shown: string, errno: string | undefined): string {
6870
+ return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
6871
+ }
6872
+
6873
+ function readRefusal(err: WorkspaceReadError, path: string): string {
6874
+ const shown = JSON.stringify(path);
6875
+ if (err instanceof RootUnavailable) {
6876
+ if (err.refusal) return err.refusal.message;
6877
+ const mount = JSON.stringify(err.mount);
6878
+ if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
6879
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
6880
+ return `the directory of mount ${mount} isn't on disk, so nothing in it can be read. ${onlyTheHost("put the mount's directory back at its path")}`;
6881
+ }
6882
+ if (err.errno !== undefined) {
6883
+ return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
6884
+ + errnoWayOn(err.errno, ROOT_RECHECK, true);
6885
+ }
6886
+ return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
6887
+ }
6888
+ return `${shown} can't be read: ${sentence(err.reason)}`;
6889
+ }
6890
+ if (err.stage === 'readlink') return sentence(err.message);
6891
+ switch (err.code) {
6892
+ case 'directory':
6893
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6894
+ case 'symlink':
6895
+ return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6896
+ + 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
6897
+ case 'escape':
6898
+ return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6899
+ + 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
6900
+ case 'changed':
6901
+ return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6902
+ case 'not_found':
6903
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') return `there's no file at ${shown}. ls or glob lists the files that exist.`;
6904
+ if (err instanceof NotRegularFile) {
6905
+ return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
6906
+ }
6907
+ if (err.stage === 'realpath') return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
6908
+ return unreadable(shown, err.errno);
6909
+ default:
6910
+ return sentence(err.message);
6911
+ }
6912
+ }
6913
+
6914
+ type LostContents = {
6915
+ path: string;
6916
+ store: Exclude<StoredBytes, { bytes: Buffer }>;
6917
+ disk: 'other' | 'none' | { unreadable: string };
6918
+ writable: boolean;
6919
+ reopen: boolean;
6920
+ };
6921
+
6922
+ function contentsSubject(what: 'saved' | 'conflict-disk', path: string): string {
6923
+ return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
6924
+ }
6925
+
6926
+ function inTheStore(stored: Exclude<StoredBytes, { bytes: Buffer }>): string {
6927
+ return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
6928
+ }
6929
+
6930
+ function lostInStore(lost: Pick<LostContents, 'store'>): string {
6931
+ return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
6932
+ }
6933
+
6934
+ function lostOnDisk(lost: LostContents): string {
6935
+ return lost.disk === 'other' ? "disk's copy holds other contents"
6936
+ : lost.disk === 'none' ? 'disk has no copy'
6937
+ : `disk's copy can't be read (${lost.disk.unreadable})`;
6938
+ }
6939
+
6940
+ function lostText(lost: LostContents, the: 'The' | 'the' = 'The'): string {
6941
+ return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
6942
+ }
6943
+
6944
+ function nothingToRead(lost: LostContents): string {
6945
+ return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
6946
+ }
6947
+
6948
+ function lostRemedy(lost: LostContents): string {
6949
+ const after = remedyOfLost(lost);
6950
+ if (!lost.reopen) return after;
6951
+ return "Nothing you do clears this until the workspace's store is reopened, and calling again won't change that; whoever runs this host can reopen it with a restart. "
6952
+ + `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
6953
+ }
6954
+
6955
+ function remedyOfLost(lost: LostContents): string {
6956
+ const rewrite = lost.writable && 'missing' in lost.store;
6957
+ if (lost.disk === 'other') return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
6958
+ if (lost.writable) return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
6959
+ return lost.disk === 'none'
6960
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy."
6961
+ : "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's.";
6608
6962
  }
6609
6963
 
6610
6964
  /**
@@ -6683,9 +7037,8 @@ function planTargetEdits(
6683
7037
  kind: 'file',
6684
7038
  edits: target.edits,
6685
7039
  path: target.path,
6686
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
6687
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
6688
- + "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
7040
+ text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize,
7041
+ "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
6689
7042
  whole: false,
6690
7043
  });
6691
7044
  return null;
@@ -6728,17 +7081,36 @@ function changesLanded(
6728
7081
  * change is stored isn't known. Its message says so after both errors.
6729
7082
  */
6730
7083
  class CommitUnknownError extends Error {
6731
- readonly setError: string;
6732
- readonly readError: string;
6733
- constructor(setError: unknown, readError: unknown) {
7084
+ constructor(readonly setCause: unknown, readonly readCause: unknown) {
6734
7085
  const text = (error: unknown) => (error instanceof Error ? error.message : String(error));
6735
- super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
7086
+ super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
6736
7087
  this.name = 'CommitUnknownError';
6737
- this.setError = text(setError);
6738
- this.readError = text(readError);
6739
7088
  }
6740
7089
  }
6741
7090
 
7091
+ function storeWords(error: unknown): string {
7092
+ const code = errnoCode(error);
7093
+ return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
7094
+ }
7095
+
7096
+ class NotStored extends Error {
7097
+ constructor(readonly thrown: unknown) {
7098
+ super('stopped before the change was stored');
7099
+ }
7100
+ }
7101
+
7102
+ class AlreadyHeld extends Error {
7103
+ constructor(readonly thrown: unknown) {
7104
+ super(thrown instanceof Error ? thrown.message : String(thrown));
7105
+ }
7106
+ }
7107
+
7108
+ function notStored(error: unknown): unknown {
7109
+ return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
7110
+ ? error : new NotStored(error);
7111
+ }
7112
+
7113
+
6742
7114
  /**
6743
7115
  * A store's set that threw after recording the change (changesLanded): the
6744
7116
  * entries are set and their intents follow, and the store's error goes on in
@@ -6752,6 +7124,14 @@ class StoredThenFailed extends Error {
6752
7124
  }
6753
7125
  }
6754
7126
 
7127
+ const ONCE_REOPENED = ', once the store is reopened';
7128
+
7129
+ const DISK_ORDER: DiskState[] = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
7130
+
7131
+ function furthestFromDisk(values: DiskState[]): DiskState {
7132
+ return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
7133
+ }
7134
+
6755
7135
  /**
6756
7136
  * The answer to a write, edit or delete that the workspace stored but
6757
7137
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -6768,6 +7148,7 @@ function storedButUnfinished(
6768
7148
  path: string,
6769
7149
  outcome: { refused: string } | { threw: string } | { storeFailed: string; pushes: boolean },
6770
7150
  note: string | null = null,
7151
+ reopen = false,
6771
7152
  ): ToolResult {
6772
7153
  const change = tool === 'delete' ? 'deletion' : tool;
6773
7154
  const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
@@ -6777,10 +7158,10 @@ function storedButUnfinished(
6777
7158
  : 'storeFailed' in outcome
6778
7159
  ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
6779
7160
  : tool === 'delete'
6780
- ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
6781
- : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
6782
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
6783
- return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
7161
+ ? ` Its push to disk didn't run, so disk keeps the file until a materialize of the path with applyDeletions deletes it${reopen ? ONCE_REOPENED : ''}.`
7162
+ : ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
7163
+ : ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
7164
+ return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
6784
7165
  }
6785
7166
 
6786
7167
  /**
@@ -6810,19 +7191,24 @@ function whichCopyRead(report: PathReport | undefined, tookChange: boolean, read
6810
7191
  // can hold it (not binary or too large). A read-only mount can hold a
6811
7192
  // copy disk doesn't, such as one a branch switch selected, but a
6812
7193
  // materialize, forced or not, writes nothing to it.
6813
- const sync = report.diskNow === 'absent'
6814
- ? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6815
- : 'Sync the path to keep disk\'s copy and give up the workspace\'s';
6816
- const force = readOnly
6817
- ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6818
- : report.diskNow === 'absent'
6819
- ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6820
- : ', or materialize it with force to write the workspace\'s over disk\'s.';
6821
- return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
7194
+ const settle = settleConflict(report.diskNow, readOnly);
7195
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0]!.toUpperCase()}${settle.slice(1)}`];
6822
7196
  }
6823
7197
  return [];
6824
7198
  }
6825
7199
 
7200
+ function settleConflict(diskNow: PathReport['diskNow'], readOnly: boolean): string {
7201
+ const sync = diskNow === 'absent'
7202
+ ? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
7203
+ : 'sync the path to keep disk\'s copy and give up the workspace\'s';
7204
+ const force = readOnly
7205
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
7206
+ : diskNow === 'absent'
7207
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
7208
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
7209
+ return `${sync}${force}`;
7210
+ }
7211
+
6826
7212
  /**
6827
7213
  * The least each tool's whole-number fields may be, beside their schemas,
6828
7214
  * whose type (membrane's ToolParameter) has no `minimum`.
@@ -7020,13 +7406,13 @@ function globToRegex(pattern: string): RegExp {
7020
7406
  if (pattern[j] === '{') depth++;
7021
7407
  else if (pattern[j] === '}' && --depth === 0) closeIdx = j;
7022
7408
  }
7023
- if (closeIdx < 0) throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
7409
+ if (closeIdx < 0) throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
7024
7410
  const group = pattern.slice(i + 1, closeIdx);
7025
7411
  if (group.includes('{')) {
7026
- throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
7412
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
7027
7413
  }
7028
7414
  if (!group.includes(',')) {
7029
- throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
7415
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
7030
7416
  }
7031
7417
  // A `/` right after the group goes into each alternative, as `{a,b}/x`
7032
7418
  // and `{a/,b/}x` name the same paths, so an alternative's own `**`