@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
@@ -9,10 +9,10 @@ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises
9
9
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
10
10
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
11
11
  import { MountWatcher } from './watcher.js';
12
- import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
13
- import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
12
+ import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE } from './sync.js';
13
+ import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
14
14
  import { BranchIntents } from './branch-intent.js';
15
- import { IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beginsWithDir, checkDir, checkRoot, coversLanding, diskSpelling, filesystemTrustsCtime, foldsCase, includePatternReadable, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, namesNestedPlace, nestedAtReal, nestedHolder, observePath, parsedSpelling, passedOverAt, pathSegments, readBytes, readPrefix, shownLeads, sniffImageMime, walkScope, withinIncludes } from './observe.js';
15
+ import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes } from './observe.js';
16
16
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
17
17
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
18
18
  import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
@@ -22,8 +22,11 @@ import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js
22
22
  import { grepText, textAnswer } from './result-text.js';
23
23
  import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
24
24
  import { EffectFailed, unlinkContained } from './effects.js';
25
+ import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
26
+ import { unknownToolMessage } from '../unknown-tool.js';
27
+ import { closestNames } from '../../closest-names.js';
25
28
  import { RecordJournal } from '../../record-journal.js';
26
- import { FileChanged, ROOT_MOVED, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
29
+ import { FileChanged, ROOT_MOVED, WriteRefused, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
27
30
  /** Default for how long the after-batch scan may hold the agent's next inference. */
28
31
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
29
32
  /**
@@ -42,6 +45,9 @@ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
42
45
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
43
46
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
44
47
  const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage', 'target', '__pycache__', '.venv'];
48
+ const PATTERN_FORMS = 'The list reads these of gitignore\'s forms: a name, matched at any depth, with "*" and "?" within it; '
49
+ + 'a trailing "/" to match only directories; a leading or inner "/" for a path from the mount\'s root; '
50
+ + 'a leading "**/" before one name; and a trailing "/**" for what lies beneath a directory.';
45
51
  /**
46
52
  * How many files a direct mount's grep reads, and its listings look at, at
47
53
  * once. Over a layout of seven checkouts, 5,929 files, a direct grep reading
@@ -54,16 +60,23 @@ const DIRECT_READS_AT_ONCE = 16;
54
60
  const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
55
61
  /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
56
62
  const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
57
- class WorkspaceImageReadError extends Error {
58
- code;
59
- constructor(code, message) {
60
- super(message);
61
- this.code = code;
63
+ class WorkspaceImageReadError extends Refusal {
64
+ reason;
65
+ constructor(reason, wayOn) {
66
+ super(wayOn === null ? sentence(reason) : `${reason}. ${wayOn}`);
67
+ this.reason = reason;
62
68
  this.name = 'WorkspaceImageReadError';
63
69
  }
64
70
  }
71
+ const IMAGE_STAYS = "Calling again won't change that; only a whole image in its place would.";
72
+ function truncatedImage(mountPrefixedPath) {
73
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is too short to be an image: it ends inside an image format's signature`, IMAGE_STAYS);
74
+ }
75
+ function emptyImage(mountPrefixedPath) {
76
+ return new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} is empty, so there's no image in it`, IMAGE_STAYS);
77
+ }
65
78
  /** A path the mount's `include` doesn't cover: outside the mount. */
66
- class OutsideIncludeError extends Error {
79
+ class OutsideIncludeError extends Refusal {
67
80
  constructor(shownPath, mount) {
68
81
  super(`"${shownPath}" is outside the mount's include: mount "${mount.name}" holds only ${(mount.include ?? []).join(', ')}. `
69
82
  + 'The host\'s operator sets the include in the mount\'s configuration, so calling again with this path changes nothing.');
@@ -74,9 +87,17 @@ class OutsideIncludeError extends Error {
74
87
  * mount it names. The message names the mount that holds it and the path to
75
88
  * use there.
76
89
  */
77
- class NestedMountError extends Error {
78
- constructor(path, mount, holder, use, how = 'lies in') {
79
- super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
90
+ class NestedMountError extends Refusal {
91
+ constructor(path, mount, holder, how = 'lies in') {
92
+ const verb = holder.guess === undefined ? how : how === 'lies in' ? 'may lie in' : 'may lead into';
93
+ super(`"${path}" ${verb} mount "${holder.name}", nested in mount "${mount}", which neither reads nor writes it: ${nestedStep(holder, { named: how === 'lies in' })}.`);
94
+ }
95
+ }
96
+ class PathRefusal extends Refusal {
97
+ code;
98
+ constructor(code, message) {
99
+ super(message);
100
+ this.code = code;
80
101
  }
81
102
  }
82
103
  /**
@@ -98,6 +119,22 @@ export class WorkspaceReadError extends Error {
98
119
  this.name = 'WorkspaceReadError';
99
120
  }
100
121
  }
122
+ class NotRegularFile extends WorkspaceReadError {
123
+ constructor(path, stage) {
124
+ super('not_found', `Not a regular file: ${path}`, stage);
125
+ }
126
+ }
127
+ class RootUnavailable extends WorkspaceReadError {
128
+ mount;
129
+ reason;
130
+ refusal;
131
+ constructor(mount, reason, stage, errno, refusal) {
132
+ super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
133
+ this.mount = mount;
134
+ this.reason = reason;
135
+ this.refusal = refusal;
136
+ }
137
+ }
101
138
  /**
102
139
  * Why a pass decided nothing, or null where it decided its paths. A pass gives
103
140
  * up deciding, and reports no path, when the selected branch kept changing
@@ -112,23 +149,9 @@ function errnoOf(err) {
112
149
  ? err.code
113
150
  : undefined;
114
151
  }
115
- /**
116
- * What is at a mount root's path now, followed as the root is: for a
117
- * directory, undefined; for anything else, ENOTDIR; and where the path
118
- * can't be followed to anything, the errno its stat failed with, ENOENT
119
- * when nothing is there.
120
- */
121
- async function rootAbsence(root) {
122
- try {
123
- return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
124
- }
125
- catch (err) {
126
- return errnoOf(err);
127
- }
128
- }
129
152
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
130
- function imageTooLarge(maxSize, mountPrefixedPath) {
131
- return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
153
+ function imageTooLarge(maxSize, mountPrefixedPath, size) {
154
+ return new WorkspaceImageReadError(pastSizeLimit(`${JSON.stringify(mountPrefixedPath)} is ${size} bytes`, maxSize, "read_image doesn't show it", null), null);
132
155
  }
133
156
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
134
157
  const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
@@ -144,9 +167,6 @@ const JPEG_SOI = 0xd8;
144
167
  const JPEG_EOI = 0xd9;
145
168
  const JPEG_SOS = 0xda;
146
169
  const JPEG_TEM = 0x01;
147
- function isErrnoCode(err, code) {
148
- return typeof err === 'object' && err !== null && 'code' in err && err.code === code;
149
- }
150
170
  function startsWithBytes(buffer, prefix) {
151
171
  return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
152
172
  }
@@ -180,7 +200,7 @@ async function linkReadError(mount, found, mountPrefixedPath, relativePath, nest
180
200
  const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
181
201
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
182
202
  const code = leads?.kind === 'outside' ? 'escape'
183
- : leads?.kind === 'inside' && holder && namesHolder(leads, holder) ? 'other_mount'
203
+ : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
184
204
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
185
205
  return new WorkspaceReadError(code, reason, 'readlink');
186
206
  }
@@ -211,28 +231,27 @@ function ancestorsOf(path) {
211
231
  * directory as configured beneath the outer mount's configured path
212
232
  * (`outerPath`), beneath each directory above its configured path that is
213
233
  * the outer root on disk (`above`, each with where it lies on disk), and
214
- * where its real root (`real`) lies beneath the outer root (`outerReal`),
215
- * each also as disk spells it beneath `spellAt`. Its real root is claimed
216
- * where it doesn't hold the outer root, which it would otherwise claim
217
- * whole. Undefined where it's nested nowhere.
234
+ * where its real root (`real`) lies beneath the outer root (`outerReal`).
235
+ * Its real root is claimed where it doesn't hold the outer root, which it
236
+ * would otherwise claim whole. Undefined where it's nested nowhere.
218
237
  */
219
- async function placementOf(outerPath, outerReal, innerPath, real, above, spellAt) {
238
+ function placementOf(outerPath, outerReal, innerPath, real, above) {
220
239
  const configured = nestedDir(outerPath, innerPath);
221
- const throughRoot = above.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
222
- const fromRoots = [];
223
- for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
224
- if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
225
- fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
226
- }
227
- if (fromRoots.length === 0)
240
+ const dirs = new Map();
241
+ const place = (dir, sure) => { if (dir !== undefined)
242
+ dirs.set(dir, (dirs.get(dir) ?? false) || sure); };
243
+ place(configured, true);
244
+ for (const a of above) {
245
+ if (sameRoot(a.real, outerReal))
246
+ place(nestedDir(a.dir, innerPath), surelyAtRoot(a.real, outerReal));
247
+ }
248
+ const below = beneathRoot(real, outerReal);
249
+ place(below, below !== undefined && surelyAtRoot(real, outerReal));
250
+ if (dirs.size === 0)
228
251
  return undefined;
229
- const spellings = [...fromRoots];
230
- for (const found of fromRoots) {
231
- const dir = await diskSpelling(spellAt, found.dir);
232
- if (!spellings.some((s) => s.dir === dir))
233
- spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
234
- }
235
- return { configured, fromRoots, spellings, claims: !isContainedPath(real, outerReal) };
252
+ const fromRoots = [...dirs].map(([dir, sure]) => ({ dir, ...(sure ? {} : { guessed: true }) }));
253
+ const claims = !sameRoot(outerReal, real) && beneathRoot(outerReal, real) === undefined;
254
+ return { configured, fromRoots, claims };
236
255
  }
237
256
  /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
238
257
  const LINKS_FOLLOWED = 40;
@@ -298,7 +317,7 @@ function isContainedPath(root, candidate) {
298
317
  return candidate.startsWith(normalizedRoot);
299
318
  }
300
319
  function invalidImage(format, mountPrefixedPath) {
301
- throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
320
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} starts as a ${format} image, but its data doesn't hold together as one`, IMAGE_STAYS);
302
321
  }
303
322
  function requireBufferRange(bytes, start, length, format, mountPrefixedPath) {
304
323
  if (start < 0 || length < 0 || start + length > bytes.length) {
@@ -634,21 +653,21 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
634
653
  return 'image/png';
635
654
  }
636
655
  if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
637
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
656
+ throw truncatedImage(mountPrefixedPath);
638
657
  }
639
658
  if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
640
659
  validateJpeg(bytes, mountPrefixedPath);
641
660
  return 'image/jpeg';
642
661
  }
643
662
  if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
644
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
663
+ throw truncatedImage(mountPrefixedPath);
645
664
  }
646
665
  if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
647
666
  validateGif(bytes, mountPrefixedPath);
648
667
  return 'image/gif';
649
668
  }
650
669
  if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
651
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
670
+ throw truncatedImage(mountPrefixedPath);
652
671
  }
653
672
  if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
654
673
  validateWebp(bytes, mountPrefixedPath);
@@ -656,9 +675,9 @@ function detectImageMimeType(bytes, mountPrefixedPath) {
656
675
  }
657
676
  if ((bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
658
677
  || (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)) {
659
- throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
678
+ throw truncatedImage(mountPrefixedPath);
660
679
  }
661
- throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
680
+ throw new WorkspaceImageReadError(`${JSON.stringify(mountPrefixedPath)} isn't a PNG, JPEG, GIF or WebP image, the formats read_image shows`, "read shows it, if it's text.");
662
681
  }
663
682
  export class WorkspaceModule {
664
683
  name = 'workspace';
@@ -684,14 +703,11 @@ export class WorkspaceModule {
684
703
  ctimeTrust = new Map();
685
704
  /**
686
705
  * Each mount's directories that are other mounts nested inside it, with
687
- * each mount's name and real root, in each spelling that names the
688
- * directory: as configured; as the real roots place it, where that differs
689
- * (`fromRoots`); and as disk spells each of those where that differs.
690
- * Each spelling is mount-relative and `/`-separated, with its root beneath
691
- * the mount's resolved root. The constructor nests by configured path,
692
- * with each root as resolved standing for its real one, and each start
693
- * reads it all again with the roots placed where disk has them
694
- * (readNesting).
706
+ * each mount's name and real root: as configured, and as the real roots
707
+ * place it, where that differs (`fromRoots`). Each is mount-relative and
708
+ * `/`-separated. The constructor nests by configured path, with each root
709
+ * as resolved standing for its real one, and each start reads it all again
710
+ * with the roots placed where disk has them (readNesting).
695
711
  */
696
712
  otherMounts = new Map();
697
713
  /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
@@ -748,20 +764,19 @@ export class WorkspaceModule {
748
764
  if (refused.length > 0) {
749
765
  throw new Error(`[workspace] Mount "${mount.name}" is direct, which reads disk as it is with no store, watcher or push: ${refused.join('; ')}`);
750
766
  }
751
- const include = mount.include;
752
- if (include !== undefined) {
753
- // A pattern the matcher can't read would match nothing, and take every
754
- // file out of the mount unsaid (includePatternReadable).
755
- if (!Array.isArray(include) || include.length === 0) {
756
- throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, not ${shownValue(include)}`);
767
+ for (const list of ['ignore', 'include']) {
768
+ const patterns = mount[list];
769
+ if (patterns === undefined)
770
+ continue;
771
+ if (!Array.isArray(patterns) || (list === 'include' && patterns.length === 0)) {
772
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} must be a ${list === 'include' ? 'non-empty ' : ''}list of patterns, not ${shownValue(patterns)}`);
757
773
  }
758
- // By index: an entry that is itself undefined, or a hole, is unreadable too.
759
- const at = include.findIndex((p) => !includePatternReadable(p));
760
- if (at >= 0) {
761
- const unreadable = include[at];
762
- throw new Error(`[workspace] Mount "${mount.name}": include must be a non-empty list of names, "*.ext" globs or "dir/**" globs, `
763
- + `with no "." or ".." segment${sep === '\\' ? ' and no "\\", which this platform reads as a separator' : ''}; `
764
- + `${shownValue(unreadable)} is none of these`);
774
+ for (let i = 0; i < patterns.length; i++) {
775
+ const pattern = patterns[i];
776
+ const why = patternRefusal(pattern, list);
777
+ if (why !== undefined) {
778
+ throw new Error(`[workspace] Mount "${mount.name}": ${list} pattern ${shownValue(pattern)} isn't read: ${why}. ${PATTERN_FORMS}`);
779
+ }
765
780
  }
766
781
  }
767
782
  }
@@ -782,8 +797,8 @@ export class WorkspaceModule {
782
797
  const dir = nestedDir(outerPath, innerPath);
783
798
  if (dir === undefined)
784
799
  continue;
785
- const configured = [{ dir, root: innerPath }];
786
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
800
+ const configured = [{ dir }];
801
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured }]);
787
802
  console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
788
803
  `at ${dir}/ — passing over it as another mount's directory`);
789
804
  }
@@ -1067,7 +1082,7 @@ export class WorkspaceModule {
1067
1082
  const { view, rootReal } = mount.direct ? await this.diskView(mount) : await this.runtime(mount);
1068
1083
  let start = base;
1069
1084
  for (const segment of beneath.split('/')) {
1070
- if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others) !== undefined))
1085
+ if (start !== '' && (mount.direct ? inOtherMount(start, view.others) : passedOverAt(start, view.ignore, view.others, true) !== undefined))
1071
1086
  break;
1072
1087
  const here = await checkDir(view, rootReal, start);
1073
1088
  if (here.kind !== 'checked')
@@ -1125,11 +1140,10 @@ export class WorkspaceModule {
1125
1140
  }
1126
1141
  async start(ctx) {
1127
1142
  // Which mounts are nested where, with the roots placed where disk has
1128
- // them and each nested mount's directory as disk spells it, and whether
1129
- // each mount's root folds case, read where disk has it, before this start
1130
- // sets anything, so a restart takes no call and walks or watches nothing
1131
- // before they're known. A stop begun meanwhile stands, and this start then
1132
- // sets nothing.
1143
+ // them, and whether each mount's root folds case, read where disk has
1144
+ // it, before this start sets anything, so a restart takes no call
1145
+ // and walks or watches nothing before they're known. A stop begun
1146
+ // meanwhile stands, and this start then sets nothing.
1133
1147
  const stops = this.stopsBegun;
1134
1148
  const { nesting, reals, above } = await this.readNesting();
1135
1149
  const rootFoldsCase = new Map();
@@ -1242,7 +1256,8 @@ export class WorkspaceModule {
1242
1256
  const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
1243
1257
  return (`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
1244
1258
  `(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
1245
- `never had. Pass force: true to overwrite disk from the current branch if it is canonical.`);
1259
+ `never had. ls with path ${JSON.stringify(mount.config.name)} and recursive: true shows how each file of the current branch stands against disk; ` +
1260
+ 'if the current branch is what disk should hold, pass force: true to overwrite disk from it.');
1246
1261
  }
1247
1262
  /**
1248
1263
  * Re-pin mounts whose last-materialized branch is a proven ancestor of the
@@ -1866,7 +1881,12 @@ export class WorkspaceModule {
1866
1881
  work.then(done, done);
1867
1882
  return work;
1868
1883
  }
1869
- async runToolCall(call) {
1884
+ runToolCall(call) {
1885
+ const gate = { passed: false };
1886
+ return callGate.run(gate, () => this.answerToolCall(call, gate));
1887
+ }
1888
+ async answerToolCall(call, gate) {
1889
+ const reopenAtStart = this.agreement?.awaitsReopen === true;
1870
1890
  try {
1871
1891
  // Every handler reads its input as its schema describes it; a call
1872
1892
  // that sends something else is refused here, before anything is read.
@@ -1881,12 +1901,12 @@ export class WorkspaceModule {
1881
1901
  // ResultTooLarge in its place. A call made in process carries none and
1882
1902
  // takes the data itself, so its read pages by its line limit alone.
1883
1903
  const resultMaxChars = call.resultMaxChars;
1884
- const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1904
+ const generated = schema !== undefined && typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1885
1905
  if (generated) {
1886
1906
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
1887
1907
  return await this.handleRead(input, resultMaxChars, generated.read());
1888
1908
  }
1889
- return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
1909
+ return { success: false, isError: true, error: didNothing(call.name, this.generatedFileRefusal(input.path, generated, call.callerAgentName)) };
1890
1910
  }
1891
1911
  switch (call.name) {
1892
1912
  case 'read': return await this.handleRead(input, resultMaxChars);
@@ -1902,12 +1922,40 @@ export class WorkspaceModule {
1902
1922
  case 'materialize': return await this.handleMaterialize(input);
1903
1923
  case 'sync': return await this.handleSync(input);
1904
1924
  default:
1905
- return { success: false, error: `Unknown tool: ${call.name}, so nothing was done`, isError: true };
1925
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
1906
1926
  }
1907
1927
  }
1908
1928
  catch (err) {
1909
- return { success: false, error: err instanceof Error ? err.message : String(err), isError: true };
1929
+ return { success: false, error: this.failureAnswer(call, err, reopenAtStart, gate.passed), isError: true };
1930
+ }
1931
+ }
1932
+ failureAnswer(call, err, reopenAtStart, passedGate) {
1933
+ const input = (typeof call.input === 'object' && call.input !== null ? call.input : {});
1934
+ const path = typeof input.path === 'string' ? input.path : undefined;
1935
+ const mountName = path !== undefined ? path.split('/')[0] : typeof input.mount === 'string' ? input.mount : undefined;
1936
+ const subject = path !== undefined ? JSON.stringify(path) : typeof input.mount === 'string' ? `mount ${JSON.stringify(input.mount)}` : undefined;
1937
+ const reopened = !reopenAtStart && this.agreement?.awaitsReopen === true;
1938
+ if (err instanceof EvidenceUnavailable && passedGate && changesFiles(call.name)) {
1939
+ return `The ${call.name}${subject !== undefined ? ` of ${subject}` : ''} stopped partway: ${err.message} Whether it changed anything before it stopped isn't known until then.`;
1940
+ }
1941
+ if (err instanceof Refusal)
1942
+ return didNothing(call.name, err.message);
1943
+ if (err instanceof WorkspaceReadError)
1944
+ return didNothing(call.name, path !== undefined ? readRefusal(err, path) : sentence(err.message));
1945
+ const look = call.name !== 'materialize' && call.name !== 'sync' ? undefined
1946
+ : mountName !== undefined ? `ls with path ${JSON.stringify(mountName)} and recursive: true shows how each file there stands against disk now`
1947
+ : 'ls of each mount, with recursive: true, shows how each file stands against disk now';
1948
+ if (err instanceof NotStored || err instanceof AlreadyHeld) {
1949
+ console.error(`[workspace] ${call.name} failed before it changed anything:`, err.thrown);
1950
+ return unexpectedAnswer(call.name, subject, err.thrown, { stored: err instanceof NotStored ? 'not' : 'held', reopen: reopened });
1951
+ }
1952
+ console.error(`[workspace] ${call.name} failed:`, err);
1953
+ if (err instanceof CommitUnknownError) {
1954
+ return unexpectedAnswer(call.name, subject, err.setCause, {
1955
+ said: `the store failed as it set the change (${storeWords(err.setCause)}), and reading it back failed too (${storeWords(err.readCause)})`, reopen: reopened, look,
1956
+ });
1910
1957
  }
1958
+ return unexpectedAnswer(call.name, subject, err, { reopen: reopened, look });
1911
1959
  }
1912
1960
  async onProcess(event, _state) {
1913
1961
  // Wake agents on filesystem events when the originating mount opted in.
@@ -1955,7 +2003,7 @@ export class WorkspaceModule {
1955
2003
  text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
1956
2004
  }
1957
2005
  if (conflicts && conflicts.length > 0) {
1958
- text += `\n[conflicts: ${conflicts.join(', ')}]`;
2006
+ text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
1959
2007
  }
1960
2008
  return {
1961
2009
  requestInference: true,
@@ -2003,16 +2051,15 @@ export class WorkspaceModule {
2003
2051
  resolveAbsolutePath(mountPrefixedPath) {
2004
2052
  try {
2005
2053
  // Synchronous, so the path as given. The nested-mount check takes it
2006
- // in each spelling readNesting knows for the nested mount's directory,
2007
- // or in any spelling a disk that folds names may fold to one of them,
2008
- // erring toward that mount (holderOf's asGiven, beginsWithDir). The
2009
- // include judges it as given, so on a disk that
2054
+ // in any spelling a disk that folds names may fold to the nested
2055
+ // mount's directory, erring toward that mount (holderOf). The include
2056
+ // judges it as given, so on a disk that
2010
2057
  // folds names a covered name whose file disk spells otherwise returns
2011
2058
  // that file's path, which the tools refuse, and a covered file named in
2012
2059
  // another case is null, which the tools read. A path the mount doesn't
2013
2060
  // hold, outside its include or in another mount's directory nested in
2014
2061
  // it, is null, as for a path that leaves the mount's directory.
2015
- const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath, true);
2062
+ const { mount, relativePath } = this.refuseNotHeld({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }, mountPrefixedPath);
2016
2063
  return resolve(mount.config.path, relativePath);
2017
2064
  }
2018
2065
  catch {
@@ -2040,6 +2087,9 @@ export class WorkspaceModule {
2040
2087
  * `agentName` names the agent whose write it is for the head-move notice
2041
2088
  * (a write naming none tells no agent).
2042
2089
  *
2090
+ * A success's `disk` says where the file stands on disk as the workspace
2091
+ * last saw it, as the write tool's answer does.
2092
+ *
2043
2093
  * A write whose tree entry is set but that then fails returns success
2044
2094
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
2045
2095
  * the store failed after recording the entry, which skips the push where
@@ -2067,12 +2117,14 @@ export class WorkspaceModule {
2067
2117
  isError: true,
2068
2118
  };
2069
2119
  }
2070
- const refusal = writeRefusal(mount);
2120
+ if (relativePath === '')
2121
+ return { success: false, error: namesRoot(mountPrefixedPath, mount.config.name), isError: true };
2122
+ const refusal = writeRefusal(mount, this.writableNames());
2071
2123
  if (refusal)
2072
2124
  return { success: false, error: refusal, isError: true };
2073
2125
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2074
2126
  if (data.byteLength > maxSize) {
2075
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2127
+ return { success: false, error: tooLargeToWrite(mountPrefixedPath, data.byteLength, maxSize), isError: true };
2076
2128
  }
2077
2129
  const store = this.getStore();
2078
2130
  // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
@@ -2087,7 +2139,7 @@ export class WorkspaceModule {
2087
2139
  const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2088
2140
  if ('error' in written)
2089
2141
  return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2090
- return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
2142
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
2091
2143
  }
2092
2144
  return this.withMount(mount, async () => {
2093
2145
  await this.settleBeforeMutation(mount, relativePath);
@@ -2102,9 +2154,11 @@ export class WorkspaceModule {
2102
2154
  let committed = false;
2103
2155
  let failure;
2104
2156
  let modeKept = null;
2157
+ let disk = null;
2105
2158
  try {
2106
2159
  const answer = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength, ...(existing ? { mode: existing.mode } : {}) }, () => { committed = true; });
2107
2160
  modeKept = answer.modeKept;
2161
+ disk = answer.disk;
2108
2162
  failure = answer.refused === null ? null
2109
2163
  : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${answer.refused}.`;
2110
2164
  }
@@ -2113,10 +2167,13 @@ export class WorkspaceModule {
2113
2167
  throw error;
2114
2168
  // As write words it: a store that failed after recording the file
2115
2169
  // skipped its push, so on a mount that pushes, disk lacks it.
2170
+ const reopen = this.agreement?.awaitsReopen === true;
2116
2171
  failure = error instanceof StoredThenFailed
2117
2172
  ? `Stored "${mountPrefixedPath}" in Chronicle, but the store failed after recording it: ${error.message}.`
2118
- + (error.pushes ? " Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it." : '')
2173
+ + (error.pushes ? ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.` : '')
2119
2174
  : `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
2175
+ if (reopen)
2176
+ failure += ` ${AWAITS_REOPEN}`;
2120
2177
  }
2121
2178
  if (failure !== null) {
2122
2179
  // The file is in the Chronicle tree, where the workspace's tools read
@@ -2126,7 +2183,7 @@ export class WorkspaceModule {
2126
2183
  }
2127
2184
  return {
2128
2185
  success: true,
2129
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(modeKept ? { modeKept } : {}) },
2186
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType, disk, ...(modeKept ? { modeKept } : {}) },
2130
2187
  };
2131
2188
  });
2132
2189
  }
@@ -2162,12 +2219,13 @@ export class WorkspaceModule {
2162
2219
  return this.withMount(mount, async () => {
2163
2220
  const { report, undecided } = await this.ensureSynced(mount, relativePath);
2164
2221
  const entry = store.treeGet(mount.treeStateId, relativePath);
2165
- if (!entry)
2166
- return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report) };
2167
- const blob = store.getBlob(entry.blobHash);
2168
- if (!blob)
2169
- return { error: `Blob not found for: ${mountPrefixedPath}` };
2170
- return { data: blob };
2222
+ if (!entry) {
2223
+ return { error: undecided !== null ? this.notCheckedYet(mountPrefixedPath, undecided) : this.notInWorkspace(mountPrefixedPath, report, mount, `File not found: ${mountPrefixedPath}`) };
2224
+ }
2225
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2226
+ if ('lost' in contents)
2227
+ return { error: nothingToRead(contents.lost) };
2228
+ return { data: contents.bytes };
2171
2229
  });
2172
2230
  }
2173
2231
  /**
@@ -2245,9 +2303,9 @@ export class WorkspaceModule {
2245
2303
  return { error: error instanceof Error ? error.message : String(error) };
2246
2304
  }
2247
2305
  }
2248
- /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
2306
+ /** The directories of the mounts nested inside a mount, mount-relative. */
2249
2307
  othersOf(mountName) {
2250
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
2308
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => s.dir));
2251
2309
  }
2252
2310
  /**
2253
2311
  * Which mounts are nested in which, read afresh at each start. A mount is
@@ -2262,13 +2320,8 @@ export class WorkspaceModule {
2262
2320
  * alone, with no real root, since through it it would claim the other's
2263
2321
  * every path.
2264
2322
  *
2265
- * Each way it lies there is a spelling of its directory (`fromRoots`), and
2266
- * so is the spelling disk lists each of those by, where that differs. On a
2267
- * disk that folds names, the outer mount's walks name the directory as
2268
- * disk does, and so does a path parsePath has respelled, while a path given
2269
- * as configured still names it as configured (resolveAbsolutePath). A
2270
- * directory not on disk keeps the spellings the roots give alone. Each
2271
- * mount's real root comes back beside the nesting (`reals`), for what
2323
+ * Each way it lies there is a spelling of its directory (`fromRoots`).
2324
+ * Each mount's real root comes back beside the nesting (`reals`), for what
2272
2325
  * start reads there, and each directory above each configured path with
2273
2326
  * where it lies on disk (`above`), for a direct mount whose root moves
2274
2327
  * (movedNesting).
@@ -2295,15 +2348,16 @@ export class WorkspaceModule {
2295
2348
  if (outer === inner)
2296
2349
  continue;
2297
2350
  const real = reals.get(inner.name);
2298
- const at = await placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name), outerPath);
2351
+ const at = placementOf(outerPath, outerReal, resolve(inner.path), real, above.get(inner.name));
2299
2352
  if (at === undefined)
2300
2353
  continue;
2301
- const { configured, fromRoots, spellings, claims } = at;
2302
- nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
2354
+ const { configured, fromRoots, claims } = at;
2355
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots }]);
2303
2356
  const placed = fromRoots.filter((s) => s.dir !== configured);
2304
2357
  if (placed.length > 0) {
2305
- console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
2306
- `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`);
2358
+ const at = (sure) => placed.filter((s) => !s.guessed === sure).map((s) => `${s.dir}/`).join(' and ');
2359
+ const where = [at(true) && `where it lies on disk, at ${at(true)}`, at(false) && `where it may lie on disk, at ${at(false)}`].filter(Boolean).join(', and ');
2360
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ${where} — passing over it as another mount's directory`);
2307
2361
  }
2308
2362
  }
2309
2363
  }
@@ -2311,10 +2365,10 @@ export class WorkspaceModule {
2311
2365
  }
2312
2366
  /**
2313
2367
  * The mounts nested in `mountName`, by directory, name, real root where it
2314
- * has one, and include (MountView's `nested`), in each spelling known.
2368
+ * has one, and include (MountView's `nested`).
2315
2369
  */
2316
2370
  nestedOf(mountName) {
2317
- return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2371
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.fromRoots.map((s) => ({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: o.name, real: o.real, include: this.mounts.get(o.name)?.config.include })));
2318
2372
  }
2319
2373
  /**
2320
2374
  * The mounts nested in `mount` as a call of it that resolved its root to
@@ -2416,12 +2470,13 @@ export class WorkspaceModule {
2416
2470
  continue;
2417
2471
  above = placedAbove.get(inner.name) ?? [];
2418
2472
  }
2419
- const at = await placementOf(outerPath, rootReal, innerPath, real, above, rootReal);
2473
+ const at = placementOf(outerPath, rootReal, innerPath, real, above);
2420
2474
  if (at === undefined)
2421
2475
  continue;
2422
2476
  const include = this.mounts.get(inner.name)?.config.include;
2423
- for (const s of at.spellings)
2424
- nested.push({ dir: s.dir, name: inner.name, ...(at.claims ? { real } : {}), include });
2477
+ for (const s of at.fromRoots) {
2478
+ nested.push({ dir: s.dir, ...(s.guessed ? { guessed: s.guessed } : {}), name: inner.name, ...(at.claims ? { real } : {}), include });
2479
+ }
2425
2480
  }
2426
2481
  return nested;
2427
2482
  }
@@ -2430,15 +2485,9 @@ export class WorkspaceModule {
2430
2485
  * of that mount, judged lexically where the path lands (`.`, `..` and
2431
2486
  * repeated separators resolved, as for the traversal guard; a link of the
2432
2487
  * outer mount isn't followed), with the path to use there; undefined when
2433
- * no nested mount's directory holds it. A path parsePath has respelled is
2434
- * compared with the directory's spellings exactly. One judged as given
2435
- * (`asGiven`, resolveAbsolutePath's) is compared loosely, as a disk that
2436
- * folds names may compare it (beginsWithDir), so it can't reach that
2437
- * mount's directory by spelling a segment otherwise. On a disk that tells
2438
- * names apart, that takes a directory of this mount's own spelled like the
2439
- * nested one in another way as that mount's too.
2488
+ * no nested mount's directory holds it.
2440
2489
  */
2441
- holderOf(mountName, relativePath, asGiven = false, moved = null) {
2490
+ holderOf(mountName, relativePath, moved = null) {
2442
2491
  const mount = this.mounts.get(mountName);
2443
2492
  if (!mount)
2444
2493
  return undefined;
@@ -2446,20 +2495,15 @@ export class WorkspaceModule {
2446
2495
  const resolved = resolve(root, relativePath);
2447
2496
  if (!isContainedPath(root, resolved))
2448
2497
  return undefined;
2449
- const segments = resolved === root ? [] : relative(root, resolved).split(sep);
2498
+ const path = resolved === root ? '' : relative(root, resolved).split(sep).join('/');
2450
2499
  // A direct mount whose root a call found moved is judged by that call's
2451
2500
  // nesting (movedNesting), each directory beneath its configured path.
2452
- const dirs = moved !== null
2453
- ? moved.map((n) => ({ name: n.name, dir: n.dir }))
2454
- : (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ name: o.name, dir: s.dir })));
2455
- const holder = dirs
2456
- .filter((s) => beginsWithDir(segments, s.dir.split('/'), asGiven))
2457
- .map((s) => ({ name: s.name, depth: s.dir.split('/').length }))
2458
- .sort((a, b) => b.depth - a.depth)[0];
2501
+ const holder = nestedHolder(path, moved ?? this.nestedOf(mountName));
2459
2502
  if (!holder)
2460
2503
  return undefined;
2461
- const rest = segments.slice(holder.depth).join('/');
2462
- return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name };
2504
+ const rest = restBeneath(path, holder);
2505
+ const guess = nestedGuess(mountName, path.split('/'), holder);
2506
+ return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, ...(guess === undefined ? {} : { guess }) };
2463
2507
  }
2464
2508
  /**
2465
2509
  * Why a mount leaves a path in a nested mount's directory, naming that
@@ -2467,10 +2511,10 @@ export class WorkspaceModule {
2467
2511
  * it; for a listing, whose the path is.
2468
2512
  */
2469
2513
  otherMountNote(mountName, relativePath, by, moved = null) {
2470
- const holder = this.holderOf(mountName, relativePath, false, moved);
2514
+ const holder = this.holderOf(mountName, relativePath, moved);
2471
2515
  const what = by === 'sync' ? ', which no sync of this mount takes in' : by === 'push' ? ', which no push of this mount writes' : '';
2472
2516
  return holder
2473
- ? `it lies in mount "${holder.name}", nested in this one${what}: use "${holder.use}"`
2517
+ ? `it ${holder.guess === undefined ? 'lies' : 'may lie'} in mount "${holder.name}", nested in this one${what}: ${nestedStep(holder)}`
2474
2518
  : `it lies in another mount's directory${what}`;
2475
2519
  }
2476
2520
  /**
@@ -2489,7 +2533,7 @@ export class WorkspaceModule {
2489
2533
  // Both checks judge by the nesting the parse found, and the second reads
2490
2534
  // disk beneath the root the parse resolved.
2491
2535
  const { mount, root, moved } = spelled;
2492
- const parsed = this.refuseNotHeld(spelled, path, false, moved);
2536
+ const parsed = this.refuseNotHeld(spelled, path, moved);
2493
2537
  // A path can also reach a nested mount's directory, or a file this
2494
2538
  // mount's own include leaves out, through a symlink on its way. No push
2495
2539
  // of this mount writes it there and no read reads it, so it's refused
@@ -2531,7 +2575,9 @@ export class WorkspaceModule {
2531
2575
  // whatever the place realpath stopped short of would say.
2532
2576
  if (judged && unresolved.code !== undefined)
2533
2577
  return new WorkspaceReadError('not_found', `Unable to resolve file: ${path}`, 'realpath', unresolved.code);
2534
- const holder = nestedAtReal(real, nested);
2578
+ let holder = await nestedAtReal(real, nested);
2579
+ if (holder?.guess === null)
2580
+ holder = await nestedAtReal(real, nested, { name: mount.config.name, rootReal: await realBeneath(resolve(mount.config.path)) });
2535
2581
  if (!holder) {
2536
2582
  if (!judged)
2537
2583
  return null;
@@ -2550,26 +2596,25 @@ export class WorkspaceModule {
2550
2596
  // on the place's way as that mount's, so a place beneath anything else
2551
2597
  // there, such as a file the include leaves out, isn't named; one beneath
2552
2598
  // a directory not there yet is.
2553
- const onItsWay = holder.include === undefined
2599
+ const onItsWay = !holder.judgedByInclude
2554
2600
  || await stat(dirname(real)).then((info) => info.isDirectory(), (err) => errnoOf(err) === 'ENOENT');
2555
- const directory = holder.include !== undefined && await stat(real).then((info) => info.isDirectory(), () => false);
2556
- if (!onItsWay || !namesNestedPlace(holder.rest, holder.include, directory))
2601
+ const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
2602
+ if (!onItsWay || !holder.names(directory))
2557
2603
  return new WorkspaceReadError('escape', `Symlink escape detected: ${path}`, 'realpath');
2558
- return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
2604
+ return new NestedMountError(path, mount.config.name, holder, 'leads into');
2559
2605
  }
2560
2606
  /**
2561
2607
  * A parsed path, refused where the mount doesn't hold it: in a nested
2562
2608
  * mount's directory first, naming the mount that holds it, then outside
2563
2609
  * the mount's include. Each is judged on the path as the caller parsed it:
2564
2610
  * as disk spells it for parseFilePath, as given for resolveAbsolutePath,
2565
- * which is synchronous and says so (`asGiven`, so the nested-mount check
2566
- * folds where disk does). The include's refusal shows the path as given
2611
+ * which is synchronous. The include's refusal shows the path as given
2567
2612
  * (`given`), since it answers the path the caller asked about.
2568
2613
  */
2569
- refuseNotHeld(parsed, given, asGiven = false, moved = null) {
2570
- const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, asGiven, moved);
2614
+ refuseNotHeld(parsed, given, moved = null) {
2615
+ const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath, moved);
2571
2616
  if (holder)
2572
- throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
2617
+ throw new NestedMountError(parsed.path, parsed.mount.config.name, holder);
2573
2618
  if (!insideMount(parsed.relativePath, parsed.mount.config.include)) {
2574
2619
  throw new OutsideIncludeError(given, parsed.mount.config);
2575
2620
  }
@@ -2615,7 +2660,7 @@ export class WorkspaceModule {
2615
2660
  * parse resolved (`root`), by the nesting there (`moved`, movedNesting).
2616
2661
  */
2617
2662
  async refusesAsOutside(mount, relativePath, moved, root) {
2618
- if (this.holderOf(mount.config.name, relativePath, false, moved))
2663
+ if (this.holderOf(mount.config.name, relativePath, moved))
2619
2664
  return false;
2620
2665
  if (insideMount(relativePath, mount.config.include))
2621
2666
  return false;
@@ -2690,20 +2735,12 @@ export class WorkspaceModule {
2690
2735
  }
2691
2736
  /**
2692
2737
  * How many segments the innermost nested mount's directory that holds
2693
- * `relativePath` has, in a spelling it's known by (othersOf), or, for a
2694
- * direct mount whose root has moved, in the nesting there (`moved`);
2738
+ * `relativePath` has, or, for a direct mount whose root has moved, in the
2739
+ * nesting there (`moved`);
2695
2740
  * undefined when no nested mount's directory holds it.
2696
2741
  */
2697
2742
  nestedDepth(mountName, relativePath, moved = null) {
2698
- let depth;
2699
- for (const dir of moved?.map((n) => n.dir) ?? this.othersOf(mountName)) {
2700
- if (relativePath !== dir && !relativePath.startsWith(`${dir}/`))
2701
- continue;
2702
- const segments = dir.split('/').length;
2703
- if (depth === undefined || segments > depth)
2704
- depth = segments;
2705
- }
2706
- return depth;
2743
+ return nestedHolder(relativePath, moved ?? this.nestedOf(mountName))?.dir.split('/').length;
2707
2744
  }
2708
2745
  /**
2709
2746
  * A mount's entries on the current branch, and the paths it records an
@@ -2725,11 +2762,7 @@ export class WorkspaceModule {
2725
2762
  // Only an entry this mount's include covers spells a draft's path
2726
2763
  // (insideMount): not a key no spelling names, such as `c/` or `a//b.txt`
2727
2764
  // from an earlier version, nor one the include leaves out, which would
2728
- // respell a covered draft into a name the include leaves out. An entry
2729
- // in a nested mount's directory that this mount's include covers
2730
- // spells one whatever that mount's include says, so on a disk that
2731
- // folds names, a draft naming the directory in another spelling takes
2732
- // the entry's spelling and is judged as in it.
2765
+ // respell a covered draft into a name the include leaves out.
2733
2766
  under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path).filter((path) => insideMount(path, mount.config.include)),
2734
2767
  holder: (path) => this.holderOf(mount.config.name, path)?.name,
2735
2768
  rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
@@ -2754,25 +2787,25 @@ export class WorkspaceModule {
2754
2787
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
2755
2788
  // store would keep one name and disk another.
2756
2789
  if (LONE_SURROGATE.test(path)) {
2757
- throw new Error(`The path ${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Nothing was done.`);
2790
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} holds a lone UTF-16 surrogate, which UTF-8 can't store, so no file on disk can have that name. Give the name without it.`);
2758
2791
  }
2759
2792
  const slashIdx = path.indexOf('/');
2760
2793
  const mountName = slashIdx >= 0 ? path.slice(0, slashIdx) : path;
2761
2794
  const given = slashIdx >= 0 ? path.slice(slashIdx + 1) : '';
2762
2795
  const mount = this.mounts.get(mountName);
2763
- if (!mount) {
2764
- throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
2765
- }
2796
+ if (!mount)
2797
+ throw new PathRefusal('unknown_mount', this.noSuchMount(path, mountName));
2766
2798
  const segments = pathSegments(given);
2767
2799
  if (given.startsWith('/') || (sep === '\\' && given.startsWith('\\'))) {
2768
- throw new Error(`Path traversal detected: "${path}" has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root.`);
2800
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} has a separator right after mount "${mountName}", which makes it an absolute path: give it from the mount's root, as "${mountName}/<path>".`);
2769
2801
  }
2770
2802
  const kept = [];
2771
2803
  for (const segment of segments) {
2772
2804
  if (segment !== '..')
2773
2805
  kept.push(segment);
2774
2806
  else if (kept.pop() === undefined) {
2775
- throw new Error(`Path traversal detected: "${path}" climbs above the root of mount "${mountName}".`);
2807
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} climbs above the root of mount "${mountName}" with "..", and a path can't leave its mount. `
2808
+ + `Name a file from its own mount's name; ${this.mountList()}.`);
2776
2809
  }
2777
2810
  }
2778
2811
  const relativePath = kept.join('/');
@@ -2786,10 +2819,37 @@ export class WorkspaceModule {
2786
2819
  const root = resolve(mount.config.path);
2787
2820
  const resolved = resolve(root, relativePath);
2788
2821
  if (!isContainedPath(root, resolved)) {
2789
- throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
2822
+ throw new PathRefusal('traversal', `${JSON.stringify(path)} resolves outside mount "${mountName}", as a drive or root named in it does: give the path from the mount's root, as "${mountName}/<path>".`);
2790
2823
  }
2791
2824
  return { mount, relativePath };
2792
2825
  }
2826
+ writableNames() {
2827
+ return [...this.mounts.values()].filter((m) => writeRefusal(m) === null).map((m) => m.config.name);
2828
+ }
2829
+ mountList() {
2830
+ const names = [...this.mounts.keys()];
2831
+ return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
2832
+ }
2833
+ noSuchMountField(given, bare) {
2834
+ const [close] = closestNames(given, [...this.mounts.keys()], 1);
2835
+ return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
2836
+ + (close !== undefined ? ` Did you mean ${JSON.stringify(close)}?` : '')
2837
+ + ` Leaving "mount" out ${bare}.`;
2838
+ }
2839
+ noSuchMount(path, mountName) {
2840
+ const names = [...this.mounts.keys()];
2841
+ const rest = path.slice(mountName.length);
2842
+ const unslashed = path.replace(/^[/\\]+/, '');
2843
+ if (unslashed !== path && names.some((name) => unslashed === name || unslashed.startsWith(`${name}/`))) {
2844
+ return `${JSON.stringify(path)} starts with "/", and a path starts with its mount's name: give ${JSON.stringify(unslashed)}.`;
2845
+ }
2846
+ if (mountName === '')
2847
+ return `${JSON.stringify(path)} doesn't start with a mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
2848
+ const [close] = closestNames(mountName, names, 1);
2849
+ const said = rest === '' ? `${JSON.stringify(path)} is no mount's name; ${this.mountList()}.`
2850
+ : `${JSON.stringify(path)} starts with ${JSON.stringify(mountName)}, which is no mount's name, and a path starts with its mount's name; ${this.mountList()}.`;
2851
+ return said + (close !== undefined ? ` Did you mean ${JSON.stringify(close + rest)}?` : '');
2852
+ }
2793
2853
  /**
2794
2854
  * Whether the module takes work: stop hasn't finished. Work admitted while
2795
2855
  * stop drains still runs; once stop has saved the pins and watermarks,
@@ -2808,25 +2868,23 @@ export class WorkspaceModule {
2808
2868
  }
2809
2869
  validateImageBytes(bytes, mountPrefixedPath, maxSize) {
2810
2870
  if (bytes.byteLength === 0) {
2811
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
2871
+ throw emptyImage(mountPrefixedPath);
2812
2872
  }
2813
2873
  if (bytes.byteLength > maxSize)
2814
- throw imageTooLarge(maxSize, mountPrefixedPath);
2874
+ throw imageTooLarge(maxSize, mountPrefixedPath, bytes.byteLength.toLocaleString('en-US'));
2815
2875
  return {
2816
2876
  bytes,
2817
2877
  mimeType: detectImageMimeType(bytes, mountPrefixedPath),
2818
2878
  };
2819
2879
  }
2820
- tryReadImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
2821
- const store = this.getStore();
2822
- const entry = store.treeGet(mount.treeStateId, relativePath);
2880
+ async readImageFromTree(mount, relativePath, mountPrefixedPath, maxSize) {
2881
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
2823
2882
  if (!entry)
2824
2883
  return null;
2825
- const blob = store.getBlob(entry.blobHash);
2826
- if (!blob) {
2827
- throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
2828
- }
2829
- return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
2884
+ const contents = await this.contentsOf(mount, relativePath, mountPrefixedPath, entry);
2885
+ if ('lost' in contents)
2886
+ return contents;
2887
+ return this.validateImageBytes(contents.bytes, mountPrefixedPath, maxSize);
2830
2888
  }
2831
2889
  /**
2832
2890
  * The mount root a disk read reads beneath: resolved, then checked as
@@ -2842,20 +2900,14 @@ export class WorkspaceModule {
2842
2900
  * another directory carries none. The identity is rootIdentityForRead's,
2843
2901
  * which answers while the evidence awaits reopening; where even it can't
2844
2902
  * be read, as when the evidence was never read back whole, the read is
2845
- * refused too, with an errno only where no directory is at the root's path.
2903
+ * refused before its root is resolved, with an errno only where no
2904
+ * directory is at the root's path.
2846
2905
  * A direct mount's root is checked against no identity, and its read reads
2847
2906
  * no evidence: it promises what's at its root now (diskView).
2848
2907
  */
2849
2908
  async readRoot(mount) {
2850
2909
  const name = mount.config.name;
2851
2910
  const root = resolve(mount.config.path);
2852
- let real;
2853
- try {
2854
- real = await realpath(root);
2855
- }
2856
- catch (err) {
2857
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
2858
- }
2859
2911
  let rootIdentity;
2860
2912
  if (!mount.direct) {
2861
2913
  try {
@@ -2864,9 +2916,16 @@ export class WorkspaceModule {
2864
2916
  catch (err) {
2865
2917
  // The evidence can't be read back, so neither can the check be made.
2866
2918
  // Where no directory is at the root's path, its errno still says so.
2867
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2919
+ throw new RootUnavailable(name, err instanceof Error ? err.message : String(err), 'root', await rootAbsence(root), err instanceof Refusal ? err : undefined);
2868
2920
  }
2869
2921
  }
2922
+ let real;
2923
+ try {
2924
+ real = await realpath(root);
2925
+ }
2926
+ catch (err) {
2927
+ throw new RootUnavailable(name, null, 'realpath_root', errnoOf(err));
2928
+ }
2870
2929
  const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2871
2930
  if ('dir' in checked)
2872
2931
  return { real, dir: checked.dir };
@@ -2874,7 +2933,7 @@ export class WorkspaceModule {
2874
2933
  // root's path now decides: nothing, something that isn't a directory, or
2875
2934
  // a directory the path has come to name since it was resolved (no errno).
2876
2935
  const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2877
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2936
+ throw new RootUnavailable(name, checked.reason, 'root', errno);
2878
2937
  }
2879
2938
  /**
2880
2939
  * The refusal for a root that stopped being the directory a read checked,
@@ -2888,7 +2947,7 @@ export class WorkspaceModule {
2888
2947
  async rootMoved(mount) {
2889
2948
  const errno = await rootAbsence(resolve(mount.config.path));
2890
2949
  const reason = errno !== undefined ? ROOT_UNAVAILABLE : mount.direct ? DIRECT_ROOT_REPLACED : ROOT_REPLACED;
2891
- return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2950
+ return new RootUnavailable(mount.config.name, reason, 'root', errno);
2892
2951
  }
2893
2952
  /**
2894
2953
  * Open a mount-relative file for reading with the mount boundary enforced on
@@ -2978,7 +3037,7 @@ export class WorkspaceModule {
2978
3037
  }
2979
3038
  if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2980
3039
  // A FIFO, socket or device is never opened.
2981
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
3040
+ throw new NotRegularFile(mountPrefixedPath, 'lstat');
2982
3041
  }
2983
3042
  let handle;
2984
3043
  try {
@@ -3009,7 +3068,7 @@ export class WorkspaceModule {
3009
3068
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
3010
3069
  }
3011
3070
  if (!fileStat.isFile()) {
3012
- throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
3071
+ throw new NotRegularFile(mountPrefixedPath, 'fstat');
3013
3072
  }
3014
3073
  if (!follow && !useNoFollow) {
3015
3074
  let postOpenInfo;
@@ -3047,13 +3106,13 @@ export class WorkspaceModule {
3047
3106
  if (resolvedThrough !== null)
3048
3107
  throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, relativePath, nested, realMountRoot);
3049
3108
  const inside = isContainedPath(realMountRoot, realFilePath);
3050
- const holder = inside ? nestedAtReal(realFilePath, nested) : undefined;
3109
+ const holder = inside ? await nestedAtReal(realFilePath, nested, { name: mount.config.name, rootReal: realMountRoot }) : undefined;
3051
3110
  // A place in a nested mount's directory that its include leaves out is
3052
3111
  // no mount's, so the path leaves this one (namesNestedPlace), and so
3053
3112
  // does one reaching a file this mount's own include leaves out
3054
3113
  // (coversLanding).
3055
3114
  const own = holder === undefined && inside && !(await coversLanding({ include: mount.config.include }, realMountRoot, realFilePath, relativePath));
3056
- if (!inside || own || (holder !== undefined && !namesNestedPlace(holder.rest, holder.include, false))) {
3115
+ if (!inside || own || (holder !== undefined && !holder.names(false))) {
3057
3116
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
3058
3117
  }
3059
3118
  if (holder)
@@ -3122,7 +3181,9 @@ export class WorkspaceModule {
3122
3181
  * (`outside_include`), a path in another mount's directory nested in the one named
3123
3182
  * (`other_mount`), unavailable mount, missing or non-regular file,
3124
3183
  * directory, policy-denied symlink, outside-mount target, and
3125
- * file-changed-during-read.
3184
+ * file-changed-during-read. An error met while parsing the path that is
3185
+ * none of these refusals, such as the store's, is thrown as it is, and a
3186
+ * `maxBytes` that isn't a non-negative integer throws a `RangeError`.
3126
3187
  */
3127
3188
  async readFileFromDisk(mountPrefixedPath, options = {}) {
3128
3189
  if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
@@ -3137,11 +3198,12 @@ export class WorkspaceModule {
3137
3198
  // A link's refusal (parseFilePath) carries its own code.
3138
3199
  if (err instanceof WorkspaceReadError)
3139
3200
  throw err;
3140
- const message = err instanceof Error ? err.message : String(err);
3141
3201
  const code = err instanceof OutsideIncludeError ? 'outside_include'
3142
3202
  : err instanceof NestedMountError ? 'other_mount'
3143
- : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
3144
- throw new WorkspaceReadError(code, message, 'parse');
3203
+ : err instanceof PathRefusal ? err.code : null;
3204
+ if (code === null)
3205
+ throw err;
3206
+ throw new WorkspaceReadError(code, err.message, 'parse');
3145
3207
  }
3146
3208
  if (!relativePath) {
3147
3209
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
@@ -3171,93 +3233,25 @@ export class WorkspaceModule {
3171
3233
  await handle.close();
3172
3234
  }
3173
3235
  }
3174
- /** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
3175
- imageErrorFromRead(err, mount, mountPrefixedPath) {
3176
- const p = mountPrefixedPath;
3177
- switch (err.code) {
3178
- case 'mount_unavailable':
3179
- // With why, where the root's check says, such as a directory that
3180
- // isn't the mount's.
3181
- return new WorkspaceImageReadError('mount_unavailable', err.message);
3182
- case 'directory':
3183
- return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
3184
- case 'symlink':
3185
- // The core reader's own words for a link at the path: "Symlinks are not
3186
- // allowed: <path>".
3187
- return new WorkspaceImageReadError('symlink', err.message);
3188
- case 'parent_symlink':
3189
- // The core reader's own words for a link on its way: which link and
3190
- // where it leads, with the setting for a link to a directory.
3191
- return new WorkspaceImageReadError('parent_symlink', err.message);
3192
- case 'other_mount':
3193
- // The core reader's own words for a path a link takes into a nested
3194
- // mount's directory: that mount and the path to use there. (#460's
3195
- // own other_mount, a path in that directory, is refused at parse,
3196
- // and so is a link found there before the read.)
3197
- return new WorkspaceImageReadError('other_mount', err.message);
3198
- case 'escape':
3199
- // The core reader's own words: "Symlink escape detected: <path>" for a
3200
- // followed link that resolves outside the mount, and for a link on the
3201
- // way that leads outside a mount that doesn't follow links, which link.
3202
- return new WorkspaceImageReadError('escape', err.message);
3203
- case 'changed':
3204
- return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
3205
- case 'not_found':
3206
- default:
3207
- // The core reader's own words for a file that isn't regular, and for
3208
- // a directory on the way it couldn't ask about.
3209
- if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
3210
- return new WorkspaceImageReadError('not_found', err.message);
3211
- }
3212
- if (err.message.startsWith('File not found')) {
3213
- return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
3214
- }
3215
- if (err.stage === 'realpath') {
3216
- return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
3217
- }
3218
- if (err.stage === 'stat') {
3219
- return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
3220
- }
3221
- return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
3222
- }
3223
- }
3224
3236
  async readImageFromFilesystem(mount, relativePath, mountPrefixedPath, maxSize) {
3225
- let opened;
3226
- try {
3227
- opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3228
- if (fileStat.size === 0) {
3229
- throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
3230
- }
3231
- if (fileStat.size > maxSize)
3232
- throw imageTooLarge(maxSize, mountPrefixedPath);
3233
- });
3234
- }
3235
- catch (err) {
3236
- if (err instanceof WorkspaceImageReadError)
3237
- throw err;
3238
- if (err instanceof WorkspaceReadError)
3239
- throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
3240
- if (isErrnoCode(err, 'ENOENT')) {
3241
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3242
- }
3243
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3244
- }
3245
- const { handle, fileStat } = opened;
3237
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
3238
+ if (fileStat.size === 0)
3239
+ throw emptyImage(mountPrefixedPath);
3240
+ if (fileStat.size > maxSize)
3241
+ throw imageTooLarge(maxSize, mountPrefixedPath, fileStat.size.toLocaleString('en-US'));
3242
+ });
3246
3243
  try {
3247
3244
  // No more than maxSize is loaded: a file grown past it since the fstat
3248
3245
  // above is refused here, as one already over it was there.
3249
3246
  const { bytes, more } = await readPrefix(handle, maxSize, fileStat.size);
3250
3247
  if (more)
3251
- throw imageTooLarge(maxSize, mountPrefixedPath);
3248
+ throw imageTooLarge(maxSize, mountPrefixedPath, `at least ${(maxSize + 1).toLocaleString('en-US')}`);
3252
3249
  return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
3253
3250
  }
3254
3251
  catch (err) {
3255
3252
  if (err instanceof WorkspaceImageReadError)
3256
3253
  throw err;
3257
- if (isErrnoCode(err, 'ENOENT')) {
3258
- throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
3259
- }
3260
- throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
3254
+ throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
3261
3255
  }
3262
3256
  finally {
3263
3257
  await handle.close();
@@ -3289,13 +3283,14 @@ export class WorkspaceModule {
3289
3283
  * downstream agents. And, apart from it, why a write whose bytes reached
3290
3284
  * disk left the file's mode as disk had it (`modeKept`), and, for a
3291
3285
  * deletion that cleared a conflict naming a disk file the workspace never
3292
- * took in, that nothing was unlinked (`note`). The caller holds the mount's
3293
- * turn and has settled the path first (settleBeforeMutation, or a pass over
3294
- * the path, as an edit makes).
3286
+ * took in, that nothing was unlinked (`note`). And the answer's `disk` for
3287
+ * the path, unless it was refused. The caller holds the mount's turn and has
3288
+ * settled the path first (settleBeforeMutation, or a pass over the path, as
3289
+ * an edit makes).
3295
3290
  */
3296
3291
  async commitToolChange(mount, relativePath, change, onCommitted) {
3297
- const { refused, modeKept, notes } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3298
- return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null };
3292
+ const { refused, modeKept, notes, disk } = await this.commitToolChanges(mount, [{ path: relativePath, change }], onCommitted && (() => onCommitted()));
3293
+ return { refused: refused.get(relativePath) ?? null, modeKept: modeKept.get(relativePath) ?? null, note: notes.get(relativePath) ?? null, disk: disk.get(relativePath) ?? null };
3299
3294
  }
3300
3295
  /**
3301
3296
  * commitToolChange for several paths of one mount at once, as edit_many
@@ -3310,9 +3305,10 @@ export class WorkspaceModule {
3310
3305
  * (changesLanded).
3311
3306
  * Answers, by path, the reason for each that reached the workspace but not
3312
3307
  * disk (`refused`), and, apart from it, why one whose bytes reached disk
3313
- * left the file's mode as disk had it (`modeKept`), and that a deletion
3314
- * clearing a conflict that named a disk file unlinked nothing (`notes`), as
3315
- * commitToolChange answers them for one path.
3308
+ * left the file's mode as disk had it (`modeKept`), that a deletion
3309
+ * clearing a conflict that named a disk file unlinked nothing (`notes`),
3310
+ * and the answer's `disk` for each path not refused, as commitToolChange
3311
+ * answers them for one path.
3316
3312
  */
3317
3313
  async commitToolChanges(mount, changes, onCommitted) {
3318
3314
  const store = this.getStore();
@@ -3461,7 +3457,7 @@ export class WorkspaceModule {
3461
3457
  // nothing.
3462
3458
  if (changing.length === 0 || !changesLanded(store, mount.treeStateId, changing, error)) {
3463
3459
  putBack();
3464
- throw error;
3460
+ throw changing.length === 0 ? new AlreadyHeld(error) : error;
3465
3461
  }
3466
3462
  landedThenThrew = { error };
3467
3463
  }
@@ -3479,8 +3475,9 @@ export class WorkspaceModule {
3479
3475
  throw new StoredThenFailed(landedThenThrew.error, this.pushesOnCommit(mount) && pushing.length > 0, notes);
3480
3476
  const refused = new Map();
3481
3477
  const modeKept = new Map();
3482
- if (!this.pushesOnCommit(mount) || pushing.length === 0)
3483
- return { refused, modeKept, notes };
3478
+ if (!this.pushesOnCommit(mount) || pushing.length === 0) {
3479
+ return { refused, modeKept, notes, disk: new Map(changes.map(({ path }) => [path, this.diskOf(mount, path)])) };
3480
+ }
3484
3481
  const pushed = await this.pushUnlocked(mount, pushing.map(({ path }) => path), {
3485
3482
  applyDeletions: pushing.some(({ change }) => change.kind === 'remove'),
3486
3483
  branchId: store.currentBranch().id,
@@ -3502,7 +3499,7 @@ export class WorkspaceModule {
3502
3499
  error: skipped.reason,
3503
3500
  });
3504
3501
  }
3505
- return { refused, modeKept, notes };
3502
+ return { refused, modeKept, notes, disk: new Map(changes.filter(({ path }) => !refused.has(path)).map(({ path }) => [path, 'done'])) };
3506
3503
  }
3507
3504
  // ==========================================================================
3508
3505
  // Lazy Sync
@@ -3551,25 +3548,29 @@ export class WorkspaceModule {
3551
3548
  * call made again checks it.
3552
3549
  */
3553
3550
  notCheckedYet(path, reason) {
3554
- return `${this.notInWorkspace(path, { path, state: 'unverified', note: reason })} Make the same call again, which checks disk first.`;
3551
+ return `${JSON.stringify(path)} isn't in the workspace, and disk couldn't be checked: ${reason}. Make the same call again, which checks disk first.`;
3555
3552
  }
3556
3553
  /** Why the workspace holds no entry at a path, when disk explains it. */
3557
- notInWorkspace(path, report) {
3554
+ notInWorkspace(path, report, mount, missing) {
3555
+ const shown = JSON.stringify(path);
3558
3556
  switch (report?.state) {
3559
3557
  case 'workspace-deleted':
3560
- return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3558
+ return `${shown} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
3561
3559
  case 'not-in-branch':
3562
- return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3563
- case 'disk-only':
3564
- return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
3565
- (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
3560
+ return `${shown} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
3561
+ case 'disk-only': {
3562
+ const shows = report.mimeType?.startsWith('image/') === true && report.size !== undefined && report.size <= (mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE);
3563
+ return `${shown} is on disk, but the workspace doesn't store it: it's binary, or over this mount's size limit. `
3564
+ + 'read, edit and delete act only on the files it stores, so calling again won\'t change that'
3565
+ + (shows ? '; read_image shows it, if its data holds together as an image.' : '.');
3566
+ }
3566
3567
  case 'conflict':
3567
- return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
3568
+ return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3568
3569
  'Sync this path to take the disk version.';
3569
3570
  case 'unverified':
3570
- return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
3571
+ return uncheckedFile(shown, report.note);
3571
3572
  default:
3572
- return `File not found: ${path}`;
3573
+ return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3573
3574
  }
3574
3575
  }
3575
3576
  /**
@@ -3611,15 +3612,15 @@ export class WorkspaceModule {
3611
3612
  // A pass also drops a copy disk made binary or too large, which
3612
3613
  // the disk-only answer describes; any other drop is disk's deletion.
3613
3614
  return {
3614
- error: took('deleted') && report?.state !== 'disk-only'
3615
- ? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3616
- : [this.notInWorkspace(path, report), ...again].join(' '),
3615
+ error: took('deleted') && report?.state !== 'disk-only' ? deletedOnDisk(path)
3616
+ : undecided !== null ? this.notCheckedYet(path, undecided)
3617
+ : this.notInWorkspace(path, report, mount),
3617
3618
  };
3618
3619
  }
3619
- const blob = store.getBlob(entry.blobHash);
3620
- if (!blob)
3621
- return { error: `Blob not found for: ${path}` };
3622
- return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3620
+ const contents = await this.contentsOf(mount, relativePath, path, entry);
3621
+ if ('lost' in contents)
3622
+ return { error: nothingToRead(contents.lost) };
3623
+ return { bytes: contents.bytes, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3623
3624
  });
3624
3625
  }
3625
3626
  /**
@@ -3632,37 +3633,145 @@ export class WorkspaceModule {
3632
3633
  */
3633
3634
  async bytesOnDisk(mount, relativePath, path) {
3634
3635
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3635
- const over = `${path} is over the size limit (${maxSize} bytes), so read doesn't show it.`;
3636
+ const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "read doesn't show it", null);
3636
3637
  let opened;
3637
3638
  try {
3638
3639
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3639
3640
  if (fileStat.size > maxSize)
3640
- throw new Error(over);
3641
+ throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
3641
3642
  });
3642
3643
  }
3643
3644
  catch (err) {
3644
- // A refusal that names only the path says why with its errno, as a
3645
- // failed read below does.
3646
- return { error: containedRefusal(err, path) };
3645
+ if (err instanceof Refusal)
3646
+ return { error: err.message };
3647
+ if (err instanceof WorkspaceReadError)
3648
+ return { error: readRefusal(err, path) };
3649
+ throw err;
3647
3650
  }
3648
3651
  try {
3649
3652
  // No more than the limit is loaded, as for a file over it at its fstat.
3650
3653
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
3651
3654
  if (more)
3652
- return { error: over };
3655
+ return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
3653
3656
  if (looksBinary(bytes)) {
3654
- return { error: `${path} is binary, so read doesn't show it as text${sniffImageMime(bytes) ? '; read it with read_image.' : '.'}` };
3657
+ return { error: `${JSON.stringify(path)} is binary, so read doesn't show it as text. ${sniffImageMime(bytes) ? 'read_image shows it, if its data holds together as an image.' : 'No tool shows it as text, so calling again won\'t change that.'}` };
3655
3658
  }
3656
3659
  return { bytes };
3657
3660
  }
3658
3661
  catch (err) {
3659
- const errno = errnoOf(err);
3660
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
3662
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
3661
3663
  }
3662
3664
  finally {
3663
3665
  await opened.handle.close();
3664
3666
  }
3665
3667
  }
3668
+ async contentsOf(mount, relativePath, path, version, what = 'saved', tell = 'now') {
3669
+ const store = this.getStore();
3670
+ const stored = storedBytes(store, version.blobHash);
3671
+ if ('bytes' in stored)
3672
+ return stored;
3673
+ const otherSize = new Error('other size');
3674
+ let found;
3675
+ try {
3676
+ const opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
3677
+ if (fileStat.size !== version.size)
3678
+ throw otherSize;
3679
+ });
3680
+ try {
3681
+ const read = await readPrefix(opened.handle, version.size, opened.fileStat.size);
3682
+ found = !read.more && hashContent(read.bytes) === version.blobHash ? { bytes: read.bytes } : { disk: 'other' };
3683
+ }
3684
+ finally {
3685
+ await opened.handle.close();
3686
+ }
3687
+ }
3688
+ catch (err) {
3689
+ found = { disk: err === otherSize ? 'other' : await this.diskLacks(mount, relativePath, path, err) };
3690
+ }
3691
+ const subject = contentsSubject(what, path);
3692
+ const data = { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in stored ? { storeError: stored.unreadable } : {}) };
3693
+ if ('disk' in found) {
3694
+ const now = storedBytes(store, version.blobHash);
3695
+ if ('bytes' in now)
3696
+ return now;
3697
+ const lost = { path, store: now, disk: found.disk, writable: writeRefusal(mount) === null, reopen: this.agreementOrThrow().awaitsReopen };
3698
+ if (tell === 'now')
3699
+ this.tellLost(mount.config.name, what, version.blobHash, lost);
3700
+ return { lost };
3701
+ }
3702
+ const { bytes } = found;
3703
+ if ('bytes' in storedBytes(store, version.blobHash))
3704
+ return { bytes };
3705
+ let thrown = null;
3706
+ try {
3707
+ store.storeBlob(bytes, 'application/octet-stream');
3708
+ }
3709
+ catch (error) {
3710
+ thrown = error instanceof Error ? error.message : String(error);
3711
+ }
3712
+ // Chronicle's storeBlob takes any file already at the hash's path as
3713
+ // stored, so whether the bytes took is what a read of them gives now.
3714
+ const now = storedBytes(store, version.blobHash);
3715
+ if ('bytes' in now) {
3716
+ this.ctx?.notifyOps?.('workspace-contents-restored', 'workspace', `Blob ${version.blobHash}: ${subject} were ${inTheStore(stored)}, and were stored again from disk's copy, whose bytes have their hash.`, data);
3717
+ }
3718
+ else {
3719
+ const unstored = thrown ?? ('missing' in now ? 'the store has no file for them' : `the store can't read its file for them (${now.unreadable})`);
3720
+ this.ctx?.notifyOps?.('workspace-contents-unstored', 'workspace', `Blob ${version.blobHash}: ${subject} are ${inTheStore(now)}. Disk's copy has their hash, so reads are answered from it, but storing it again didn't take: ${unstored}. `
3721
+ + ('unreadable' in now
3722
+ ? "Each read does this again while that lasts. Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them."
3723
+ : 'Each read does this again until the store takes them.'), { mount: mount.config.name, path, blobHash: version.blobHash, ...('unreadable' in now ? { storeError: now.unreadable } : {}), unstored });
3724
+ }
3725
+ return { bytes };
3726
+ }
3727
+ tellLost(mountName, what, blobHash, lost) {
3728
+ this.ctx?.notifyOps?.('workspace-contents-lost', 'workspace', `Blob ${blobHash}: ${contentsSubject(what, lost.path)} are ${inTheStore(lost.store)}, and no copy with their hash could be read from disk to restore them from: ${lostOnDisk(lost)}. `
3729
+ + "Putting back the store's blob file for that hash, from a copy of the store, brings them back.", { mount: mountName, path: lost.path, blobHash, ...('unreadable' in lost.store ? { storeError: lost.store.unreadable } : {}) });
3730
+ }
3731
+ async diskLacks(mount, relativePath, path, err) {
3732
+ const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
3733
+ if (!absent)
3734
+ return { unreadable: containedRefusal(err, path) };
3735
+ const root = resolve(mount.config.path);
3736
+ const rootReal = await realpath(root).catch(() => root);
3737
+ const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
3738
+ const proof = await confirmAbsent(view, rootReal, relativePath);
3739
+ if (typeof proof !== 'string')
3740
+ return 'none';
3741
+ return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
3742
+ }
3743
+ async restoreEach(mount, versions, each) {
3744
+ const byHash = new Map();
3745
+ for (const version of versions) {
3746
+ const sharing = byHash.get(version.hash);
3747
+ if (sharing)
3748
+ sharing.push(version);
3749
+ else
3750
+ byHash.set(version.hash, [version]);
3751
+ }
3752
+ for (const [hash, sharing] of byHash) {
3753
+ let bytes = null;
3754
+ const lost = [];
3755
+ for (const version of sharing) {
3756
+ const contents = await this.contentsOf(mount, version.relativePath, version.file, { blobHash: hash, size: version.size }, version.what, 'later');
3757
+ if ('bytes' in contents) {
3758
+ bytes = contents.bytes;
3759
+ break;
3760
+ }
3761
+ lost.push([version, contents.lost]);
3762
+ }
3763
+ if (bytes === null) {
3764
+ for (const [version, found] of lost) {
3765
+ this.tellLost(mount.config.name, version.what, hash, found);
3766
+ each(version, { lost: found });
3767
+ }
3768
+ }
3769
+ else {
3770
+ for (const version of sharing)
3771
+ each(version, { bytes });
3772
+ }
3773
+ }
3774
+ }
3666
3775
  // ==========================================================================
3667
3776
  // Tool Handlers
3668
3777
  // ==========================================================================
@@ -3677,16 +3786,18 @@ export class WorkspaceModule {
3677
3786
  // Each number is a whole number of at least its schema's minimum by now
3678
3787
  // (inputProblem).
3679
3788
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
3680
- return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
3789
+ return { success: false, isError: true, error: didNothing('read', 'it was given both "offset"/"limit", which page by lines, and "offsetChars"/"limitChars", which page by characters. Give one pair.') };
3681
3790
  }
3682
3791
  let file;
3683
3792
  let notes = [];
3684
3793
  if (generatedContent === undefined) {
3685
3794
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3795
+ if (relativePath === '')
3796
+ return { success: false, error: didNothing('read', namesRoot(input.path, mount.config.name)), isError: true };
3686
3797
  input = { ...input, path };
3687
3798
  const found = await this.bytesOf(mount, relativePath, path);
3688
3799
  if ('error' in found)
3689
- return { success: false, error: found.error, isError: true };
3800
+ return { success: false, error: didNothing('read', found.error), isError: true };
3690
3801
  file = fileText(found.bytes);
3691
3802
  notes = found.notes;
3692
3803
  }
@@ -3702,31 +3813,23 @@ export class WorkspaceModule {
3702
3813
  : linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
3703
3814
  }
3704
3815
  async handleReadImage(input) {
3705
- let mount;
3706
- let relativePath;
3707
- try {
3708
- let path;
3709
- ({ mount, relativePath, path } = await this.parseFilePath(input.path));
3710
- input = { ...input, path };
3711
- }
3712
- catch (err) {
3713
- return {
3714
- success: false,
3715
- error: err instanceof Error ? err.message : String(err),
3716
- isError: true,
3717
- };
3718
- }
3719
- if (!relativePath) {
3720
- return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
3721
- }
3816
+ const given = input.path;
3817
+ const refused = (body) => ({ success: false, isError: true, error: didNothing('read_image', body) });
3818
+ const { mount, relativePath, path } = await this.parseFilePath(given);
3819
+ if (!relativePath)
3820
+ return refused(namesRoot(given, mount.config.name));
3821
+ input = { ...input, path };
3722
3822
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3723
3823
  let treeError = null;
3824
+ let lost = null;
3724
3825
  // The workspace's copy first, where it holds one, then disk's. A direct
3725
3826
  // mount holds none, so its image is disk's.
3726
3827
  if (!mount.direct) {
3727
3828
  try {
3728
- const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
3729
- if (image)
3829
+ const image = await this.readImageFromTree(mount, relativePath, input.path, maxSize);
3830
+ if (image && 'lost' in image)
3831
+ lost = image.lost;
3832
+ else if (image)
3730
3833
  return imageResult(input.path, image);
3731
3834
  }
3732
3835
  catch (err) {
@@ -3739,31 +3842,40 @@ export class WorkspaceModule {
3739
3842
  }
3740
3843
  }
3741
3844
  try {
3742
- return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize));
3845
+ const note = lost === null ? undefined
3846
+ : `The workspace's copy of ${input.path} is ${lostInStore(lost)}, so this is disk's copy${lost.disk === 'other' ? ', which holds other contents' : ''}.`;
3847
+ return imageResult(input.path, await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize), note);
3743
3848
  }
3744
3849
  catch (err) {
3745
- if (err instanceof WorkspaceImageReadError) {
3746
- if (err.code === 'not_found' && treeError) {
3747
- return { success: false, error: treeError.message, isError: true };
3748
- }
3749
- return { success: false, error: err.message, isError: true };
3850
+ if (!(err instanceof WorkspaceImageReadError) && !(err instanceof WorkspaceReadError))
3851
+ throw err;
3852
+ const noFile = err instanceof WorkspaceReadError && err.code === 'not_found';
3853
+ if (lost) {
3854
+ const why = err instanceof WorkspaceImageReadError ? err.reason : containedRefusal(err, input.path);
3855
+ return refused(noFile || lost.disk !== 'other' ? nothingToRead(lost)
3856
+ : `${lostText(lost, 'the')}, so it can't be restored, and disk's copy can't be shown (${why}). ${lostRemedy(lost)}`);
3750
3857
  }
3751
- throw err;
3858
+ if (noFile && treeError)
3859
+ return refused(treeError.message);
3860
+ return refused(err instanceof WorkspaceImageReadError ? err.message : readRefusal(err, given));
3752
3861
  }
3753
3862
  }
3754
3863
  async handleWrite(input, callerAgentName) {
3755
3864
  if (LONE_SURROGATE.test(input.content)) {
3756
- return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
3865
+ return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store. Give it without the surrogate, or with the other half of its pair.' };
3757
3866
  }
3758
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
3867
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
3868
+ if (relativePath === '')
3869
+ return { success: false, error: didNothing('write', namesRoot(input.path, mount.config.name)), isError: true };
3759
3870
  input = { ...input, path };
3760
- const refusal = writeRefusal(mount);
3871
+ const refusal = writeRefusal(mount, this.writableNames());
3761
3872
  if (refusal)
3762
- return { success: false, error: refusal, isError: true };
3873
+ return { success: false, error: didNothing('write', refusal), isError: true };
3763
3874
  const store = this.getStore();
3764
3875
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3765
- if (Buffer.byteLength(input.content) > maxSize) {
3766
- return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
3876
+ const bytes = Buffer.byteLength(input.content);
3877
+ if (bytes > maxSize) {
3878
+ return { success: false, error: didNothing('write', tooLargeToWrite(input.path, bytes, maxSize)), isError: true };
3767
3879
  }
3768
3880
  const buffer = Buffer.from(input.content, 'utf-8');
3769
3881
  const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
@@ -3772,13 +3884,14 @@ export class WorkspaceModule {
3772
3884
  const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
3773
3885
  if ('error' in written)
3774
3886
  return { success: false, isError: true, error: written.error };
3775
- return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
3887
+ return { success: true, data: { ...receipt, disk: 'done', ...(written.note ? { note: written.note } : {}) } };
3776
3888
  }
3777
3889
  let committed = false;
3778
3890
  let refused;
3779
3891
  let modeKept;
3892
+ let disk;
3780
3893
  try {
3781
- ({ refused, modeKept } = await this.withMount(mount, async () => {
3894
+ ({ refused, modeKept, disk } = await this.withMount(mount, async () => {
3782
3895
  await this.settleBeforeMutation(mount, relativePath);
3783
3896
  const blobHash = store.storeBlob(buffer, 'text/plain');
3784
3897
  // An overwrite keeps the file's mode, as an edit does.
@@ -3788,14 +3901,14 @@ export class WorkspaceModule {
3788
3901
  }
3789
3902
  catch (error) {
3790
3903
  if (!committed)
3791
- throw error;
3904
+ throw notStored(error);
3792
3905
  return storedButUnfinished('write', input.path, error instanceof StoredThenFailed
3793
3906
  ? { storeFailed: error.message, pushes: error.pushes }
3794
- : { threw: error instanceof Error ? error.message : String(error) });
3907
+ : { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
3795
3908
  }
3796
3909
  if (refused !== null)
3797
3910
  return storedButUnfinished('write', input.path, { refused });
3798
- return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
3911
+ return { success: true, data: { ...receipt, disk, ...(modeKept ? { modeKept } : {}) } };
3799
3912
  }
3800
3913
  async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3801
3914
  return this.applyEdits('edit', [{
@@ -3835,12 +3948,14 @@ export class WorkspaceModule {
3835
3948
  * stored where its record landed, and that it can't tell where they
3836
3949
  * couldn't be read back.
3837
3950
  * Under the mount's turn, one pass settles every path with disk, by the
3838
- * rule a listing uses, so the edits apply to what disk now holds. That pass
3839
- * is the only step that awaits before the commit: every file is then read,
3840
- * every edit planned, and every entry set (commitToolChanges) with no turn
3841
- * of the event loop between, so no pass, branch change or other call moves
3842
- * the tree between a file's read and its set. Disk then takes what it can
3843
- * of the stored change.
3951
+ * rule a listing uses, so the edits apply to what disk now holds. Each
3952
+ * file's stored contents are then read; where the store has lost some,
3953
+ * they're restored from disk's matching copy (contentsOf) and the pass runs
3954
+ * again, so the plan reads a pass taken after every await. From there
3955
+ * nothing awaits before the commit: every edit is planned and every entry
3956
+ * set (commitToolChanges) with no turn of the event loop between, so no
3957
+ * pass, branch change or other call moves the tree between a file's read
3958
+ * and its set. Disk then takes what it can of the stored change.
3844
3959
  *
3845
3960
  * The answer is held to `maxChars`, the length the call's result may have
3846
3961
  * (ToolCall.resultMaxChars), as edit-text.ts describes.
@@ -3875,7 +3990,7 @@ export class WorkspaceModule {
3875
3990
  }
3876
3991
  for (const field of ['oldString', 'newString']) {
3877
3992
  if (LONE_SURROGATE.test(edit[field])) {
3878
- problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` });
3993
+ problems.push({ kind: 'input', edit: index, text: `"${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store. Give it without the surrogate, or with the other half of its pair.` });
3879
3994
  }
3880
3995
  }
3881
3996
  if (problems.length === found)
@@ -3937,7 +4052,31 @@ export class WorkspaceModule {
3937
4052
  return await this.withMount(mount, async () => {
3938
4053
  // As every file tool's pass does, it names the paths it acts on, so a
3939
4054
  // file the mount passes over by design is observed (see PassOptions.named).
3940
- const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4055
+ let pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4056
+ let ops = pass.ops;
4057
+ const contents = new Map();
4058
+ const lost = new Map();
4059
+ const toRecover = [];
4060
+ for (const target of targets.values()) {
4061
+ const entry = store.treeGet(mount.treeStateId, target.relativePath);
4062
+ if (!entry || contents.has(entry.blobHash))
4063
+ continue;
4064
+ const stored = storedBytes(store, entry.blobHash);
4065
+ if ('bytes' in stored)
4066
+ contents.set(entry.blobHash, stored.bytes);
4067
+ else
4068
+ toRecover.push({ relativePath: target.relativePath, file: target.path, hash: entry.blobHash, size: entry.size, what: 'saved' });
4069
+ }
4070
+ if (toRecover.length > 0) {
4071
+ await this.restoreEach(mount, toRecover, (version, found) => {
4072
+ if ('bytes' in found)
4073
+ contents.set(version.hash, found.bytes);
4074
+ else
4075
+ lost.set(version.relativePath, { blobHash: version.hash, lost: found.lost });
4076
+ });
4077
+ pass = await this.passUnlocked(mount, { kind: 'paths', paths: [...targets.values()].map((target) => target.relativePath) }, { named: true });
4078
+ ops = [...ops, ...pass.ops];
4079
+ }
3941
4080
  // A pass that decided nothing checked no file's disk copy.
3942
4081
  const undecided = undecidedOf(pass);
3943
4082
  // Nothing below awaits until the commit's push.
@@ -3957,7 +4096,7 @@ export class WorkspaceModule {
3957
4096
  // A mode taken in alone isn't a change to the text: it brings nothing
3958
4097
  // new to read, as it makes no event (emitFsEvents), and the edits
3959
4098
  // then write at the mode disk has, so it leaves nothing to say.
3960
- const took = (op) => pass.ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
4099
+ const took = (op) => ops.some((o) => o.path === target.relativePath && o.op === op && !o.modeOnly);
3961
4100
  // Which copy the edits used, when it isn't the one the workspace
3962
4101
  // last agreed on with disk: disk's newer text, or the workspace's
3963
4102
  // copy because disk's couldn't be read or disk has none. A file the
@@ -4021,18 +4160,28 @@ export class WorkspaceModule {
4021
4160
  // A pass also drops a copy disk made binary or too large, which
4022
4161
  // the disk-only answer describes; any other drop is disk's deletion.
4023
4162
  const text = took('deleted') && report?.state !== 'disk-only'
4024
- ? `File not found: ${target.path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
4025
- : this.notInWorkspace(target.path, report);
4026
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: true });
4163
+ ? deletedOnDisk(target.path)
4164
+ : this.notInWorkspace(target.path, report, mount);
4165
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text, whole: false });
4027
4166
  continue;
4028
4167
  }
4029
- notes.push(...lookedIn);
4030
- const blob = store.getBlob(entry.blobHash);
4031
- if (!blob) {
4032
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4168
+ const known = contents.get(entry.blobHash);
4169
+ const stored = known !== undefined ? { bytes: known } : storedBytes(store, entry.blobHash);
4170
+ if (!('bytes' in stored)) {
4171
+ const found = lost.get(target.relativePath);
4172
+ problems.push({
4173
+ kind: 'file',
4174
+ edits: target.edits,
4175
+ path: target.path,
4176
+ text: found?.blobHash === entry.blobHash
4177
+ ? `${lostText(found.lost)}, so it can't be restored, and ${subject} changed nothing. ${lostRemedy(found.lost)}`
4178
+ : `The workspace's copy of ${target.path} changed while ${subject} restored lost contents from disk, and is ${lostInStore({ store: stored })} too, so ${subject} changed nothing. Make the same call again.`,
4179
+ whole: true,
4180
+ });
4033
4181
  continue;
4034
4182
  }
4035
- const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
4183
+ notes.push(...lookedIn);
4184
+ const plan = planTargetEdits(tool, target, stored.bytes, hints, maxSize, counts, problems);
4036
4185
  if (plan === null)
4037
4186
  continue;
4038
4187
  plans.push({
@@ -4048,7 +4197,7 @@ export class WorkspaceModule {
4048
4197
  }
4049
4198
  if (problems.length)
4050
4199
  return refuse(problems, notes);
4051
- const { refused, modeKept } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4200
+ const { refused, modeKept, disk } = await this.commitToolChanges(mount, plans.map(({ target, plan, mode }) => ({
4052
4201
  path: target.relativePath,
4053
4202
  change: { kind: 'set', blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4054
4203
  })), () => { committed = true; });
@@ -4059,7 +4208,7 @@ export class WorkspaceModule {
4059
4208
  plan,
4060
4209
  notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
4061
4210
  modeKept: modeKept.get(target.relativePath) ?? null,
4062
- })), given.length, maxChars);
4211
+ })), given.length, maxChars, furthestFromDisk(plans.map(({ target }) => disk.get(target.relativePath))));
4063
4212
  });
4064
4213
  }
4065
4214
  catch (error) {
@@ -4067,12 +4216,12 @@ export class WorkspaceModule {
4067
4216
  if (committed) {
4068
4217
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, error instanceof StoredThenFailed
4069
4218
  ? { storeFailed: message, pushes: error.pushes }
4070
- : { threw: message }, maxChars);
4219
+ : { threw: message }, maxChars, this.agreement?.awaitsReopen === true);
4071
4220
  }
4072
4221
  // A set that threw and couldn't be read back may have stored the edits,
4073
4222
  // and a second call could then apply them twice.
4074
4223
  if (error instanceof CommitUnknownError) {
4075
- const [set, read] = [cutLine(error.setError), cutLine(error.readError)];
4224
+ const [set, read] = [storeWords(error.setCause), storeWords(error.readCause)];
4076
4225
  return {
4077
4226
  success: false,
4078
4227
  isError: true,
@@ -4085,10 +4234,8 @@ export class WorkspaceModule {
4085
4234
  }
4086
4235
  // The commit sets every entry in one record or none, and a set that
4087
4236
  // threw was read back, so nothing is stored; or every change was one
4088
- // the tree already held, which leaves the tree as it was either way.
4089
- if (tool === 'edit_many')
4090
- return { success: false, isError: true, error: `The edit_many call stored none of its edits: ${cutLine(message)}` };
4091
- throw error;
4237
+ // the tree already held (AlreadyHeld).
4238
+ throw notStored(error);
4092
4239
  }
4093
4240
  }
4094
4241
  /**
@@ -4101,7 +4248,7 @@ export class WorkspaceModule {
4101
4248
  * (handleToolCall checks a call's `path`, which edit_many's input lacks).
4102
4249
  */
4103
4250
  async editablePath(tool, path, callerAgentName) {
4104
- const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: true });
4251
+ const unusable = (text) => ({ kind: 'file', edits: [], path, text, whole: false });
4105
4252
  try {
4106
4253
  // In the one spelling every file tool reads, as disk spells it, so two
4107
4254
  // spellings of a file are one entry and, in one call, one plan
@@ -4111,17 +4258,21 @@ export class WorkspaceModule {
4111
4258
  // One that names the mount's root, its bare name included, names no
4112
4259
  // file.
4113
4260
  if (where.relativePath === '')
4114
- return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
4115
- const refusal = writeRefusal(where.mount);
4261
+ return unusable(namesRoot(path, where.mount.config.name));
4262
+ const refusal = writeRefusal(where.mount, this.writableNames());
4116
4263
  if (refusal)
4117
4264
  return unusable(refusal);
4118
4265
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4119
4266
  if (generated)
4120
- return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4267
+ return unusable(this.generatedFileRefusal(path, generated, callerAgentName));
4121
4268
  return where;
4122
4269
  }
4123
4270
  catch (error) {
4124
- return unusable(error instanceof Error ? error.message : String(error));
4271
+ if (error instanceof Refusal)
4272
+ return unusable(error.message);
4273
+ if (error instanceof WorkspaceReadError)
4274
+ return unusable(readRefusal(error, path));
4275
+ throw notStored(error);
4125
4276
  }
4126
4277
  }
4127
4278
  /**
@@ -4129,10 +4280,10 @@ export class WorkspaceModule {
4129
4280
  * and the agent's own is read-only, since it shows the agent's tool
4130
4281
  * presentation, which set_tool_visibility and set_tool_description change.
4131
4282
  */
4132
- generatedFileRefusal(generated, callerAgentName) {
4283
+ generatedFileRefusal(path, generated, callerAgentName) {
4133
4284
  return callerAgentName !== generated.agentName
4134
- ? 'Generated file belongs to another agent'
4135
- : 'Generated file is read-only; change the tool presentation it shows with set_tool_visibility or set_tool_description instead';
4285
+ ? `${JSON.stringify(path)} is generated from another agent's tool presentation, and only that agent reads it. Calling again won't change that.`
4286
+ : `${JSON.stringify(path)} is generated from your tool presentation, so it's read-only; set_tool_visibility and set_tool_description change what it shows.`;
4136
4287
  }
4137
4288
  /**
4138
4289
  * The answer to edits stored but not finished: the commit set every file's
@@ -4148,12 +4299,12 @@ export class WorkspaceModule {
4148
4299
  * which stand as on a success, it names each conflict the success answer
4149
4300
  * would (`conflicts`, found only where the mount doesn't push).
4150
4301
  */
4151
- editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars) {
4302
+ editsStoredButUnfinished(tool, targets, applied, conflicts, outcome, maxChars, reopen = false) {
4152
4303
  const conflictOf = (target) => ('storeFailed' in outcome ? conflicts.get(target) ?? null : null);
4153
4304
  if (tool === 'edit') {
4154
4305
  const [target] = targets;
4155
4306
  const note = [applied.get(target) ?? null, conflictOf(target)].filter((text) => text !== null).join(' ');
4156
- return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null);
4307
+ return storedButUnfinished('edit', target.path, 'refused' in outcome ? { refused: outcome.refused.get(target.relativePath) } : outcome, note || null, reopen);
4157
4308
  }
4158
4309
  const stored = "Every edit of this call is stored in the workspace, so don't send the call again: a second call can apply its edits twice.";
4159
4310
  // The head is never cut, so what stands for the whole call goes in it,
@@ -4176,10 +4327,11 @@ export class WorkspaceModule {
4176
4327
  }
4177
4328
  else if ('storeFailed' in outcome) {
4178
4329
  head = `${stored}\n\nThe store failed after recording them: ${cutLine(outcome.storeFailed)}.`
4179
- + (outcome.pushes ? " Their push to disk didn't run, so disk lacks them until a materialize of their files writes them." : '');
4330
+ + (outcome.pushes ? ` Their push to disk didn't run, so disk lacks them until a materialize of their files writes them${reopen ? ONCE_REOPENED : ''}.` : '')
4331
+ + (reopen ? ` ${AWAITS_REOPEN}` : '');
4180
4332
  }
4181
4333
  else {
4182
- head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}. ls shows each path's state.`;
4334
+ head = `${stored}\n\nThe rest of the call failed: ${cutLine(outcome.threw)}.${reopen ? ` ${AWAITS_REOPEN}` : " ls shows each path's state."}`;
4183
4335
  }
4184
4336
  const kept = 'modeKept' in outcome
4185
4337
  ? targets.flatMap((target) => {
@@ -4213,7 +4365,7 @@ export class WorkspaceModule {
4213
4365
  staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4214
4366
  }
4215
4367
  catch (error) {
4216
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4368
+ return { error: directDidNothing('write', path, error) };
4217
4369
  }
4218
4370
  try {
4219
4371
  await directWriteTestSeam.afterStage?.([staged.path]);
@@ -4228,7 +4380,7 @@ export class WorkspaceModule {
4228
4380
  }
4229
4381
  catch (error) {
4230
4382
  await discard(staged);
4231
- return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4383
+ return { error: directDidNothing('write', path, error) };
4232
4384
  }
4233
4385
  this.recordDirectWrite(mount, agentName);
4234
4386
  return { note: linksNote(path, staged, 'write') };
@@ -4288,7 +4440,7 @@ export class WorkspaceModule {
4288
4440
  return 'changed';
4289
4441
  }
4290
4442
  if ('error' in got)
4291
- problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
4443
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: false });
4292
4444
  else
4293
4445
  read.set(target, got);
4294
4446
  }
@@ -4358,7 +4510,8 @@ export class WorkspaceModule {
4358
4510
  edits: target.edits,
4359
4511
  path: target.path,
4360
4512
  text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4361
- + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
4513
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : '')
4514
+ + (directWayOn(error) !== null ? ` ${directWayOn(error)}` : ''),
4362
4515
  whole: true,
4363
4516
  }]);
4364
4517
  for (const { target, plan } of plans) {
@@ -4428,7 +4581,7 @@ export class WorkspaceModule {
4428
4581
  plan: plans.find((p) => p.target === target).plan,
4429
4582
  notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
4430
4583
  modeKept: null,
4431
- })), editCount, maxChars);
4584
+ })), editCount, maxChars, 'done');
4432
4585
  }
4433
4586
  /**
4434
4587
  * The answer to an edit_many call on a direct mount whose rename of its
@@ -4440,7 +4593,8 @@ export class WorkspaceModule {
4440
4593
  directEditsPartway(staged, failed, error, maxChars) {
4441
4594
  // The failed file is named among the outcomes, so the head stays as
4442
4595
  // long as its reason, whatever the paths' lengths.
4443
- const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
4596
+ const can = error instanceof WriteRefused && error.errno !== undefined ? hostFix(error.errno) : undefined;
4597
+ const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}.${can !== undefined ? ` Whoever runs this host can ${can}.` : ''} `
4444
4598
  + "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
4445
4599
  const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
4446
4600
  const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
@@ -4457,33 +4611,37 @@ export class WorkspaceModule {
4457
4611
  */
4458
4612
  async directEditText(mount, relativePath, path) {
4459
4613
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4460
- const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
4614
+ const over = (size) => pastSizeLimit(`${JSON.stringify(path)} is ${size} bytes`, maxSize, "the workspace doesn't edit it", null);
4461
4615
  let opened;
4462
4616
  try {
4463
4617
  opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4464
4618
  if (fileStat.size > maxSize)
4465
- throw new Error(over);
4619
+ throw new Refusal(over(fileStat.size.toLocaleString('en-US')));
4466
4620
  });
4467
4621
  }
4468
4622
  catch (err) {
4469
4623
  if (err instanceof WorkspaceReadError && err.code === 'changed')
4470
4624
  return { changed: true };
4471
- return { error: containedRefusal(err, path) };
4625
+ if (err instanceof Refusal)
4626
+ return { error: err.message };
4627
+ if (err instanceof WorkspaceReadError)
4628
+ return { error: readRefusal(err, path) };
4629
+ throw err;
4472
4630
  }
4473
4631
  try {
4474
4632
  const before = identityOf(await opened.handle.stat({ bigint: true }));
4475
4633
  const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4476
4634
  if (more)
4477
- return { error: over };
4478
- if (looksBinary(bytes))
4479
- return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
4635
+ return { error: over(`at least ${(maxSize + 1).toLocaleString('en-US')}`) };
4636
+ if (looksBinary(bytes)) {
4637
+ return { error: `${JSON.stringify(path)} is binary, so the workspace doesn't edit it as text. A write replaces the whole file, if that's what you mean to do.` };
4638
+ }
4480
4639
  if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
4481
4640
  return { changed: true };
4482
4641
  return { bytes, identity: before };
4483
4642
  }
4484
4643
  catch (err) {
4485
- const errno = errnoOf(err);
4486
- return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4644
+ return { error: unreadable(JSON.stringify(path), errnoOf(err)) };
4487
4645
  }
4488
4646
  finally {
4489
4647
  await opened.handle.close();
@@ -4512,11 +4670,9 @@ export class WorkspaceModule {
4512
4670
  catch (err) {
4513
4671
  const code = errnoOf(err);
4514
4672
  const why = code === 'ENOENT' || code === 'ENOTDIR'
4515
- ? `the mount root is gone (${code}), and calling again is refused the same way until whoever runs the host puts a directory back at the mount's path`
4516
- : code === 'EACCES' || code === 'EPERM'
4517
- ? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
4518
- : `the mount root can't be read (${code ?? String(err)})`;
4519
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
4673
+ ? `the mount root isn't on disk. ${onlyTheHost("put the mount's directory back at its path")}`
4674
+ : `the mount root can't be read: ${systemWords(code, err)}. ${errnoWayOn(code, ROOT_RECHECK, true)}`;
4675
+ return { success: false, isError: true, error: didNothing('delete', why, path) };
4520
4676
  }
4521
4677
  const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4522
4678
  const { rootReal } = disk;
@@ -4526,10 +4682,12 @@ export class WorkspaceModule {
4526
4682
  }
4527
4683
  catch (err) {
4528
4684
  // The open's own words for a replaced root speak of reading.
4529
- if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
4530
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
4685
+ if (err instanceof RootUnavailable && err.reason === DIRECT_ROOT_REPLACED) {
4686
+ return { success: false, isError: true, error: didNothing('delete', `${ROOT_MOVED}.`, path) };
4531
4687
  }
4532
- return { success: false, isError: true, error: containedRefusal(err, path) };
4688
+ if (err instanceof WorkspaceReadError)
4689
+ return { success: false, isError: true, error: didNothing('delete', readRefusal(err, path)) };
4690
+ throw err;
4533
4691
  }
4534
4692
  // The unlink is awaited, unlike a write's rename, so where the head
4535
4693
  // was just before it says whether the head moved while it ran.
@@ -4544,10 +4702,10 @@ export class WorkspaceModule {
4544
4702
  this.recordDirectWrite(mount, agentName, since);
4545
4703
  return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
4546
4704
  }
4547
- return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
4705
+ return { success: false, isError: true, error: directDidNothing('delete', path, error) };
4548
4706
  }
4549
4707
  this.recordDirectWrite(mount, agentName, since);
4550
- return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
4708
+ return { success: true, data: { path, deleted: true, disk: 'done' }, keepWhenWithheld: ['path', 'deleted', 'disk'] };
4551
4709
  });
4552
4710
  }
4553
4711
  /**
@@ -4695,11 +4853,13 @@ export class WorkspaceModule {
4695
4853
  this.historyLookup = lookup;
4696
4854
  }
4697
4855
  async handleDelete(input, callerAgentName) {
4698
- const { mount, relativePath, path } = await this.parseFilePath(input.path);
4856
+ const { mount, relativePath, path } = await this.parseFilePath(input.path).catch((error) => { throw notStored(error); });
4857
+ if (relativePath === '')
4858
+ return { success: false, error: didNothing('delete', namesRoot(input.path, mount.config.name)), isError: true };
4699
4859
  input = { ...input, path };
4700
- const refusal = writeRefusal(mount);
4860
+ const refusal = writeRefusal(mount, this.writableNames());
4701
4861
  if (refusal)
4702
- return { success: false, error: refusal, isError: true };
4862
+ return { success: false, error: didNothing('delete', refusal), isError: true };
4703
4863
  if (mount.direct)
4704
4864
  return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
4705
4865
  const store = this.getStore();
@@ -4710,31 +4870,31 @@ export class WorkspaceModule {
4710
4870
  await this.settleBeforeMutation(mount, relativePath);
4711
4871
  const entry = store.treeGet(mount.treeStateId, relativePath);
4712
4872
  if (!entry) {
4713
- const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report);
4714
- return { success: false, error, isError: true };
4873
+ const error = undecided !== null ? this.notCheckedYet(input.path, undecided) : this.notInWorkspace(input.path, report, mount);
4874
+ return { success: false, error: didNothing('delete', error), isError: true };
4715
4875
  }
4716
- const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
4876
+ const { refused, note, disk } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
4717
4877
  if (refused !== null)
4718
4878
  return storedButUnfinished('delete', input.path, { refused });
4719
4879
  // Its whole receipt is what it changed: kept in the stub if the guard
4720
4880
  // withholds the result (agent-framework #277).
4721
4881
  return {
4722
4882
  success: true,
4723
- data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
4724
- keepWhenWithheld: ['path', 'deleted', 'note'],
4883
+ data: { path: input.path, deleted: true, disk, ...(note ? { note } : {}) },
4884
+ keepWhenWithheld: ['path', 'deleted', 'disk', 'note'],
4725
4885
  };
4726
4886
  });
4727
4887
  }
4728
4888
  catch (error) {
4729
4889
  if (!committed)
4730
- throw error;
4890
+ throw notStored(error);
4731
4891
  if (error instanceof StoredThenFailed) {
4732
4892
  // Its note, as the success answer's: a deletion that cleared a
4733
4893
  // conflict naming a disk file unlinked nothing.
4734
4894
  const note = error.notes.get(relativePath);
4735
- return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`);
4895
+ return storedButUnfinished('delete', input.path, { storeFailed: error.message, pushes: error.pushes }, note === undefined ? null : `${note.charAt(0).toUpperCase()}${note.slice(1)}.`, this.agreement?.awaitsReopen === true);
4736
4896
  }
4737
- return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) });
4897
+ return storedButUnfinished('delete', input.path, { threw: error instanceof Error ? error.message : String(error) }, null, this.agreement?.awaitsReopen === true);
4738
4898
  }
4739
4899
  }
4740
4900
  async handleLs(input) {
@@ -4758,7 +4918,7 @@ export class WorkspaceModule {
4758
4918
  // A path outside the mount's include that's no scope (refusesAsOutside)
4759
4919
  // is refused, as glob refuses it, and shown as given.
4760
4920
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4761
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
4921
+ return { success: false, error: didNothing('ls', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4762
4922
  }
4763
4923
  input = { ...input, path };
4764
4924
  const recursive = input.recursive === true;
@@ -4767,6 +4927,9 @@ export class WorkspaceModule {
4767
4927
  // shows disk's.
4768
4928
  const listing = await this.listingOf(mount, relativePath, recursive);
4769
4929
  const incomplete = listing.incomplete.length > 0 ? { incomplete: listing.incomplete } : {};
4930
+ const empty = relativePath !== '' && listing.files.length === 0 && listing.dirs.length === 0 && listing.incomplete.length === 0;
4931
+ const note = empty ? await this.emptyScopeNote(mount, relativePath, path) : null;
4932
+ const noted = note !== null ? { note } : {};
4770
4933
  if (recursive) {
4771
4934
  return {
4772
4935
  success: true,
@@ -4775,6 +4938,7 @@ export class WorkspaceModule {
4775
4938
  entries: listing.files,
4776
4939
  count: listing.files.length,
4777
4940
  ...incomplete,
4941
+ ...noted,
4778
4942
  },
4779
4943
  };
4780
4944
  }
@@ -4791,9 +4955,34 @@ export class WorkspaceModule {
4791
4955
  entries: children,
4792
4956
  count: children.length,
4793
4957
  ...incomplete,
4958
+ ...noted,
4794
4959
  },
4795
4960
  };
4796
4961
  }
4962
+ async emptyScopeNote(mount, relativePath, path) {
4963
+ const shown = JSON.stringify(path);
4964
+ const isFile = `${shown} is a file, not a directory, so there's nothing in it to list.`;
4965
+ const { view, rootReal } = await this.diskView(mount);
4966
+ const seen = await observePath(view, rootReal, relativePath);
4967
+ if (seen.kind === 'file')
4968
+ return isFile;
4969
+ if (seen.kind !== 'absent')
4970
+ return null;
4971
+ if (!mount.direct && this.getStore().treeGet(mount.treeStateId, relativePath) !== null)
4972
+ return isFile;
4973
+ return this.holdsAnythingAt(mount, relativePath) ? null : `${shown} isn't a directory in the workspace or on disk, so there's nothing to list.`;
4974
+ }
4975
+ async absentOnDisk(mount, relativePath) {
4976
+ const { view, rootReal } = await this.diskView(mount);
4977
+ return (await observePath(view, rootReal, relativePath)).kind === 'absent';
4978
+ }
4979
+ holdsAnythingAt(mount, relativePath) {
4980
+ if (mount.direct)
4981
+ return false;
4982
+ const store = this.getStore();
4983
+ const trees = [mount.treeStateId, this.intents.get(mount.config.name)?.treeStateId].filter((tree) => tree !== undefined);
4984
+ return trees.some((tree) => store.treeGet(tree, relativePath) !== null || store.treeList(tree, `${relativePath}/`).length > 0);
4985
+ }
4797
4986
  /**
4798
4987
  * What a listing shows of a directory scope, from where the mount keeps
4799
4988
  * what it reads: the store-backed kind's pass (listingFromStore), or, on a
@@ -4895,7 +5084,7 @@ export class WorkspaceModule {
4895
5084
  if (input.path) {
4896
5085
  const { mount, relativePath, moved, root } = mountsToSearch[0];
4897
5086
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4898
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5087
+ return { success: false, error: didNothing('glob', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4899
5088
  }
4900
5089
  }
4901
5090
  // Every match lies beneath the pattern's literal directory, so the walk
@@ -4916,7 +5105,7 @@ export class WorkspaceModule {
4916
5105
  if (prefixed) {
4917
5106
  const dir = deeperOf(relativePath, prefixed.dir);
4918
5107
  if (dir === null) {
4919
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5108
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
4920
5109
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4921
5110
  continue;
4922
5111
  }
@@ -4952,8 +5141,16 @@ export class WorkspaceModule {
4952
5141
  regex = new RegExp(input.pattern);
4953
5142
  }
4954
5143
  catch (e) {
4955
- // The parser's reason says where the pattern goes wrong.
4956
- return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
5144
+ // V8's message ends with the parser's reason, after the pattern: "Invalid
5145
+ // regular expression: /foo(/: Unterminated group".
5146
+ const message = e instanceof Error ? e.message : String(e);
5147
+ const why = /: ([^:/]+)$/.exec(message)?.[1] ?? message;
5148
+ return {
5149
+ success: false,
5150
+ isError: true,
5151
+ error: didNothing('grep', `"pattern" ${JSON.stringify(input.pattern)} isn't a valid regular expression (${why}). `
5152
+ + 'To match a character such as "(", "[" or "*" as itself, put "\\" before it.'),
5153
+ };
4957
5154
  }
4958
5155
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4959
5156
  // The filter matches mount-relative paths, from the mount's root whatever
@@ -4979,7 +5176,7 @@ export class WorkspaceModule {
4979
5176
  if (input.path) {
4980
5177
  const { mount, relativePath, moved, root } = mountsToSearch[0];
4981
5178
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
4982
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5179
+ return { success: false, error: didNothing('grep', new OutsideIncludeError(input.path, mount.config).message), isError: true };
4983
5180
  }
4984
5181
  }
4985
5182
  const results = [];
@@ -5002,10 +5199,6 @@ export class WorkspaceModule {
5002
5199
  const search = (text) => {
5003
5200
  const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5004
5201
  const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
5005
- if (lineEndings !== undefined && !crlfFiles.has(text.file))
5006
- crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
5007
- if (bom && !bomFiles.has(text.file))
5008
- bomFiles.set(text.file, text.version);
5009
5202
  const found = [];
5010
5203
  for (let i = 0; i < lines.length; i++) {
5011
5204
  if (regex.test(lines[i])) {
@@ -5018,14 +5211,14 @@ export class WorkspaceModule {
5018
5211
  found.push(match);
5019
5212
  }
5020
5213
  }
5021
- return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
5214
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found, bom };
5022
5215
  };
5023
5216
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
5024
5217
  // A path named in another mount's directory, as holderOf resolves it,
5025
5218
  // is that mount's: grep searches nothing of this mount's there, and
5026
5219
  // says whose it is. A direct mount's is judged by the nesting its parse
5027
5220
  // found where its root lies now (movedNesting).
5028
- if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5221
+ if (this.holderOf(mount.config.name, relativePath, moved)) {
5029
5222
  incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5030
5223
  continue;
5031
5224
  }
@@ -5044,16 +5237,23 @@ export class WorkspaceModule {
5044
5237
  }
5045
5238
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
5046
5239
  const searched = (text) => {
5047
- const hits = search(text);
5048
- if (hits.matches.length > 0)
5049
- results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
5240
+ const { bom, ...hits } = search(text);
5241
+ const { file, state, version } = text;
5242
+ return () => {
5243
+ if (hits.lineEndings !== undefined && !crlfFiles.has(file))
5244
+ crlfFiles.set(file, { ends: hits.lineEndings, version });
5245
+ if (bom && !bomFiles.has(file))
5246
+ bomFiles.set(file, version);
5247
+ if (hits.matches.length > 0)
5248
+ results.push({ file, ...(state !== undefined ? { state } : {}), version, ...hits });
5249
+ };
5050
5250
  };
5051
5251
  const kept = prefixed
5052
5252
  ? (path) => prefixed.regex.test(path)
5053
5253
  : (path) => fileGlob === null || fileGlob.test(path)
5054
5254
  || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
5055
5255
  const found = mount.direct
5056
- ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
5256
+ ? await this.textsFromDisk(mount, relativePath, root, start, kept, (text) => searched(text)())
5057
5257
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
5058
5258
  skipped.push(...found.skipped);
5059
5259
  incomplete.push(...found.incomplete);
@@ -5102,12 +5302,13 @@ export class WorkspaceModule {
5102
5302
  /**
5103
5303
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
5104
5304
  * single file `relativePath` names, those `keep` lets through, each handed
5105
- * to `each` in path order: a pass over the scope, then each path's
5106
- * workspace copy, labelled with its state, and a conflict's disk side.
5305
+ * to `search`, whose answers are recorded in path order: a pass over the
5306
+ * scope, then each path's workspace copy, labelled with its state, and a
5307
+ * conflict's disk side.
5107
5308
  * What it doesn't search is in `skipped`, and what it couldn't see in
5108
5309
  * `incomplete`, each mount-prefixed.
5109
5310
  */
5110
- async textsFromStore(mount, relativePath, start, keep, each) {
5311
+ async textsFromStore(mount, relativePath, start, keep, search) {
5111
5312
  const store = this.getStore();
5112
5313
  const skipped = [];
5113
5314
  const incomplete = [];
@@ -5160,17 +5361,18 @@ export class WorkspaceModule {
5160
5361
  incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
5161
5362
  }
5162
5363
  const intents = this.intents.get(mount.config.name);
5364
+ const found = [];
5163
5365
  for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
5164
5366
  if (!keep(report.path))
5165
5367
  continue;
5166
5368
  const file = `${mount.config.name}/${report.path}`;
5167
5369
  if (report.state === 'disk-only') {
5168
- skipped.push({
5169
- file,
5170
- reason: 'disk-only: binary or over the size limit, never stored',
5171
- ...(report.size !== undefined ? { size: report.size } : {}),
5172
- ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5173
- });
5370
+ found.push({ skip: {
5371
+ file,
5372
+ reason: 'disk-only: binary or over the size limit, never stored',
5373
+ ...(report.size !== undefined ? { size: report.size } : {}),
5374
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
5375
+ } });
5174
5376
  continue;
5175
5377
  }
5176
5378
  // A path in another mount's directory is that mount's: grep gives
@@ -5180,17 +5382,16 @@ export class WorkspaceModule {
5180
5382
  continue;
5181
5383
  const refusal = refused.get(report.path);
5182
5384
  if (refusal !== undefined) {
5183
- skipped.push({ file, reason: refusal });
5385
+ found.push({ skip: { file, reason: refusal } });
5184
5386
  continue;
5185
5387
  }
5186
5388
  // The workspace's version, labelled with the path's state.
5187
5389
  const entry = store.treeGet(mount.treeStateId, report.path);
5188
- const blob = entry ? store.getBlob(entry.blobHash) : null;
5189
- if (blob) {
5190
- each({ file, state: report.state, version: 'workspace', bytes: blob });
5191
- }
5192
- else if (entry) {
5193
- skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
5390
+ if (entry) {
5391
+ const stored = storedBytes(store, entry.blobHash);
5392
+ found.push('bytes' in stored
5393
+ ? { searched: search({ file, state: report.state, version: 'workspace', bytes: stored.bytes }) }
5394
+ : { lost: { file, state: report.state, version: 'workspace', relativePath: report.path, hash: entry.blobHash, size: entry.size, what: 'saved' } });
5194
5395
  }
5195
5396
  if (report.state !== 'conflict')
5196
5397
  continue;
@@ -5201,22 +5402,40 @@ export class WorkspaceModule {
5201
5402
  // disk version was never stored either.
5202
5403
  const disk = intents.get(report.path)?.conflict?.disk;
5203
5404
  const changed = report.conflict?.diskChangedSinceRecorded === true;
5204
- const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
5205
- if (diskBlob) {
5206
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
5207
- }
5208
- if (disk?.stored && !diskBlob) {
5209
- skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
5405
+ if (disk?.stored) {
5406
+ const version = changed ? 'recorded-disk' : 'conflicting-disk';
5407
+ const stored = storedBytes(store, disk.stored);
5408
+ found.push('bytes' in stored
5409
+ ? { searched: search({ file, state: report.state, version, bytes: stored.bytes }) }
5410
+ : { lost: { file, state: report.state, version, relativePath: report.path, hash: disk.stored, size: disk.size, what: 'conflict-disk' } });
5210
5411
  }
5211
5412
  if (changed) {
5212
5413
  if (report.diskNow === 'file') {
5213
- skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
5414
+ found.push({ skip: { file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' } });
5214
5415
  }
5215
5416
  }
5216
5417
  else if (disk && !disk.stored) {
5217
- skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
5418
+ found.push({ skip: { file, reason: `conflict: the disk version is binary or over the size limit, so the workspace never stored it, and grep searches only what it stores; ${settleConflict('file', mount.config.mode === 'read-only').replace(/\.$/, '')}`, size: disk.size } });
5218
5419
  }
5219
5420
  }
5421
+ const answers = new Map();
5422
+ await this.restoreEach(mount, found.flatMap((item) => ('lost' in item ? [item.lost] : [])), (lost, contents) => {
5423
+ const { file, state, version, what } = lost;
5424
+ answers.set(lost, 'bytes' in contents
5425
+ ? { searched: search({ file, state, version, bytes: contents.bytes }) }
5426
+ : { skip: {
5427
+ file,
5428
+ reason: `${what === 'saved' ? "the workspace's copy" : 'conflict: the disk version recorded with the conflict'} is ${lostInStore(contents.lost)}, `
5429
+ + `and ${lostOnDisk(contents.lost)}, so it was not searched`,
5430
+ } });
5431
+ });
5432
+ for (const item of found) {
5433
+ const answer = 'lost' in item ? answers.get(item.lost) : item;
5434
+ if ('searched' in answer)
5435
+ answer.searched();
5436
+ else
5437
+ skipped.push(answer.skip);
5438
+ }
5220
5439
  });
5221
5440
  return { skipped, incomplete };
5222
5441
  }
@@ -5261,7 +5480,7 @@ export class WorkspaceModule {
5261
5480
  const store = this.getStore();
5262
5481
  const status = {};
5263
5482
  if (input.mount !== undefined && !this.mounts.has(input.mount)) {
5264
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5483
+ return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5265
5484
  }
5266
5485
  for (const [name, mount] of this.mounts) {
5267
5486
  if (input.mount !== undefined && name !== input.mount)
@@ -5403,9 +5622,6 @@ export class WorkspaceModule {
5403
5622
  */
5404
5623
  entriesByEvidence(mount) {
5405
5624
  const store = this.getStore();
5406
- const agreement = this.agreementOrThrow();
5407
- const name = mount.config.name;
5408
- const intents = this.intents.get(name);
5409
5625
  const owed = [];
5410
5626
  const unchecked = [];
5411
5627
  let agreed = 0;
@@ -5415,20 +5631,37 @@ export class WorkspaceModule {
5415
5631
  // in it: neither owed to disk nor counted as on it.
5416
5632
  if (!insideMount(e.path, mount.config.include) || !own(e.path))
5417
5633
  continue;
5418
- const p = agreement.get(name, e.path);
5419
- if (p === undefined)
5420
- (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
5421
- else if (p.kind !== 'content' || p.hash !== e.blobHash)
5422
- owed.push(e.path);
5423
- // A mode the store records, other than the one disk last agreed with,
5424
- // where modes are observed: elsewhere no push applies one.
5425
- else if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
5634
+ const kind = this.evidenceOf(mount, e);
5635
+ if (kind === 'owed')
5426
5636
  owed.push(e.path);
5637
+ else if (kind === 'unchecked')
5638
+ unchecked.push(e.path);
5427
5639
  else
5428
5640
  agreed++;
5429
5641
  }
5430
5642
  return { owed, unchecked, agreed };
5431
5643
  }
5644
+ evidenceOf(mount, e) {
5645
+ const p = this.agreementOrThrow().get(mount.config.name, e.path);
5646
+ if (p === undefined)
5647
+ return this.intents.get(mount.config.name).get(e.path)?.origin === 'store' ? 'owed' : 'unchecked';
5648
+ if (p.kind !== 'content' || p.hash !== e.blobHash)
5649
+ return 'owed';
5650
+ // A mode the store records, other than the one disk last agreed with,
5651
+ // where modes are observed: elsewhere no push applies one.
5652
+ if (this.modesObserved && p.mode !== undefined && (entryMode(e.mode) ?? p.mode) !== p.mode)
5653
+ return 'owed';
5654
+ return 'agreed';
5655
+ }
5656
+ diskOf(mount, relativePath) {
5657
+ const intent = this.intents.get(mount.config.name).get(relativePath);
5658
+ if (intent?.conflict)
5659
+ return 'in conflict with disk';
5660
+ const entry = this.getStore().treeGet(mount.treeStateId, relativePath);
5661
+ if (!entry)
5662
+ return intent?.tombstone ? 'waits for materialize with applyDeletions' : 'done';
5663
+ return this.evidenceOf(mount, { ...entry, path: relativePath }) === 'agreed' ? 'done' : 'waits for materialize';
5664
+ }
5432
5665
  async handleMaterialize(input) {
5433
5666
  const store = this.getStore();
5434
5667
  const allWritten = [];
@@ -5443,7 +5676,7 @@ export class WorkspaceModule {
5443
5676
  // is refused; beneath a directory, the push leaves out what the include
5444
5677
  // doesn't cover.
5445
5678
  if (await this.refusesAsOutside(parsed.mount, parsed.relativePath, parsed.moved, parsed.root)) {
5446
- return { success: false, error: new OutsideIncludeError(input.path, explicit.mount.config).message, isError: true };
5679
+ return { success: false, error: didNothing('materialize', new OutsideIncludeError(input.path, explicit.mount.config).message), isError: true };
5447
5680
  }
5448
5681
  }
5449
5682
  let mountsToMaterialize;
@@ -5453,7 +5686,7 @@ export class WorkspaceModule {
5453
5686
  else if (input.mount) {
5454
5687
  const m = this.mounts.get(input.mount);
5455
5688
  if (!m) {
5456
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5689
+ return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
5457
5690
  }
5458
5691
  mountsToMaterialize = [{ name: input.mount, mount: m }];
5459
5692
  }
@@ -5567,15 +5800,20 @@ export class WorkspaceModule {
5567
5800
  if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
5568
5801
  return {
5569
5802
  success: false,
5570
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
5803
+ error: didNothing('materialize', blocked.map((b) => `mount ${JSON.stringify(b.mount)}: ${b.reason}`).join('; ')),
5571
5804
  isError: true,
5572
5805
  };
5573
5806
  }
5807
+ const held = explicit;
5808
+ const heldNothing = held !== null && held.relativePath !== '' && !held.mount.direct
5809
+ && allWritten.length === 0 && allDeleted.length === 0 && pendingDeletions.length === 0 && blocked.length === 0 && passedOver.length === 0
5810
+ && !this.holdsAnythingAt(held.mount, held.relativePath);
5574
5811
  return {
5575
5812
  success: true,
5576
5813
  data: {
5577
5814
  materialized: allWritten,
5578
5815
  count: allWritten.length,
5816
+ ...(heldNothing ? { note: `The workspace holds nothing at ${JSON.stringify(`${held.mount.config.name}/${held.relativePath}`)}, so there was nothing to write there.` } : {}),
5579
5817
  ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
5580
5818
  ...(pendingDeletions.length > 0
5581
5819
  ? {
@@ -5637,7 +5875,13 @@ export class WorkspaceModule {
5637
5875
  const allIgnored = [];
5638
5876
  const rootsAccepted = [];
5639
5877
  if (input.acceptRoot && input.path) {
5640
- return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
5878
+ const named = input.path.split('/')[0];
5879
+ return {
5880
+ success: false,
5881
+ isError: true,
5882
+ error: didNothing('sync', `acceptRoot takes a whole mount's root, so it isn't given with "path" (${JSON.stringify(input.path)}). `
5883
+ + `${this.mounts.has(named) ? `Give "mount" (${JSON.stringify(named)}) with acceptRoot to take that mount's root` : 'Give "mount", naming the mount whose root to take, with acceptRoot'}, or leave acceptRoot out to sync the path.`),
5884
+ };
5641
5885
  }
5642
5886
  // A path names its own mount; otherwise the given mount, or every mount.
5643
5887
  let targets;
@@ -5647,14 +5891,14 @@ export class WorkspaceModule {
5647
5891
  // is refused; beneath a directory, the walk passes over what the include
5648
5892
  // doesn't cover.
5649
5893
  if (await this.refusesAsOutside(mount, relativePath, moved, root)) {
5650
- return { success: false, error: new OutsideIncludeError(input.path, mount.config).message, isError: true };
5894
+ return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
5651
5895
  }
5652
5896
  targets = [{ name: mount.config.name, mount, relativePath }];
5653
5897
  }
5654
5898
  else if (input.mount) {
5655
5899
  const m = this.mounts.get(input.mount);
5656
5900
  if (!m) {
5657
- return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
5901
+ return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
5658
5902
  }
5659
5903
  targets = [{ name: input.mount, mount: m, relativePath: '' }];
5660
5904
  }
@@ -5668,6 +5912,7 @@ export class WorkspaceModule {
5668
5912
  allSkipped.push({ mount: name, path: relativePath, reason: DIRECT_SYNC_SKIPPED });
5669
5913
  continue;
5670
5914
  }
5915
+ let namedDirectory = false;
5671
5916
  const pass = await this.withMount(mount, async () => {
5672
5917
  if (input.acceptRoot && await this.acceptRootUnlocked(mount))
5673
5918
  rootsAccepted.push(name);
@@ -5682,6 +5927,7 @@ export class WorkspaceModule {
5682
5927
  store.treeList(mount.treeStateId, prefix).length > 0 ||
5683
5928
  this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
5684
5929
  this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
5930
+ namedDirectory = (await directoryAt(join(mount.config.path, relativePath), mount.config.followSymlinks === true)) ?? isDir;
5685
5931
  // named: ignore patterns covering the path asked for don't apply
5686
5932
  // beneath it (a directory scope's walk; a path scope doesn't walk).
5687
5933
  const opts = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
@@ -5745,7 +5991,7 @@ export class WorkspaceModule {
5745
5991
  // without a path passes over it, so nothing else keeps it current
5746
5992
  // (agent-framework #276). A path in another mount's directory isn't
5747
5993
  // synced at all, and the note says so.
5748
- const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name));
5994
+ const passedOver = passedOverAt(relativePath, this.ignoreOf(mount), this.othersOf(name), namedDirectory);
5749
5995
  if (passedOver !== undefined) {
5750
5996
  allIgnored.push({
5751
5997
  mount: name,
@@ -5758,10 +6004,17 @@ export class WorkspaceModule {
5758
6004
  });
5759
6005
  }
5760
6006
  }
6007
+ const named = input.path !== undefined && input.path !== '' ? targets[0] : null;
6008
+ const nothingThere = named !== null && named.relativePath !== '' && !named.mount.direct
6009
+ && allResults.every((r) => r.synced.length === 0 && r.conflicts.length === 0 && (r.discarded?.length ?? 0) === 0)
6010
+ && allSkipped.length === 0 && allIncomplete.length === 0 && allIgnored.length === 0
6011
+ && !this.holdsAnythingAt(named.mount, named.relativePath)
6012
+ && await this.absentOnDisk(named.mount, named.relativePath);
5761
6013
  return {
5762
6014
  success: true,
5763
6015
  data: {
5764
6016
  results: allResults,
6017
+ ...(nothingThere ? { note: `Neither the workspace nor disk has anything at ${JSON.stringify(`${named.name}/${named.relativePath}`)}, so there was nothing to sync.` } : {}),
5765
6018
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
5766
6019
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
5767
6020
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
@@ -6007,13 +6260,13 @@ export async function inOrder(items, width, work, each) {
6007
6260
  }
6008
6261
  }
6009
6262
  /** read_image's answer for an image, wherever it was read from. */
6010
- function imageResult(path, image) {
6263
+ function imageResult(path, image, note) {
6011
6264
  return {
6012
6265
  success: true,
6013
6266
  data: [
6014
6267
  {
6015
6268
  type: 'text',
6016
- text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
6269
+ text: `Path: ${path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}${note === undefined ? '' : `\n${note}`}`,
6017
6270
  },
6018
6271
  {
6019
6272
  type: 'image',
@@ -6067,14 +6320,16 @@ function watchOf(config) {
6067
6320
  * add to what keeps it off direct; refused, what's held only shrinks, and
6068
6321
  * the next start once none is held runs it direct.
6069
6322
  */
6070
- function writeRefusal(mount) {
6323
+ function writeRefusal(mount, writable = []) {
6071
6324
  const name = mount.config.name;
6072
- if (mount.config.mode === 'read-only')
6073
- return `Mount "${name}" is read-only`;
6325
+ if (mount.config.mode === 'read-only') {
6326
+ return `mount "${name}" is read-only. ${onlyTheHost('make it read-write')}`
6327
+ + (writable.length > 0 ? ` The mounts that take writes are ${listed(writable.map((other) => JSON.stringify(other)))}.` : '');
6328
+ }
6074
6329
  if (mount.config.backend === 'direct' && !mount.direct) {
6075
- return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6330
+ return `mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6076
6331
  + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6077
- + `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct`;
6332
+ + `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct.`;
6078
6333
  }
6079
6334
  return null;
6080
6335
  }
@@ -6150,6 +6405,13 @@ function directReason(error) {
6150
6405
  return ROOT_MOVED;
6151
6406
  return error instanceof Error ? error.message : String(error);
6152
6407
  }
6408
+ function directWayOn(error) {
6409
+ return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
6410
+ }
6411
+ function directDidNothing(tool, path, error) {
6412
+ const on = directWayOn(error);
6413
+ return didNothing(tool, `${directReason(error)}.${on !== null ? ` ${on}` : ''}`, path);
6414
+ }
6153
6415
  /** "a", "a and b", or "a, b and c". */
6154
6416
  function listed(items) {
6155
6417
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
@@ -6172,7 +6434,119 @@ function linksNote(path, staged, tool) {
6172
6434
  */
6173
6435
  function containedRefusal(err, path) {
6174
6436
  const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6175
- return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
6437
+ return bare ? `${err.message} (${errnoWords(err.errno)})` : err instanceof Error ? err.message : String(err);
6438
+ }
6439
+ function deletedOnDisk(path) {
6440
+ return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
6441
+ }
6442
+ function uncheckedFile(shown, note) {
6443
+ if (note === DIRECTORY)
6444
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6445
+ const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
6446
+ : note === ROOT_REPLACED ? null
6447
+ : note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
6448
+ : note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
6449
+ : note === NOT_REGULAR ? onlyTheHost('put a regular file there')
6450
+ : note === CHANGED ? 'Calling again checks it afresh.'
6451
+ : 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
6452
+ return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
6453
+ }
6454
+ function namesRoot(path, mount) {
6455
+ return `${JSON.stringify(path)} names the root of mount "${mount}", which isn't a file; ls "${mount}" lists the files in it.`;
6456
+ }
6457
+ function tooLargeToWrite(path, bytes, maxSize) {
6458
+ return pastSizeLimit(`${JSON.stringify(path)} would be ${bytes.toLocaleString('en-US')} bytes`, maxSize, "the workspace couldn't keep it", 'Content within the limit is written.');
6459
+ }
6460
+ function pastSizeLimit(what, maxSize, outcome, within) {
6461
+ return `${what}, past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so ${outcome}.${within !== null ? ` ${within}` : ''} `
6462
+ + "The limit is the mount's maxFileSize setting, which only whoever runs the host can change.";
6463
+ }
6464
+ function sentence(text) {
6465
+ return /[.!?]$/.test(text) ? text : `${text}.`;
6466
+ }
6467
+ function unreadable(shown, errno) {
6468
+ return `${shown} can't be read from disk: ${errnoWords(errno)}. ${errnoWayOn(errno)}`;
6469
+ }
6470
+ function readRefusal(err, path) {
6471
+ const shown = JSON.stringify(path);
6472
+ if (err instanceof RootUnavailable) {
6473
+ if (err.refusal)
6474
+ return err.refusal.message;
6475
+ const mount = JSON.stringify(err.mount);
6476
+ if (err.reason === ROOT_UNAVAILABLE || err.reason === null) {
6477
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR') {
6478
+ return `the directory of mount ${mount} isn't on disk, so nothing in it can be read. ${onlyTheHost("put the mount's directory back at its path")}`;
6479
+ }
6480
+ if (err.errno !== undefined) {
6481
+ return `the directory of mount ${mount} can't be read: ${errnoWords(err.errno)}. `
6482
+ + errnoWayOn(err.errno, ROOT_RECHECK, true);
6483
+ }
6484
+ return `the directory of mount ${mount} can't be checked at its path, so nothing in it can be read. ${ROOT_RECHECK}`;
6485
+ }
6486
+ return `${shown} can't be read: ${sentence(err.reason)}`;
6487
+ }
6488
+ if (err.stage === 'readlink')
6489
+ return sentence(err.message);
6490
+ switch (err.code) {
6491
+ case 'directory':
6492
+ return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6493
+ case 'symlink':
6494
+ return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6495
+ + 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
6496
+ case 'escape':
6497
+ return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6498
+ + 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
6499
+ case 'changed':
6500
+ return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6501
+ case 'not_found':
6502
+ if (err.errno === 'ENOENT' || err.errno === 'ENOTDIR')
6503
+ return `there's no file at ${shown}. ls or glob lists the files that exist.`;
6504
+ if (err instanceof NotRegularFile) {
6505
+ return `${shown} isn't a regular file (it's a pipe, a socket or a device), so the workspace doesn't read it. ${onlyTheHost('put a regular file there')}`;
6506
+ }
6507
+ if (err.stage === 'realpath')
6508
+ return `where ${shown} leads on disk can't be resolved: ${errnoWords(err.errno)}. ${errnoWayOn(err.errno)}`;
6509
+ return unreadable(shown, err.errno);
6510
+ default:
6511
+ return sentence(err.message);
6512
+ }
6513
+ }
6514
+ function contentsSubject(what, path) {
6515
+ return what === 'saved' ? `the saved contents of ${path}` : `the version of ${path} that its conflict recorded as disk's`;
6516
+ }
6517
+ function inTheStore(stored) {
6518
+ return 'missing' in stored ? "missing from the workspace's store" : `unreadable in the workspace's store (${stored.unreadable})`;
6519
+ }
6520
+ function lostInStore(lost) {
6521
+ return 'missing' in lost.store ? 'missing from the store' : `unreadable in the store (${lost.store.unreadable})`;
6522
+ }
6523
+ function lostOnDisk(lost) {
6524
+ return lost.disk === 'other' ? "disk's copy holds other contents"
6525
+ : lost.disk === 'none' ? 'disk has no copy'
6526
+ : `disk's copy can't be read (${lost.disk.unreadable})`;
6527
+ }
6528
+ function lostText(lost, the = 'The') {
6529
+ return `${the} workspace's copy of ${lost.path} is ${lostInStore(lost)}, and ${lostOnDisk(lost)}`;
6530
+ }
6531
+ function nothingToRead(lost) {
6532
+ return `${lostText(lost, 'the')}, so it can't be restored or read. ${lostRemedy(lost)}`;
6533
+ }
6534
+ function lostRemedy(lost) {
6535
+ const after = remedyOfLost(lost);
6536
+ if (!lost.reopen)
6537
+ return after;
6538
+ return "Nothing you do clears this until the workspace's store is reopened, and calling again won't change that; whoever runs this host can reopen it with a restart. "
6539
+ + `After that, ${after.charAt(0).toLowerCase()}${after.slice(1)}`;
6540
+ }
6541
+ function remedyOfLost(lost) {
6542
+ const rewrite = lost.writable && 'missing' in lost.store;
6543
+ if (lost.disk === 'other')
6544
+ return `Sync the path to keep disk's copy and give up the workspace's${rewrite ? ', or write the file again' : ''}.`;
6545
+ if (lost.writable)
6546
+ return rewrite ? 'Write the file again, or delete it.' : 'Delete the file to clear it.';
6547
+ return lost.disk === 'none'
6548
+ ? "Sync the path to take disk's deletion, which removes the workspace's copy."
6549
+ : "Once disk's copy can be read, read the file again, which reads it from disk's copy if that copy has the same contents, or sync the path to keep disk's copy and give up the workspace's.";
6176
6550
  }
6177
6551
  /**
6178
6552
  * The notice that writes an agent made direct remain on disk from
@@ -6229,9 +6603,7 @@ function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems)
6229
6603
  kind: 'file',
6230
6604
  edits: target.edits,
6231
6605
  path: target.path,
6232
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
6233
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
6234
- + "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
6606
+ text: pastSizeLimit(`${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}`, maxSize, "the workspace couldn't keep it", 'An edit that leaves the file within the limit applies.'),
6235
6607
  whole: false,
6236
6608
  });
6237
6609
  return null;
@@ -6267,16 +6639,38 @@ function changesLanded(store, treeStateId, changes, setError) {
6267
6639
  * change is stored isn't known. Its message says so after both errors.
6268
6640
  */
6269
6641
  class CommitUnknownError extends Error {
6270
- setError;
6271
- readError;
6272
- constructor(setError, readError) {
6642
+ setCause;
6643
+ readCause;
6644
+ constructor(setCause, readCause) {
6273
6645
  const text = (error) => (error instanceof Error ? error.message : String(error));
6274
- super(`${text(setError)}; reading its entries back failed too (${text(readError)}), so whether the change is stored isn't known`);
6646
+ super(`${text(setCause)}; reading its entries back failed too (${text(readCause)}), so whether the change is stored isn't known`);
6647
+ this.setCause = setCause;
6648
+ this.readCause = readCause;
6275
6649
  this.name = 'CommitUnknownError';
6276
- this.setError = text(setError);
6277
- this.readError = text(readError);
6278
6650
  }
6279
6651
  }
6652
+ function storeWords(error) {
6653
+ const code = errnoCode(error);
6654
+ return code !== undefined ? errnoWords(code) : cutLine(error instanceof Error ? error.message : String(error));
6655
+ }
6656
+ class NotStored extends Error {
6657
+ thrown;
6658
+ constructor(thrown) {
6659
+ super('stopped before the change was stored');
6660
+ this.thrown = thrown;
6661
+ }
6662
+ }
6663
+ class AlreadyHeld extends Error {
6664
+ thrown;
6665
+ constructor(thrown) {
6666
+ super(thrown instanceof Error ? thrown.message : String(thrown));
6667
+ this.thrown = thrown;
6668
+ }
6669
+ }
6670
+ function notStored(error) {
6671
+ return error instanceof Refusal || error instanceof WorkspaceReadError || error instanceof CommitUnknownError || error instanceof AlreadyHeld || error instanceof NotStored
6672
+ ? error : new NotStored(error);
6673
+ }
6280
6674
  /**
6281
6675
  * A store's set that threw after recording the change (changesLanded): the
6282
6676
  * entries are set and their intents follow, and the store's error goes on in
@@ -6293,6 +6687,11 @@ class StoredThenFailed extends Error {
6293
6687
  this.name = 'StoredThenFailed';
6294
6688
  }
6295
6689
  }
6690
+ const ONCE_REOPENED = ', once the store is reopened';
6691
+ const DISK_ORDER = ['in conflict with disk', 'waits for materialize with applyDeletions', 'waits for materialize', 'done'];
6692
+ function furthestFromDisk(values) {
6693
+ return DISK_ORDER.find((state) => values.includes(state)) ?? 'done';
6694
+ }
6296
6695
  /**
6297
6696
  * The answer to a write, edit or delete that the workspace stored but
6298
6697
  * couldn't finish: its push to disk didn't complete, or what follows the tree
@@ -6304,7 +6703,7 @@ class StoredThenFailed extends Error {
6304
6703
  * script. A note comes last: an edit's on which copy it applied to, or a
6305
6704
  * deletion's that it unlinked nothing.
6306
6705
  */
6307
- function storedButUnfinished(tool, path, outcome, note = null) {
6706
+ function storedButUnfinished(tool, path, outcome, note = null, reopen = false) {
6308
6707
  const change = tool === 'delete' ? 'deletion' : tool;
6309
6708
  const stored = `The ${change} of "${path}" is stored in the workspace, so don't send it again`
6310
6709
  + (tool === 'edit' ? ': a second edit can apply the change twice.' : '.');
@@ -6313,10 +6712,10 @@ function storedButUnfinished(tool, path, outcome, note = null) {
6313
6712
  : 'storeFailed' in outcome
6314
6713
  ? ` The store failed after recording it: ${outcome.storeFailed}.` + (!outcome.pushes ? ''
6315
6714
  : tool === 'delete'
6316
- ? ' Its push to disk didn\'t run, so disk keeps the file until a materialize of the path with applyDeletions deletes it.'
6317
- : ' Its push to disk didn\'t run, so disk lacks the change until a materialize of the path writes it.')
6318
- : ` The rest of the call failed: ${outcome.threw}. ls shows the path's state.`;
6319
- return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
6715
+ ? ` Its push to disk didn't run, so disk keeps the file until a materialize of the path with applyDeletions deletes it${reopen ? ONCE_REOPENED : ''}.`
6716
+ : ` Its push to disk didn't run, so disk lacks the change until a materialize of the path writes it${reopen ? ONCE_REOPENED : ''}.`)
6717
+ : ` The rest of the call failed: ${outcome.threw}.${reopen ? '' : " ls shows the path's state."}`;
6718
+ return { success: false, isError: true, error: stored + rest + (reopen ? ` ${AWAITS_REOPEN}` : '') + (note !== null ? ` ${note}` : '') };
6320
6719
  }
6321
6720
  /**
6322
6721
  * What a read says about the copy it shows, when that isn't simply the one the
@@ -6345,18 +6744,22 @@ function whichCopyRead(report, tookChange, readOnly) {
6345
6744
  // can hold it (not binary or too large). A read-only mount can hold a
6346
6745
  // copy disk doesn't, such as one a branch switch selected, but a
6347
6746
  // materialize, forced or not, writes nothing to it.
6348
- const sync = report.diskNow === 'absent'
6349
- ? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6350
- : 'Sync the path to keep disk\'s copy and give up the workspace\'s';
6351
- const force = readOnly
6352
- ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6353
- : report.diskNow === 'absent'
6354
- ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6355
- : ', or materialize it with force to write the workspace\'s over disk\'s.';
6356
- return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
6747
+ const settle = settleConflict(report.diskNow, readOnly);
6748
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${settle[0].toUpperCase()}${settle.slice(1)}`];
6357
6749
  }
6358
6750
  return [];
6359
6751
  }
6752
+ function settleConflict(diskNow, readOnly) {
6753
+ const sync = diskNow === 'absent'
6754
+ ? 'sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6755
+ : 'sync the path to keep disk\'s copy and give up the workspace\'s';
6756
+ const force = readOnly
6757
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6758
+ : diskNow === 'absent'
6759
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6760
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
6761
+ return `${sync}${force}`;
6762
+ }
6360
6763
  /**
6361
6764
  * The least each tool's whole-number fields may be, beside their schemas,
6362
6765
  * whose type (membrane's ToolParameter) has no `minimum`.
@@ -6548,13 +6951,13 @@ function globToRegex(pattern) {
6548
6951
  closeIdx = j;
6549
6952
  }
6550
6953
  if (closeIdx < 0)
6551
- throw new Error(braceRefusal(pattern, 'has a "{" with no closing "}"'));
6954
+ throw new Refusal(braceRefusal(pattern, 'has a "{" with no closing "}"'));
6552
6955
  const group = pattern.slice(i + 1, closeIdx);
6553
6956
  if (group.includes('{')) {
6554
- throw new Error(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
6957
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group inside another; put all its alternatives in one group'));
6555
6958
  }
6556
6959
  if (!group.includes(',')) {
6557
- throw new Error(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
6960
+ throw new Refusal(braceRefusal(pattern, 'has a "{" group with no ","; a group needs two or more alternatives, separated by ","'));
6558
6961
  }
6559
6962
  // A `/` right after the group goes into each alternative, as `{a,b}/x`
6560
6963
  // and `{a/,b/}x` name the same paths, so an alternative's own `**`