@animalabs/agent-framework 0.22.4 → 0.23.1

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 +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -6,14 +6,14 @@
6
6
  */
7
7
  import { isUtf8 } from 'node:buffer';
8
8
  import { constants as fsConstants } from 'node:fs';
9
- import { open, readFile, readdir, stat, lstat, realpath } from 'node:fs/promises';
10
- import { isAbsolute, join, resolve, relative, sep } from 'node:path';
9
+ import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
10
+ import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
11
11
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
12
12
  import { MountWatcher } from './watcher.js';
13
13
  import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
14
14
  import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
15
15
  import { BranchIntents } from './branch-intent.js';
16
- import { IGNORED, OTHER_MOUNT, checkDir, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js';
16
+ import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix } from './observe.js';
17
17
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
18
18
  import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
19
19
  /** Default for how long the after-batch scan may hold the agent's next inference. */
@@ -44,8 +44,8 @@ class WorkspaceImageReadError extends Error {
44
44
  * use there.
45
45
  */
46
46
  class NestedMountError extends Error {
47
- constructor(path, mount, holder, use) {
48
- super(`"${path}" lies in mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
47
+ constructor(path, mount, holder, use, how = 'lies in') {
48
+ super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`);
49
49
  }
50
50
  }
51
51
  /**
@@ -72,6 +72,20 @@ function errnoOf(err) {
72
72
  ? err.code
73
73
  : undefined;
74
74
  }
75
+ /**
76
+ * What is at a mount root's path now, followed as the root is: for a
77
+ * directory, undefined; for anything else, ENOTDIR; and where the path
78
+ * can't be followed to anything, the errno its stat failed with, ENOENT
79
+ * when nothing is there.
80
+ */
81
+ async function rootAbsence(root) {
82
+ try {
83
+ return (await stat(root)).isDirectory() ? undefined : 'ENOTDIR';
84
+ }
85
+ catch (err) {
86
+ return errnoOf(err);
87
+ }
88
+ }
75
89
  /** read_image's refusal of a file over the mount's size limit, wherever it's found to be. */
76
90
  function imageTooLarge(maxSize, mountPrefixedPath) {
77
91
  return new WorkspaceImageReadError('too_large', `Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`);
@@ -99,6 +113,77 @@ function startsWithBytes(buffer, prefix) {
99
113
  function matchesPartialPrefix(buffer, prefix) {
100
114
  return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer);
101
115
  }
116
+ /**
117
+ * A disk reader's refusal for what linkOnTheWay found on a path's way: the
118
+ * link's reason, as the observations give it, after the path; or, for a
119
+ * directory the walk couldn't ask, a failure to read, coded `not_found` with
120
+ * the readlink's errno, as every failed lookup is. A link's code follows
121
+ * where it leads, so a caller choosing advice by code gets advice that fits:
122
+ * `escape` for a link leading outside the mount, the code a path resolving
123
+ * outside it gets too; `other_mount` for one into a nested mount's directory,
124
+ * the code that mount's own paths get; and `parent_symlink` for any other,
125
+ * which `symlink`, a file that is itself a link, leaves apart.
126
+ */
127
+ async function linkReadError(mount, found, mountPrefixedPath, nested) {
128
+ if (!('link' in found)) {
129
+ return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
130
+ }
131
+ const rootReal = await realpath(mount.config.path).catch(() => resolve(mount.config.path));
132
+ const leads = await linkLeads(mount.config.path, rootReal, found.link);
133
+ const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested)}`;
134
+ const code = leads.kind === 'outside' ? 'escape'
135
+ : leads.kind === 'inside' && nestedHolder(leads.target, nested) ? 'other_mount'
136
+ : 'parent_symlink';
137
+ return new WorkspaceReadError(code, reason, 'readlink');
138
+ }
139
+ /**
140
+ * `to` beneath `from`, mount-relative and `/`-separated, whatever the
141
+ * platform's separator; undefined where it isn't beneath it. Outside,
142
+ * `relative` gives a path that climbs with a `..` segment, or an absolute one
143
+ * across Windows drives. A name that only begins with two dots, such as
144
+ * `..cache`, is inside.
145
+ */
146
+ function nestedDir(from, to) {
147
+ const rel = relative(from, to);
148
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
149
+ return undefined;
150
+ return rel.split(sep).join('/');
151
+ }
152
+ /** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */
153
+ const LINKS_FOLLOWED = 40;
154
+ /**
155
+ * An absolute path, a mount root's or one beneath it, as realpath resolves
156
+ * it, with the links on its way and in it taken out, read without holding
157
+ * the process. Where nothing is there yet, a link leads where its target
158
+ * would be, and anything else lies beneath its nearest existing directory's
159
+ * real path, with the rest as given. A link's target is read from the
160
+ * link's directory as it is on disk, and nothing is resolved lexically on
161
+ * the way, so a `..` is taken from where the links before it lead, as the
162
+ * kernel takes it. Where realpath fails for any other reason, such as a
163
+ * loop or a lack of permission, the whole path as path.resolve reads it.
164
+ */
165
+ async function realBeneath(given, links = 0) {
166
+ // A trailing separator, as a link to a directory is often written, names
167
+ // the same place, but readlink would then follow the link it names.
168
+ const { root } = parse(given);
169
+ let absolute = given;
170
+ while (absolute.length > root.length && (absolute.endsWith('/') || absolute.endsWith(sep)))
171
+ absolute = absolute.slice(0, -1);
172
+ try {
173
+ return await realpath(absolute);
174
+ }
175
+ catch (err) {
176
+ const code = errnoOf(err);
177
+ const parent = dirname(absolute);
178
+ if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute)
179
+ return resolve(absolute);
180
+ const place = await realBeneath(parent, links);
181
+ const target = await readlink(absolute).catch(() => null);
182
+ if (target !== null)
183
+ return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1);
184
+ return join(place, basename(absolute));
185
+ }
186
+ }
102
187
  function isContainedPath(root, candidate) {
103
188
  if (candidate === root)
104
189
  return true;
@@ -474,6 +559,8 @@ export class WorkspaceModule {
474
559
  store = null;
475
560
  /** Set once stop has drained and saved, cleared by start (running()). */
476
561
  stopped = false;
562
+ /** Stops begun, so a start that read disk meanwhile can tell that one came. */
563
+ stopsBegun = 0;
477
564
  config;
478
565
  mounts = new Map();
479
566
  watchers = new Map();
@@ -489,10 +576,19 @@ export class WorkspaceModule {
489
576
  /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
490
577
  ctimeTrust = new Map();
491
578
  /**
492
- * Each mount's directories that are other mounts nested inside it:
493
- * mount-relative, `/`-separated, with each mount's name and resolved root.
579
+ * Each mount's directories that are other mounts nested inside it, with
580
+ * each mount's name and real root, in each spelling that names the
581
+ * directory: as configured; as the real roots place it, where that differs
582
+ * (`fromRoots`); and as disk spells each of those where that differs.
583
+ * Each spelling is mount-relative and `/`-separated, with its root beneath
584
+ * the mount's resolved root. The constructor nests by configured path,
585
+ * with each root as resolved standing for its real one, and each start
586
+ * reads it all again with the roots placed where disk has them
587
+ * (readNesting).
494
588
  */
495
589
  otherMounts = new Map();
590
+ /** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
591
+ rootFoldsCase = new Map();
496
592
  /**
497
593
  * Whether a file's mode is observed, taken in and applied (MountRuntime's
498
594
  * `modesObserved`): not on Windows, which reports no POSIX modes. A test
@@ -518,26 +614,22 @@ export class WorkspaceModule {
518
614
  // named or not, and in its watcher (OTHER_MOUNT). It's kept apart from
519
615
  // the outer mount's ignore list, which a sync that names a path sets
520
616
  // aside for what it names (#276), so no sync of the outer mount takes in
521
- // the inner one's files.
617
+ // the inner one's files. Here by configured path, before any path is
618
+ // parsed; each start places the roots where disk has them too
619
+ // (readNesting).
522
620
  for (const outer of config.mounts) {
523
621
  for (const inner of config.mounts) {
524
622
  if (outer === inner)
525
623
  continue;
526
624
  const outerPath = resolve(outer.path);
527
625
  const innerPath = resolve(inner.path);
528
- const rel = relative(outerPath, innerPath);
529
- // Outside the outer mount, `relative` gives a path that climbs with a
530
- // `..` segment, or an absolute one across Windows drives. A name that
531
- // only begins with two dots, such as `..cache`, is inside it.
532
- if (rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)) {
533
- // inner is nested under outer: passed over as exactly that
534
- // directory, read against `/`-separated paths, whatever the
535
- // platform's.
536
- const relPath = rel.split(sep).join('/');
537
- this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { dir: relPath, name: inner.name, root: innerPath }]);
538
- console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
539
- `at ${relPath}/ — passing over it as another mount's directory`);
540
- }
626
+ const dir = nestedDir(outerPath, innerPath);
627
+ if (dir === undefined)
628
+ continue;
629
+ const configured = [{ dir, root: innerPath }];
630
+ this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]);
631
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` +
632
+ `at ${dir}/ — passing over it as another mount's directory`);
541
633
  }
542
634
  }
543
635
  this.config = config;
@@ -617,6 +709,24 @@ export class WorkspaceModule {
617
709
  throw new Error('Workspace store not initialized');
618
710
  return this.agreement;
619
711
  }
712
+ /**
713
+ * A mount as observation reads it, with the root identity the workspace
714
+ * recorded for it, if any: what a pass reads beneath (runtime), with
715
+ * rootIdentity's, and what a disk read checks its root against (readRoot),
716
+ * with rootIdentityForRead's.
717
+ */
718
+ viewOf(mount, rootIdentity) {
719
+ return {
720
+ root: resolve(mount.config.path),
721
+ name: mount.config.name,
722
+ rootIdentity,
723
+ followSymlinks: mount.config.followSymlinks === true,
724
+ maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
725
+ ignore: mount.config.ignore ?? [],
726
+ others: this.othersOf(mount.config.name),
727
+ nested: this.nestedOf(mount.config.name),
728
+ };
729
+ }
620
730
  /** The view reconciliation needs of a mount, refreshed per pass. */
621
731
  async runtime(mount) {
622
732
  const root = resolve(mount.config.path);
@@ -629,14 +739,7 @@ export class WorkspaceModule {
629
739
  }
630
740
  return {
631
741
  name: mount.config.name,
632
- view: {
633
- root,
634
- rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
635
- followSymlinks: mount.config.followSymlinks === true,
636
- maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
637
- ignore: mount.config.ignore ?? [],
638
- others: this.othersOf(mount.config.name),
639
- },
742
+ view: this.viewOf(mount, this.agreementOrThrow().rootIdentity(mount.config.name)),
640
743
  rootReal,
641
744
  ctimeTrustedDev: trustedDev,
642
745
  treeStateId: mount.treeStateId,
@@ -648,14 +751,14 @@ export class WorkspaceModule {
648
751
  };
649
752
  }
650
753
  /**
651
- * Where a search's walk starts: `base`, its path as given, then each
652
- * segment of `beneath` while disk lists it, in the directory reached so
653
- * far, as a directory with exactly that spelling. The walk names what it
654
- * finds from its start's spelling, and a case-insensitive disk would let
655
- * it go on under a spelling disk doesn't list, taking files in under that
656
- * spelling beside their own. So the start stops before a segment spelled
657
- * any other way, not there, or not a directory, and before a link, which
658
- * a walk of the whole mount doesn't go through either. It lists only
754
+ * Where a search's walk starts: `base`, its path as parsePath spells it,
755
+ * then each segment of `beneath` while disk lists it, in the directory
756
+ * reached so far, as a directory with exactly that spelling. The walk names
757
+ * what it finds from its start's spelling, and a case-insensitive disk
758
+ * would let it go on under a spelling disk doesn't list, taking files in
759
+ * under that spelling beside their own. So the start stops before a segment
760
+ * spelled any other way, not there, or not a directory, and before a link,
761
+ * which a walk of the whole mount doesn't go through either. It lists only
659
762
  * directories it has checked lie inside the mount, and none the mount
660
763
  * passes over by design, which the walk then answers for.
661
764
  */
@@ -707,6 +810,21 @@ export class WorkspaceModule {
707
810
  });
708
811
  }
709
812
  async start(ctx) {
813
+ // Which mounts are nested where, with the roots placed where disk has
814
+ // them and each nested mount's directory as disk spells it, and whether
815
+ // each mount's root folds case, read where disk has it, before this start
816
+ // sets anything, so a restart takes no call and walks or watches nothing
817
+ // before they're known. A stop begun meanwhile stands, and this start then
818
+ // sets nothing.
819
+ const stops = this.stopsBegun;
820
+ const { nesting, reals } = await this.readNesting();
821
+ const rootFoldsCase = new Map();
822
+ for (const mount of this.config.mounts)
823
+ rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)));
824
+ if (this.stopsBegun !== stops)
825
+ return;
826
+ this.otherMounts = nesting;
827
+ this.rootFoldsCase = rootFoldsCase;
710
828
  this.ctx = ctx;
711
829
  this.stopped = false;
712
830
  // Decode persisted state if restarting; application waits until mounts
@@ -944,6 +1062,7 @@ export class WorkspaceModule {
944
1062
  }
945
1063
  }
946
1064
  async stop() {
1065
+ this.stopsBegun++;
947
1066
  // No new watcher-driven passes, then let every pass and write finish
948
1067
  // before the store goes away — including any queued behind the ones in
949
1068
  // flight (a tool call, a scan continuing past its deadline): drained
@@ -954,11 +1073,13 @@ export class WorkspaceModule {
954
1073
  for (const watcher of stopping) {
955
1074
  await watcher.stop();
956
1075
  }
1076
+ // A call under way can await disk (parsePath) before it takes its turn,
1077
+ // so the calls are waited for as well as the turns.
957
1078
  for (;;) {
958
1079
  const tails = [...this.mountTurns.values()];
959
- await Promise.all(tails);
1080
+ await Promise.allSettled([...this.callsUnderWay, ...tails]);
960
1081
  const now = [...this.mountTurns.values()];
961
- if (now.length === tails.length && now.every((tail, i) => tail === tails[i]))
1082
+ if (this.callsUnderWay.size === 0 && now.length === tails.length && now.every((tail, i) => tail === tails[i]))
962
1083
  break;
963
1084
  }
964
1085
  // Nothing is queued now, and nothing more is taken: what follows saves
@@ -1233,6 +1354,22 @@ export class WorkspaceModule {
1233
1354
  async handleToolCall(call) {
1234
1355
  if (!this.running())
1235
1356
  return { success: false, isError: true, error: NOT_RUNNING };
1357
+ return this.underWay(this.runToolCall(call));
1358
+ }
1359
+ /**
1360
+ * Tool calls, and the public reads and writes that take a mount's turn,
1361
+ * under way. Each can await disk (parsePath) before it takes its turn, so
1362
+ * stop waits for these as well as for the turns.
1363
+ */
1364
+ callsUnderWay = new Set();
1365
+ /** `work`, recorded as under way until it settles (callsUnderWay). */
1366
+ underWay(work) {
1367
+ this.callsUnderWay.add(work);
1368
+ const done = () => { this.callsUnderWay.delete(work); };
1369
+ work.then(done, done);
1370
+ return work;
1371
+ }
1372
+ async runToolCall(call) {
1236
1373
  try {
1237
1374
  // Every handler reads its input as its schema describes it; a call
1238
1375
  // that sends something else is refused here, before anything is read.
@@ -1356,7 +1493,10 @@ export class WorkspaceModule {
1356
1493
  */
1357
1494
  resolveAbsolutePath(mountPrefixedPath) {
1358
1495
  try {
1359
- const { mount, relativePath } = this.parseFilePath(mountPrefixedPath);
1496
+ // Synchronous, so the path as given: it reaches the same file, and
1497
+ // the nested-mount check matches it as configured or as disk spells
1498
+ // the nested mount's directory (readNesting).
1499
+ const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath });
1360
1500
  return resolve(mount.config.path, relativePath);
1361
1501
  }
1362
1502
  catch {
@@ -1387,10 +1527,13 @@ export class WorkspaceModule {
1387
1527
  async writeBinary(mountPrefixedPath, data, mimeType, options = {}) {
1388
1528
  if (!this.running())
1389
1529
  return { success: false, isError: true, error: NOT_RUNNING };
1530
+ return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
1531
+ }
1532
+ async writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options) {
1390
1533
  let mount;
1391
1534
  let relativePath;
1392
1535
  try {
1393
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1536
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1394
1537
  }
1395
1538
  catch (error) {
1396
1539
  return {
@@ -1451,10 +1594,13 @@ export class WorkspaceModule {
1451
1594
  async readBinary(mountPrefixedPath) {
1452
1595
  if (!this.running())
1453
1596
  return { error: NOT_RUNNING };
1597
+ return this.underWay(this.readBinaryUnderWay(mountPrefixedPath));
1598
+ }
1599
+ async readBinaryUnderWay(mountPrefixedPath) {
1454
1600
  let mount;
1455
1601
  let relativePath;
1456
1602
  try {
1457
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1603
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1458
1604
  }
1459
1605
  catch (error) {
1460
1606
  return { error: error instanceof Error ? error.message : String(error) };
@@ -1483,38 +1629,150 @@ export class WorkspaceModule {
1483
1629
  * permanent cost to send something once is the wrong trade.
1484
1630
  *
1485
1631
  * Same mount boundary and traversal guard as every other path here: only
1486
- * declared mounts, nothing above their root. Deliberately does NOT sync,
1487
- * register, or hash anything — it reads and returns.
1632
+ * declared mounts, nothing above their root, and the file reached as
1633
+ * readFileFromDisk reaches it (openContainedFile), so the mount's
1634
+ * `followSymlinks` holds for the file and the directories on its way, and
1635
+ * what it reaches stays canonically inside the mount. Deliberately does NOT
1636
+ * sync, register, or hash anything — it reads and returns.
1637
+ *
1638
+ * `maxBytes` refuses a larger file as "File too large", having loaded at
1639
+ * most `maxBytes` bytes and one more: the reads decide where the file ends,
1640
+ * not the size fstat gave at the open, so the bound holds for a file that
1641
+ * grows while it's read.
1488
1642
  */
1489
1643
  async readBinaryFromDisk(mountPrefixedPath, opts = {}) {
1490
1644
  let mount;
1491
1645
  let relativePath;
1492
1646
  try {
1493
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
1647
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1494
1648
  }
1495
1649
  catch (error) {
1496
1650
  return { error: error instanceof Error ? error.message : String(error) };
1497
1651
  }
1498
1652
  const absolutePath = resolve(mount.config.path, relativePath);
1653
+ const maxBytes = opts.maxBytes;
1654
+ let tooLarge;
1499
1655
  try {
1500
- const info = await stat(absolutePath);
1501
- if (!info.isFile())
1502
- return { error: `Not a file: ${mountPrefixedPath}` };
1503
- if (opts.maxBytes !== undefined && info.size > opts.maxBytes) {
1504
- return { error: `File too large: ${info.size} > ${opts.maxBytes} bytes` };
1656
+ const { handle, fileStat } = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
1657
+ if (maxBytes !== undefined && fileStat.size > maxBytes) {
1658
+ tooLarge = `File too large: ${fileStat.size} > ${maxBytes} bytes`;
1659
+ throw new Error(tooLarge);
1660
+ }
1661
+ });
1662
+ try {
1663
+ if (maxBytes === undefined)
1664
+ return { data: await handle.readFile(), absolutePath };
1665
+ // No more than maxBytes is loaded: a file grown past it since the
1666
+ // fstat above is refused here, as one already over it was there. Its
1667
+ // size is an fstat's after the read, and at least one more than the
1668
+ // bytes kept, so the refusal's size is always past maxBytes.
1669
+ const { bytes, more } = await readPrefix(handle, maxBytes, fileStat.size);
1670
+ if (more)
1671
+ return { error: `File too large: ${Math.max((await handle.stat()).size, bytes.length + 1)} > ${maxBytes} bytes` };
1672
+ return { data: bytes, absolutePath };
1673
+ }
1674
+ finally {
1675
+ await handle.close();
1505
1676
  }
1506
- return { data: await readFile(absolutePath), absolutePath };
1507
1677
  }
1508
1678
  catch (error) {
1509
- const code = error.code;
1510
- if (code === 'ENOENT')
1679
+ if (tooLarge !== undefined)
1680
+ return { error: tooLarge };
1681
+ // A directory, or a file that isn't regular (the reader's refusal of
1682
+ // one carries no errno), and a missing file keep this reader's words.
1683
+ if (error instanceof WorkspaceReadError && (error.code === 'directory' || (error.code === 'not_found' && error.errno === undefined))) {
1684
+ return { error: `Not a file: ${mountPrefixedPath}` };
1685
+ }
1686
+ if (error instanceof WorkspaceReadError && error.code === 'not_found' && (error.errno === 'ENOENT' || error.errno === 'ENOTDIR')) {
1511
1687
  return { error: `File not found on disk: ${mountPrefixedPath}` };
1688
+ }
1512
1689
  return { error: error instanceof Error ? error.message : String(error) };
1513
1690
  }
1514
1691
  }
1515
- /** The directories of the mounts nested inside a mount, mount-relative. */
1692
+ /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */
1516
1693
  othersOf(mountName) {
1517
- return (this.otherMounts.get(mountName) ?? []).map((o) => o.dir);
1694
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir));
1695
+ }
1696
+ /**
1697
+ * Which mounts are nested in which, read afresh at each start. A mount is
1698
+ * nested in another where its configured path lies beneath the other's
1699
+ * root, as configured or as it lies on disk, whatever links the path
1700
+ * reaches that root through, or where its real root lies beneath the
1701
+ * other's. Each root, and each directory above a configured path, is
1702
+ * placed by realBeneath, awaited, so a root on a share that stops
1703
+ * answering holds this start and not the process, and a root moved while
1704
+ * the module was stopped is placed where it is now. A mount whose real
1705
+ * root is the other's own, or holds it, is nested by its configured path
1706
+ * alone, with no real root, since through it it would claim the other's
1707
+ * every path.
1708
+ *
1709
+ * Each way it lies there is a spelling of its directory (`fromRoots`), and
1710
+ * so is the spelling disk lists each of those by, where that differs. On a
1711
+ * disk that folds names, the outer mount's walks name the directory as
1712
+ * disk does, and so does a path parsePath has respelled, while a path given
1713
+ * as configured still names it as configured (resolveAbsolutePath). A
1714
+ * directory not on disk keeps the spellings the roots give alone. Each
1715
+ * mount's real root comes back beside the nesting (`reals`), for what
1716
+ * start reads there.
1717
+ */
1718
+ async readNesting() {
1719
+ const reals = new Map();
1720
+ // Each directory above a mount's configured path, with where it lies on
1721
+ // disk, so a path that reaches another mount's root through a link, as
1722
+ // one under macOS's /var reaches /private/var, is still read beneath it.
1723
+ const above = new Map();
1724
+ for (const mount of this.config.mounts) {
1725
+ const given = resolve(mount.path);
1726
+ reals.set(mount.name, await realBeneath(given));
1727
+ const dirs = [];
1728
+ for (let dir = dirname(given);; dir = dirname(dir)) {
1729
+ dirs.push({ dir, real: await realBeneath(dir) });
1730
+ if (dirname(dir) === dir)
1731
+ break;
1732
+ }
1733
+ above.set(mount.name, dirs);
1734
+ }
1735
+ const nesting = new Map();
1736
+ for (const outer of this.config.mounts) {
1737
+ const outerPath = resolve(outer.path);
1738
+ const outerReal = reals.get(outer.name);
1739
+ for (const inner of this.config.mounts) {
1740
+ if (outer === inner)
1741
+ continue;
1742
+ const real = reals.get(inner.name);
1743
+ const innerPath = resolve(inner.path);
1744
+ const configured = nestedDir(outerPath, innerPath);
1745
+ const fromRoots = [];
1746
+ // Its directory as configured, read against this mount's root as
1747
+ // configured and from each directory above it that is this mount's
1748
+ // root on disk, and its real root against this mount's.
1749
+ const throughRoot = above.get(inner.name).filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath));
1750
+ for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) {
1751
+ if (dir !== undefined && !fromRoots.some((s) => s.dir === dir))
1752
+ fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) });
1753
+ }
1754
+ if (fromRoots.length === 0)
1755
+ continue;
1756
+ const spellings = [...fromRoots];
1757
+ for (const found of fromRoots) {
1758
+ const dir = await diskSpelling(outerPath, found.dir);
1759
+ if (!spellings.some((s) => s.dir === dir))
1760
+ spellings.push({ dir, root: join(outerPath, ...dir.split('/')) });
1761
+ }
1762
+ const claims = !isContainedPath(real, outerReal);
1763
+ nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]);
1764
+ const placed = fromRoots.filter((s) => s.dir !== configured);
1765
+ if (placed.length > 0) {
1766
+ console.warn(`[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` +
1767
+ `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`);
1768
+ }
1769
+ }
1770
+ }
1771
+ return { nesting, reals };
1772
+ }
1773
+ /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
1774
+ nestedOf(mountName) {
1775
+ return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real })));
1518
1776
  }
1519
1777
  /**
1520
1778
  * The innermost mount nested in `mountName` whose directory holds a path
@@ -1529,7 +1787,7 @@ export class WorkspaceModule {
1529
1787
  return undefined;
1530
1788
  const resolved = resolve(mount.config.path, relativePath);
1531
1789
  const holder = (this.otherMounts.get(mountName) ?? [])
1532
- .filter((o) => isContainedPath(o.root, resolved))
1790
+ .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root })))
1533
1791
  .sort((a, b) => b.root.length - a.root.length)[0];
1534
1792
  if (!holder)
1535
1793
  return undefined;
@@ -1555,17 +1813,98 @@ export class WorkspaceModule {
1555
1813
  * observe it and its pushes don't write it, so its file operations refuse
1556
1814
  * it, naming the innermost mount that holds it and the path to use there.
1557
1815
  */
1558
- parseFilePath(path) {
1559
- const parsed = this.parsePath(path);
1816
+ async parseFilePath(path) {
1817
+ const parsed = this.refuseNested(await this.parsePath(path));
1818
+ // A path can also reach a nested mount's directory through a symlink on
1819
+ // its way. No push of this mount writes it there and no read reads it,
1820
+ // so it's refused here, before anything is stored, and the checks where
1821
+ // disk is acted on refuse it again.
1822
+ const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path);
1823
+ if (refusal)
1824
+ throw refusal;
1825
+ return parsed;
1826
+ }
1827
+ /**
1828
+ * The refusal for a path of `mount` that reaches a nested mount's directory
1829
+ * through a symlink on its way (nestedAtReal), or null where it doesn't. On
1830
+ * a mount that doesn't follow the link, the refusal is the link's own
1831
+ * (linkWords), as the readers and pushes give it. A mount with nothing
1832
+ * nested has nothing to find, so it pays nothing.
1833
+ */
1834
+ async leadsIntoNested(mount, relativePath, path) {
1835
+ const nested = this.nestedOf(mount.config.name);
1836
+ if (nested.length === 0)
1837
+ return null;
1838
+ const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested);
1839
+ if (!holder)
1840
+ return null;
1841
+ if (mount.config.followSymlinks !== true) {
1842
+ const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath);
1843
+ if (found !== null)
1844
+ return await linkReadError(mount, found, path, nested);
1845
+ }
1846
+ return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into');
1847
+ }
1848
+ /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */
1849
+ refuseNested(parsed) {
1560
1850
  const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath);
1561
1851
  if (holder)
1562
- throw new NestedMountError(path, parsed.mount.config.name, holder.name, holder.use);
1852
+ throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use);
1563
1853
  return parsed;
1564
1854
  }
1565
1855
  /**
1566
- * Parse a mount-prefixed path into (mountName, relativePath).
1856
+ * Parse a mount-prefixed path into its mount and mount-relative path, as
1857
+ * disk spells it (diskSpelling), with `path` the mount-prefixed path so
1858
+ * spelled. On a disk that folds names, a path spelled otherwise reaches
1859
+ * disk's file, so every tool takes it by disk's name: one name per file,
1860
+ * and the names its answers give are disk's, except that a path that names
1861
+ * a workspace entry exactly, or does once its directories take disk's
1862
+ * spelling, keeps the entry's spelling. A path the workspace records an
1863
+ * intent at, such as a deletion disk hasn't been given yet, counts as an
1864
+ * entry here, so the calls that settle it reach it. The workspace's
1865
+ * entries (entriesOf) stand in for disk where it has nothing yet, so a
1866
+ * draft has one name too. Neither applies to a path that disk's spelling
1867
+ * puts in a nested mount's directory.
1868
+ * A handler that must read something at the moment it's asked, as
1869
+ * handleSync reads the branch a path sync adopts on, reads it before
1870
+ * calling this, since this awaits disk. The entries are read when the
1871
+ * step needs them, some after those awaits, from the branch current then.
1567
1872
  */
1568
- parsePath(path) {
1873
+ async parsePath(path) {
1874
+ const given = this.parsePathAsGiven(path);
1875
+ const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
1876
+ return {
1877
+ mount: given.mount,
1878
+ relativePath,
1879
+ path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
1880
+ };
1881
+ }
1882
+ /**
1883
+ * A mount's entries on the current branch, and the paths it records an
1884
+ * intent at there, as diskSpelling reads them when it does, whether its
1885
+ * root folds case as the last start read it (false before the first), and
1886
+ * which nested mount's directory holds a path, by holderOf, as the
1887
+ * nested-mount checks read it. diskSpelling keeps disk's spelling of a
1888
+ * path that lies in a nested mount's directory as disk spells it, so the
1889
+ * checks refuse at least what they would refuse without the entries.
1890
+ */
1891
+ entriesOf(mount) {
1892
+ const store = this.getStore();
1893
+ const intents = this.intents.get(mount.config.name);
1894
+ return {
1895
+ // An intent recorded at a path, such as a deletion disk hasn't been
1896
+ // given yet or a conflict, is the workspace's own name for the file
1897
+ // there, as an entry is, so the calls that settle it reach it.
1898
+ has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
1899
+ under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
1900
+ holder: (path) => this.holderOf(mount.config.name, path)?.name,
1901
+ rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
1902
+ };
1903
+ }
1904
+ /**
1905
+ * Parse a mount-prefixed path into (mountName, relativePath), as spelled.
1906
+ */
1907
+ parsePathAsGiven(path) {
1569
1908
  // A file's name on disk is UTF-8, which can't hold a lone surrogate: the
1570
1909
  // store would keep one name and disk another.
1571
1910
  if (LONE_SURROGATE.test(path)) {
@@ -1629,29 +1968,125 @@ export class WorkspaceModule {
1629
1968
  }
1630
1969
  return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
1631
1970
  }
1971
+ /**
1972
+ * The mount root a disk read reads beneath: resolved, then checked as
1973
+ * observation checks it (checkRoot), so the read and the evidence agree on
1974
+ * which directory the mount is. Once the workspace has recorded the root's
1975
+ * identity, a root that resolves to any other directory is refused, in
1976
+ * ROOT_REPLACED's words; with none recorded yet, it need only be a
1977
+ * directory there. A refusal where no directory is at the root's path
1978
+ * carries the errno that gives (rootAbsence: ENOENT when nothing is there
1979
+ * and ENOTDIR when something else is, which a caller can read as no file,
1980
+ * or the errno the root's stat failed with otherwise, such as EACCES). A
1981
+ * root whose realpath fails carries realpath's errno, and one that is
1982
+ * another directory carries none. The identity is rootIdentityForRead's,
1983
+ * which answers while the evidence awaits reopening; where even it can't
1984
+ * be read, as when the evidence was never read back whole, the read is
1985
+ * refused too, with an errno only where no directory is at the root's path.
1986
+ */
1987
+ async readRoot(mount) {
1988
+ const name = mount.config.name;
1989
+ const root = resolve(mount.config.path);
1990
+ let real;
1991
+ try {
1992
+ real = await realpath(root);
1993
+ }
1994
+ catch (err) {
1995
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}`, 'realpath_root', errnoOf(err));
1996
+ }
1997
+ let rootIdentity;
1998
+ try {
1999
+ rootIdentity = this.agreementOrThrow().rootIdentityForRead(name);
2000
+ }
2001
+ catch (err) {
2002
+ // The evidence can't be read back, so neither can the check be made.
2003
+ // Where no directory is at the root's path, its errno still says so.
2004
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${err instanceof Error ? err.message : String(err)}`, 'root', await rootAbsence(root));
2005
+ }
2006
+ const checked = await checkRoot(this.viewOf(mount, rootIdentity), real);
2007
+ if ('dir' in checked)
2008
+ return { real, dir: checked.dir };
2009
+ // Another directory is no absence. For any other refusal, what is at the
2010
+ // root's path now decides: nothing, something that isn't a directory, or
2011
+ // a directory the path has come to name since it was resolved (no errno).
2012
+ const errno = checked.reason === ROOT_REPLACED ? undefined : await rootAbsence(root);
2013
+ throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${name}: ${checked.reason}`, 'root', errno);
2014
+ }
2015
+ /**
2016
+ * The refusal for a root that stopped being the directory a read checked,
2017
+ * with the code and errno readRoot gives what is at its path now (its stage
2018
+ * and words are this check's): gone, or not a directory, with ENOENT or
2019
+ * ENOTDIR, so a caller reads a root gone mid-read as one gone before it; or
2020
+ * another directory, with none.
2021
+ */
2022
+ async rootMoved(mount) {
2023
+ const errno = await rootAbsence(resolve(mount.config.path));
2024
+ const reason = errno === undefined ? ROOT_REPLACED : ROOT_UNAVAILABLE;
2025
+ return new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}: ${reason}`, 'root', errno);
2026
+ }
1632
2027
  /**
1633
2028
  * Open a mount-relative file for reading with the mount boundary enforced on
1634
2029
  * the *filesystem*, not just the path string:
1635
2030
  *
1636
- * 1. `lstat` the lexical path — refuse directories; refuse a final-component
1637
- * symlink when the mount does not `followSymlinks`.
1638
- * 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
2031
+ * 0. Check the root (readRoot) before anything beneath it: under a root
2032
+ * that isn't the mount's directory, no path says anything about the
2033
+ * mount's files, an absence included.
2034
+ * 1. On a mount that does not `followSymlinks`, refuse a path with a symlink
2035
+ * among the directories on its way beneath the mount's root, wherever it
2036
+ * leads (linkOnTheWay), before anything resolves it: as `escape` for a
2037
+ * link leading outside the mount, as `other_mount` for one into a nested
2038
+ * mount's directory, and as `parent_symlink` for any other.
2039
+ * 2. `lstat` the lexical path — refuse directories; refuse a final-component
2040
+ * symlink when the mount does not `followSymlinks`; refuse anything else
2041
+ * that isn't a regular file, such as a FIFO, without opening it.
2042
+ * 3. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
1639
2043
  * holds against a symlink swapped in between lstat and open; where it
1640
- * doesn't (Windows), re-`lstat` after opening instead.
1641
- * 3. `realpath` both the mount root and the opened path and require canonical
1642
- * containment — this is what catches an *intermediate* symlinked
1643
- * directory escaping the mount, and a followed symlink whose target lands
1644
- * outside, on every platform (junctions included).
1645
- * 4. Confirm the descriptor and the canonical path are the same inode.
2044
+ * doesn't (Windows), re-`lstat` after opening instead. `O_NONBLOCK`, so a
2045
+ * FIFO swapped in meanwhile doesn't wait for a writer.
2046
+ * 4. `realpath` the opened path. On a mount that does not `followSymlinks`,
2047
+ * refuse it again for a symlinked directory on its way, which catches one
2048
+ * swapped in since step 1 (linkRefusal). Then require canonical
2049
+ * containment in the root step 0 resolved — this is what catches an
2050
+ * *intermediate* symlinked directory escaping the mount, and a followed
2051
+ * symlink whose target lands outside, on every platform (junctions
2052
+ * included).
2053
+ * 5. Confirm the descriptor and the canonical path are the same inode, and
2054
+ * the root still the directory step 0 checked.
1646
2055
  *
1647
- * `inspect` runs right after the descriptor is stat'd and before the
1648
- * containment work, so callers can impose size policy in the same order the
1649
- * image reader always has. The returned handle is the caller's to close.
2056
+ * Whatever steps 1 to 5 refuse, a missing file included, is said of the
2057
+ * mount's files only while the root is still that directory. If it isn't,
2058
+ * the read is refused with the code and errno step 0 gives what is at the
2059
+ * root's path then (rootMoved), as step 5's own check of the root does.
2060
+ *
2061
+ * `inspect` runs last, after the boundary's checks, so a caller's own check
2062
+ * of the file, such as a size limit, never answers for a file the boundary
2063
+ * refuses. The returned handle is the caller's to close.
1650
2064
  */
1651
2065
  async openContainedFile(mount, relativePath, mountPrefixedPath, inspect) {
2066
+ const root = await this.readRoot(mount);
2067
+ try {
2068
+ return await this.openBeneathRoot(mount, root, relativePath, mountPrefixedPath, inspect);
2069
+ }
2070
+ catch (err) {
2071
+ if (err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && !(await root.dir.stillHere())) {
2072
+ throw await this.rootMoved(mount);
2073
+ }
2074
+ throw err;
2075
+ }
2076
+ }
2077
+ /** Steps 1 to 5 of openContainedFile, beneath the root that step 0 checked. */
2078
+ async openBeneathRoot(mount, { real: realMountRoot, dir: rootDir }, relativePath, mountPrefixedPath, inspect) {
1652
2079
  const lexicalPath = resolve(mount.config.path, relativePath);
1653
2080
  const follow = mount.config.followSymlinks === true;
1654
2081
  const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
2082
+ // Before anything resolves the path, so a link's own answer comes first: a
2083
+ // mount that doesn't follow symlinks goes through none among the
2084
+ // directories on the way, wherever it leads. Asked again of the path's
2085
+ // resolution after the open, below.
2086
+ const linkView = { root: mount.config.path, followSymlinks: follow };
2087
+ const onTheWay = await linkOnTheWay(linkView, relativePath, 'parents');
2088
+ if (onTheWay !== null)
2089
+ throw await linkReadError(mount, onTheWay, mountPrefixedPath, this.nestedOf(mount.config.name));
1655
2090
  let fileInfo;
1656
2091
  try {
1657
2092
  fileInfo = await lstat(lexicalPath);
@@ -1669,16 +2104,15 @@ export class WorkspaceModule {
1669
2104
  if (fileInfo.isSymbolicLink() && !follow) {
1670
2105
  throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
1671
2106
  }
1672
- let realMountRoot;
1673
- try {
1674
- realMountRoot = await realpath(mount.config.path);
1675
- }
1676
- catch (err) {
1677
- throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
2107
+ if (!fileInfo.isFile() && !fileInfo.isSymbolicLink()) {
2108
+ // A FIFO, socket or device is never opened.
2109
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'lstat');
1678
2110
  }
1679
2111
  let handle;
1680
2112
  try {
1681
- handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
2113
+ // O_NONBLOCK: a FIFO swapped in since the lstat opens at once instead of
2114
+ // waiting for a writer, and the regular-file check below refuses it.
2115
+ handle = await open(lexicalPath, fsConstants.O_RDONLY | NONBLOCK | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
1682
2116
  }
1683
2117
  catch (err) {
1684
2118
  const code = errnoOf(err);
@@ -1703,9 +2137,8 @@ export class WorkspaceModule {
1703
2137
  throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
1704
2138
  }
1705
2139
  if (!fileStat.isFile()) {
1706
- throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
2140
+ throw new WorkspaceReadError('not_found', `Not a regular file: ${mountPrefixedPath}`, 'fstat');
1707
2141
  }
1708
- inspect?.(fileStat);
1709
2142
  if (!follow && !useNoFollow) {
1710
2143
  let postOpenInfo;
1711
2144
  try {
@@ -1733,9 +2166,17 @@ export class WorkspaceModule {
1733
2166
  }
1734
2167
  throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
1735
2168
  }
2169
+ // A directory swapped for a link since the walk above is caught here, by
2170
+ // what the path resolved to after the open.
2171
+ const resolvedThrough = await linkRefusal(linkView, realMountRoot, relativePath, realFilePath);
2172
+ if (resolvedThrough !== null)
2173
+ throw await linkReadError(mount, resolvedThrough, mountPrefixedPath, this.nestedOf(mount.config.name));
1736
2174
  if (!isContainedPath(realMountRoot, realFilePath)) {
1737
2175
  throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
1738
2176
  }
2177
+ const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name));
2178
+ if (holder)
2179
+ throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath');
1739
2180
  let pathStat;
1740
2181
  try {
1741
2182
  pathStat = await stat(realFilePath);
@@ -1750,6 +2191,16 @@ export class WorkspaceModule {
1750
2191
  if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
1751
2192
  throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
1752
2193
  }
2194
+ // Containment compares paths, so it can't see another directory put at
2195
+ // the root's own path since step 0 (a drive unmounted mid-read): the
2196
+ // root is asked again, as an effect asks it (effects.ts moved()). This
2197
+ // sees the root as it is now, so a root replaced and put back before it
2198
+ // isn't seen (observe.ts's header says why no check can be sure).
2199
+ if (!(await rootDir.stillHere()))
2200
+ throw await this.rootMoved(mount);
2201
+ // The caller's checks of the file, such as a size limit, come after the
2202
+ // boundary's, so they never answer for a file the boundary refuses.
2203
+ inspect?.(fileStat);
1753
2204
  return { handle, fileStat, realFilePath };
1754
2205
  }
1755
2206
  catch (err) {
@@ -1773,11 +2224,20 @@ export class WorkspaceModule {
1773
2224
  * (`truncated`). The reads decide where the file ends, not the size fstat
1774
2225
  * gave at the open, so the bound holds for a file that grows while it's
1775
2226
  * read.
2227
+ * - Once the workspace has recorded the mount root's identity, with the
2228
+ * first evidence it gathers there, a root that is another directory is
2229
+ * an unavailable mount: a replaced directory, a link swapped in for it,
2230
+ * or an unmounted drive's mountpoint. In any mount, recorded or not, a
2231
+ * root that stops being the directory the read checked until the file
2232
+ * has been opened and checked is unavailable too: with ENOENT or
2233
+ * ENOTDIR where no directory is there, as before the read, and with no
2234
+ * errno for another directory. The file's bytes are then read through
2235
+ * the descriptor that was checked.
1776
2236
  *
1777
2237
  * Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
1778
2238
  * mount, lexical traversal, a path in another mount's directory nested in
1779
- * the one named (`other_mount`), unavailable mount, missing file,
1780
- * directory, policy-denied symlink, outside-mount target, and
2239
+ * the one named (`other_mount`), unavailable mount, missing or non-regular
2240
+ * file, directory, policy-denied symlink, outside-mount target, and
1781
2241
  * file-changed-during-read.
1782
2242
  */
1783
2243
  async readFileFromDisk(mountPrefixedPath, options = {}) {
@@ -1787,9 +2247,12 @@ export class WorkspaceModule {
1787
2247
  let mount;
1788
2248
  let relativePath;
1789
2249
  try {
1790
- ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath));
2250
+ ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath));
1791
2251
  }
1792
2252
  catch (err) {
2253
+ // A link's refusal (parseFilePath) carries its own code.
2254
+ if (err instanceof WorkspaceReadError)
2255
+ throw err;
1793
2256
  const message = err instanceof Error ? err.message : String(err);
1794
2257
  const code = err instanceof NestedMountError ? 'other_mount'
1795
2258
  : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
@@ -1828,17 +2291,39 @@ export class WorkspaceModule {
1828
2291
  const p = mountPrefixedPath;
1829
2292
  switch (err.code) {
1830
2293
  case 'mount_unavailable':
1831
- return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
2294
+ // With why, where the root's check says, such as a directory that
2295
+ // isn't the mount's.
2296
+ return new WorkspaceImageReadError('mount_unavailable', err.message);
1832
2297
  case 'directory':
1833
2298
  return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
1834
2299
  case 'symlink':
1835
- return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
2300
+ // The core reader's own words for a link at the path: "Symlinks are not
2301
+ // allowed: <path>".
2302
+ return new WorkspaceImageReadError('symlink', err.message);
2303
+ case 'parent_symlink':
2304
+ // The core reader's own words for a link on its way: which link and
2305
+ // where it leads, with the setting for a link to a directory.
2306
+ return new WorkspaceImageReadError('parent_symlink', err.message);
2307
+ case 'other_mount':
2308
+ // The core reader's own words for a path a link takes into a nested
2309
+ // mount's directory: that mount and the path to use there. (#460's
2310
+ // own other_mount, a path in that directory, is refused at parse,
2311
+ // and so is a link found there before the read.)
2312
+ return new WorkspaceImageReadError('other_mount', err.message);
1836
2313
  case 'escape':
1837
- return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
2314
+ // The core reader's own words: "Symlink escape detected: <path>" for a
2315
+ // followed link that resolves outside the mount, and for a link on the
2316
+ // way that leads outside a mount that doesn't follow links, which link.
2317
+ return new WorkspaceImageReadError('escape', err.message);
1838
2318
  case 'changed':
1839
2319
  return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
1840
2320
  case 'not_found':
1841
2321
  default:
2322
+ // The core reader's own words for a file that isn't regular, and for
2323
+ // a directory on the way it couldn't ask about.
2324
+ if (err.message.startsWith('Not a regular file') || err.stage === 'readlink') {
2325
+ return new WorkspaceImageReadError('not_found', err.message);
2326
+ }
1842
2327
  if (err.message.startsWith('File not found')) {
1843
2328
  return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
1844
2329
  }
@@ -1902,8 +2387,10 @@ export class WorkspaceModule {
1902
2387
  }
1903
2388
  /**
1904
2389
  * Commit a tool's write or delete to the workspace tree, with the branch
1905
- * intent it implies (store origin for a path disk never agreed with; a
1906
- * tombstone for a deletion), and push it to disk when the mount opts in via
2390
+ * intent it implies: store origin for a path disk never agreed with; for a
2391
+ * deletion, a tombstone, unless the evidence holds no agreed disk copy (P
2392
+ * absent, or no P and store origin), when it clears the path's intent and
2393
+ * pushes nothing. It pushes the change to disk when the mount opts in via
1907
2394
  * `autoMaterialize` — required for cross-agent pipelines, since another
1908
2395
  * agent's watcher on the same directory only sees real filesystem events.
1909
2396
  * The push follows materialize's rule: a disk copy that changed since it
@@ -1913,9 +2400,11 @@ export class WorkspaceModule {
1913
2400
  * change reached the workspace but not disk (`refused`): the
1914
2401
  * autoMaterialize contract is that disk is the source of truth for
1915
2402
  * downstream agents. And, apart from it, why a write whose bytes reached
1916
- * disk left the file's mode as disk had it (`modeKept`). The caller holds
1917
- * the mount's turn and has settled the path first (settleBeforeMutation, or
1918
- * a pass over the path, as an edit makes).
2403
+ * disk left the file's mode as disk had it (`modeKept`), and, for a
2404
+ * deletion that cleared a conflict naming a disk file the workspace never
2405
+ * took in, that nothing was unlinked (`note`). The caller holds the mount's
2406
+ * turn and has settled the path first (settleBeforeMutation, or a pass over
2407
+ * the path, as an edit makes).
1919
2408
  */
1920
2409
  async commitToolChange(mount, relativePath, change, onCommitted) {
1921
2410
  const store = this.getStore();
@@ -1923,7 +2412,8 @@ export class WorkspaceModule {
1923
2412
  // The mutation's precondition, enforced here, synchronously with the
1924
2413
  // commit: whatever settleBeforeMutation's pass could or couldn't settle.
1925
2414
  settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
1926
- const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
2415
+ const evidence = this.agreementOrThrow().get(mount.config.name, relativePath);
2416
+ const known = evidence !== undefined;
1927
2417
  if (change.kind === 'set') {
1928
2418
  store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
1929
2419
  // A later intent update or disk push can throw; the tree entry is
@@ -1944,21 +2434,51 @@ export class WorkspaceModule {
1944
2434
  else {
1945
2435
  store.treeRemove(mount.treeStateId, relativePath);
1946
2436
  onCommitted?.();
2437
+ // A deletion whose evidence holds no agreed disk copy (disk last agreed
2438
+ // it had nothing here, or has never agreed with this entry, which the
2439
+ // workspace created) puts the store back at the agreement. Nothing on
2440
+ // disk is the workspace's to unlink, so it pushes nothing, and it
2441
+ // clears the path's whole intent: no tombstone, so it needs no proof of
2442
+ // absence, which a path disk can't show, such as one beneath a symlink
2443
+ // the mount doesn't follow, would never give; and no conflict, which
2444
+ // has nothing left to be about once the workspace has given up its
2445
+ // side. Whatever disk holds there is a sync's to observe, a file the
2446
+ // workspace never saw included. Any other deletion keeps its
2447
+ // tombstone, which guards it: a copy disk agreed with, a push of ours
2448
+ // that may have landed (pending or interrupted), or an entry from
2449
+ // before the evidence.
2450
+ let agreed = true;
2451
+ let note = null;
1947
2452
  intents.update(relativePath, (cur) => {
1948
- const next = { ...cur, tombstone: { hash: change.previousHash } };
1949
- delete next.origin;
1950
- return next;
2453
+ agreed = !(evidence?.kind === 'absent' || (evidence === undefined && cur.origin === 'store'));
2454
+ if (agreed) {
2455
+ const next = { ...cur, tombstone: { hash: change.previousHash } };
2456
+ delete next.origin;
2457
+ return next;
2458
+ }
2459
+ // A conflict it clears may name a file disk held there, which the
2460
+ // workspace never took in: the answer says that file wasn't unlinked,
2461
+ // where the deletion alone would read as having removed it. The
2462
+ // deletion doesn't look at disk, so a file nothing recorded gets no
2463
+ // note.
2464
+ if (cur.conflict?.disk) {
2465
+ note = `nothing was unlinked: when last observed, disk held a file at ${mount.config.name}/${relativePath} ` +
2466
+ `that the workspace never took in (conflict: ${cur.conflict.kind}), and a sync observes whatever disk holds there`;
2467
+ }
2468
+ return {};
1951
2469
  });
2470
+ if (!agreed)
2471
+ return { refused: null, modeKept: null, note };
1952
2472
  }
1953
2473
  if (!this.pushesOnCommit(mount))
1954
- return { refused: null, modeKept: null };
2474
+ return { refused: null, modeKept: null, note: null };
1955
2475
  const pushed = await this.pushUnlocked(mount, [relativePath], {
1956
2476
  applyDeletions: change.kind === 'remove',
1957
2477
  branchId: store.currentBranch().id,
1958
2478
  });
1959
2479
  const refused = pushed.skipped.find((s) => s.path === relativePath);
1960
2480
  if (!refused)
1961
- return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null };
2481
+ return { refused: null, modeKept: pushed.modesKept.find((k) => k.path === relativePath)?.reason ?? null, note: null };
1962
2482
  this.ctx?.pushEvent({
1963
2483
  type: 'workspace:materialize-failed',
1964
2484
  mount: mount.config.name,
@@ -1966,7 +2486,7 @@ export class WorkspaceModule {
1966
2486
  op: change.kind === 'set' ? 'write' : 'delete',
1967
2487
  error: refused.reason,
1968
2488
  });
1969
- return { refused: refused.reason, modeKept: null };
2489
+ return { refused: refused.reason, modeKept: null, note: null };
1970
2490
  }
1971
2491
  // ==========================================================================
1972
2492
  // Lazy Sync
@@ -2036,7 +2556,8 @@ export class WorkspaceModule {
2036
2556
  }
2037
2557
  let content = generatedContent;
2038
2558
  if (content === undefined) {
2039
- const { mount, relativePath } = this.parseFilePath(input.path);
2559
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2560
+ input = { ...input, path };
2040
2561
  const store = this.getStore();
2041
2562
  const found = await this.withMount(mount, async () => {
2042
2563
  const report = await this.ensureSynced(mount, relativePath);
@@ -2123,7 +2644,9 @@ export class WorkspaceModule {
2123
2644
  let mount;
2124
2645
  let relativePath;
2125
2646
  try {
2126
- ({ mount, relativePath } = this.parseFilePath(input.path));
2647
+ let path;
2648
+ ({ mount, relativePath, path } = await this.parseFilePath(input.path));
2649
+ input = { ...input, path };
2127
2650
  }
2128
2651
  catch (err) {
2129
2652
  return {
@@ -2195,7 +2718,8 @@ export class WorkspaceModule {
2195
2718
  if (LONE_SURROGATE.test(input.content)) {
2196
2719
  return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
2197
2720
  }
2198
- const { mount, relativePath } = this.parseFilePath(input.path);
2721
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2722
+ input = { ...input, path };
2199
2723
  if (mount.config.mode === 'read-only') {
2200
2724
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2201
2725
  }
@@ -2238,7 +2762,8 @@ export class WorkspaceModule {
2238
2762
  return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` };
2239
2763
  }
2240
2764
  }
2241
- const { mount, relativePath } = this.parseFilePath(input.path);
2765
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2766
+ input = { ...input, path };
2242
2767
  if (mount.config.mode === 'read-only') {
2243
2768
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2244
2769
  }
@@ -2376,7 +2901,8 @@ export class WorkspaceModule {
2376
2901
  }
2377
2902
  }
2378
2903
  async handleDelete(input) {
2379
- const { mount, relativePath } = this.parseFilePath(input.path);
2904
+ const { mount, relativePath, path } = await this.parseFilePath(input.path);
2905
+ input = { ...input, path };
2380
2906
  if (mount.config.mode === 'read-only') {
2381
2907
  return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2382
2908
  }
@@ -2390,12 +2916,16 @@ export class WorkspaceModule {
2390
2916
  if (!entry) {
2391
2917
  return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2392
2918
  }
2393
- const { refused } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
2919
+ const { refused, note } = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash }, () => { committed = true; });
2394
2920
  if (refused !== null)
2395
2921
  return storedButUnfinished('delete', input.path, { refused });
2396
2922
  // Its whole receipt is what it changed: kept in the stub if the guard
2397
2923
  // withholds the result (agent-framework #277).
2398
- return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
2924
+ return {
2925
+ success: true,
2926
+ data: { path: input.path, deleted: true, ...(note ? { note } : {}) },
2927
+ keepWhenWithheld: ['path', 'deleted', 'note'],
2928
+ };
2399
2929
  });
2400
2930
  }
2401
2931
  catch (error) {
@@ -2415,7 +2945,8 @@ export class WorkspaceModule {
2415
2945
  }));
2416
2946
  return { success: true, data: { mounts } };
2417
2947
  }
2418
- const { mount, relativePath } = this.parsePath(input.path);
2948
+ const { mount, relativePath, path } = await this.parsePath(input.path);
2949
+ input = { ...input, path };
2419
2950
  const recursive = input.recursive === true;
2420
2951
  // Every listing checks the paths it shows against disk first, under the
2421
2952
  // three-way rule, whatever the mount's watch mode.
@@ -2464,7 +2995,7 @@ export class WorkspaceModule {
2464
2995
  const incomplete = [];
2465
2996
  // Search across mounts
2466
2997
  const mountsToSearch = input.path
2467
- ? [this.parsePath(input.path)]
2998
+ ? [await this.parsePath(input.path)]
2468
2999
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2469
3000
  // Every match lies beneath the pattern's literal directory, so the walk
2470
3001
  // starts there: past the walk's cap, a walk of the whole scope can stop
@@ -2511,7 +3042,7 @@ export class WorkspaceModule {
2511
3042
  const contextBefore = input.contextBefore ?? 0;
2512
3043
  const contextAfter = input.contextAfter ?? 0;
2513
3044
  const mountsToSearch = input.path
2514
- ? [this.parsePath(input.path)]
3045
+ ? [await this.parsePath(input.path)]
2515
3046
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2516
3047
  const results = [];
2517
3048
  const skipped = [];
@@ -2557,13 +3088,31 @@ export class WorkspaceModule {
2557
3088
  // on: one selected in between would label its content with the other
2558
3089
  // branch's states. So a pass whose branch is no longer selected runs
2559
3090
  // again, and a branch that keeps changing leaves the scope unsearched.
3091
+ //
3092
+ // A copy held at a path that now leads into a nested mount's directory
3093
+ // through a link is that mount's file too: read refuses it
3094
+ // (parseFilePath), so grep searches none of it and says why. The pass
3095
+ // can't check such a path's disk, so only an unverified one can be
3096
+ // one. Asking awaits disk, so it's done before the branch is
3097
+ // compared, and a branch selected meanwhile runs the pass again.
2560
3098
  let pass = null;
3099
+ let refused = new Map();
2561
3100
  for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
2562
3101
  const next = await this.passUnlocked(mount, fileScope
2563
3102
  ? { kind: 'paths', paths: [relativePath] }
2564
3103
  : { kind: 'dir', dir: start, recursive: true });
2565
- if (next.branchId === undefined || next.branchId === store.currentBranch().id)
3104
+ const asked = new Map();
3105
+ for (const report of next.reports.values()) {
3106
+ if (report.state !== 'unverified')
3107
+ continue;
3108
+ const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`);
3109
+ if (refusal)
3110
+ asked.set(report.path, refusal.message);
3111
+ }
3112
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) {
2566
3113
  pass = next;
3114
+ refused = asked;
3115
+ }
2567
3116
  }
2568
3117
  if (pass === null) {
2569
3118
  incomplete.push({
@@ -2594,6 +3143,11 @@ export class WorkspaceModule {
2594
3143
  // scope it's passed over by design, like the directory.
2595
3144
  if (inOtherMount(report.path, this.othersOf(mount.config.name)))
2596
3145
  continue;
3146
+ const refusal = refused.get(report.path);
3147
+ if (refusal !== undefined) {
3148
+ skipped.push({ file, reason: refusal });
3149
+ continue;
3150
+ }
2597
3151
  // The workspace's version, labelled with the path's state.
2598
3152
  const entry = store.treeGet(mount.treeStateId, report.path);
2599
3153
  const blob = entry ? store.getBlob(entry.blobHash) : null;
@@ -2780,7 +3334,7 @@ export class WorkspaceModule {
2780
3334
  const modesKept = [];
2781
3335
  let explicit = null;
2782
3336
  if (input.path)
2783
- explicit = this.parsePath(input.path);
3337
+ explicit = await this.parsePath(input.path);
2784
3338
  let mountsToMaterialize;
2785
3339
  if (explicit) {
2786
3340
  mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
@@ -2957,7 +3511,7 @@ export class WorkspaceModule {
2957
3511
  // A path names its own mount; otherwise the given mount, or every mount.
2958
3512
  let targets;
2959
3513
  if (input.path) {
2960
- const { mount, relativePath } = this.parsePath(input.path);
3514
+ const { mount, relativePath } = await this.parsePath(input.path);
2961
3515
  targets = [{ name: mount.config.name, mount, relativePath }];
2962
3516
  }
2963
3517
  else if (input.mount) {