@animalabs/agent-framework 0.28.0 → 0.30.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 (289) hide show
  1. package/README.md +88 -25
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +21 -0
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +47 -7
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +3 -12
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/api/server.d.ts.map +1 -1
  14. package/dist/src/api/server.js +7 -3
  15. package/dist/src/api/server.js.map +1 -1
  16. package/dist/src/author-presentation.d.ts +50 -0
  17. package/dist/src/author-presentation.d.ts.map +1 -0
  18. package/dist/src/author-presentation.js +58 -0
  19. package/dist/src/author-presentation.js.map +1 -0
  20. package/dist/src/auto-continue.d.ts +2 -0
  21. package/dist/src/auto-continue.d.ts.map +1 -1
  22. package/dist/src/auto-continue.js +5 -2
  23. package/dist/src/auto-continue.js.map +1 -1
  24. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  25. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  26. package/dist/src/code-execution/isolate/runner.js +7 -5
  27. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  28. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  29. package/dist/src/code-execution/isolate/scripts.js +3 -2
  30. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  31. package/dist/src/dendrite/index.d.ts +6 -0
  32. package/dist/src/dendrite/index.d.ts.map +1 -0
  33. package/dist/src/dendrite/index.js +3 -0
  34. package/dist/src/dendrite/index.js.map +1 -0
  35. package/dist/src/dendrite/presets.d.ts +60 -0
  36. package/dist/src/dendrite/presets.d.ts.map +1 -0
  37. package/dist/src/dendrite/presets.js +117 -0
  38. package/dist/src/dendrite/presets.js.map +1 -0
  39. package/dist/src/dendrite/registry.d.ts +260 -0
  40. package/dist/src/dendrite/registry.d.ts.map +1 -0
  41. package/dist/src/dendrite/registry.js +742 -0
  42. package/dist/src/dendrite/registry.js.map +1 -0
  43. package/dist/src/dendrite/types.d.ts +392 -0
  44. package/dist/src/dendrite/types.d.ts.map +1 -0
  45. package/dist/src/dendrite/types.js +24 -0
  46. package/dist/src/dendrite/types.js.map +1 -0
  47. package/dist/src/framework.d.ts +846 -67
  48. package/dist/src/framework.d.ts.map +1 -1
  49. package/dist/src/framework.js +4213 -924
  50. package/dist/src/framework.js.map +1 -1
  51. package/dist/src/gate/event-gate.d.ts +46 -14
  52. package/dist/src/gate/event-gate.d.ts.map +1 -1
  53. package/dist/src/gate/event-gate.js +143 -53
  54. package/dist/src/gate/event-gate.js.map +1 -1
  55. package/dist/src/gate/index.d.ts +1 -1
  56. package/dist/src/gate/index.d.ts.map +1 -1
  57. package/dist/src/gate/index.js +1 -1
  58. package/dist/src/gate/index.js.map +1 -1
  59. package/dist/src/gate/types.d.ts +12 -2
  60. package/dist/src/gate/types.d.ts.map +1 -1
  61. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  62. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  63. package/dist/src/heartbeat/heartbeat.js +34 -8
  64. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  65. package/dist/src/held-writes.d.ts +3 -3
  66. package/dist/src/held-writes.js +3 -3
  67. package/dist/src/host-presentation.d.ts +174 -0
  68. package/dist/src/host-presentation.d.ts.map +1 -0
  69. package/dist/src/host-presentation.js +565 -0
  70. package/dist/src/host-presentation.js.map +1 -0
  71. package/dist/src/index.d.ts +7 -4
  72. package/dist/src/index.d.ts.map +1 -1
  73. package/dist/src/index.js +7 -2
  74. package/dist/src/index.js.map +1 -1
  75. package/dist/src/line-reader.d.ts +4 -0
  76. package/dist/src/line-reader.d.ts.map +1 -0
  77. package/dist/src/line-reader.js +59 -0
  78. package/dist/src/line-reader.js.map +1 -0
  79. package/dist/src/mcpl/channel-registry.d.ts +118 -15
  80. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  81. package/dist/src/mcpl/channel-registry.js +266 -110
  82. package/dist/src/mcpl/channel-registry.js.map +1 -1
  83. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  84. package/dist/src/mcpl/conversation-router.js +2 -1
  85. package/dist/src/mcpl/conversation-router.js.map +1 -1
  86. package/dist/src/mcpl/convert-block.d.ts +1 -0
  87. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  88. package/dist/src/mcpl/convert-block.js +16 -4
  89. package/dist/src/mcpl/convert-block.js.map +1 -1
  90. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  91. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  92. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  93. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  94. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  95. package/dist/src/mcpl/inbound-source.js +3 -1
  96. package/dist/src/mcpl/inbound-source.js.map +1 -1
  97. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/modern-connection.js +11 -7
  99. package/dist/src/mcpl/modern-connection.js.map +1 -1
  100. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  101. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  102. package/dist/src/mcpl/protocol-family.js +25 -3
  103. package/dist/src/mcpl/protocol-family.js.map +1 -1
  104. package/dist/src/mcpl/push-coalescer.d.ts +9 -0
  105. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  106. package/dist/src/mcpl/push-coalescer.js +21 -0
  107. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  108. package/dist/src/mcpl/push-handler.js +2 -2
  109. package/dist/src/mcpl/push-handler.js.map +1 -1
  110. package/dist/src/mcpl/references.d.ts +12 -40
  111. package/dist/src/mcpl/references.d.ts.map +1 -1
  112. package/dist/src/mcpl/references.js +29 -23
  113. package/dist/src/mcpl/references.js.map +1 -1
  114. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  115. package/dist/src/mcpl/server-connection.js +20 -20
  116. package/dist/src/mcpl/server-connection.js.map +1 -1
  117. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  118. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  119. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  120. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  121. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  122. package/dist/src/mcpl/tool-presence.js +18 -17
  123. package/dist/src/mcpl/tool-presence.js.map +1 -1
  124. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  125. package/dist/src/mcpl/tool-signature.js +17 -16
  126. package/dist/src/mcpl/tool-signature.js.map +1 -1
  127. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  128. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  129. package/dist/src/mcpl/toolset-host.js +16 -6
  130. package/dist/src/mcpl/toolset-host.js.map +1 -1
  131. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  132. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  133. package/dist/src/mcpl/toolsets-module.js +187 -50
  134. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  135. package/dist/src/mcpl/transport.d.ts +1 -1
  136. package/dist/src/mcpl/transport.d.ts.map +1 -1
  137. package/dist/src/mcpl/transport.js +16 -16
  138. package/dist/src/mcpl/transport.js.map +1 -1
  139. package/dist/src/mcpl/types.d.ts +28 -12
  140. package/dist/src/mcpl/types.d.ts.map +1 -1
  141. package/dist/src/mcpl/types.js.map +1 -1
  142. package/dist/src/module-registry.d.ts +6 -7
  143. package/dist/src/module-registry.d.ts.map +1 -1
  144. package/dist/src/module-registry.js +6 -7
  145. package/dist/src/module-registry.js.map +1 -1
  146. package/dist/src/modules/api/index.d.ts.map +1 -1
  147. package/dist/src/modules/api/index.js +4 -2
  148. package/dist/src/modules/api/index.js.map +1 -1
  149. package/dist/src/modules/discord/index.d.ts.map +1 -1
  150. package/dist/src/modules/discord/index.js +33 -25
  151. package/dist/src/modules/discord/index.js.map +1 -1
  152. package/dist/src/modules/history/index.js +8 -6
  153. package/dist/src/modules/history/index.js.map +1 -1
  154. package/dist/src/modules/sender-name.d.ts +7 -0
  155. package/dist/src/modules/sender-name.d.ts.map +1 -0
  156. package/dist/src/modules/sender-name.js +10 -0
  157. package/dist/src/modules/sender-name.js.map +1 -0
  158. package/dist/src/modules/workspace/answers.d.ts +5 -1
  159. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/answers.js +16 -1
  161. package/dist/src/modules/workspace/answers.js.map +1 -1
  162. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  163. package/dist/src/modules/workspace/direct-write.js +6 -4
  164. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  165. package/dist/src/modules/workspace/effects.d.ts +4 -2
  166. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  167. package/dist/src/modules/workspace/effects.js +50 -40
  168. package/dist/src/modules/workspace/effects.js.map +1 -1
  169. package/dist/src/modules/workspace/index.d.ts +1 -0
  170. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  171. package/dist/src/modules/workspace/index.js +66 -36
  172. package/dist/src/modules/workspace/index.js.map +1 -1
  173. package/dist/src/modules/workspace/observe.d.ts +51 -20
  174. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  175. package/dist/src/modules/workspace/observe.js +120 -82
  176. package/dist/src/modules/workspace/observe.js.map +1 -1
  177. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  178. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  179. package/dist/src/modules/workspace/reconcile.js +133 -37
  180. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  181. package/dist/src/notice-tags.d.ts +81 -0
  182. package/dist/src/notice-tags.d.ts.map +1 -0
  183. package/dist/src/notice-tags.js +85 -0
  184. package/dist/src/notice-tags.js.map +1 -0
  185. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  186. package/dist/src/reasoning-loss-notice.js +5 -3
  187. package/dist/src/reasoning-loss-notice.js.map +1 -1
  188. package/dist/src/silent-heartbeat.d.ts +5 -55
  189. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  190. package/dist/src/silent-heartbeat.js +63 -7
  191. package/dist/src/silent-heartbeat.js.map +1 -1
  192. package/dist/src/stored-injections.d.ts +4 -0
  193. package/dist/src/stored-injections.d.ts.map +1 -1
  194. package/dist/src/stored-injections.js +6 -0
  195. package/dist/src/stored-injections.js.map +1 -1
  196. package/dist/src/supplied-text.d.ts +35 -0
  197. package/dist/src/supplied-text.d.ts.map +1 -0
  198. package/dist/src/supplied-text.js +141 -0
  199. package/dist/src/supplied-text.js.map +1 -0
  200. package/dist/src/tool-args.d.ts.map +1 -1
  201. package/dist/src/tool-args.js +9 -8
  202. package/dist/src/tool-args.js.map +1 -1
  203. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  204. package/dist/src/tool-image-ledger.js +2 -1
  205. package/dist/src/tool-image-ledger.js.map +1 -1
  206. package/dist/src/tool-presentation.d.ts.map +1 -1
  207. package/dist/src/tool-presentation.js +5 -4
  208. package/dist/src/tool-presentation.js.map +1 -1
  209. package/dist/src/tune-out/coordinator.d.ts +382 -11
  210. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  211. package/dist/src/tune-out/coordinator.js +1100 -119
  212. package/dist/src/tune-out/coordinator.js.map +1 -1
  213. package/dist/src/tune-out/tools.d.ts +101 -5
  214. package/dist/src/tune-out/tools.d.ts.map +1 -1
  215. package/dist/src/tune-out/tools.js +55 -18
  216. package/dist/src/tune-out/tools.js.map +1 -1
  217. package/dist/src/types/agent.d.ts +48 -9
  218. package/dist/src/types/agent.d.ts.map +1 -1
  219. package/dist/src/types/framework.d.ts +142 -0
  220. package/dist/src/types/framework.d.ts.map +1 -1
  221. package/dist/src/types/module.d.ts +22 -11
  222. package/dist/src/types/module.d.ts.map +1 -1
  223. package/dist/src/types/trace.d.ts +136 -0
  224. package/dist/src/types/trace.d.ts.map +1 -1
  225. package/package.json +3 -3
  226. package/src/admission/admit.ts +113 -0
  227. package/src/agent.ts +52 -7
  228. package/src/api/mcp-server-core.ts +3 -14
  229. package/src/api/server.ts +7 -3
  230. package/src/author-presentation.ts +81 -0
  231. package/src/auto-continue.ts +8 -2
  232. package/src/code-execution/isolate/runner.ts +7 -5
  233. package/src/code-execution/isolate/scripts.ts +3 -2
  234. package/src/dendrite/index.ts +32 -0
  235. package/src/dendrite/presets.ts +149 -0
  236. package/src/dendrite/registry.ts +808 -0
  237. package/src/dendrite/types.ts +397 -0
  238. package/src/framework.ts +4362 -852
  239. package/src/gate/event-gate.ts +159 -52
  240. package/src/gate/index.ts +1 -1
  241. package/src/gate/types.ts +12 -2
  242. package/src/heartbeat/heartbeat.ts +48 -9
  243. package/src/held-writes.ts +3 -3
  244. package/src/host-presentation.ts +612 -0
  245. package/src/index.ts +17 -4
  246. package/src/line-reader.ts +58 -0
  247. package/src/mcpl/channel-registry.ts +306 -111
  248. package/src/mcpl/conversation-router.ts +2 -1
  249. package/src/mcpl/convert-block.ts +17 -4
  250. package/src/mcpl/hook-orchestrator.ts +4 -3
  251. package/src/mcpl/inbound-source.ts +3 -1
  252. package/src/mcpl/modern-connection.ts +11 -7
  253. package/src/mcpl/protocol-family.ts +24 -3
  254. package/src/mcpl/push-coalescer.ts +19 -0
  255. package/src/mcpl/push-handler.ts +2 -2
  256. package/src/mcpl/references.ts +31 -23
  257. package/src/mcpl/server-connection.ts +20 -18
  258. package/src/mcpl/skip-reply-wake.ts +35 -0
  259. package/src/mcpl/tool-presence.ts +18 -17
  260. package/src/mcpl/tool-signature.ts +17 -16
  261. package/src/mcpl/toolset-host.ts +18 -6
  262. package/src/mcpl/toolsets-module.ts +149 -50
  263. package/src/mcpl/transport.ts +18 -14
  264. package/src/mcpl/types.ts +29 -12
  265. package/src/module-registry.ts +6 -7
  266. package/src/modules/api/index.ts +4 -2
  267. package/src/modules/discord/index.ts +37 -25
  268. package/src/modules/history/index.ts +8 -6
  269. package/src/modules/sender-name.ts +9 -0
  270. package/src/modules/workspace/answers.ts +20 -2
  271. package/src/modules/workspace/direct-write.ts +4 -3
  272. package/src/modules/workspace/effects.ts +50 -40
  273. package/src/modules/workspace/index.ts +58 -34
  274. package/src/modules/workspace/observe.ts +139 -88
  275. package/src/modules/workspace/reconcile.ts +159 -36
  276. package/src/notice-tags.ts +86 -0
  277. package/src/reasoning-loss-notice.ts +5 -3
  278. package/src/silent-heartbeat.ts +11 -7
  279. package/src/stored-injections.ts +7 -0
  280. package/src/supplied-text.ts +154 -0
  281. package/src/tool-args.ts +9 -8
  282. package/src/tool-image-ledger.ts +2 -1
  283. package/src/tool-presentation.ts +5 -4
  284. package/src/tune-out/coordinator.ts +1252 -123
  285. package/src/tune-out/tools.ts +152 -11
  286. package/src/types/agent.ts +51 -9
  287. package/src/types/framework.ts +146 -0
  288. package/src/types/module.ts +23 -11
  289. package/src/types/trace.ts +150 -0
@@ -47,7 +47,7 @@ import { lstat, mkdir, open, readdir, readlink, realpath, stat, statfs } from 'n
47
47
  import type { FileHandle } from 'node:fs/promises';
48
48
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
49
49
  import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
50
- import { errnoCode, systemWords } from './answers.js';
50
+ import { OUTSIDE_STEP, UNFOLLOWED_STEP, errnoClause, errnoCode, systemWords } from './answers.js';
51
51
 
52
52
  export interface MountView {
53
53
  /**
@@ -115,7 +115,17 @@ export type Observation =
115
115
  | { kind: 'absent' }
116
116
  /** `mode`: the file's permission bits (`st_mode & 0o777`). */
117
117
  | { kind: 'file'; size: number; mtimeMs: number; mode: number; fp: StatFingerprint }
118
- | { kind: 'unobserved'; reason: string };
118
+ | ({ kind: 'unobserved' } & NotShown);
119
+
120
+ export interface Cause {
121
+ errno?: string;
122
+ again?: true;
123
+ ownStep?: true;
124
+ }
125
+
126
+ export interface NotShown extends Cause {
127
+ reason: string;
128
+ }
119
129
 
120
130
  export interface ReadResult {
121
131
  kind: 'read';
@@ -157,13 +167,21 @@ export const ROOT_REPLACED =
157
167
  'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' +
158
168
  'reconnect it; if it was replaced on purpose, sync the mount with acceptRoot to take it as it is now';
159
169
  export const CHANGED = 'it changed while it was observed';
160
- const CHANGED_WALK = 'the directory changed while the walk ran';
161
- const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
170
+ export const CHANGED_WALK = 'the directory changed while the walk ran';
171
+ export const FILE_CHANGED = 'the file changed while it was read';
172
+ export const RESOLVES_OUTSIDE = 'the directory resolves outside the mount';
173
+ export const LINK_TO_DIRECTORY = 'a symlink to a directory, not walked';
174
+ export const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
162
175
 
163
176
  export function errno(err: unknown): string | undefined {
164
177
  return (err as { code?: string } | null)?.code;
165
178
  }
166
179
 
180
+ function failedOn(what: string, err: unknown): NotShown {
181
+ const code = errno(err);
182
+ return { reason: `${what} (${systemWords(code, err)})`, ...(code !== undefined ? { errno: code } : {}) };
183
+ }
184
+
167
185
  export function contained(rootReal: string, candidate: string): boolean {
168
186
  return candidate === rootReal || candidate.startsWith(rootReal.endsWith(sep) ? rootReal : rootReal + sep);
169
187
  }
@@ -265,7 +283,9 @@ export async function linkRefusal(
265
283
  /**
266
284
  * Where a symlink the walk found leads, for its refusal: to a path inside
267
285
  * the mount (mount-relative, as disk spells it, and whether it's a
268
- * directory), outside the mount, or nowhere it can be resolved to. One
286
+ * directory), outside the mount, or nowhere (nothing is there, or the links
287
+ * loop); null where the realpath fails otherwise, as on a directory this
288
+ * process can't search, so where it leads isn't known. One
269
289
  * realpath, one stat and the respelling, made only when refusing. realpath
270
290
  * keeps the spelling the link's target gives, which on a disk that folds
271
291
  * names need not be disk's, so the target is respelled before anything
@@ -273,9 +293,14 @@ export async function linkRefusal(
273
293
  */
274
294
  export type LinkLeads = { kind: 'inside'; target: string; directory: boolean } | { kind: 'outside' } | { kind: 'nowhere' };
275
295
 
276
- export async function linkLeads(root: string, rootReal: string, link: string): Promise<LinkLeads> {
277
- const leads = await realpath(join(root, link)).catch(() => null);
278
- if (leads === null) return { kind: 'nowhere' };
296
+ export async function linkLeads(root: string, rootReal: string, link: string): Promise<LinkLeads | null> {
297
+ let leads: string;
298
+ try {
299
+ leads = await realpath(join(root, link));
300
+ } catch (err) {
301
+ const code = errno(err);
302
+ return code === 'ENOENT' || code === 'ENOTDIR' || code === 'ELOOP' ? { kind: 'nowhere' } : null;
303
+ }
279
304
  if (!contained(rootReal, leads)) return { kind: 'outside' };
280
305
  const directory = await stat(leads).then((info) => info.isDirectory(), () => false);
281
306
  return { kind: 'inside', target: await diskSpelling(rootReal, relative(rootReal, leads).split(sep).join('/')), directory };
@@ -397,16 +422,23 @@ export async function coversLanding(view: Pick<MountView, 'include'>, rootReal:
397
422
 
398
423
  /**
399
424
  * Why a path is refused for what linkOnTheWay found, given where a link
400
- * leads (linkLeads). For a link, the link is the subject, with where it
401
- * leads: for a link inside the mount, its target, and for a link to a
402
- * directory there, the setting the mount's host can change (the link stands
403
- * among the path's directories, so following a link to a file can't make the
404
- * path beneath it exist); for one leading outside the mount, that it does;
405
- * for one that can't be resolved, nothing more. A target in a nested mount's
406
- * directory (`nested`) is that mount's: the refusal names the mount and the
407
- * path to use there in place of the link, and offers no setting, since
408
- * following the link would only reach into another mount's directory. For a
409
- * directory the walk couldn't ask, which one, and the answer it got. Paths
425
+ * leads (linkLeads), and the way on. For a link, the link is the subject,
426
+ * with where it leads: for a link inside the mount, its target, as the path
427
+ * to use in its place, and for a link to a directory there, the setting
428
+ * whoever runs the host can change too; for a link to something that isn't
429
+ * a directory, that nothing lies beneath it (the link stands among the
430
+ * path's directories, so following it can't make the path beneath it
431
+ * exist); for one leading outside the mount, that it does, and that
432
+ * whoever runs the host can mount what it leads to, or a directory that
433
+ * holds it; for one leading nowhere, that whoever runs the host can remove it or make
434
+ * it lead to what it stood for; for one whose target the refusal doesn't
435
+ * tell, what whoever runs the host can do wherever it leads
436
+ * (UNFOLLOWED_STEP).
437
+ * A target in a nested mount's directory (`nested`) is that mount's: the
438
+ * refusal names the mount and the path to use there in place of the link,
439
+ * and offers no setting, since following the link would only reach into
440
+ * another mount's directory. For a directory the walk couldn't ask, which
441
+ * one, the answer it got, and the errno's way on (errnoClause). Paths
410
442
  * are spelled as the tools take them, from the mount's name. On a mount with
411
443
  * an include, an inside target is named only where a tool could name it: a
412
444
  * file the include covers, or a directory, which a listing names whatever
@@ -417,23 +449,33 @@ export async function coversLanding(view: Pick<MountView, 'include'>, rootReal:
417
449
  */
418
450
  export function linkWords(name: string, found: LinkFound, leads: LinkLeads | null, nested: readonly NestedMount[] = [], include?: readonly string[]): string {
419
451
  const spell = (rel: string) => `"${rel === '' ? name : `${name}/${rel}`}"`;
420
- if (!('link' in found)) return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${systemWords(errnoCode({ code: found.code }), found.code)})`;
452
+ if (!('link' in found)) {
453
+ const code = errnoCode({ code: found.code });
454
+ return `${UNSURE_PREFIX}${name}/${found.unsure}" is a symlink (${systemWords(code, found.code)}); ${errnoClause(code, 'calling again', 'calling again then gets past it')}`;
455
+ }
421
456
  const link = `${spell(found.link)} is a symlink`;
422
- if (leads === null || leads.kind === 'nowhere') return `${link}, which this mount does not follow`;
423
- if (leads.kind === 'outside') return `${link} leading outside the mount, which this mount does not follow`;
457
+ const unknown = `${link}, which this mount does not follow; ${UNFOLLOWED_STEP}`;
458
+ if (leads === null) return unknown;
459
+ if (leads.kind === 'nowhere') {
460
+ return `${link} that leads nowhere, which this mount does not follow; whoever runs this host can remove it or make it lead to what it stood for`;
461
+ }
462
+ if (leads.kind === 'outside') {
463
+ return `${link} leading outside the mount, which this mount does not follow; followed or not, ${OUTSIDE_STEP}`;
464
+ }
424
465
  const holder = nestedHolder(leads.target, nested);
425
466
  if (holder) {
426
- if (!namesHolder(leads, nested)) return `${link}, which this mount does not follow`;
467
+ if (!namesHolder(leads, nested)) return unknown;
427
468
  const rest = restBeneath(leads.target, holder);
428
469
  const guess = nestedGuess(name, leads.target.split('/'), holder);
429
470
  return `${link} to ${spell(leads.target)}, which this mount does not follow; it ${guess === undefined ? 'lies' : 'may lie'} in mount ` +
430
471
  `"${holder.name}", nested in mount "${name}": ${nestedStep({ name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name, guess }, { after: ` in place of ${spell(found.link)}` })}`;
431
472
  }
432
- if (!leads.directory && !insideMount(leads.target, include)) return `${link}, which this mount does not follow`;
433
- // Following it serves only what the include covers where it leads (judgedAt).
434
- const setting = !leads.directory ? '' : include === undefined ? "; the mount's host can set followSymlinks: true"
435
- : "; the mount's host can set followSymlinks: true, for what the include covers where it leads";
436
- return `${link} to ${spell(leads.target)}, which this mount does not follow${setting}`;
473
+ if (!leads.directory && !insideMount(leads.target, include)) return unknown;
474
+ if (!leads.directory) return `${link} to ${spell(leads.target)}, which this mount does not follow; that isn't a directory, so nothing lies beneath it`;
475
+ // Either way serves only what the include covers where it leads (judgedAt).
476
+ const covered = include === undefined ? '' : '; either way, the mount reaches only what its include covers there';
477
+ return `${link} to ${spell(leads.target)}, which this mount does not follow; use ${spell(leads.target)} in place of ${spell(found.link)}, ` +
478
+ `or whoever runs this host can set followSymlinks: true on mount "${name}"${covered}`;
437
479
  }
438
480
 
439
481
  /**
@@ -450,8 +492,8 @@ export async function shownLeads(root: string, rootReal: string, found: LinkFoun
450
492
  }
451
493
 
452
494
  /** Why a path is refused for what linkOnTheWay found (linkWords, with where the link leads, as shownLeads tells it). */
453
- export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested' | 'include'>, rootReal: string, found: LinkFound): Promise<string> {
454
- return linkWords(view.name, found, await shownLeads(view.root, rootReal, found, view.include), view.nested, view.include);
495
+ export async function linkReason(view: Pick<MountView, 'root' | 'name' | 'nested' | 'include'>, rootReal: string, found: LinkFound): Promise<NotShown> {
496
+ return { reason: linkWords(view.name, found, await shownLeads(view.root, rootReal, found, view.include), view.nested, view.include), ownStep: true };
455
497
  }
456
498
 
457
499
  export function realSegments(path: string): string[] {
@@ -963,7 +1005,7 @@ export type DirCheck =
963
1005
  */
964
1006
  | { kind: 'absent'; by: string }
965
1007
  /** Unproven either way; `outside` when it resolves outside the mount, `errno` when a system call failed. */
966
- | { kind: 'unobserved'; reason: string; outside?: true; errno?: string };
1008
+ | ({ kind: 'unobserved'; outside?: true } & NotShown);
967
1009
 
968
1010
  /** Errors meaning a directory can't be synced on this filesystem, not that syncing it failed. */
969
1011
  const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
@@ -1070,13 +1112,13 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
1070
1112
  // A link on the way that can't be resolved, such as a loop, is still a
1071
1113
  // link this mount doesn't follow.
1072
1114
  const found = rel === '' ? null : await linkOnTheWay(view, rel);
1073
- if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1074
- return { kind: 'unobserved', reason: `cannot resolve the directory (${systemWords(code, err)})`, ...(code !== undefined ? { errno: code } : {}) };
1115
+ if (found !== null) return { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1116
+ return { kind: 'unobserved', ...failedOn('cannot resolve the directory', err) };
1075
1117
  }
1076
1118
  // A mount that doesn't follow symlinks goes through none on the way to a
1077
1119
  // directory, inside the mount or out of it.
1078
1120
  const found = await linkRefusal(view, rootReal, rel, real);
1079
- if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1121
+ if (found !== null) return { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1080
1122
  if (rel === '') {
1081
1123
  // The root is whatever the configured path names, and it must still be
1082
1124
  // the root the pass was given.
@@ -1091,7 +1133,7 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
1091
1133
  const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1092
1134
  if (holder) {
1093
1135
  const directory = holder.judgedByInclude && await stat(real).then((info) => info.isDirectory(), () => false);
1094
- return holder.names(directory) ? { kind: 'unobserved', reason: leadsInto(view.name, rel, holder) } : { kind: 'missing' };
1136
+ return holder.names(directory) ? { kind: 'unobserved', reason: leadsInto(view.name, rel, holder), ownStep: true } : { kind: 'missing' };
1095
1137
  }
1096
1138
  }
1097
1139
  const checked = await checkedAt(view, rel, real);
@@ -1111,7 +1153,7 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
1111
1153
  * The mount root as it is now: its identity if it is available (a directory,
1112
1154
  * the one the pass was given, and the one disk last agreed under), else why not.
1113
1155
  */
1114
- export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string }> {
1156
+ export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string; errno?: string }> {
1115
1157
  const found = await checkRoot(view, rootReal);
1116
1158
  return 'dir' in found ? { identity: found.dir.identity() } : found;
1117
1159
  }
@@ -1120,7 +1162,10 @@ export async function locateRoot(view: MountView, rootReal: string): Promise<{ i
1120
1162
  export async function checkRoot(view: MountView, rootReal: string): Promise<{ dir: CheckedDir } | { reason: string; errno?: string }> {
1121
1163
  const found = await locateDir(view, rootReal, '');
1122
1164
  if (found.kind === 'checked') return { dir: found.dir };
1123
- return found.kind === 'unobserved' ? { reason: found.reason, ...(found.errno !== undefined ? { errno: found.errno } : {}) } : { reason: ROOT_UNAVAILABLE };
1165
+ const reason = found.kind === 'unobserved' ? found.reason : ROOT_UNAVAILABLE;
1166
+ // What is at the root's path now says whether the directory is gone from it.
1167
+ const code = found.kind === 'unobserved' && found.errno !== undefined ? found.errno : reason === ROOT_UNAVAILABLE ? await rootAbsence(view.root) : undefined;
1168
+ return { reason, ...(code !== undefined ? { errno: code } : {}) };
1124
1169
  }
1125
1170
 
1126
1171
  /**
@@ -1135,7 +1180,7 @@ export async function checkDir(view: MountView, rootReal: string, rel: string, o
1135
1180
  if (found.kind !== 'missing') return found;
1136
1181
  if (opts.create) return createDir(view, rootReal, rel);
1137
1182
  const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
1138
- return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
1183
+ return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent', by: proof.by };
1139
1184
  }
1140
1185
 
1141
1186
  async function createDir(view: MountView, rootReal: string, rel: string): Promise<DirCheck> {
@@ -1156,12 +1201,12 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
1156
1201
  // Nothing is made in a nested mount's directory, its root included where
1157
1202
  // it isn't made yet, by a path that reaches it through a link.
1158
1203
  const holder = await nestedAtReal(base.at(parts[i]!), view.nested, { name: view.name, rootReal });
1159
- if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder) };
1204
+ if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder), ownStep: true };
1160
1205
  try {
1161
1206
  await mkdir(base.at(parts[i]!));
1162
1207
  } catch (err) {
1163
1208
  const code = errno(err);
1164
- if (code !== 'EEXIST') return { kind: 'unobserved', reason: `cannot create the directory ${childRel} (${systemWords(code, err)})`, ...(code !== undefined ? { errno: code } : {}) };
1209
+ if (code !== 'EEXIST') return { kind: 'unobserved', ...failedOn(`cannot create the directory ${childRel}`, err) };
1165
1210
  }
1166
1211
  // Made, or there already (made meanwhile, or not a directory): it must
1167
1212
  // be a real directory now, never a symlink or file in its place.
@@ -1170,7 +1215,7 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
1170
1215
  // A symlink in the way, such as one that leads nowhere, on a mount that
1171
1216
  // doesn't follow symlinks, is named as one.
1172
1217
  const there = view.followSymlinks ? null : await lstat(base.at(parts[i]!)).catch(() => null);
1173
- if (there?.isSymbolicLink()) return { kind: 'unobserved', reason: await linkReason(view, rootReal, { link: childRel }) };
1218
+ if (there?.isSymbolicLink()) return { kind: 'unobserved', ...(await linkReason(view, rootReal, { link: childRel })) };
1174
1219
  return { kind: 'unobserved', reason: NOT_A_DIRECTORY };
1175
1220
  }
1176
1221
  base = checked;
@@ -1193,14 +1238,14 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis
1193
1238
  * Returns the directory whose entries prove it, or why absence couldn't be
1194
1239
  * shown.
1195
1240
  */
1196
- export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | string> {
1241
+ export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | NotShown> {
1197
1242
  const root = await locateDir(view, rootReal, '');
1198
- if (root.kind !== 'checked') return ROOT_UNAVAILABLE;
1243
+ if (root.kind !== 'checked') return { reason: ROOT_UNAVAILABLE };
1199
1244
  let cur: CheckedDir = root.dir;
1200
1245
  /** Every directory the walk went through, the root first. */
1201
1246
  const walked: CheckedDir[] = [cur];
1202
- const proven = async (): Promise<{ by: string } | string> => {
1203
- for (const dir of walked) if (!(await dir.stillHere())) return CHANGED;
1247
+ const proven = async (): Promise<{ by: string } | NotShown> => {
1248
+ for (const dir of walked) if (!(await dir.stillHere())) return { reason: CHANGED };
1204
1249
  return { by: cur.rel };
1205
1250
  };
1206
1251
  const parts = rel.split('/');
@@ -1213,41 +1258,44 @@ export async function confirmAbsent(view: MountView, rootReal: string, rel: stri
1213
1258
  info = await lstat(cur.at(name), { bigint: true });
1214
1259
  } catch (err) {
1215
1260
  const code = errno(err);
1216
- if (code === 'ENOTDIR') return CHANGED;
1217
- if (code !== 'ENOENT') return `cannot stat (${systemWords(code, err)})`;
1261
+ if (code === 'ENOTDIR') return { reason: CHANGED };
1262
+ if (code !== 'ENOENT') return failedOn('cannot stat', err);
1218
1263
  // Absent from a directory that, like each above it, is still the one its path names.
1219
1264
  return proven();
1220
1265
  }
1221
1266
  if (info.isDirectory()) {
1222
- if (last) return CHANGED; // present after all
1267
+ if (last) return { reason: CHANGED }; // present after all
1223
1268
  cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, info.birthtimeNs);
1224
1269
  walked.push(cur);
1225
1270
  continue;
1226
1271
  }
1227
1272
  if (info.isSymbolicLink()) {
1228
- if (last && target === 'entry') return CHANGED; // present: the link itself
1273
+ if (last && target === 'entry') return { reason: CHANGED }; // present: the link itself
1229
1274
  // A mount that doesn't follow symlinks goes through none on the way.
1230
1275
  if (!view.followSymlinks) return linkReason(view, rootReal, { link: childRel });
1231
1276
  const located = await locateDir(view, rootReal, childRel);
1232
- if (located.kind !== 'checked') return BENEATH_SYMLINK;
1233
- if (last) return CHANGED; // a directory there after all
1277
+ if (located.kind === 'unobserved' && located.outside !== true) {
1278
+ return { reason: located.reason, ...(located.errno !== undefined ? { errno: located.errno } : {}), ...(located.ownStep === true ? { ownStep: true } : {}) };
1279
+ }
1280
+ if (located.kind !== 'checked') return { reason: BENEATH_SYMLINK };
1281
+ if (last) return { reason: CHANGED }; // a directory there after all
1234
1282
  cur = located.dir;
1235
1283
  walked.push(cur);
1236
1284
  continue;
1237
1285
  }
1238
- if (last && target === 'entry') return CHANGED; // present after all
1286
+ if (last && target === 'entry') return { reason: CHANGED }; // present after all
1239
1287
  // A non-directory: nothing can exist beneath it, while it is still this
1240
1288
  // same one, in the directory still at its path.
1241
1289
  const again = await lstat(cur.at(name), { bigint: true }).catch(() => null);
1242
- if (again === null || again.isDirectory() || again.isSymbolicLink() || again.dev !== info.dev || again.ino !== info.ino) return CHANGED;
1290
+ if (again === null || again.isDirectory() || again.isSymbolicLink() || again.dev !== info.dev || again.ino !== info.ino) return { reason: CHANGED };
1243
1291
  return proven();
1244
1292
  }
1245
- return CHANGED;
1293
+ return { reason: CHANGED };
1246
1294
  }
1247
1295
 
1248
1296
  async function absence(view: MountView, rootReal: string, rel: string): Promise<Exclude<Observation, { kind: 'file' }>> {
1249
1297
  const proof = await confirmAbsent(view, rootReal, rel);
1250
- return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent' };
1298
+ return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent' };
1251
1299
  }
1252
1300
 
1253
1301
  // ===========================================================================
@@ -1323,26 +1371,26 @@ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
1323
1371
  * final component — which on a mount that doesn't follow symlinks is any
1324
1372
  * symlink on the way.
1325
1373
  */
1326
- async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<{ inside: string } | 'gone' | { reason: string }> {
1374
+ async function parentPlacement(view: MountView, rootReal: string, relativePath: string): Promise<{ inside: string } | 'gone' | NotShown> {
1327
1375
  try {
1328
1376
  const parent = await realpath(dirname(join(view.root, relativePath)));
1329
1377
  const found = await linkRefusal(view, rootReal, relativePath, parent, 'parents');
1330
- if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1378
+ if (found !== null) return linkReason(view, rootReal, found);
1331
1379
  if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT };
1332
1380
  const holder = await nestedAtReal(parent, view.nested, { name: view.name, rootReal });
1333
1381
  if (!holder) return { inside: parent };
1334
1382
  // A place there that the nested mount's include leaves out is no mount's,
1335
1383
  // and holds nothing beneath it, as beneath any file (namesNestedPlace).
1336
1384
  const directory = holder.judgedByInclude && await stat(parent).then((info) => info.isDirectory(), () => false);
1337
- return holder.names(directory) ? { reason: leadsInto(view.name, parentOf(relativePath), holder) } : 'gone';
1385
+ return holder.names(directory) ? { reason: leadsInto(view.name, parentOf(relativePath), holder), ownStep: true } : 'gone';
1338
1386
  } catch (err) {
1339
1387
  const code = errno(err);
1340
1388
  if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
1341
1389
  // A link on the way that can't be resolved, such as a loop, is still a
1342
1390
  // link this mount doesn't follow.
1343
1391
  const found = await linkOnTheWay(view, relativePath, 'parents');
1344
- if (found !== null) return { reason: await linkReason(view, rootReal, found) };
1345
- return { reason: `cannot resolve the parent directory (${systemWords(code, err)})` };
1392
+ if (found !== null) return linkReason(view, rootReal, found);
1393
+ return failedOn('cannot resolve the parent directory', err);
1346
1394
  }
1347
1395
  }
1348
1396
 
@@ -1356,14 +1404,14 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1356
1404
  const lexical = join(view.root, relativePath);
1357
1405
  const placement = await parentPlacement(view, rootReal, relativePath);
1358
1406
  if (placement === 'gone') return absence(view, rootReal, relativePath);
1359
- if ('reason' in placement) return { kind: 'unobserved', reason: placement.reason };
1407
+ if ('reason' in placement) return { kind: 'unobserved', ...placement };
1360
1408
  let info;
1361
1409
  try {
1362
1410
  info = await lstat(lexical, { bigint: true });
1363
1411
  } catch (err) {
1364
1412
  const code = errno(err);
1365
1413
  if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
1366
- return { kind: 'unobserved', reason: `cannot stat (${systemWords(code, err)})` };
1414
+ return { kind: 'unobserved', ...failedOn('cannot stat', err) };
1367
1415
  }
1368
1416
  if (info.isSymbolicLink()) {
1369
1417
  if (!view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
@@ -1371,23 +1419,23 @@ export async function observePath(view: MountView, rootReal: string, relativePat
1371
1419
  try {
1372
1420
  target = await stat(lexical, { bigint: true });
1373
1421
  } catch (err) {
1374
- return { kind: 'unobserved', reason: `a symlink whose target cannot be read (${systemWords(errno(err), err)})` };
1422
+ return { kind: 'unobserved', ...failedOn('a symlink whose target cannot be read', err) };
1375
1423
  }
1376
1424
  let real: string;
1377
1425
  try {
1378
1426
  real = await realpath(lexical);
1379
1427
  } catch (err) {
1380
- return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${systemWords(errno(err), err)})` };
1428
+ return { kind: 'unobserved', ...failedOn('a symlink that cannot be resolved', err) };
1381
1429
  }
1382
1430
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: LEAVES_MOUNT };
1383
1431
  const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1384
1432
  // A place a nested mount's include leaves out is no mount's, so the link
1385
1433
  // leaves this one (namesNestedPlace).
1386
1434
  if (holder) {
1387
- return { kind: 'unobserved', reason: holder.names(target.isDirectory()) ? leadsInto(view.name, relativePath, holder) : LEAVES_MOUNT };
1435
+ return holder.names(target.isDirectory()) ? { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder), ownStep: true } : { kind: 'unobserved', reason: LEAVES_MOUNT };
1388
1436
  }
1389
1437
  if (!target.isFile()) {
1390
- return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : NOT_REGULAR };
1438
+ return { kind: 'unobserved', reason: target.isDirectory() ? LINK_TO_DIRECTORY : NOT_REGULAR };
1391
1439
  }
1392
1440
  // A file this mount's own include leaves out where the link lands is no
1393
1441
  // file of this mount's (coversLanding). It's judged here, and not only
@@ -1436,35 +1484,35 @@ async function openContained(
1436
1484
  if (code === 'ELOOP' && !view.followSymlinks) {
1437
1485
  // The link at the path, or a looping one on its way.
1438
1486
  const found = await linkOnTheWay(view, relativePath, 'parents');
1439
- return { kind: 'unobserved', reason: found === null ? NOT_FOLLOWED : await linkReason(view, rootReal, found) };
1487
+ return found === null ? { kind: 'unobserved', reason: NOT_FOLLOWED } : { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1440
1488
  }
1441
1489
  if (code === 'EISDIR') return { kind: 'unobserved', reason: DIRECTORY };
1442
- return { kind: 'unobserved', reason: `cannot open (${systemWords(code, err)})` };
1490
+ return { kind: 'unobserved', ...failedOn('cannot open', err) };
1443
1491
  }
1444
1492
  let keep = false;
1445
1493
  try {
1446
1494
  const info = await handle.stat({ bigint: true });
1447
1495
  const real = await realpath(lexical).catch(() => null);
1448
- if (real === null) return { kind: 'unobserved', reason: 'the file changed while it was read' };
1496
+ if (real === null) return { kind: 'unobserved', reason: FILE_CHANGED };
1449
1497
  // O_NOFOLLOW guards only the final component: a mount that doesn't
1450
1498
  // follow symlinks goes through none among the directories on the way
1451
1499
  // either. Asked of what the path resolved to after the open, which the
1452
1500
  // check below holds to the descriptor's file, and before what the
1453
1501
  // descriptor holds, so a link gets its own answer whatever it leads to.
1454
1502
  const found = await linkRefusal(view, rootReal, relativePath, real);
1455
- if (found !== null) return { kind: 'unobserved', reason: await linkReason(view, rootReal, found) };
1503
+ if (found !== null) return { kind: 'unobserved', ...(await linkReason(view, rootReal, found)) };
1456
1504
  if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? DIRECTORY : NOT_REGULAR };
1457
1505
  const leaves = view.followSymlinks ? LEAVES_MOUNT : OUTSIDE_PARENT;
1458
1506
  if (!contained(rootReal, real)) return { kind: 'unobserved', reason: leaves };
1459
1507
  const holder = await nestedAtReal(real, view.nested, { name: view.name, rootReal });
1460
1508
  // A place a nested mount's include leaves out is no mount's. Reached
1461
1509
  // through a link this mount doesn't follow, the link is what's refused.
1462
- if (holder) return { kind: 'unobserved', reason: holder.names(false) ? leadsInto(view.name, relativePath, holder) : view.followSymlinks ? leaves : NOT_FOLLOWED };
1510
+ if (holder) return holder.names(false) ? { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder), ownStep: true } : { kind: 'unobserved', reason: view.followSymlinks ? leaves : NOT_FOLLOWED };
1463
1511
  // So is a file this mount's own include leaves out (coversLanding).
1464
1512
  if (!(await coversLanding(view, rootReal, real, relativePath))) return { kind: 'unobserved', reason: view.followSymlinks ? leaves : NOT_FOLLOWED };
1465
1513
  const named = await stat(real, { bigint: true }).catch(() => null);
1466
1514
  if (named === null || named.dev !== info.dev || named.ino !== info.ino) {
1467
- return { kind: 'unobserved', reason: 'the file changed while it was read' };
1515
+ return { kind: 'unobserved', reason: FILE_CHANGED };
1468
1516
  }
1469
1517
  if (!view.followSymlinks) {
1470
1518
  // Where no symlink is followed, the path itself must be the file the
@@ -1473,7 +1521,7 @@ async function openContained(
1473
1521
  const entry = await lstat(lexical, { bigint: true }).catch(() => null);
1474
1522
  if (entry?.isSymbolicLink()) return { kind: 'unobserved', reason: NOT_FOLLOWED };
1475
1523
  if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) {
1476
- return { kind: 'unobserved', reason: 'the file changed while it was read' };
1524
+ return { kind: 'unobserved', reason: FILE_CHANGED };
1477
1525
  }
1478
1526
  }
1479
1527
  keep = true;
@@ -1481,7 +1529,7 @@ async function openContained(
1481
1529
  } catch (err) {
1482
1530
  const code = errno(err);
1483
1531
  if (code === 'ENOENT') return absence(view, rootReal, relativePath);
1484
- return { kind: 'unobserved', reason: `cannot read (${systemWords(code, err)})` };
1532
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1485
1533
  } finally {
1486
1534
  if (!keep) await handle.close();
1487
1535
  }
@@ -1579,7 +1627,7 @@ export async function readPath(
1579
1627
  } catch (err) {
1580
1628
  const code = errno(err);
1581
1629
  if (code === 'ENOENT') return absence(view, rootReal, relativePath);
1582
- return { kind: 'unobserved', reason: `cannot read (${systemWords(code, err)})` };
1630
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1583
1631
  } finally {
1584
1632
  await handle.close();
1585
1633
  }
@@ -1639,7 +1687,7 @@ export async function readBytes(
1639
1687
  if (more) return { kind: 'bytes', size: Math.max(Number((await handle.stat({ bigint: true })).size), bytes.length + 1), bytes: null, head: bytes.subarray(0, HEAD_BYTES) };
1640
1688
  return { kind: 'bytes', size: bytes.byteLength, bytes, head: bytes.subarray(0, HEAD_BYTES) };
1641
1689
  } catch (err) {
1642
- return { kind: 'unobserved', reason: `cannot read (${systemWords(errno(err), err)})` };
1690
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1643
1691
  } finally {
1644
1692
  await handle.close();
1645
1693
  }
@@ -1663,7 +1711,7 @@ export async function readHead(
1663
1711
  const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
1664
1712
  return { kind: 'head', size: Number(info.size), mtimeMs: Number(info.mtimeMs), head: head.subarray(0, bytesRead) };
1665
1713
  } catch (err) {
1666
- return { kind: 'unobserved', reason: `cannot read (${systemWords(errno(err), err)})` };
1714
+ return { kind: 'unobserved', ...failedOn('cannot read', err) };
1667
1715
  } finally {
1668
1716
  await handle.close();
1669
1717
  }
@@ -1735,7 +1783,7 @@ export interface Walk {
1735
1783
  * `symlink` region is never walked; the pass reports the link itself by
1736
1784
  * path.
1737
1785
  */
1738
- incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink' }>;
1786
+ incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink'; errno?: string }>;
1739
1787
  /**
1740
1788
  * When the walk cap stopped the walk, the last entry it took. A walk that
1741
1789
  * resumes after it takes the entries after it first, in the walk's order
@@ -1956,7 +2004,7 @@ const EDITOR_FILE = /^\..+\.sw[px]$|~$|\.subl.*\.tmp$/;
1956
2004
  /** The walk's reason for an entry the mount's ignore list covers. */
1957
2005
  export const IGNORED = 'ignored by the mount';
1958
2006
  /** The walk's reason for an entry `EDITOR_FILE` names. */
1959
- const EDITOR_FILE_REASON = "an editor's swap or backup file";
2007
+ export const EDITOR_FILE_REASON = "an editor's swap or backup file";
1960
2008
 
1961
2009
  /**
1962
2010
  * The walk's reason for a link it doesn't enter. On a mount with an include,
@@ -1965,7 +2013,7 @@ const EDITOR_FILE_REASON = "an editor's swap or backup file";
1965
2013
  * that follows links, one that leads to a directory, and on one that
1966
2014
  * doesn't, every one.
1967
2015
  */
1968
- const LINK_NOT_WALKED = 'a symlink, not walked';
2016
+ export const LINK_NOT_WALKED = 'a symlink, not walked';
1969
2017
 
1970
2018
  /** Whether a reason the walk gave an entry is one it passes over by design. */
1971
2019
  export function passedOverByDesign(why: string | undefined): boolean {
@@ -2053,9 +2101,9 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
2053
2101
  try {
2054
2102
  info = await lstat(real, { bigint: true });
2055
2103
  } catch (err) {
2056
- if (errno(err) !== 'ENOENT') return { kind: 'unobserved', reason: `cannot stat (${systemWords(errno(err), err)})` };
2104
+ if (errno(err) !== 'ENOENT') return { kind: 'unobserved', ...failedOn('cannot stat', err) };
2057
2105
  const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
2058
- return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
2106
+ return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent', by: proof.by };
2059
2107
  }
2060
2108
  if (!info.isDirectory()) return { kind: 'unobserved', reason: CHANGED_WALK };
2061
2109
  return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info.birthtimeNs) };
@@ -2120,7 +2168,7 @@ export async function walkScope(
2120
2168
  return walk;
2121
2169
  }
2122
2170
  if (top.kind === 'unobserved') {
2123
- walk.incomplete.push({ path: scope, reason: top.outside ? 'the directory resolves outside the mount' : top.reason, kind: 'error' });
2171
+ walk.incomplete.push({ path: scope, reason: top.outside ? RESOLVES_OUTSIDE : top.reason, kind: 'error', ...(top.errno !== undefined ? { errno: top.errno } : {}) });
2124
2172
  return walk;
2125
2173
  }
2126
2174
  // A scope a path sync names is walked even where the ignore list covers
@@ -2148,10 +2196,10 @@ export async function walkScope(
2148
2196
  const visited = new Set<string>();
2149
2197
  /** Paths already reported as errors, so a second visit doesn't report them again. */
2150
2198
  const failed = new Set<string>();
2151
- const fail = (path: string, reason: string): void => {
2199
+ const fail = (path: string, reason: string, code?: string): void => {
2152
2200
  if (failed.has(path)) return;
2153
2201
  failed.add(path);
2154
- walk.incomplete.push({ path, reason, kind: 'error' });
2202
+ walk.incomplete.push({ path, reason, kind: 'error', ...(code !== undefined ? { errno: code } : {}) });
2155
2203
  };
2156
2204
  let left = cap;
2157
2205
  let lastTaken: string | undefined;
@@ -2214,6 +2262,7 @@ export async function walkScope(
2214
2262
  // Gone since it was checked: absent beneath only if that can be shown.
2215
2263
  const proof = rel === '' ? ROOT_UNAVAILABLE : await confirmAbsent(view, rootReal, rel, 'beneath');
2216
2264
  if (typeof proof === 'string') fail(rel, proof);
2265
+ else if ('reason' in proof) fail(rel, proof.reason, proof.errno);
2217
2266
  else {
2218
2267
  walk.missing.add(rel);
2219
2268
  // Nothing was listed, so a later check that finds it again, on the
@@ -2222,7 +2271,8 @@ export async function walkScope(
2222
2271
  }
2223
2272
  return null;
2224
2273
  }
2225
- fail(rel, `cannot list (${code ?? String(err)})`);
2274
+ const listed = failedOn('cannot list', err);
2275
+ fail(rel, listed.reason, listed.errno);
2226
2276
  return null;
2227
2277
  }
2228
2278
  entries.sort((a, b) => compareNames(a.name, b.name));
@@ -2380,7 +2430,7 @@ export async function walkScope(
2380
2430
  // directory there now: what the walk read of it describes another.
2381
2431
  dropBeneath(sub);
2382
2432
  if (child.kind === 'absent') walk.missing.add(sub);
2383
- else fail(sub, child.reason);
2433
+ else fail(sub, child.reason, child.errno);
2384
2434
  }
2385
2435
  };
2386
2436
 
@@ -2395,18 +2445,19 @@ export async function walkScope(
2395
2445
  // directories a listing showed that weren't listed. A walk that doesn't
2396
2446
  // descend leaves its subdirectories by design.
2397
2447
  const capped = `the walk cap (${cap}) was reached`;
2448
+ const raise = "whoever runs this host can raise the mount's walkCap";
2398
2449
  const holdingUntaken = new Set<string>();
2399
2450
  for (const f of walk.seen) holdingUntaken.add(parentOf(f));
2400
2451
  for (const [p, why] of walk.others) if (!passedOverByDesign(why) && !walk.othersTaken.has(p)) holdingUntaken.add(parentOf(p));
2401
2452
  for (const d of holdingUntaken) {
2402
- walk.incomplete.push({ path: d, reason: `${capped} before everything here was taken`, kind: 'cap' });
2453
+ walk.incomplete.push({ path: d, reason: `${capped} before everything here was taken; naming a path beneath it walks only that path, and ${raise}`, kind: 'cap' });
2403
2454
  }
2404
2455
  let unlisted = 0;
2405
2456
  if (recursive) {
2406
2457
  for (const d of walk.dirs) {
2407
2458
  if (visited.has(d) || walk.missing.has(d) || failed.has(d)) continue;
2408
2459
  unlisted++;
2409
- walk.incomplete.push({ path: d, reason: `not visited: ${capped}`, kind: 'cap' });
2460
+ walk.incomplete.push({ path: d, reason: `not visited: ${capped}; naming this path walks only it, and ${raise}`, kind: 'cap' });
2410
2461
  }
2411
2462
  }
2412
2463
  // The cap stopped the walk only if it left something: a walk whose last