@animalabs/agent-framework 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +88 -25
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +4 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  6. package/dist/src/api/mcp-server-core.js +3 -12
  7. package/dist/src/api/mcp-server-core.js.map +1 -1
  8. package/dist/src/api/server.d.ts.map +1 -1
  9. package/dist/src/api/server.js +7 -3
  10. package/dist/src/api/server.js.map +1 -1
  11. package/dist/src/auto-continue.d.ts +2 -0
  12. package/dist/src/auto-continue.d.ts.map +1 -1
  13. package/dist/src/auto-continue.js +5 -2
  14. package/dist/src/auto-continue.js.map +1 -1
  15. package/dist/src/code-execution/isolate/runner.d.ts +7 -5
  16. package/dist/src/code-execution/isolate/runner.d.ts.map +1 -1
  17. package/dist/src/code-execution/isolate/runner.js +7 -5
  18. package/dist/src/code-execution/isolate/runner.js.map +1 -1
  19. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.js +3 -2
  21. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  22. package/dist/src/framework.d.ts +164 -36
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1120 -479
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts +34 -10
  27. package/dist/src/gate/event-gate.d.ts.map +1 -1
  28. package/dist/src/gate/event-gate.js +118 -48
  29. package/dist/src/gate/event-gate.js.map +1 -1
  30. package/dist/src/gate/index.d.ts +1 -1
  31. package/dist/src/gate/index.d.ts.map +1 -1
  32. package/dist/src/gate/index.js +1 -1
  33. package/dist/src/gate/index.js.map +1 -1
  34. package/dist/src/gate/types.d.ts +12 -2
  35. package/dist/src/gate/types.d.ts.map +1 -1
  36. package/dist/src/heartbeat/heartbeat.d.ts +18 -0
  37. package/dist/src/heartbeat/heartbeat.d.ts.map +1 -1
  38. package/dist/src/heartbeat/heartbeat.js +34 -8
  39. package/dist/src/heartbeat/heartbeat.js.map +1 -1
  40. package/dist/src/held-writes.d.ts +3 -3
  41. package/dist/src/held-writes.js +3 -3
  42. package/dist/src/host-presentation.d.ts +170 -0
  43. package/dist/src/host-presentation.d.ts.map +1 -0
  44. package/dist/src/host-presentation.js +561 -0
  45. package/dist/src/host-presentation.js.map +1 -0
  46. package/dist/src/index.d.ts +2 -2
  47. package/dist/src/index.d.ts.map +1 -1
  48. package/dist/src/index.js +1 -1
  49. package/dist/src/index.js.map +1 -1
  50. package/dist/src/line-reader.d.ts +4 -0
  51. package/dist/src/line-reader.d.ts.map +1 -0
  52. package/dist/src/line-reader.js +59 -0
  53. package/dist/src/line-reader.js.map +1 -0
  54. package/dist/src/mcpl/channel-registry.d.ts +34 -6
  55. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  56. package/dist/src/mcpl/channel-registry.js +131 -53
  57. package/dist/src/mcpl/channel-registry.js.map +1 -1
  58. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  59. package/dist/src/mcpl/conversation-router.js +2 -1
  60. package/dist/src/mcpl/conversation-router.js.map +1 -1
  61. package/dist/src/mcpl/convert-block.d.ts +1 -0
  62. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  63. package/dist/src/mcpl/convert-block.js +16 -4
  64. package/dist/src/mcpl/convert-block.js.map +1 -1
  65. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  66. package/dist/src/mcpl/hook-orchestrator.js +4 -3
  67. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  68. package/dist/src/mcpl/inbound-source.d.ts +3 -1
  69. package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
  70. package/dist/src/mcpl/inbound-source.js +3 -1
  71. package/dist/src/mcpl/inbound-source.js.map +1 -1
  72. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  73. package/dist/src/mcpl/modern-connection.js +11 -7
  74. package/dist/src/mcpl/modern-connection.js.map +1 -1
  75. package/dist/src/mcpl/protocol-family.d.ts +3 -2
  76. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  77. package/dist/src/mcpl/protocol-family.js +25 -3
  78. package/dist/src/mcpl/protocol-family.js.map +1 -1
  79. package/dist/src/mcpl/push-handler.js +2 -2
  80. package/dist/src/mcpl/push-handler.js.map +1 -1
  81. package/dist/src/mcpl/references.d.ts +12 -40
  82. package/dist/src/mcpl/references.d.ts.map +1 -1
  83. package/dist/src/mcpl/references.js +29 -23
  84. package/dist/src/mcpl/references.js.map +1 -1
  85. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  86. package/dist/src/mcpl/server-connection.js +20 -20
  87. package/dist/src/mcpl/server-connection.js.map +1 -1
  88. package/dist/src/mcpl/skip-reply-wake.d.ts +25 -0
  89. package/dist/src/mcpl/skip-reply-wake.d.ts.map +1 -1
  90. package/dist/src/mcpl/skip-reply-wake.js +25 -0
  91. package/dist/src/mcpl/skip-reply-wake.js.map +1 -1
  92. package/dist/src/mcpl/tool-presence.d.ts.map +1 -1
  93. package/dist/src/mcpl/tool-presence.js +18 -17
  94. package/dist/src/mcpl/tool-presence.js.map +1 -1
  95. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  96. package/dist/src/mcpl/tool-signature.js +17 -16
  97. package/dist/src/mcpl/tool-signature.js.map +1 -1
  98. package/dist/src/mcpl/toolset-host.d.ts +8 -26
  99. package/dist/src/mcpl/toolset-host.d.ts.map +1 -1
  100. package/dist/src/mcpl/toolset-host.js +16 -6
  101. package/dist/src/mcpl/toolset-host.js.map +1 -1
  102. package/dist/src/mcpl/toolsets-module.d.ts +14 -25
  103. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.js +180 -50
  105. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  106. package/dist/src/mcpl/transport.d.ts +1 -1
  107. package/dist/src/mcpl/transport.d.ts.map +1 -1
  108. package/dist/src/mcpl/transport.js +16 -16
  109. package/dist/src/mcpl/transport.js.map +1 -1
  110. package/dist/src/mcpl/types.d.ts +28 -12
  111. package/dist/src/mcpl/types.d.ts.map +1 -1
  112. package/dist/src/mcpl/types.js.map +1 -1
  113. package/dist/src/module-registry.d.ts +6 -7
  114. package/dist/src/module-registry.d.ts.map +1 -1
  115. package/dist/src/module-registry.js +6 -7
  116. package/dist/src/module-registry.js.map +1 -1
  117. package/dist/src/modules/api/index.d.ts.map +1 -1
  118. package/dist/src/modules/api/index.js +4 -2
  119. package/dist/src/modules/api/index.js.map +1 -1
  120. package/dist/src/modules/discord/index.d.ts.map +1 -1
  121. package/dist/src/modules/discord/index.js +33 -25
  122. package/dist/src/modules/discord/index.js.map +1 -1
  123. package/dist/src/modules/history/index.js +8 -6
  124. package/dist/src/modules/history/index.js.map +1 -1
  125. package/dist/src/modules/sender-name.d.ts +7 -0
  126. package/dist/src/modules/sender-name.d.ts.map +1 -0
  127. package/dist/src/modules/sender-name.js +10 -0
  128. package/dist/src/modules/sender-name.js.map +1 -0
  129. package/dist/src/modules/workspace/answers.d.ts +5 -1
  130. package/dist/src/modules/workspace/answers.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/answers.js +16 -1
  132. package/dist/src/modules/workspace/answers.js.map +1 -1
  133. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.js +6 -4
  135. package/dist/src/modules/workspace/direct-write.js.map +1 -1
  136. package/dist/src/modules/workspace/effects.d.ts +4 -2
  137. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  138. package/dist/src/modules/workspace/effects.js +50 -40
  139. package/dist/src/modules/workspace/effects.js.map +1 -1
  140. package/dist/src/modules/workspace/index.d.ts +1 -0
  141. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/index.js +66 -36
  143. package/dist/src/modules/workspace/index.js.map +1 -1
  144. package/dist/src/modules/workspace/observe.d.ts +51 -20
  145. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/observe.js +120 -82
  147. package/dist/src/modules/workspace/observe.js.map +1 -1
  148. package/dist/src/modules/workspace/reconcile.d.ts +7 -1
  149. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/reconcile.js +133 -37
  151. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  152. package/dist/src/notice-tags.d.ts +79 -0
  153. package/dist/src/notice-tags.d.ts.map +1 -0
  154. package/dist/src/notice-tags.js +83 -0
  155. package/dist/src/notice-tags.js.map +1 -0
  156. package/dist/src/reasoning-loss-notice.d.ts.map +1 -1
  157. package/dist/src/reasoning-loss-notice.js +5 -3
  158. package/dist/src/reasoning-loss-notice.js.map +1 -1
  159. package/dist/src/silent-heartbeat.d.ts +1 -53
  160. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  161. package/dist/src/silent-heartbeat.js +56 -3
  162. package/dist/src/silent-heartbeat.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +35 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -0
  165. package/dist/src/supplied-text.js +141 -0
  166. package/dist/src/supplied-text.js.map +1 -0
  167. package/dist/src/tool-args.d.ts.map +1 -1
  168. package/dist/src/tool-args.js +9 -8
  169. package/dist/src/tool-args.js.map +1 -1
  170. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  171. package/dist/src/tool-image-ledger.js +2 -1
  172. package/dist/src/tool-image-ledger.js.map +1 -1
  173. package/dist/src/tool-presentation.d.ts.map +1 -1
  174. package/dist/src/tool-presentation.js +5 -4
  175. package/dist/src/tool-presentation.js.map +1 -1
  176. package/dist/src/tune-out/coordinator.d.ts +3 -2
  177. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  178. package/dist/src/tune-out/coordinator.js +24 -26
  179. package/dist/src/tune-out/coordinator.js.map +1 -1
  180. package/dist/src/types/agent.d.ts +23 -9
  181. package/dist/src/types/agent.d.ts.map +1 -1
  182. package/dist/src/types/framework.d.ts +43 -0
  183. package/dist/src/types/framework.d.ts.map +1 -1
  184. package/dist/src/types/module.d.ts +22 -11
  185. package/dist/src/types/module.d.ts.map +1 -1
  186. package/package.json +2 -2
  187. package/src/agent.ts +4 -1
  188. package/src/api/mcp-server-core.ts +3 -14
  189. package/src/api/server.ts +7 -3
  190. package/src/auto-continue.ts +8 -2
  191. package/src/code-execution/isolate/runner.ts +7 -5
  192. package/src/code-execution/isolate/scripts.ts +3 -2
  193. package/src/framework.ts +1132 -492
  194. package/src/gate/event-gate.ts +128 -47
  195. package/src/gate/index.ts +1 -1
  196. package/src/gate/types.ts +12 -2
  197. package/src/heartbeat/heartbeat.ts +48 -9
  198. package/src/held-writes.ts +3 -3
  199. package/src/host-presentation.ts +605 -0
  200. package/src/index.ts +2 -2
  201. package/src/line-reader.ts +58 -0
  202. package/src/mcpl/channel-registry.ts +137 -52
  203. package/src/mcpl/conversation-router.ts +2 -1
  204. package/src/mcpl/convert-block.ts +17 -4
  205. package/src/mcpl/hook-orchestrator.ts +4 -3
  206. package/src/mcpl/inbound-source.ts +3 -1
  207. package/src/mcpl/modern-connection.ts +11 -7
  208. package/src/mcpl/protocol-family.ts +24 -3
  209. package/src/mcpl/push-handler.ts +2 -2
  210. package/src/mcpl/references.ts +31 -23
  211. package/src/mcpl/server-connection.ts +20 -18
  212. package/src/mcpl/skip-reply-wake.ts +35 -0
  213. package/src/mcpl/tool-presence.ts +18 -17
  214. package/src/mcpl/tool-signature.ts +17 -16
  215. package/src/mcpl/toolset-host.ts +18 -6
  216. package/src/mcpl/toolsets-module.ts +142 -50
  217. package/src/mcpl/transport.ts +18 -14
  218. package/src/mcpl/types.ts +29 -12
  219. package/src/module-registry.ts +6 -7
  220. package/src/modules/api/index.ts +4 -2
  221. package/src/modules/discord/index.ts +37 -25
  222. package/src/modules/history/index.ts +8 -6
  223. package/src/modules/sender-name.ts +9 -0
  224. package/src/modules/workspace/answers.ts +20 -2
  225. package/src/modules/workspace/direct-write.ts +4 -3
  226. package/src/modules/workspace/effects.ts +50 -40
  227. package/src/modules/workspace/index.ts +58 -34
  228. package/src/modules/workspace/observe.ts +139 -88
  229. package/src/modules/workspace/reconcile.ts +159 -36
  230. package/src/notice-tags.ts +84 -0
  231. package/src/reasoning-loss-notice.ts +5 -3
  232. package/src/silent-heartbeat.ts +4 -3
  233. package/src/supplied-text.ts +154 -0
  234. package/src/tool-args.ts +9 -8
  235. package/src/tool-image-ledger.ts +2 -1
  236. package/src/tool-presentation.ts +5 -4
  237. package/src/tune-out/coordinator.ts +25 -27
  238. package/src/types/agent.ts +23 -9
  239. package/src/types/framework.ts +44 -0
  240. package/src/types/module.ts +23 -11
@@ -45,12 +45,16 @@ import { constants as fsConstants } from 'node:fs';
45
45
  import { lstat, open, realpath, stat, unlink } from 'node:fs/promises';
46
46
  import type { FileHandle } from 'node:fs/promises';
47
47
  import {
48
+ type Cause,
48
49
  type CheckedDir,
49
50
  type MountView,
51
+ DIRECTORY,
50
52
  LEAVES_MOUNT,
53
+ LINK_TO_DIRECTORY,
51
54
  NONBLOCK,
52
55
  NOT_A_DIRECTORY,
53
56
  NOT_FOLLOWED,
57
+ NOT_REGULAR,
54
58
  OUTSIDE_PARENT,
55
59
  ROOT_REPLACED,
56
60
  contained,
@@ -63,6 +67,7 @@ import {
63
67
  nestedAtReal,
64
68
  parentOf,
65
69
  } from './observe.js';
70
+ import { errnoCode, systemWords } from './answers.js';
66
71
 
67
72
  /** What a write or unlink may find at its path when it acts. */
68
73
  export type Expect =
@@ -79,12 +84,18 @@ export class EffectFailed extends Error {
79
84
  /** Whether the path's entry was touched before the failure. */
80
85
  readonly touched: boolean;
81
86
 
82
- constructor(message: string, touched: boolean) {
87
+ constructor(message: string, touched: boolean, readonly detail: Cause = {}) {
83
88
  super(message);
84
89
  this.touched = touched;
85
90
  }
86
91
  }
87
92
 
93
+ export function callFailed(what: string, err: unknown, touched: boolean, sep: 'paren' | 'colon' = 'paren'): EffectFailed {
94
+ const code = errnoCode(err);
95
+ const words = systemWords(code, err);
96
+ return new EffectFailed(sep === 'paren' ? `${what} (${words})` : `${what}: ${words}`, touched, code !== undefined ? { errno: code } : {});
97
+ }
98
+
88
99
  const CHANGED = 'disk changed since it was checked';
89
100
  const MODE_CHANGED = 'disk\'s mode changed since it was checked; a sync takes it in';
90
101
  /**
@@ -115,10 +126,6 @@ const HARD_LINKED = 'a file with other hard links, which writing it in place wou
115
126
  + 'this; to write this path alone, give it a copy of its own first (copy it to a new name, then move that over it) '
116
127
  + 'and materialize again';
117
128
 
118
- function message(err: unknown): string {
119
- return err instanceof Error ? err.message : String(err);
120
- }
121
-
122
129
  function noFollowFlag(view: MountView): number {
123
130
  return !view.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
124
131
  }
@@ -134,9 +141,9 @@ function noFollowFlag(view: MountView): number {
134
141
  */
135
142
  async function parentFor(view: MountView, rootReal: string, rel: string, create: boolean) {
136
143
  const root = await checkRoot(view, rootReal);
137
- if ('reason' in root) throw new EffectFailed(root.reason, false);
144
+ if ('reason' in root) throw new EffectFailed(root.reason, false, root.errno !== undefined ? { errno: root.errno } : {});
138
145
  const checked = await checkDir(view, rootReal, parentOf(rel), { create });
139
- if (checked.kind === 'unobserved') throw new EffectFailed(checked.outside ? OUTSIDE_PARENT : checked.reason, false);
146
+ if (checked.kind === 'unobserved') throw new EffectFailed(checked.outside ? OUTSIDE_PARENT : checked.reason, false, checked.errno !== undefined ? { errno: checked.errno } : {});
140
147
  return { ...checked, root: root.dir };
141
148
  }
142
149
 
@@ -171,11 +178,11 @@ async function hashAt(target: string, id: { dev: bigint; ino: bigint }, flags: n
171
178
  try {
172
179
  handle = await open(target, fsConstants.O_RDONLY | NONBLOCK | flags);
173
180
  } catch (err) {
174
- throw new EffectFailed(`cannot verify the disk copy (${errno(err) ?? message(err)})`, false);
181
+ throw callFailed('cannot verify the disk copy', err, false);
175
182
  }
176
183
  try {
177
184
  const info = await handle.stat({ bigint: true });
178
- if (info.dev !== id.dev || info.ino !== id.ino) throw new EffectFailed(CHANGED, false);
185
+ if (info.dev !== id.dev || info.ino !== id.ino) throw new EffectFailed(CHANGED, false, { again: true });
179
186
  const hash = createHash('sha256');
180
187
  const chunk = Buffer.alloc(1 << 20);
181
188
  for (let offset = 0; ;) {
@@ -185,6 +192,9 @@ async function hashAt(target: string, id: { dev: bigint; ino: bigint }, flags: n
185
192
  offset += bytesRead;
186
193
  }
187
194
  return hash.digest('hex');
195
+ } catch (err) {
196
+ if (err instanceof EffectFailed) throw err;
197
+ throw callFailed('cannot verify the disk copy', err, false);
188
198
  } finally {
189
199
  await handle.close();
190
200
  }
@@ -266,10 +276,10 @@ export async function writeContained(
266
276
  const leads = link ? await stat(target).then(() => 'somewhere', (e: unknown) => errno(e)) : null;
267
277
  throw new EffectFailed(leads === 'ENOENT' || leads === 'ENOTDIR'
268
278
  ? 'a symlink that leads nowhere is at this path, and a push makes a file only where nothing is, forced or not; '
269
- + 'whoever runs this host can remove the link or make it lead to a file, and a materialize then writes it'
270
- : 'something appeared at this path since it was checked', false);
279
+ + 'whoever runs this host can remove the link, and materializing again then gets past it'
280
+ : 'something appeared at this path since it was checked', false, leads === 'ENOENT' || leads === 'ENOTDIR' ? {} : { again: true });
271
281
  }
272
- throw new EffectFailed(`cannot create the file (${errno(err) ?? message(err)})`, false);
282
+ throw callFailed('cannot create the file', err, false);
273
283
  }
274
284
  };
275
285
  let handle: FileHandle;
@@ -282,11 +292,11 @@ export async function writeContained(
282
292
  } catch (err) {
283
293
  const code = errno(err);
284
294
  if (code === 'ENOENT' && expect.kind === 'any') handle = await create();
285
- else if (code === 'ENOENT') throw new EffectFailed('the file was removed since it was checked', false);
295
+ else if (code === 'ENOENT') throw new EffectFailed('the file was removed since it was checked', false, { again: true });
286
296
  else if (code === 'ELOOP' && !view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
287
- else if (code === 'EISDIR') throw new EffectFailed('a directory', false);
288
- else if (code === 'ENXIO') throw new EffectFailed('not a regular file', false); // a FIFO with no reader
289
- else throw new EffectFailed(`cannot open the file (${code ?? message(err)})`, false);
297
+ else if (code === 'EISDIR') throw new EffectFailed((await lstat(target).catch(() => null))?.isSymbolicLink() === true ? LINK_TO_DIRECTORY : DIRECTORY, false);
298
+ else if (code === 'ENXIO') throw new EffectFailed(NOT_REGULAR, false); // a FIFO with no reader
299
+ else throw callFailed('cannot open the file', err, false);
290
300
  }
291
301
  }
292
302
 
@@ -379,7 +389,7 @@ export async function writeContained(
379
389
  return { perm: Number((await handle.stat({ bigint: true })).mode & 0o777n), widenRefused, modeKept };
380
390
  } catch (err) {
381
391
  if (err instanceof EffectFailed) throw err;
382
- throw new EffectFailed(`could not ${stage}: ${message(err)}`, touched);
392
+ throw callFailed(`could not ${stage}`, err, touched, 'colon');
383
393
  } finally {
384
394
  await handle.close();
385
395
  }
@@ -429,30 +439,30 @@ async function checkOpened(
429
439
  touched: boolean,
430
440
  ): Promise<void> {
431
441
  const info = await handle.stat({ bigint: true });
432
- if (!info.isFile()) throw new EffectFailed('not a regular file', touched);
442
+ if (!info.isFile()) throw new EffectFailed(NOT_REGULAR, touched);
433
443
  const located = async (): Promise<string> => {
434
444
  const real = await realpath(target).catch(() => null);
435
- if (real === null) throw new EffectFailed(CHANGED, touched);
445
+ if (real === null) throw new EffectFailed(CHANGED, touched, { again: true });
436
446
  const refused = await refusedLanding(view, rootReal, rel, real);
437
447
  if (refused !== null) throw new EffectFailed(refused, touched);
438
448
  return real;
439
449
  };
440
450
  if (expect.kind === 'content') {
441
451
  await located();
442
- if ((await hashAt(target, info, noFollow)) !== expect.hash) throw new EffectFailed(CHANGED, touched);
452
+ if ((await hashAt(target, info, noFollow)) !== expect.hash) throw new EffectFailed(CHANGED, touched, { again: true });
443
453
  }
444
454
  const real = await located();
445
455
  const named = await stat(real, { bigint: true }).catch(() => null);
446
- if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
456
+ if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched, { again: true });
447
457
  if (!view.followSymlinks) {
448
458
  const entry = await lstat(target, { bigint: true }).catch(() => null);
449
459
  if (entry?.isSymbolicLink()) throw new EffectFailed(NOT_FOLLOWED, touched);
450
- if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) throw new EffectFailed(CHANGED, touched);
460
+ if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) throw new EffectFailed(CHANGED, touched, { again: true });
451
461
  }
452
462
  if (info.nlink > 1n) throw new EffectFailed(HARD_LINKED, touched);
453
463
  const gone = await moved(root, parent);
454
- if (gone !== null) throw new EffectFailed(gone, touched);
455
- if (!unchanged(info, await handle.stat({ bigint: true }).catch(() => null))) throw new EffectFailed(CHANGED, touched);
464
+ if (gone !== null) throw new EffectFailed(gone, touched, gone === MOVED ? { again: true } : {});
465
+ if (!unchanged(info, await handle.stat({ bigint: true }).catch(() => null))) throw new EffectFailed(CHANGED, touched, { again: true });
456
466
  }
457
467
 
458
468
  /** The sticky bit, the one special bit the chmod before a write's bytes keeps. */
@@ -490,7 +500,7 @@ export async function chmodContained(
490
500
  view: MountView, rootReal: string, rel: string, mode: number, expect: Expect, from?: number,
491
501
  ): Promise<{ refused: boolean; narrowing: boolean }> {
492
502
  const checked = await parentFor(view, rootReal, rel, false);
493
- if (checked.kind !== 'checked') throw new EffectFailed('the file was removed since it was checked', false);
503
+ if (checked.kind !== 'checked') throw new EffectFailed('the file was removed since it was checked', false, { again: true });
494
504
  const parent = checked.dir;
495
505
  const root = checked.root;
496
506
  const target = parent.at(nameOf(rel));
@@ -503,9 +513,9 @@ export async function chmodContained(
503
513
  handle = await open(target, fsConstants.O_RDONLY | NONBLOCK | noFollow);
504
514
  } catch (err) {
505
515
  const code = errno(err);
506
- if (code === 'ENOENT') throw new EffectFailed('the file was removed since it was checked', false);
516
+ if (code === 'ENOENT') throw new EffectFailed('the file was removed since it was checked', false, { again: true });
507
517
  if (code === 'ELOOP' && !view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false);
508
- throw new EffectFailed(`cannot open the file (${code ?? message(err)})`, false);
518
+ throw callFailed('cannot open the file', err, false);
509
519
  }
510
520
  let touched = false;
511
521
  let stage = 'set the file\'s mode';
@@ -526,7 +536,7 @@ export async function chmodContained(
526
536
  return { refused: false, narrowing };
527
537
  } catch (err) {
528
538
  if (err instanceof EffectFailed) throw err;
529
- throw new EffectFailed(`could not ${stage}: ${message(err)}`, touched);
539
+ throw callFailed(`could not ${stage}`, err, touched, 'colon');
530
540
  } finally {
531
541
  await handle.close();
532
542
  }
@@ -559,16 +569,16 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
559
569
  } catch (err) {
560
570
  if (errno(err) === 'ENOENT') {
561
571
  const gone = await moved(root, parent);
562
- if (gone !== null) throw new EffectFailed(gone, false);
572
+ if (gone !== null) throw new EffectFailed(gone, false, gone === MOVED ? { again: true } : {});
563
573
  return parent.rel;
564
574
  }
565
- throw new EffectFailed(`cannot stat (${errno(err) ?? message(err)})`, false);
575
+ throw callFailed('cannot stat', err, false);
566
576
  }
567
577
  // Confirming an unlink that already happened never removes a file that
568
578
  // appeared since.
569
579
  if (expect.kind === 'absent') {
570
580
  const kind = info.isDirectory() ? 'a directory' : info.isSymbolicLink() ? 'a symlink' : info.isFile() ? 'a file' : 'a pipe, socket or device';
571
- throw new EffectFailed(`${kind} appeared at this path since it was checked`, false);
581
+ throw new EffectFailed(`${kind} appeared at this path since it was checked`, false, { again: true });
572
582
  }
573
583
  let file = info;
574
584
  if (info.isSymbolicLink()) {
@@ -586,21 +596,21 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
586
596
  throw new EffectFailed(holder.names(directory) ? leadsInto(view.name, rel, holder) : LEAVES_MOUNT, false);
587
597
  }
588
598
  const through = await stat(real, { bigint: true }).catch(() => null);
589
- if (through === null) throw new EffectFailed(CHANGED, false);
599
+ if (through === null) throw new EffectFailed(CHANGED, false, { again: true });
590
600
  // A followed link stands for the file it leads to, so it's removed only
591
601
  // for a regular one, as the file the workspace deleted. This refusal,
592
602
  // and the one below for what isn't a regular file at the path, use
593
603
  // observation's words for the path, so the push answers a forced unlink
594
604
  // and an unforced one alike (reconcile.ts inTheWay).
595
- if (!through.isFile()) throw new EffectFailed(through.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file', false);
605
+ if (!through.isFile()) throw new EffectFailed(through.isDirectory() ? LINK_TO_DIRECTORY : NOT_REGULAR, false);
596
606
  // One leading to a file this mount's own include leaves out leaves the
597
607
  // mount too, and that file isn't read for a hash (coversLanding).
598
608
  if (!(await coversLanding(view, rootReal, real, rel))) throw new EffectFailed(LEAVES_MOUNT, false);
599
609
  file = through;
600
610
  } else if (info.isDirectory()) {
601
- throw new EffectFailed('a directory', false);
611
+ throw new EffectFailed(DIRECTORY, false);
602
612
  } else if (!info.isFile()) {
603
- throw new EffectFailed('not a regular file', false);
613
+ throw new EffectFailed(NOT_REGULAR, false);
604
614
  } else {
605
615
  // A regular file reached through a link among its directories lands
606
616
  // where the mount may not act: in a nested mount's directory, or on a
@@ -610,15 +620,15 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
610
620
  const refused = await refusedLanding(view, rootReal, rel, target);
611
621
  if (refused !== null) throw new EffectFailed(refused, false);
612
622
  }
613
- if (expect.kind === 'content' && (await hashAt(target, file, noFollowFlag(view))) !== expect.hash) throw new EffectFailed(CHANGED, false);
623
+ if (expect.kind === 'content' && (await hashAt(target, file, noFollowFlag(view))) !== expect.hash) throw new EffectFailed(CHANGED, false, { again: true });
614
624
  const gone = await moved(root, parent);
615
- if (gone !== null) throw new EffectFailed(gone, false);
625
+ if (gone !== null) throw new EffectFailed(gone, false, gone === MOVED ? { again: true } : {});
616
626
  // The entry checked is still the one at the path, unwritten since it was
617
627
  // hashed, as close to the unlink as Node allows (see the header): a file
618
628
  // replaced or edited while it was hashed isn't removed.
619
629
  const again = await lstat(target, { bigint: true }).catch(() => null);
620
630
  const named = again?.isSymbolicLink() ? await stat(target, { bigint: true }).catch(() => null) : again;
621
- if (!unchanged(file, named)) throw new EffectFailed(CHANGED, false);
631
+ if (!unchanged(file, named)) throw new EffectFailed(CHANGED, false, { again: true });
622
632
  try {
623
633
  await unlink(target);
624
634
  } catch (err) {
@@ -626,7 +636,7 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st
626
636
  // An unlink is all or nothing: one refused outright changed nothing, so
627
637
  // the path's evidence can go back as it was. Anything else may have
628
638
  // landed (a network filesystem's EIO, say): the next observation resolves it.
629
- if (code !== 'ENOENT') throw new EffectFailed(`could not unlink the file: ${message(err)}`, !UNLINK_REFUSALS.has(code ?? ''));
639
+ if (code !== 'ENOENT') throw callFailed('could not unlink the file', err, !UNLINK_REFUSALS.has(code ?? ''), 'colon');
630
640
  }
631
641
  return parent.rel;
632
642
  }
@@ -661,7 +671,7 @@ export async function syncDirectories(view: MountView, rootReal: string, rels: I
661
671
  await checked.dir.sync();
662
672
  if (!(await root.dir.stillHere())) failed.set(rel, ROOT_REPLACED);
663
673
  } catch (err) {
664
- failed.set(rel, message(err));
674
+ failed.set(rel, systemWords(errno(err), err));
665
675
  }
666
676
  }
667
677
  return failed;
@@ -5,6 +5,7 @@
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
7
 
8
+ import { NOTICE_TAG } from '../../notice-tags.js';
8
9
  import { constants as fsConstants } from 'node:fs';
9
10
  import type { BigIntStats, Stats } from 'node:fs';
10
11
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
@@ -40,7 +41,7 @@ import { MountWatcher, type FsChange } from './watcher.js';
40
41
  import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
41
42
  import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
42
43
  import { type BranchIntent, BranchIntents } from './branch-intent.js';
43
- import { CHANGED, DIRECTORY, IGNORED, LEAVES_MOUNT, NONBLOCK, NOT_FOLLOWED, NOT_REGULAR, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
44
+ import { CHANGED, type Cause, DIRECTORY, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
44
45
  import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
45
46
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
46
47
  import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
@@ -60,12 +61,14 @@ import {
60
61
  NOT_PUSHED_IN_OTHER_MOUNT,
61
62
  WALK_CAP,
62
63
  entryMode,
64
+ explained,
63
65
  pushPaths,
64
66
  reconcilePass,
65
67
  settleAdoptionBeforeMutation,
68
+ wayOn,
66
69
  } from './reconcile.js';
67
70
  import { EffectFailed, unlinkContained } from './effects.js';
68
- import { AWAITS_REOPEN, ROOT_RECHECK, ROOT_UNCHECKED, Refusal, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
71
+ import { AWAITS_REOPEN, MOUNT_IT, ROOT_RECHECK, Refusal, UNFOLLOWED_STEP, changesFiles, didNothing, errnoCode, errnoWayOn, errnoWords, hostFix, onlyTheHost, systemWords, unexpectedAnswer } from './answers.js';
69
72
  import { unknownToolMessage } from '../unknown-tool.js';
70
73
  import { closestNames } from '../../closest-names.js';
71
74
  import { RecordJournal } from '../../record-journal.js';
@@ -270,6 +273,12 @@ class NotRegularFile extends WorkspaceReadError {
270
273
  }
271
274
  }
272
275
 
276
+ class LinkOnTheWay extends WorkspaceReadError {
277
+ constructor(code: WorkspaceReadErrorCode, message: string, readonly reason: string, errno?: string) {
278
+ super(code, message, 'readlink', errno);
279
+ }
280
+ }
281
+
273
282
  class RootUnavailable extends WorkspaceReadError {
274
283
  constructor(readonly mount: string, readonly reason: string | null, stage: WorkspaceReadStage, errno?: string, readonly refusal?: Refusal) {
275
284
  super('mount_unavailable', reason === null ? `Mount unavailable: ${mount}` : `Mount unavailable: ${mount}: ${reason}`, stage, errno);
@@ -316,10 +325,10 @@ export interface ReadFileFromDiskOptions {
316
325
  * Why a pass decided nothing, or null where it decided its paths. A pass gives
317
326
  * up deciding, and reports no path, when the selected branch kept changing
318
327
  * while it gathered, or when a sync's branch was no longer selected; it then
319
- * carries no branchId, and the region it leaves incomplete says why.
328
+ * carries no branchId, and says why in `undecided`.
320
329
  */
321
330
  function undecidedOf(pass: PassResult): string | null {
322
- return pass.branchId === undefined ? (pass.incomplete[0]?.reason ?? 'the pass decided nothing') : null;
331
+ return pass.branchId === undefined ? (pass.undecided ?? 'the pass decided nothing') : null;
323
332
  }
324
333
 
325
334
  function errnoOf(err: unknown): string | undefined {
@@ -385,15 +394,16 @@ async function linkReadError(
385
394
  rootReal: string,
386
395
  ): Promise<WorkspaceReadError> {
387
396
  if (!('link' in found)) {
388
- return new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}: ${linkWords(mount.config.name, found, null)}`, 'readlink', found.code);
397
+ const reason = linkWords(mount.config.name, found, null);
398
+ return new LinkOnTheWay('not_found', `Unable to read file: ${mountPrefixedPath}: ${reason}`, reason, found.code);
389
399
  }
390
400
  const leads = await shownLeads(rootReal, rootReal, found, mount.config.include);
391
- const reason = `${mountPrefixedPath}: ${linkWords(mount.config.name, found, leads, nested, mount.config.include)}`;
401
+ const reason = linkWords(mount.config.name, found, leads, nested, mount.config.include);
392
402
  const holder = leads?.kind === 'inside' ? nestedHolder(leads.target, nested) : undefined;
393
403
  const code = leads?.kind === 'outside' ? 'escape'
394
404
  : leads?.kind === 'inside' && holder && namesHolder(leads, nested) ? 'other_mount'
395
405
  : relativePath === found.link ? 'symlink' : 'parent_symlink';
396
- return new WorkspaceReadError(code, reason, 'readlink');
406
+ return new LinkOnTheWay(code, `${mountPrefixedPath}: ${reason}`, reason);
397
407
  }
398
408
 
399
409
  /**
@@ -2290,13 +2300,13 @@ export class WorkspaceModule implements Module {
2290
2300
 
2291
2301
  let text: string;
2292
2302
  if (paths.length === 1) {
2293
- text = `[workspace event · ${op} · ${paths[0]}]`;
2303
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
2294
2304
  } else {
2295
2305
  const list = paths.map(p => `- ${p}`).join('\n');
2296
- text = `[workspace event · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2306
+ text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
2297
2307
  }
2298
2308
  if (conflicts && conflicts.length > 0) {
2299
- text += `\n[conflicts with disk: ${conflicts.join(', ')}; reading each shows how to settle it]`;
2309
+ text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
2300
2310
  }
2301
2311
 
2302
2312
  return {
@@ -3133,6 +3143,14 @@ export class WorkspaceModule implements Module {
3133
3143
  return names.length === 0 ? 'this workspace has no mounts' : `the mounts are ${listed(names.map((name) => JSON.stringify(name)))}`;
3134
3144
  }
3135
3145
 
3146
+ private emptyPathField(mount: string | undefined, bare: string): string {
3147
+ const empty = '"path" is "", which names no file or directory.';
3148
+ if (mount === undefined) return `${empty} Leaving "path" out ${bare}.`;
3149
+ if (mount === '') return `${empty} "mount" is "" too; leaving both out ${bare}.`;
3150
+ if (this.mounts.has(mount)) return `${empty} Leaving "path" out covers mount ${JSON.stringify(mount)}.`;
3151
+ return `${empty} "mount" is ${JSON.stringify(mount)}, which is no mount's name; ${this.mountList()}. Leaving both out ${bare}.`;
3152
+ }
3153
+
3136
3154
  private noSuchMountField(given: string, bare: string): string {
3137
3155
  const [close] = closestNames(given, [...this.mounts.keys()], 1);
3138
3156
  return `"mount" is ${JSON.stringify(given)}, which is no mount's name; ${this.mountList()}.`
@@ -3896,7 +3914,7 @@ export class WorkspaceModule implements Module {
3896
3914
  return `${shown} is in conflict: the workspace deleted it and disk changed it since. ` +
3897
3915
  'Sync this path to take the disk version.';
3898
3916
  case 'unverified':
3899
- return uncheckedFile(shown, report.note);
3917
+ return uncheckedFile(shown, report.note, report.noteCause ?? {});
3900
3918
  default:
3901
3919
  return missing ?? `there's no file at ${shown}. ls or glob lists the files that exist.`;
3902
3920
  }
@@ -4063,8 +4081,8 @@ export class WorkspaceModule implements Module {
4063
4081
  const rootReal = await realpath(root).catch(() => root);
4064
4082
  const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
4065
4083
  const proof = await confirmAbsent(view, rootReal, relativePath);
4066
- if (typeof proof !== 'string') return 'none';
4067
- return { unreadable: proof === CHANGED ? containedRefusal(err, path) : proof };
4084
+ if (!('reason' in proof)) return 'none';
4085
+ return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
4068
4086
  }
4069
4087
 
4070
4088
  private async restoreEach<T extends { relativePath: string; file: string; hash: string; size: number; what: 'saved' | 'conflict-disk' }>(
@@ -5031,7 +5049,7 @@ export class WorkspaceModule implements Module {
5031
5049
  // whether the file is gone, so it's taken as the agent's write too.
5032
5050
  if (error instanceof EffectFailed && error.touched) {
5033
5051
  this.recordDirectWrite(mount, agentName, since);
5034
- return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
5052
+ return { success: false, isError: true, error: `The delete of ${JSON.stringify(path)} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
5035
5053
  }
5036
5054
  return { success: false, isError: true, error: directDidNothing('delete', path, error) };
5037
5055
  }
@@ -5384,7 +5402,8 @@ export class WorkspaceModule implements Module {
5384
5402
  .filter((region) => region.kind === 'error' || region.kind === 'cap' || (region.kind === 'ignored' && region.path === dir && dir !== ''))
5385
5403
  .map((region) => ({
5386
5404
  path: region.path,
5387
- reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved) : region.reason,
5405
+ reason: region.reason === OTHER_MOUNT ? this.otherMountNote(mount.config.name, region.path, 'list', moved)
5406
+ : explained(region.reason, region.errno !== undefined ? { errno: region.errno } : {}, 'list'),
5388
5407
  }));
5389
5408
  return {
5390
5409
  view,
@@ -6003,7 +6022,10 @@ export class WorkspaceModule implements Module {
6003
6022
  const modesKept: Array<{ mount: string; path: string; reason: string }> = [];
6004
6023
 
6005
6024
  let explicit: { mount: MountState; relativePath: string } | null = null;
6006
- if (input.path) {
6025
+ if (input.path !== undefined) {
6026
+ if (input.path === '') {
6027
+ return { success: false, error: didNothing('materialize', this.emptyPathField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
6028
+ }
6007
6029
  const parsed = await this.parsePath(input.path);
6008
6030
  explicit = parsed;
6009
6031
  // A path outside the mount's include that's no scope (refusesAsOutside)
@@ -6017,7 +6039,7 @@ export class WorkspaceModule implements Module {
6017
6039
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
6018
6040
  if (explicit) {
6019
6041
  mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
6020
- } else if (input.mount) {
6042
+ } else if (input.mount !== undefined) {
6021
6043
  const m = this.mounts.get(input.mount);
6022
6044
  if (!m) {
6023
6045
  return { success: false, error: didNothing('materialize', this.noSuchMountField(input.mount, "covers every mount that takes writes and doesn't already write them straight to disk")), isError: true };
@@ -6224,7 +6246,8 @@ export class WorkspaceModule implements Module {
6224
6246
 
6225
6247
  // A path names its own mount; otherwise the given mount, or every mount.
6226
6248
  let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
6227
- if (input.path) {
6249
+ if (input.path !== undefined) {
6250
+ if (input.path === '') return { success: false, error: didNothing('sync', this.emptyPathField(input.mount, 'covers every mount')), isError: true };
6228
6251
  const { mount, relativePath, moved, root } = await this.parsePath(input.path);
6229
6252
  // A path outside the mount's include that's no scope (refusesAsOutside)
6230
6253
  // is refused; beneath a directory, the walk passes over what the include
@@ -6233,7 +6256,7 @@ export class WorkspaceModule implements Module {
6233
6256
  return { success: false, error: didNothing('sync', new OutsideIncludeError(input.path, mount.config).message), isError: true };
6234
6257
  }
6235
6258
  targets = [{ name: mount.config.name, mount, relativePath }];
6236
- } else if (input.mount) {
6259
+ } else if (input.mount !== undefined) {
6237
6260
  const m = this.mounts.get(input.mount);
6238
6261
  if (!m) {
6239
6262
  return { success: false, error: didNothing('sync', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
@@ -6312,7 +6335,7 @@ export class WorkspaceModule implements Module {
6312
6335
  // refused" don't look identical from the outside.
6313
6336
  for (const r of pass.reports.values()) {
6314
6337
  if (r.state === 'unverified') {
6315
- allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
6338
+ allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${explained(r.note ?? 'not observed', r.noteCause ?? {}, 'sync')}` });
6316
6339
  } else if (r.state === 'disk-only') {
6317
6340
  allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
6318
6341
  }
@@ -6799,7 +6822,13 @@ function directReason(error: unknown): string {
6799
6822
  }
6800
6823
 
6801
6824
  function directWayOn(error: unknown): string | null {
6802
- return error instanceof FileChanged ? 'Calling again reads it afresh.' : error instanceof WriteRefused ? error.wayOn : null;
6825
+ if (error instanceof FileChanged) return 'Calling again reads it afresh.';
6826
+ if (error instanceof WriteRefused) return error.wayOn;
6827
+ if (error instanceof EffectFailed) {
6828
+ if (error.detail.errno !== undefined) return errnoWayOn(error.detail.errno);
6829
+ if (error.detail.again === true) return 'Calling again checks it afresh.';
6830
+ }
6831
+ return null;
6803
6832
  }
6804
6833
 
6805
6834
  function directDidNothing(tool: string, path: string, error: unknown): string {
@@ -6837,16 +6866,11 @@ function deletedOnDisk(path: string): string {
6837
6866
  return `there's no file at ${JSON.stringify(path)} any more: disk's copy was deleted since the workspace last agreed with it, and the workspace took that in first. ls or glob lists the files that exist.`;
6838
6867
  }
6839
6868
 
6840
- function uncheckedFile(shown: string, note: string | undefined): string {
6869
+ function uncheckedFile(shown: string, note: string | undefined, cause: Cause): string {
6841
6870
  if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6842
- const on = note === ROOT_UNAVAILABLE ? ROOT_UNCHECKED
6843
- : note === ROOT_REPLACED ? null
6844
- : note === NOT_FOLLOWED ? "Calling again won't change that; if you know the file it leads to, name that file by its own path."
6845
- : note === LEAVES_MOUNT ? "Calling again won't change that; if a mount holds the file it leads to, name it by its path there."
6846
- : note === NOT_REGULAR ? onlyTheHost('put a regular file there')
6847
- : note === CHANGED ? 'Calling again checks it afresh.'
6848
- : 'Calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk.';
6849
- return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on}` : ''}`;
6871
+ const on = note === ROOT_REPLACED || cause.ownStep === true ? null
6872
+ : (note === undefined ? null : wayOn(note, cause, 'read')) ?? 'calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk';
6873
+ return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on[0]!.toUpperCase()}${sentence(on.slice(1))}` : ''}`;
6850
6874
  }
6851
6875
 
6852
6876
  function namesRoot(path: string, mount: string): string {
@@ -6887,16 +6911,16 @@ function readRefusal(err: WorkspaceReadError, path: string): string {
6887
6911
  }
6888
6912
  return `${shown} can't be read: ${sentence(err.reason)}`;
6889
6913
  }
6890
- if (err.stage === 'readlink') return sentence(err.message);
6914
+ if (err instanceof LinkOnTheWay) return `${shown} can't be reached: ${sentence(err.reason)}`;
6891
6915
  switch (err.code) {
6892
6916
  case 'directory':
6893
6917
  return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
6894
6918
  case 'symlink':
6895
6919
  return `${shown} is a symlink, and its mount doesn't follow symlinks, so the workspace's tools don't go through it. `
6896
- + 'Calling again won\'t change that; if you know the file it leads to, name that file by its own path.';
6920
+ + `Calling again won't change that; ${UNFOLLOWED_STEP}.`;
6897
6921
  case 'escape':
6898
6922
  return `${shown} leads through a symlink to a place outside its mount, so the workspace's tools don't reach it. `
6899
- + 'Calling again won\'t change that; if a mount holds that file, name it by its path there.';
6923
+ + `Calling again won't change that; ${MOUNT_IT}.`;
6900
6924
  case 'changed':
6901
6925
  return `${shown} changed on disk while it was being read. Calling again reads it afresh.`;
6902
6926
  case 'not_found':
@@ -6970,7 +6994,7 @@ function remedyOfLost(lost: LostContents): string {
6970
6994
  */
6971
6995
  function headMoveText(mounts: readonly string[]): string {
6972
6996
  const one = mounts.length === 1;
6973
- return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6997
+ return `[${NOTICE_TAG.workspace}] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6974
6998
  + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6975
6999
  + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6976
7000
  }