@animalabs/agent-framework 0.26.1 → 0.27.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 (276) hide show
  1. package/README.md +57 -7
  2. package/dist/src/agent.d.ts +263 -22
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +531 -54
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +1 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +33 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +35 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/framework.d.ts +829 -156
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3906 -1008
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +309 -46
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +2117 -395
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -2
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -1
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  30. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  31. package/dist/src/gate/recipe-reconcile.js +325 -0
  32. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +68 -8
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/index.d.ts +6 -4
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -2
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +1152 -434
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  44. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  45. package/dist/src/mcpl/conversation-router.js +29 -15
  46. package/dist/src/mcpl/conversation-router.js.map +1 -1
  47. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  48. package/dist/src/mcpl/convert-block.js +14 -2
  49. package/dist/src/mcpl/convert-block.js.map +1 -1
  50. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  51. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  52. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  53. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  54. package/dist/src/mcpl/index.d.ts +5 -1
  55. package/dist/src/mcpl/index.d.ts.map +1 -1
  56. package/dist/src/mcpl/index.js +4 -0
  57. package/dist/src/mcpl/index.js.map +1 -1
  58. package/dist/src/mcpl/inference-router.d.ts +14 -2
  59. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  60. package/dist/src/mcpl/inference-router.js +73 -28
  61. package/dist/src/mcpl/inference-router.js.map +1 -1
  62. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  63. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-grammar.js +6 -0
  65. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  66. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  67. package/dist/src/mcpl/protocol-family.js +3 -0
  68. package/dist/src/mcpl/protocol-family.js.map +1 -1
  69. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  70. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  71. package/dist/src/mcpl/push-coalescer.js +33 -11
  72. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  73. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  74. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  75. package/dist/src/mcpl/reference-fetcher.js +177 -145
  76. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  77. package/dist/src/mcpl/references.d.ts +380 -46
  78. package/dist/src/mcpl/references.d.ts.map +1 -1
  79. package/dist/src/mcpl/references.js +737 -78
  80. package/dist/src/mcpl/references.js.map +1 -1
  81. package/dist/src/mcpl/server-connection.d.ts +82 -3
  82. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  83. package/dist/src/mcpl/server-connection.js +132 -7
  84. package/dist/src/mcpl/server-connection.js.map +1 -1
  85. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  86. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  87. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  88. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  89. package/dist/src/mcpl/tool-presence.d.ts +259 -0
  90. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  91. package/dist/src/mcpl/tool-presence.js +628 -0
  92. package/dist/src/mcpl/tool-presence.js.map +1 -0
  93. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  94. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  95. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  96. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  97. package/dist/src/mcpl/tool-signature.d.ts +39 -0
  98. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  99. package/dist/src/mcpl/tool-signature.js +292 -0
  100. package/dist/src/mcpl/tool-signature.js.map +1 -0
  101. package/dist/src/mcpl/toolset-host.d.ts +200 -0
  102. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  103. package/dist/src/mcpl/toolset-host.js +621 -0
  104. package/dist/src/mcpl/toolset-host.js.map +1 -0
  105. package/dist/src/mcpl/toolsets-module.d.ts +80 -0
  106. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  107. package/dist/src/mcpl/toolsets-module.js +431 -0
  108. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  109. package/dist/src/mcpl/transport.d.ts +2 -2
  110. package/dist/src/mcpl/transport.d.ts.map +1 -1
  111. package/dist/src/mcpl/transport.js +3 -2
  112. package/dist/src/mcpl/transport.js.map +1 -1
  113. package/dist/src/mcpl/types.d.ts +21 -1
  114. package/dist/src/mcpl/types.d.ts.map +1 -1
  115. package/dist/src/mcpl/types.js.map +1 -1
  116. package/dist/src/mcpl/visible-content.d.ts +7 -9
  117. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  118. package/dist/src/mcpl/visible-content.js +21 -9
  119. package/dist/src/mcpl/visible-content.js.map +1 -1
  120. package/dist/src/module-registry.d.ts +23 -8
  121. package/dist/src/module-registry.d.ts.map +1 -1
  122. package/dist/src/module-registry.js +68 -15
  123. package/dist/src/module-registry.js.map +1 -1
  124. package/dist/src/modules/discord/index.js +1 -1
  125. package/dist/src/modules/discord/index.js.map +1 -1
  126. package/dist/src/modules/history/index.d.ts +2 -1
  127. package/dist/src/modules/history/index.d.ts.map +1 -1
  128. package/dist/src/modules/history/index.js +51 -17
  129. package/dist/src/modules/history/index.js.map +1 -1
  130. package/dist/src/modules/history/semantic.d.ts +108 -6
  131. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  132. package/dist/src/modules/history/semantic.js +273 -57
  133. package/dist/src/modules/history/semantic.js.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.d.ts +181 -0
  135. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  136. package/dist/src/modules/workspace/direct-write.js +384 -0
  137. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  138. package/dist/src/modules/workspace/edit-text.d.ts +26 -12
  139. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  140. package/dist/src/modules/workspace/edit-text.js +58 -28
  141. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  142. package/dist/src/modules/workspace/effects.d.ts +10 -0
  143. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  144. package/dist/src/modules/workspace/effects.js +1 -1
  145. package/dist/src/modules/workspace/effects.js.map +1 -1
  146. package/dist/src/modules/workspace/index.d.ts +246 -27
  147. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  148. package/dist/src/modules/workspace/index.js +1216 -236
  149. package/dist/src/modules/workspace/index.js.map +1 -1
  150. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  151. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  152. package/dist/src/modules/workspace/read-page.js +213 -0
  153. package/dist/src/modules/workspace/read-page.js.map +1 -0
  154. package/dist/src/modules/workspace/result-text.d.ts +67 -0
  155. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  156. package/dist/src/modules/workspace/result-text.js +94 -0
  157. package/dist/src/modules/workspace/result-text.js.map +1 -0
  158. package/dist/src/modules/workspace/text.d.ts +29 -12
  159. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/text.js +35 -33
  161. package/dist/src/modules/workspace/text.js.map +1 -1
  162. package/dist/src/modules/workspace/types.d.ts +41 -17
  163. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  164. package/dist/src/modules/workspace/types.js.map +1 -1
  165. package/dist/src/sent-rows.d.ts +59 -0
  166. package/dist/src/sent-rows.d.ts.map +1 -0
  167. package/dist/src/sent-rows.js +145 -0
  168. package/dist/src/sent-rows.js.map +1 -0
  169. package/dist/src/silent-heartbeat.d.ts +17 -10
  170. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  171. package/dist/src/silent-heartbeat.js.map +1 -1
  172. package/dist/src/stable-hash.d.ts +3 -0
  173. package/dist/src/stable-hash.d.ts.map +1 -0
  174. package/dist/src/stable-hash.js +15 -0
  175. package/dist/src/stable-hash.js.map +1 -0
  176. package/dist/src/stored-injections.d.ts +99 -0
  177. package/dist/src/stored-injections.d.ts.map +1 -0
  178. package/dist/src/stored-injections.js +106 -0
  179. package/dist/src/stored-injections.js.map +1 -0
  180. package/dist/src/text-as-stored.d.ts +4 -3
  181. package/dist/src/text-as-stored.d.ts.map +1 -1
  182. package/dist/src/text-as-stored.js +4 -3
  183. package/dist/src/text-as-stored.js.map +1 -1
  184. package/dist/src/thinking-drops.d.ts +81 -0
  185. package/dist/src/thinking-drops.d.ts.map +1 -0
  186. package/dist/src/thinking-drops.js +103 -0
  187. package/dist/src/thinking-drops.js.map +1 -0
  188. package/dist/src/tool-image-ledger.d.ts +9 -8
  189. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  190. package/dist/src/tool-image-ledger.js +9 -8
  191. package/dist/src/tool-image-ledger.js.map +1 -1
  192. package/dist/src/tool-presentation.d.ts.map +1 -1
  193. package/dist/src/tool-presentation.js +3 -1
  194. package/dist/src/tool-presentation.js.map +1 -1
  195. package/dist/src/tool-result-guard.d.ts +4 -2
  196. package/dist/src/tool-result-guard.d.ts.map +1 -1
  197. package/dist/src/tool-result-guard.js +21 -6
  198. package/dist/src/tool-result-guard.js.map +1 -1
  199. package/dist/src/tool-result-history.d.ts +186 -21
  200. package/dist/src/tool-result-history.d.ts.map +1 -1
  201. package/dist/src/tool-result-history.js +428 -89
  202. package/dist/src/tool-result-history.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +17 -9
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +84 -37
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/types/agent.d.ts +186 -7
  208. package/dist/src/types/agent.d.ts.map +1 -1
  209. package/dist/src/types/events.d.ts +19 -2
  210. package/dist/src/types/events.d.ts.map +1 -1
  211. package/dist/src/types/framework.d.ts +24 -3
  212. package/dist/src/types/framework.d.ts.map +1 -1
  213. package/dist/src/types/module.d.ts +90 -7
  214. package/dist/src/types/module.d.ts.map +1 -1
  215. package/dist/src/types/trace.d.ts +57 -2
  216. package/dist/src/types/trace.d.ts.map +1 -1
  217. package/package.json +4 -3
  218. package/src/agent.ts +593 -60
  219. package/src/api/server.ts +1 -2
  220. package/src/auto-continue.ts +42 -0
  221. package/src/code-execution/isolate/scripts.ts +11 -3
  222. package/src/framework.ts +4004 -1026
  223. package/src/gate/event-gate.ts +2315 -389
  224. package/src/gate/index.ts +2 -1
  225. package/src/gate/recipe-reconcile.ts +355 -0
  226. package/src/gate/types.ts +70 -8
  227. package/src/index.ts +7 -4
  228. package/src/mcpl/channel-registry.ts +1220 -467
  229. package/src/mcpl/conversation-router.ts +34 -16
  230. package/src/mcpl/convert-block.ts +14 -1
  231. package/src/mcpl/hook-orchestrator.ts +33 -6
  232. package/src/mcpl/index.ts +6 -1
  233. package/src/mcpl/inference-router.ts +79 -34
  234. package/src/mcpl/prose-grammar.ts +7 -0
  235. package/src/mcpl/protocol-family.ts +2 -0
  236. package/src/mcpl/push-coalescer.ts +51 -13
  237. package/src/mcpl/reference-fetcher.ts +201 -162
  238. package/src/mcpl/references.ts +852 -78
  239. package/src/mcpl/server-connection.ts +145 -7
  240. package/src/mcpl/tool-lifecycle.ts +21 -0
  241. package/src/mcpl/tool-presence.ts +650 -0
  242. package/src/mcpl/tool-result-normalize.ts +18 -6
  243. package/src/mcpl/tool-signature.ts +285 -0
  244. package/src/mcpl/toolset-host.ts +637 -0
  245. package/src/mcpl/toolsets-module.ts +451 -0
  246. package/src/mcpl/transport.ts +3 -2
  247. package/src/mcpl/types.ts +22 -1
  248. package/src/mcpl/visible-content.ts +22 -9
  249. package/src/module-registry.ts +77 -24
  250. package/src/modules/discord/index.ts +1 -1
  251. package/src/modules/history/index.ts +48 -18
  252. package/src/modules/history/semantic.ts +308 -54
  253. package/src/modules/workspace/direct-write.ts +425 -0
  254. package/src/modules/workspace/edit-text.ts +82 -35
  255. package/src/modules/workspace/effects.ts +1 -1
  256. package/src/modules/workspace/index.ts +1274 -241
  257. package/src/modules/workspace/read-page.ts +222 -0
  258. package/src/modules/workspace/result-text.ts +119 -0
  259. package/src/modules/workspace/text.ts +58 -32
  260. package/src/modules/workspace/types.ts +41 -17
  261. package/src/sent-rows.ts +162 -0
  262. package/src/silent-heartbeat.ts +17 -10
  263. package/src/stable-hash.ts +14 -0
  264. package/src/stored-injections.ts +131 -0
  265. package/src/text-as-stored.ts +4 -3
  266. package/src/thinking-drops.ts +141 -0
  267. package/src/tool-image-ledger.ts +9 -8
  268. package/src/tool-presentation.ts +3 -1
  269. package/src/tool-result-guard.ts +19 -6
  270. package/src/tool-result-history.ts +488 -88
  271. package/src/tune-out/coordinator.ts +80 -37
  272. package/src/types/agent.ts +189 -7
  273. package/src/types/events.ts +19 -2
  274. package/src/types/framework.ts +21 -3
  275. package/src/types/module.ts +86 -7
  276. package/src/types/trace.ts +58 -2
@@ -17,7 +17,13 @@ import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-
17
17
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
18
18
  import { countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
19
19
  import { cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
20
+ import { fileText } from './text.js';
21
+ import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
22
+ import { grepText, textAnswer } from './result-text.js';
20
23
  import { NOT_PUSHED_IN_OTHER_MOUNT, WALK_CAP, entryMode, pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
24
+ import { EffectFailed, unlinkContained } from './effects.js';
25
+ import { RecordJournal } from '../../record-journal.js';
26
+ import { FileChanged, ROOT_MOVED, checkUnchanged, directWriteTestSeam, discard, identityOf, placedStill, renameInto, sameIdentity, stageContained, } from './direct-write.js';
21
27
  /** Default for how long the after-batch scan may hold the agent's next inference. */
22
28
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
23
29
  /**
@@ -30,6 +36,8 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
30
36
  const CATCH_UP_BOUND_MS = 30_000;
31
37
  /** Why a call is refused once the module has stopped. */
32
38
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
39
+ /** writeBinary's refusal when the store's branch is no longer the one its file is for. */
40
+ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
33
41
  /** The workspace's own deadline must end before the framework's hook bound. */
34
42
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
35
43
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
@@ -44,6 +52,8 @@ const DIRECT_MOUNT_IGNORE = ['node_modules', 'dist', '.git', 'build', 'coverage'
44
52
  const DIRECT_READS_AT_ONCE = 16;
45
53
  /** Why sync passes over a direct mount, as its result's `skipped` says. */
46
54
  const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
55
+ /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
56
+ const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
47
57
  class WorkspaceImageReadError extends Error {
48
58
  code;
49
59
  constructor(code, message) {
@@ -707,6 +717,23 @@ export class WorkspaceModule {
707
717
  * ensureRunning to say once both halves of the lifecycle have run.
708
718
  */
709
719
  notDirect = new Map();
720
+ /**
721
+ * Each agent's latest direct write on each mount it wrote direct, P, with
722
+ * the notice of its departure from it, N (DirectHead), by mount, then by
723
+ * agent. Saved at stop() in a global record (headsJournal), not the
724
+ * module's branch state: they describe disk, which doesn't rewind with a
725
+ * branch, so a restart on another branch, such as one an offline rollback
726
+ * made, still finds them.
727
+ */
728
+ directHeads = new Map();
729
+ /** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
730
+ headsJournal = null;
731
+ /** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
732
+ loadedHeads = null;
733
+ /** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
734
+ carriedHeads = [];
735
+ /** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
736
+ historyLookup = null;
710
737
  constructor(config) {
711
738
  for (const mount of config.mounts) {
712
739
  const cap = mount.walkCap;
@@ -830,9 +857,64 @@ export class WorkspaceModule {
830
857
  this.agreement = new DiskAgreement(store, { modesObserved: () => this.modesObserved });
831
858
  this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
832
859
  this.chooseKinds();
860
+ this.loadDirectHeads(store);
833
861
  this.applySavedState();
834
862
  this.ensureRunning();
835
863
  }
864
+ /**
865
+ * The positions stop() last saved (directHeads), before any call can
866
+ * write: each saved entry is a mount's positions at one root, and a mount
867
+ * this session configures direct gets the entry at its own root. Every
868
+ * other entry, a mount configured at another root, not direct, or not at
869
+ * all, is kept for the next save (carriedHeads), since its writes are
870
+ * still on disk beneath that root; a mount removed for good, and each
871
+ * root a mount has moved from, leave that small entry behind. A record
872
+ * the store can't read is logged, and this session starts with none and
873
+ * saves none, so the record stands for a session that can read it. One
874
+ * read but not of the shape saved is logged too, and this session starts
875
+ * with none and its stop replaces it, so a damaged record doesn't keep
876
+ * every later session from saving. Either way, a move away from the
877
+ * writes before it goes untold, as after a crash.
878
+ */
879
+ loadDirectHeads(store) {
880
+ const journal = new RecordJournal(store, { type: DIRECT_HEADS_RECORD_TYPE });
881
+ const restored = new Map();
882
+ const carried = [];
883
+ let loaded;
884
+ try {
885
+ loaded = journal.load().snapshot;
886
+ }
887
+ catch (err) {
888
+ console.error('[workspace] could not read the saved direct-write positions:', err);
889
+ return;
890
+ }
891
+ let snapshot;
892
+ let text;
893
+ try {
894
+ snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
895
+ text = canonicalHeads(snapshot);
896
+ }
897
+ catch (err) {
898
+ console.error("[workspace] the saved direct-write positions aren't of the shape saved, so this session starts with none and its stop replaces them:", err);
899
+ snapshot = [];
900
+ text = null;
901
+ }
902
+ for (const saved of snapshot) {
903
+ // Only a direct mount's writes are the agent's on disk, and a
904
+ // store-backed one's disk follows its store.
905
+ const mount = this.mounts.get(saved.mount);
906
+ if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
907
+ carried.push(saved);
908
+ continue;
909
+ }
910
+ restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) => [agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
911
+ }
912
+ this.headsJournal = journal;
913
+ this.loadedHeads = text;
914
+ this.carriedHeads = carried;
915
+ for (const [name, heads] of restored)
916
+ this.directHeads.set(name, heads);
917
+ }
836
918
  /**
837
919
  * Whether each mount configured direct runs direct: not while the store
838
920
  * holds changes there that disk lacks, on the branch selected then, so
@@ -1318,8 +1400,8 @@ export class WorkspaceModule {
1318
1400
  const message = (one
1319
1401
  ? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
1320
1402
  : "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
1321
- "sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
1322
- `follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1403
+ "sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
1404
+ `and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1323
1405
  `${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
1324
1406
  console.warn(`[workspace] ${message}`);
1325
1407
  this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
@@ -1388,26 +1470,76 @@ export class WorkspaceModule {
1388
1470
  }
1389
1471
  this.ctx.setState(state);
1390
1472
  }
1473
+ this.saveDirectHeads();
1391
1474
  this.ctx = null;
1392
1475
  }
1393
- /** Mount names + modes, and each mount's include where it sets one, for
1394
- * peer callers (framework spill/journal paths) that need a mount for files
1395
- * of their own, the first writable one without an include
1396
- * (firstWritableMountName), without reaching into private state. */
1476
+ /**
1477
+ * Save directHeads as one global record, the latest of which the next
1478
+ * start loads (loadDirectHeads): each mount's positions with the root
1479
+ * they were made under, and the entries this session didn't take up
1480
+ * (carriedHeads), unchanged. Only when that differs from what the
1481
+ * start loaded, so a session that changed nothing, or a store no direct
1482
+ * write reached, adds no record, and always where the record read wasn't
1483
+ * of the shape saved, which this replaces. Nothing where the store couldn't
1484
+ * read the record, so it stands. A save that fails is logged, and the next
1485
+ * start finds the positions of the last save that landed, as after a
1486
+ * crash.
1487
+ */
1488
+ saveDirectHeads() {
1489
+ if (!this.headsJournal)
1490
+ return;
1491
+ const snapshot = [
1492
+ ...this.carriedHeads,
1493
+ ...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
1494
+ mount: name,
1495
+ root: resolve(this.mounts.get(name).config.path),
1496
+ agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
1497
+ })),
1498
+ ];
1499
+ const text = canonicalHeads(snapshot);
1500
+ if (text === this.loadedHeads)
1501
+ return;
1502
+ try {
1503
+ this.headsJournal.checkpoint(snapshot);
1504
+ this.loadedHeads = text;
1505
+ }
1506
+ catch (err) {
1507
+ console.error('[workspace] could not save the direct-write positions:', err);
1508
+ }
1509
+ }
1510
+ /** Mount names + modes, each mount's include where it sets one, and
1511
+ * `backend: 'direct'` for one configured direct, whether it runs direct
1512
+ * this session or, while the store holds work there disk lacks, as
1513
+ * 'chronicle': either way it's a directory agents write directly when it
1514
+ * runs direct, whose files an undo leaves. For the
1515
+ * framework's tool-result spills, saved payloads and fetched references,
1516
+ * which need a mount for files of their own: the first writable one
1517
+ * without an include that isn't direct (firstWritableMountName), without
1518
+ * reaching into private state. */
1397
1519
  getMounts() {
1398
1520
  return [...this.mounts.values()].map((m) => ({
1399
1521
  name: m.config.name,
1400
- mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
1522
+ // Read-only where this session takes no writes there (writeRefusal).
1523
+ mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
1401
1524
  ...(m.config.include ? { include: [...m.config.include] } : {}),
1525
+ ...(m.config.backend === 'direct' ? { backend: 'direct' } : {}),
1402
1526
  }));
1403
1527
  }
1404
1528
  // ==========================================================================
1405
1529
  // Tool Definitions
1406
1530
  // ==========================================================================
1531
+ /**
1532
+ * Whether any of this host's mounts runs direct. The tools' descriptions
1533
+ * and grep's notes mention direct mounts only on such a host, so both ask
1534
+ * this one question.
1535
+ */
1536
+ anyRunsDirect() {
1537
+ return [...this.mounts.values()].some((m) => m.direct);
1538
+ }
1407
1539
  getTools() {
1408
1540
  // Where a mount runs direct, four descriptions say what that changes.
1409
1541
  // A host with none keeps them as they were, and its residents' caches.
1410
- const direct = [...this.mounts.values()].some((m) => m.direct);
1542
+ const direct = this.anyRunsDirect();
1411
1543
  const lsDirect = direct
1412
1544
  ? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
1413
1545
  + 'its size and no state, or a note where the mount doesn\'t take it. A path its ignore list covers is listed when you name it.'
@@ -1418,6 +1550,23 @@ export class WorkspaceModule {
1418
1550
  : '';
1419
1551
  const globDirect = direct ? ' On a direct mount, a match carries its size, or a note where the mount doesn\'t take it, and no state.' : '';
1420
1552
  const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
1553
+ // Where a direct mount is writable, the four writing tools say what a
1554
+ // write is there: on disk when it answers, and left there by a head move.
1555
+ const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
1556
+ const writeDirect = writable
1557
+ ? ' On a direct mount, the file is on disk, whole and with its mode kept, when the call answers, and an undo or other head move leaves it there.'
1558
+ : '';
1559
+ const editDirect = writable
1560
+ ? " On a direct mount, the edit applies to disk's text as it is now and is on disk when the call answers; a change another writer makes "
1561
+ + 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
1562
+ : '';
1563
+ const editManyDirect = writable
1564
+ ? " On a direct mount, every edit is checked against disk's text before any file is written, and then the files are written one by one: "
1565
+ + "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
1566
+ + 'An undo or other head move leaves them on disk.'
1567
+ : '';
1568
+ const deleteDirect = writable ? ' On a direct mount, the file is removed from disk at once, and an undo or other head move doesn\'t bring it back.' : '';
1569
+ const materializeDirect = writable ? ' A direct mount writes its files to disk as they\'re made, so materialize passes over it, under `skipped` where you name it.' : '';
1421
1570
  return [
1422
1571
  {
1423
1572
  name: 'read',
@@ -1451,7 +1600,7 @@ export class WorkspaceModule {
1451
1600
  },
1452
1601
  {
1453
1602
  name: 'write',
1454
- description: 'Create or overwrite a file in the workspace.',
1603
+ description: 'Create or overwrite a file in the workspace.' + writeDirect,
1455
1604
  inputSchema: {
1456
1605
  type: 'object',
1457
1606
  properties: {
@@ -1472,7 +1621,7 @@ export class WorkspaceModule {
1472
1621
  + "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
1473
1622
  + 'A change disk made to the file is taken in first, and the answer says so. '
1474
1623
  + 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
1475
- + 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
1624
+ + 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
1476
1625
  inputSchema: {
1477
1626
  type: 'object',
1478
1627
  properties: {
@@ -1494,7 +1643,8 @@ export class WorkspaceModule {
1494
1643
  + "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
1495
1644
  + 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
1496
1645
  + "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
1497
- + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
1646
+ + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
1647
+ + editManyDirect,
1498
1648
  inputSchema: {
1499
1649
  type: 'object',
1500
1650
  properties: {
@@ -1523,7 +1673,7 @@ export class WorkspaceModule {
1523
1673
  name: 'delete',
1524
1674
  description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
1525
1675
  + 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
1526
- + 'or a sync of the path restores it.',
1676
+ + 'or a sync of the path restores it.' + deleteDirect,
1527
1677
  inputSchema: {
1528
1678
  type: 'object',
1529
1679
  properties: {
@@ -1576,7 +1726,7 @@ export class WorkspaceModule {
1576
1726
  properties: {
1577
1727
  pattern: { type: 'string', description: 'Regular expression pattern' },
1578
1728
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1579
- glob: { type: 'string', description: 'Glob pattern to filter files, relative to the mount root (e.g., "src/**/*.ts"); in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), and in every other mount, as written' },
1729
+ glob: { type: 'string', description: 'Glob pattern to filter files: one with a "/" is matched from the mount\'s root, whatever the path (e.g., "src/**/*.ts"), and in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), in every other mount as written; one with no "/" also matches a file\'s name at any depth (e.g., "*.ts")' },
1580
1730
  contextBefore: { type: 'integer', description: 'Context lines before match' },
1581
1731
  contextAfter: { type: 'integer', description: 'Context lines after match' },
1582
1732
  },
@@ -1598,7 +1748,7 @@ export class WorkspaceModule {
1598
1748
  description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1599
1749
  + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
1600
1750
  + 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
1601
- + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
1751
+ + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
1602
1752
  inputSchema: {
1603
1753
  type: 'object',
1604
1754
  properties: {
@@ -1726,20 +1876,25 @@ export class WorkspaceModule {
1726
1876
  return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
1727
1877
  }
1728
1878
  const input = call.input;
1879
+ // The most of this call's result that can reach its caller whole: past
1880
+ // it, a model's result spills into a file, and a script gets
1881
+ // ResultTooLarge in its place. A call made in process carries none and
1882
+ // takes the data itself, so its read pages by its line limit alone.
1883
+ const resultMaxChars = call.resultMaxChars;
1729
1884
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1730
1885
  if (generated) {
1731
1886
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
1732
- return await this.handleRead(input, generated.read());
1887
+ return await this.handleRead(input, resultMaxChars, generated.read());
1733
1888
  }
1734
1889
  return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
1735
1890
  }
1736
1891
  switch (call.name) {
1737
- case 'read': return await this.handleRead(input);
1892
+ case 'read': return await this.handleRead(input, resultMaxChars);
1738
1893
  case 'read_image': return await this.handleReadImage(input);
1739
- case 'write': return await this.handleWrite(input);
1740
- case 'edit': return await this.handleEdit(input, call.resultMaxChars);
1894
+ case 'write': return await this.handleWrite(input, call.callerAgentName);
1895
+ case 'edit': return await this.handleEdit(input, call.resultMaxChars, call.callerAgentName);
1741
1896
  case 'edit_many': return await this.handleEditMany(input, call.resultMaxChars, call.callerAgentName);
1742
- case 'delete': return await this.handleDelete(input);
1897
+ case 'delete': return await this.handleDelete(input, call.callerAgentName);
1743
1898
  case 'ls': return await this.handleLs(input);
1744
1899
  case 'glob': return await this.handleGlob(input);
1745
1900
  case 'grep': return await this.handleGrep(input);
@@ -1879,6 +2034,12 @@ export class WorkspaceModule {
1879
2034
  * withheld original this way, for the branch its stub is on
1880
2035
  * (agent-framework #277).
1881
2036
  *
2037
+ * On a mount running direct, the bytes go to disk as a direct `write`'s
2038
+ * do, whole or not at all, and nothing goes into the store: `branch` is
2039
+ * checked before the write starts and again just before its rename, and
2040
+ * `agentName` names the agent whose write it is for the head-move notice
2041
+ * (a write naming none tells no agent).
2042
+ *
1882
2043
  * A write whose tree entry is set but that then fails returns success
1883
2044
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
1884
2045
  * the store failed after recording the entry, which skips the push where
@@ -1906,20 +2067,34 @@ export class WorkspaceModule {
1906
2067
  isError: true,
1907
2068
  };
1908
2069
  }
1909
- if (mount.config.mode === 'read-only') {
1910
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
1911
- }
2070
+ const refusal = writeRefusal(mount);
2071
+ if (refusal)
2072
+ return { success: false, error: refusal, isError: true };
1912
2073
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
1913
2074
  if (data.byteLength > maxSize) {
1914
2075
  return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
1915
2076
  }
1916
2077
  const store = this.getStore();
2078
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
2079
+ // as the caller's agent's write, where it names one. A branch it names
2080
+ // is checked just before the rename, since disk is no branch's.
2081
+ if (mount.direct) {
2082
+ // Checked first too, so a request already for a left branch makes no
2083
+ // directory and stages nothing.
2084
+ if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
2085
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
2086
+ }
2087
+ const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2088
+ if ('error' in written)
2089
+ return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2090
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
2091
+ }
1917
2092
  return this.withMount(mount, async () => {
1918
2093
  await this.settleBeforeMutation(mount, relativePath);
1919
2094
  // Checked inside the mount's turn, after anything the write waited on:
1920
2095
  // commitToolChange sets the tree entry before it first yields.
1921
2096
  if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
1922
- return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
2097
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
1923
2098
  }
1924
2099
  const blobHash = store.storeBlob(data, mimeType);
1925
2100
  // An overwrite keeps the file's mode, as the write tool's does.
@@ -1956,11 +2131,15 @@ export class WorkspaceModule {
1956
2131
  });
1957
2132
  }
1958
2133
  /**
1959
- * Read a file's raw bytes from a mount (through the Chronicle tree, synced
1960
- * first like the `read` tool; on a direct mount, from disk as
1961
- * readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
1962
- * API for the framework's synthesized `read_image` tool and other peer
1963
- * callers that need binary content.
2134
+ * Read a file's raw bytes from a mount: through the Chronicle tree, a path
2135
+ * the workspace doesn't hold observed first (ensureSynced); or, on a direct
2136
+ * mount, from disk as readBinaryFromDisk reads it, at most the mount's
2137
+ * `maxFileSize`. A path the workspace holds is read as the workspace holds
2138
+ * it, not settled as the `read` tool settles it: the tree takes in no
2139
+ * binary disk copy, so a pass over a held binary file that disk changed
2140
+ * would drop the workspace's copy. Public API for the framework's
2141
+ * synthesized `read_image` tool, where the workspace module doesn't serve
2142
+ * read_image itself, and other peer callers that need binary content.
1964
2143
  */
1965
2144
  async readBinary(mountPrefixedPath) {
1966
2145
  if (!this.running())
@@ -2169,8 +2348,10 @@ export class WorkspaceModule {
2169
2348
  * mount nested by its directories alone, since its real root holds this
2170
2349
  * one, is judged by the parse's nesting, so a root repointed between a
2171
2350
  * read's parse and its open can have it serve a path in that mount's
2172
- * directory. Each mount is placed as a start places it (placementOf), from
2173
- * where its real root and the directories above its configured path lie on
2351
+ * directory, and one repointed between a direct write's parse and its
2352
+ * staging can have the write land there. Each mount is placed as a start
2353
+ * places it (placementOf), from where its real root and the directories
2354
+ * above its configured path lie on
2174
2355
  * disk. For a mount configured at this mount's own path or beneath it,
2175
2356
  * that's where they lie now: beneath `rootReal` where they lie beneath this
2176
2357
  * mount's path, and the directories above this mount's path as resolving
@@ -3343,15 +3524,19 @@ export class WorkspaceModule {
3343
3524
  await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3344
3525
  }
3345
3526
  /**
3346
- * Before a read: a path the workspace doesn't hold is observed under the
3347
- * three-way rule, which ingests a new disk file but never brings back a
3348
- * workspace deletion or a file absent from this branch. The pass names the
3349
- * path, as a file tool names the one it acts on: a path the mount passes
3350
- * over by design is observed, as a sync naming it would observe it (#276),
3351
- * while a path in another mount's directory is refused before this, and
3352
- * passed over by any pass. Answers the pass's report on the path, and why
3353
- * the pass decided nothing where it did (undecidedOf), which leaves disk
3354
- * unchecked. The caller holds the mount's turn.
3527
+ * Before a deletion, and for readBinary: a path the workspace doesn't hold
3528
+ * is observed under the three-way rule, which ingests a new disk file but
3529
+ * never brings back a workspace deletion or a file absent from this branch.
3530
+ * A path it holds is left alone. For a deletion, as for a write, taking
3531
+ * disk's change in first would let the push remove that change unseen; for
3532
+ * readBinary, a pass would drop a held binary file that disk changed, since
3533
+ * the tree takes in no binary disk copy. The pass names the path, as a file
3534
+ * tool names the one it acts on: a path the mount passes over by design is
3535
+ * observed, as a sync naming it would observe it (#276), while a path in
3536
+ * another mount's directory is refused before this, and passed over by any
3537
+ * pass. Answers the pass's report on the path, and why the pass decided
3538
+ * nothing where it did (undecidedOf), which leaves disk unchecked. The
3539
+ * caller holds the mount's turn.
3355
3540
  */
3356
3541
  async ensureSynced(mount, relativePath) {
3357
3542
  const store = this.getStore();
@@ -3388,24 +3573,53 @@ export class WorkspaceModule {
3388
3573
  }
3389
3574
  }
3390
3575
  /**
3391
- * A file's bytes for read, from where the mount keeps what it reads: the
3392
- * workspace's copy, taken in from disk first where it holds none
3393
- * (ensureSynced); or, on a direct mount, disk's file as it is now
3394
- * (bytesOnDisk). An error says why there are none to read.
3576
+ * A file's bytes for read, and the notes that say which copy they are. On a
3577
+ * 'chronicle' mount it's the workspace's copy, after a pass over the path
3578
+ * takes disk's change to it in first. On a direct mount it's disk's file as
3579
+ * it is now (bytesOnDisk), with no notes, since nothing else is kept. An
3580
+ * error says why there are none to read.
3395
3581
  */
3396
3582
  async bytesOf(mount, relativePath, path) {
3397
- if (mount.direct)
3398
- return this.bytesOnDisk(mount, relativePath, path);
3583
+ if (mount.direct) {
3584
+ const read = await this.bytesOnDisk(mount, relativePath, path);
3585
+ return 'error' in read ? read : { bytes: read.bytes, notes: [] };
3586
+ }
3399
3587
  const store = this.getStore();
3400
3588
  return this.withMount(mount, async () => {
3401
- const { report } = await this.ensureSynced(mount, relativePath);
3589
+ // A read shows the path as disk holds it now: a change on disk to a
3590
+ // file the workspace holds is taken in first, by the rule a listing
3591
+ // uses, as an edit's is. Where the stored copy is a draft and disk
3592
+ // changed too, the read shows the draft and names the conflict. The
3593
+ // pass names the path, as a file tool names the one it acts on, so a
3594
+ // path the mount passes over by design is observed, as a sync naming
3595
+ // it would observe it (#276).
3596
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3597
+ // A pass that decided nothing (undecidedOf: the selected branch kept
3598
+ // changing) leaves no report. It's read as a copy disk couldn't be
3599
+ // checked, with its reason, so the answer says the check didn't finish.
3600
+ const undecided = undecidedOf(pass);
3601
+ const report = pass.reports.get(relativePath)
3602
+ ?? (undecided !== null ? { path: relativePath, state: 'unverified', note: undecided } : undefined);
3603
+ // A check that decided nothing may well finish on the next read, as
3604
+ // grep's answer to the same says of grep.
3605
+ const again = undecided !== null ? ['Read again to check it.'] : [];
3606
+ // A mode taken in alone isn't a change to the text, so it leaves the
3607
+ // read nothing to say, as it leaves an edit nothing.
3608
+ const took = (op) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
3402
3609
  const entry = store.treeGet(mount.treeStateId, relativePath);
3403
- if (!entry)
3404
- return { error: this.notInWorkspace(path, report) };
3610
+ if (!entry) {
3611
+ // A pass also drops a copy disk made binary or too large, which
3612
+ // the disk-only answer describes; any other drop is disk's deletion.
3613
+ return {
3614
+ error: took('deleted') && report?.state !== 'disk-only'
3615
+ ? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3616
+ : [this.notInWorkspace(path, report), ...again].join(' '),
3617
+ };
3618
+ }
3405
3619
  const blob = store.getBlob(entry.blobHash);
3406
3620
  if (!blob)
3407
3621
  return { error: `Blob not found for: ${path}` };
3408
- return { bytes: blob };
3622
+ return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3409
3623
  });
3410
3624
  }
3411
3625
  /**
@@ -3429,8 +3643,7 @@ export class WorkspaceModule {
3429
3643
  catch (err) {
3430
3644
  // A refusal that names only the path says why with its errno, as a
3431
3645
  // failed read below does.
3432
- const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
3433
- return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
3646
+ return { error: containedRefusal(err, path) };
3434
3647
  }
3435
3648
  try {
3436
3649
  // No more than the limit is loaded, as for a file over it at its fstat.
@@ -3453,90 +3666,40 @@ export class WorkspaceModule {
3453
3666
  // ==========================================================================
3454
3667
  // Tool Handlers
3455
3668
  // ==========================================================================
3456
- async handleRead(input, generatedContent) {
3669
+ /**
3670
+ * A read's page, held to `maxChars`, the most of this call's result that
3671
+ * can reach its caller whole. A call that carries none, made in process,
3672
+ * takes the data itself: its page holds to its line limit alone, with every
3673
+ * line whole, as it did before pages had a budget.
3674
+ */
3675
+ async handleRead(input, maxChars, generatedContent) {
3457
3676
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
3458
- const offsetChars = input.offsetChars ?? 0;
3459
- const limitChars = input.limitChars ?? 2000;
3460
3677
  // Each number is a whole number of at least its schema's minimum by now
3461
3678
  // (inputProblem).
3462
3679
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
3463
3680
  return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
3464
3681
  }
3465
- let content = generatedContent;
3466
- if (content === undefined) {
3682
+ let file;
3683
+ let notes = [];
3684
+ if (generatedContent === undefined) {
3467
3685
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3468
3686
  input = { ...input, path };
3469
3687
  const found = await this.bytesOf(mount, relativePath, path);
3470
3688
  if ('error' in found)
3471
3689
  return { success: false, error: found.error, isError: true };
3472
- content = found.bytes.toString('utf-8');
3473
- }
3474
- if (characterPaging) {
3475
- const start = Math.min(offsetChars, content.length);
3476
- const splitsPair = (at) => at > 0 && at < content.length
3477
- && content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
3478
- && content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
3479
- if (splitsPair(start)) {
3480
- return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
3481
- }
3482
- let end = start + Math.min(limitChars, content.length - start);
3483
- if (splitsPair(end))
3484
- end++;
3485
- return {
3486
- success: true,
3487
- data: {
3488
- path: input.path,
3489
- totalChars: content.length,
3490
- offsetChars: start,
3491
- nextOffsetChars: end < content.length ? end : null,
3492
- content: content.slice(start, end),
3493
- },
3494
- };
3690
+ file = fileText(found.bytes);
3691
+ notes = found.notes;
3495
3692
  }
3496
- const lines = linesOf(content);
3497
- // Apply offset/limit. An unlimited read of a large file would inject the
3498
- // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
3499
- // past the model context and 400s), so an omitted limit falls back to a
3500
- // default cap; the result reports total/from/to so the agent can page.
3501
- const DEFAULT_READ_LINE_LIMIT = 2000;
3502
- const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
3503
- if (startLine >= lines.length) {
3504
- // Nothing to show: an empty file, or an offset past the last line. No
3505
- // range is claimed, and the line count says where the file ends.
3506
- return {
3507
- success: true,
3508
- data: {
3509
- path: input.path,
3510
- totalLines: lines.length,
3511
- // Reading an empty file from its start is reading it whole.
3512
- ...(startLine > 0
3513
- ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
3514
- : {}),
3515
- content: '',
3516
- },
3517
- };
3693
+ else {
3694
+ file = fileText(Buffer.from(generatedContent, 'utf-8'));
3518
3695
  }
3519
- const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
3520
- const endLine = startLine + effectiveLimit;
3521
- const slice = lines.slice(startLine, endLine);
3522
- const truncated = endLine < lines.length;
3523
- // Format with line numbers (cat -n style)
3524
- const formatted = slice
3525
- .map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
3526
- .join('\n');
3527
- return {
3528
- success: true,
3529
- data: {
3530
- path: input.path,
3531
- totalLines: lines.length,
3532
- fromLine: startLine + 1,
3533
- toLine: Math.min(endLine, lines.length),
3534
- ...(truncated
3535
- ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
3536
- : {}),
3537
- content: formatted,
3538
- },
3539
- };
3696
+ // An unlimited read of a large file would inject the whole thing into the
3697
+ // turn (a 2MB file ≈ 600k tokens → the request blows past the model
3698
+ // context and 400s), so an omitted limit falls back to a default, and a
3699
+ // page stops at the call's result limit where it has one.
3700
+ return characterPaging
3701
+ ? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
3702
+ : linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
3540
3703
  }
3541
3704
  async handleReadImage(input) {
3542
3705
  let mount;
@@ -3588,15 +3751,15 @@ export class WorkspaceModule {
3588
3751
  throw err;
3589
3752
  }
3590
3753
  }
3591
- async handleWrite(input) {
3754
+ async handleWrite(input, callerAgentName) {
3592
3755
  if (LONE_SURROGATE.test(input.content)) {
3593
3756
  return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
3594
3757
  }
3595
3758
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3596
3759
  input = { ...input, path };
3597
- if (mount.config.mode === 'read-only') {
3598
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
3599
- }
3760
+ const refusal = writeRefusal(mount);
3761
+ if (refusal)
3762
+ return { success: false, error: refusal, isError: true };
3600
3763
  const store = this.getStore();
3601
3764
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
3602
3765
  if (Buffer.byteLength(input.content) > maxSize) {
@@ -3604,6 +3767,13 @@ export class WorkspaceModule {
3604
3767
  }
3605
3768
  const buffer = Buffer.from(input.content, 'utf-8');
3606
3769
  const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
3770
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
3771
+ if (mount.direct) {
3772
+ const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
3773
+ if ('error' in written)
3774
+ return { success: false, isError: true, error: written.error };
3775
+ return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
3776
+ }
3607
3777
  let committed = false;
3608
3778
  let refused;
3609
3779
  let modeKept;
@@ -3627,13 +3797,13 @@ export class WorkspaceModule {
3627
3797
  return storedButUnfinished('write', input.path, { refused });
3628
3798
  return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
3629
3799
  }
3630
- async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS) {
3800
+ async handleEdit(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3631
3801
  return this.applyEdits('edit', [{
3632
3802
  path: input.path,
3633
3803
  oldString: input.oldString,
3634
3804
  newString: input.newString,
3635
3805
  expected: input.replaceAll ? 'all' : 1,
3636
- }], maxChars);
3806
+ }], maxChars, callerAgentName);
3637
3807
  }
3638
3808
  async handleEditMany(input, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName) {
3639
3809
  if (input.edits.length === 0) {
@@ -3750,6 +3920,10 @@ export class WorkspaceModule {
3750
3920
  return refuse(problems);
3751
3921
  if (!mount || mounts.size > 1)
3752
3922
  return refuse(problems);
3923
+ // A direct mount's edits apply to disk's text as it is now, and land on
3924
+ // disk or don't happen (applyDirectEdits).
3925
+ if (mount.direct)
3926
+ return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
3753
3927
  const store = this.getStore();
3754
3928
  // Whether the commit set the call's entries, all of them at once.
3755
3929
  let committed = false;
@@ -3858,50 +4032,12 @@ export class WorkspaceModule {
3858
4032
  problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
3859
4033
  continue;
3860
4034
  }
3861
- // Decoding would turn each invalid sequence into U+FFFD, and writing
3862
- // the text back would change bytes the edits never named.
3863
- const file = decodeForEdit(blob);
3864
- if (!file) {
3865
- problems.push({
3866
- kind: 'file',
3867
- edits: target.edits,
3868
- path: target.path,
3869
- text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
3870
- + 'Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.',
3871
- whole: false,
3872
- });
3873
- continue;
3874
- }
3875
- if (!target.specs.length)
3876
- continue;
3877
- const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
3878
- if ('problems' in planned) {
3879
- // One at a time: a call of many edits can have more problems
3880
- // than a spread passes as arguments.
3881
- for (const problem of planned.problems)
3882
- problems.push({ ...problem, path: target.path });
3883
- continue;
3884
- }
3885
- // The planner refuses a file its counts take past the limit before
3886
- // building it, from the least its bytes can be; one it built past
3887
- // the limit, as its CRs or multi-byte characters can take it, is
3888
- // refused here.
3889
- if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
3890
- const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
3891
- problems.push({
3892
- kind: 'file',
3893
- edits: target.edits,
3894
- path: target.path,
3895
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
3896
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
3897
- + "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
3898
- whole: false,
3899
- });
4035
+ const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
4036
+ if (plan === null)
3900
4037
  continue;
3901
- }
3902
4038
  plans.push({
3903
4039
  target,
3904
- plan: planned.plan,
4040
+ plan,
3905
4041
  mode: entry.mode,
3906
4042
  backOnDisk,
3907
4043
  conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
@@ -3918,15 +4054,12 @@ export class WorkspaceModule {
3918
4054
  })), () => { committed = true; });
3919
4055
  if (refused.size > 0)
3920
4056
  return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
3921
- return {
3922
- success: true,
3923
- data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
3924
- path: target.path,
3925
- plan,
3926
- notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
3927
- modeKept: modeKept.get(target.relativePath) ?? null,
3928
- })), given.length, maxChars),
3929
- };
4057
+ return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4058
+ path: target.path,
4059
+ plan,
4060
+ notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note) => note !== null),
4061
+ modeKept: modeKept.get(target.relativePath) ?? null,
4062
+ })), given.length, maxChars);
3930
4063
  });
3931
4064
  }
3932
4065
  catch (error) {
@@ -3979,8 +4112,9 @@ export class WorkspaceModule {
3979
4112
  // file.
3980
4113
  if (where.relativePath === '')
3981
4114
  return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
3982
- if (where.mount.config.mode === 'read-only')
3983
- return unusable(`Mount "${where.mount.config.name}" is read-only`);
4115
+ const refusal = writeRefusal(where.mount);
4116
+ if (refusal)
4117
+ return unusable(refusal);
3984
4118
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
3985
4119
  if (generated)
3986
4120
  return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
@@ -4059,12 +4193,515 @@ export class WorkspaceModule {
4059
4193
  ];
4060
4194
  return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
4061
4195
  }
4062
- async handleDelete(input) {
4196
+ // ==========================================================================
4197
+ // Direct writes
4198
+ // ==========================================================================
4199
+ /**
4200
+ * A direct mount's write of a whole file: its bytes staged beside it and
4201
+ * renamed over it (direct-write.ts), under the mount's turn, so the
4202
+ * module's own calls take turns. Answers a note when the file had other
4203
+ * hard links, which keep its old text, or why nothing was written. One
4204
+ * that lands is the caller's latest write on the mount (recordDirectWrite).
4205
+ * With `branch`, nothing is written once the store's branch is another,
4206
+ * checked with no turn of the event loop before the rename (writeBinary's).
4207
+ */
4208
+ async writeDirect(mount, relativePath, path, bytes, agentName, branch) {
4209
+ return this.withMount(mount, async () => {
4210
+ const { view, rootReal } = await this.diskView(mount);
4211
+ let staged;
4212
+ try {
4213
+ staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4214
+ }
4215
+ catch (error) {
4216
+ return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4217
+ }
4218
+ try {
4219
+ await directWriteTestSeam.afterStage?.([staged.path]);
4220
+ await placedStill(staged);
4221
+ // A write reads nothing first, so it replaces whatever is there.
4222
+ checkUnchanged(staged, null);
4223
+ if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
4224
+ await discard(staged);
4225
+ return { error: BRANCH_LEFT, branchLeft: true };
4226
+ }
4227
+ renameInto(staged);
4228
+ }
4229
+ catch (error) {
4230
+ await discard(staged);
4231
+ return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4232
+ }
4233
+ this.recordDirectWrite(mount, agentName);
4234
+ return { note: linksNote(path, staged, 'write') };
4235
+ });
4236
+ }
4237
+ /**
4238
+ * applyEdits on a direct mount, under the mount's turn, whose edits land
4239
+ * on disk or don't happen. Each attempt reads every file from disk as it
4240
+ * is now (directEditText), plans every edit against those bytes as on any
4241
+ * mount (planTargetEdits), stages every file beside itself, checks every
4242
+ * one unchanged since its read, and then renames each over its file in
4243
+ * turn (direct-write.ts). A file another writer changed between its read
4244
+ * and its check has the call read and planned again against its new
4245
+ * text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
4246
+ * longer apply to it, a file that changed at every attempt, or a file the
4247
+ * next read refuses as any read would (now past the size limit, say) are
4248
+ * refused, with nothing written. A rename that fails after others leaves
4249
+ * those written and the rest as they were, and the answer names each
4250
+ * file's outcome; nothing puts the written ones back.
4251
+ */
4252
+ async applyDirectEdits(tool, mount, targets, early, editCount, maxChars, agentName) {
4253
+ const changedMeanwhile = new Set();
4254
+ return this.withMount(mount, async () => {
4255
+ for (let attempt = 1;; attempt++) {
4256
+ const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
4257
+ if (outcome !== 'changed')
4258
+ return outcome;
4259
+ if (attempt === DIRECT_EDIT_ATTEMPTS) {
4260
+ const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
4261
+ // Each attempt stops at the first file it finds changed, so these are
4262
+ // the files some attempt found changed, at most one per attempt.
4263
+ return {
4264
+ success: false,
4265
+ isError: true,
4266
+ error: tool === 'edit'
4267
+ ? `${changed[0]} kept changing on disk while this edit was being applied (${DIRECT_EDIT_ATTEMPTS} attempts), so nothing was written. Read it again before retrying.`
4268
+ : `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
4269
+ + `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
4270
+ };
4271
+ }
4272
+ }
4273
+ });
4274
+ }
4275
+ /**
4276
+ * One attempt of applyDirectEdits: its answer, or 'changed' when another
4277
+ * writer changed a file since its read, with nothing written. `early` are
4278
+ * the problems found before any file was read, which a refusal names with
4279
+ * the rest. The caller holds the mount's turn.
4280
+ */
4281
+ async attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName) {
4282
+ const problems = [...early];
4283
+ const read = new Map();
4284
+ for (const target of targets) {
4285
+ const got = await this.directEditText(mount, target.relativePath, target.path);
4286
+ if ('changed' in got) {
4287
+ changedMeanwhile.add(target);
4288
+ return 'changed';
4289
+ }
4290
+ if ('error' in got)
4291
+ problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
4292
+ else
4293
+ read.set(target, got);
4294
+ }
4295
+ // Two paths that reach one file, through a link this mount follows, or
4296
+ // as two hard links (a disk that folds case has its two spellings made
4297
+ // one path by the parse, short of a directory link on the way, past
4298
+ // which they're refused here as one file named twice): each
4299
+ // would be planned against the same text and renamed over the same file
4300
+ // or split from it, so one's edits would be lost or kept apart from the
4301
+ // other's. They're refused. One file read twice has one identity here
4302
+ // unless a writer changed it between the reads, and the check before the
4303
+ // renames then sees that and reads again.
4304
+ const byIdentity = new Map();
4305
+ for (const [target, { identity }] of read) {
4306
+ const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
4307
+ const other = byIdentity.get(key);
4308
+ if (!other) {
4309
+ byIdentity.set(key, target);
4310
+ continue;
4311
+ }
4312
+ problems.push({
4313
+ kind: 'file',
4314
+ edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
4315
+ path: other.path,
4316
+ text: `${other.path} and ${target.path} are one file, reached through a link or as two hard links; name it one way, so its edits are matched in one text`,
4317
+ whole: true,
4318
+ });
4319
+ }
4320
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4321
+ const hints = hintBudget();
4322
+ // As much as the largest file the call names holds, at least, so the
4323
+ // budget is the same whatever order its files are planned in.
4324
+ let largest = maxSize;
4325
+ for (const { bytes } of read.values())
4326
+ largest = Math.max(largest, bytes.length);
4327
+ const counts = countBudget(largest);
4328
+ const plans = [];
4329
+ for (const target of targets) {
4330
+ const got = read.get(target);
4331
+ if (!got)
4332
+ continue;
4333
+ const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
4334
+ if (plan !== null)
4335
+ plans.push({ target, plan });
4336
+ }
4337
+ // A file read again for another writer's change says so, whichever way
4338
+ // its edits went.
4339
+ const reread = (target, verb) => !changedMeanwhile.has(target) ? null
4340
+ : `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
4341
+ + `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
4342
+ const refuse = (found) => ({
4343
+ success: false,
4344
+ isError: true,
4345
+ error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note) => note !== null), maxChars, 'wrote'),
4346
+ });
4347
+ if (problems.length)
4348
+ return refuse(problems);
4349
+ const { view, rootReal } = await this.diskView(mount);
4350
+ const staged = [];
4351
+ const discardAll = async (from = 0) => {
4352
+ for (const { staged: file } of staged.slice(from))
4353
+ await discard(file);
4354
+ };
4355
+ // A refusal before any file is renamed says so where the call names several.
4356
+ const nothingWritten = (target, error) => refuse([{
4357
+ kind: 'file',
4358
+ edits: target.edits,
4359
+ path: target.path,
4360
+ text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4361
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
4362
+ whole: true,
4363
+ }]);
4364
+ for (const { target, plan } of plans) {
4365
+ try {
4366
+ staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
4367
+ }
4368
+ catch (error) {
4369
+ await discardAll();
4370
+ if (error instanceof FileChanged) {
4371
+ changedMeanwhile.add(target);
4372
+ return 'changed';
4373
+ }
4374
+ return nothingWritten(target, error);
4375
+ }
4376
+ }
4377
+ try {
4378
+ await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
4379
+ }
4380
+ catch (error) {
4381
+ await discardAll();
4382
+ throw error;
4383
+ }
4384
+ for (const { target, staged: file } of staged) {
4385
+ try {
4386
+ await placedStill(file);
4387
+ }
4388
+ catch (error) {
4389
+ await discardAll();
4390
+ return nothingWritten(target, error);
4391
+ }
4392
+ }
4393
+ // Every file is checked, then every file renamed, with no turn of the
4394
+ // event loop between: a change to any of them before its check is seen
4395
+ // before any is written, and one landing between its check and the last
4396
+ // rename isn't (checkUnchanged).
4397
+ for (const { target, staged: file } of staged) {
4398
+ try {
4399
+ checkUnchanged(file, read.get(target));
4400
+ }
4401
+ catch (error) {
4402
+ await discardAll();
4403
+ if (error instanceof FileChanged) {
4404
+ changedMeanwhile.add(target);
4405
+ return 'changed';
4406
+ }
4407
+ return nothingWritten(target, error);
4408
+ }
4409
+ }
4410
+ for (const [i, { target, staged: file }] of staged.entries()) {
4411
+ try {
4412
+ renameInto(file);
4413
+ }
4414
+ catch (error) {
4415
+ if (i === 0) {
4416
+ await discardAll();
4417
+ return nothingWritten(target, error);
4418
+ }
4419
+ // Read with no turn of the event loop since the last rename.
4420
+ this.recordDirectWrite(mount, agentName);
4421
+ await discardAll(i);
4422
+ return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
4423
+ }
4424
+ }
4425
+ this.recordDirectWrite(mount, agentName);
4426
+ return editReceipt(tool, staged.map(({ target, staged: file }) => ({
4427
+ path: target.path,
4428
+ plan: plans.find((p) => p.target === target).plan,
4429
+ notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note) => note !== null),
4430
+ modeKept: null,
4431
+ })), editCount, maxChars);
4432
+ }
4433
+ /**
4434
+ * The answer to an edit_many call on a direct mount whose rename of its
4435
+ * `failed`th file failed after the ones before it were written: what was
4436
+ * written, the failure, and that nothing puts them back, then each file's
4437
+ * outcome, within `maxChars`, with the hard links a written one split
4438
+ * from, as a full answer names them (linksNote).
4439
+ */
4440
+ directEditsPartway(staged, failed, error, maxChars) {
4441
+ // The failed file is named among the outcomes, so the head stays as
4442
+ // long as its reason, whatever the paths' lengths.
4443
+ const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
4444
+ + "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
4445
+ const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
4446
+ const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note) => note !== null);
4447
+ return fitted(head, parts, notes, maxChars, 'written');
4448
+ }
4449
+ /**
4450
+ * A direct mount's file as an edit reads it: its bytes on disk as they are
4451
+ * now, through the mount's boundary as read reaches them
4452
+ * (openContainedFile), with the file's identity when the read began, which
4453
+ * the write checks it against just before its rename. A file changed while
4454
+ * it was read is `changed`, so the call reads it again. What the
4455
+ * workspace doesn't show as text, it doesn't edit either: a file over the
4456
+ * mount's size limit, and a binary one.
4457
+ */
4458
+ async directEditText(mount, relativePath, path) {
4459
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4460
+ const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
4461
+ let opened;
4462
+ try {
4463
+ opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4464
+ if (fileStat.size > maxSize)
4465
+ throw new Error(over);
4466
+ });
4467
+ }
4468
+ catch (err) {
4469
+ if (err instanceof WorkspaceReadError && err.code === 'changed')
4470
+ return { changed: true };
4471
+ return { error: containedRefusal(err, path) };
4472
+ }
4473
+ try {
4474
+ const before = identityOf(await opened.handle.stat({ bigint: true }));
4475
+ const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4476
+ if (more)
4477
+ return { error: over };
4478
+ if (looksBinary(bytes))
4479
+ return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
4480
+ if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true }))))
4481
+ return { changed: true };
4482
+ return { bytes, identity: before };
4483
+ }
4484
+ catch (err) {
4485
+ const errno = errnoOf(err);
4486
+ return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4487
+ }
4488
+ finally {
4489
+ await opened.handle.close();
4490
+ }
4491
+ }
4492
+ /**
4493
+ * A direct mount's deletion: the file the read tools reach, by their
4494
+ * refusals (openContainedFile), unlinked through the mount's boundary as
4495
+ * a push's deletion unlinks one (unlinkContained). A followed link is the
4496
+ * entry removed, as there. One that lands is the caller's latest write on
4497
+ * the mount (recordDirectWrite).
4498
+ */
4499
+ async deleteDirect(mount, relativePath, path, agentName) {
4500
+ return this.withMount(mount, async () => {
4501
+ // One root for the call, by its identity as the call starts: the
4502
+ // open's checks and the unlink's own refuse any other directory there,
4503
+ // in ROOT_MOVED's words, so a root replaced after this stat is refused
4504
+ // rather than taken, but in the instant between the unlink's last
4505
+ // check and the unlink, which Node can't close (effects.ts's header).
4506
+ // One that can't be read now is refused here.
4507
+ const disk = await this.diskView(mount);
4508
+ let rootStat;
4509
+ try {
4510
+ rootStat = await stat(disk.rootReal, { bigint: true });
4511
+ }
4512
+ catch (err) {
4513
+ const code = errnoOf(err);
4514
+ const why = code === 'ENOENT' || code === 'ENOTDIR'
4515
+ ? `the mount root is gone (${code}), and calling again is refused the same way until whoever runs the host puts a directory back at the mount's path`
4516
+ : code === 'EACCES' || code === 'EPERM'
4517
+ ? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
4518
+ : `the mount root can't be read (${code ?? String(err)})`;
4519
+ return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
4520
+ }
4521
+ const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4522
+ const { rootReal } = disk;
4523
+ try {
4524
+ const opened = await this.openContainedFile(mount, relativePath, path);
4525
+ await opened.handle.close();
4526
+ }
4527
+ catch (err) {
4528
+ // The open's own words for a replaced root speak of reading.
4529
+ if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
4530
+ return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
4531
+ }
4532
+ return { success: false, isError: true, error: containedRefusal(err, path) };
4533
+ }
4534
+ // The unlink is awaited, unlike a write's rename, so where the head
4535
+ // was just before it says whether the head moved while it ran.
4536
+ const since = this.positionNow();
4537
+ try {
4538
+ await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
4539
+ }
4540
+ catch (error) {
4541
+ // A failure after the unlink reached the file's entry can't say
4542
+ // whether the file is gone, so it's taken as the agent's write too.
4543
+ if (error instanceof EffectFailed && error.touched) {
4544
+ this.recordDirectWrite(mount, agentName, since);
4545
+ return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
4546
+ }
4547
+ return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
4548
+ }
4549
+ this.recordDirectWrite(mount, agentName, since);
4550
+ return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
4551
+ });
4552
+ }
4553
+ /**
4554
+ * After a direct write lands: the agent's latest write on the mount, P,
4555
+ * moves to it, where the agent's history holds P or the notice of its
4556
+ * departure from P, N (historyHoldsWrites), and N clears. Otherwise P
4557
+ * stays where it is: the head moved away from it under the call's turn,
4558
+ * and the next start's notice covers what it left (headMoveNotice). P is
4559
+ * the branch and the store's head as it is now, read with no turn of the
4560
+ * event loop since the write, so no record that lands after the write is
4561
+ * taken for one before it. A deletion, whose unlink is awaited, passes
4562
+ * where the head was just before its unlink (`since`): where the branch
4563
+ * changed meanwhile, the head moved under the call, and P is set there,
4564
+ * so the next start judges the deletion by the branch its turn was on,
4565
+ * unless the history doesn't hold P already, whose notice is owed. A write no agent
4566
+ * made tells no one. The store's reads here come after the write landed,
4567
+ * so one that throws is logged and P stays, as for a history that can't
4568
+ * be read, and the write still answers that it landed.
4569
+ */
4570
+ recordDirectWrite(mount, agentName, since) {
4571
+ if (agentName === undefined)
4572
+ return;
4573
+ try {
4574
+ const store = this.getStore();
4575
+ let heads = this.directHeads.get(mount.config.name);
4576
+ if (!heads)
4577
+ this.directHeads.set(mount.config.name, (heads = new Map()));
4578
+ const head = heads.get(agentName);
4579
+ const branchId = store.currentBranch().id;
4580
+ if (since && since.branchId !== branchId) {
4581
+ if (!head || this.historyHoldsWrites(agentName, head) === true)
4582
+ heads.set(agentName, { ...since, notice: null });
4583
+ return;
4584
+ }
4585
+ if (head && this.historyHoldsWrites(agentName, head) !== true)
4586
+ return;
4587
+ heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
4588
+ }
4589
+ catch (err) {
4590
+ console.error('[workspace] could not record a direct write\'s position:', err);
4591
+ }
4592
+ }
4593
+ /** The store's branch and head now, or null where the store can't say. */
4594
+ positionNow() {
4595
+ try {
4596
+ const store = this.getStore();
4597
+ return { branchId: store.currentBranch().id, after: store.currentSequence() };
4598
+ }
4599
+ catch {
4600
+ return null;
4601
+ }
4602
+ }
4603
+ /**
4604
+ * Whether an agent's history holds its latest direct write on a mount
4605
+ * (holdsPosition), or the notice of its departure from it, judged by the
4606
+ * notice's own presence there, through the lookup the framework hands the
4607
+ * module (useHistory); null where that lookup threw, which is logged. A
4608
+ * history that can't be read decides nothing: a write takes it as not
4609
+ * held, so its position stays and the notice stays owed, and a start
4610
+ * gives no notice, which may not be true of a history it can't read, and
4611
+ * leaves the next start to decide (headMoveNotice).
4612
+ */
4613
+ historyHoldsWrites(agentName, head) {
4614
+ if (this.holdsPosition(head))
4615
+ return true;
4616
+ if (head.notice === null)
4617
+ return false;
4618
+ try {
4619
+ return this.historyLookup?.(agentName, head.notice) === true;
4620
+ }
4621
+ catch (err) {
4622
+ console.error('[workspace] history lookup for the head-move notice failed:', err);
4623
+ return null;
4624
+ }
4625
+ }
4626
+ /**
4627
+ * Whether the store's current branch holds what came before a direct
4628
+ * write: it's the write's branch, or continues it with every fork point on
4629
+ * the way after `after`, the store's head when the write landed. A fork at
4630
+ * exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
4631
+ * which is the head at the write when nothing was recorded in the turn
4632
+ * before it. A missing branch or one without fork metadata holds nothing,
4633
+ * as for the materialize guard (isLinearContinuation).
4634
+ */
4635
+ holdsPosition({ branchId, after }) {
4636
+ const store = this.getStore();
4637
+ const current = store.currentBranch();
4638
+ if (current.id === branchId)
4639
+ return true;
4640
+ const byId = new Map(store.listBranches().map((b) => [b.id, b]));
4641
+ const visited = new Set();
4642
+ let cursor = byId.get(current.id);
4643
+ while (cursor && !visited.has(cursor.id)) {
4644
+ visited.add(cursor.id);
4645
+ if (cursor.id === branchId)
4646
+ return true;
4647
+ if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after)
4648
+ return false;
4649
+ cursor = byId.get(cursor.parentId);
4650
+ }
4651
+ return false;
4652
+ }
4653
+ /**
4654
+ * The framework's check at every start of an agent's turn, opening,
4655
+ * continuing or resuming: the notice due for each mount the agent wrote
4656
+ * direct where its history holds neither its latest write there nor the
4657
+ * notice of its departure from it (historyHoldsWrites), with those
4658
+ * mounts' names; or null, as on a head that hasn't moved. The framework
4659
+ * stores the text in the agent's history before the turn's compile and
4660
+ * hands its message id back (headMoveNoticeStored).
4661
+ */
4662
+ headMoveNotice(agentName) {
4663
+ if (!this.store)
4664
+ return null;
4665
+ const due = [];
4666
+ // Every mount where the agent wrote direct, whether it runs direct this
4667
+ // session or as 'chronicle': the writes are on disk either way.
4668
+ for (const name of this.mounts.keys()) {
4669
+ const head = this.directHeads.get(name)?.get(agentName);
4670
+ if (!head)
4671
+ continue;
4672
+ const held = this.historyHoldsWrites(agentName, head);
4673
+ // A history that can't be read decides nothing at this start.
4674
+ if (held === null)
4675
+ return null;
4676
+ if (!held)
4677
+ due.push(name);
4678
+ }
4679
+ return due.length ? { text: headMoveText(due), mounts: due } : null;
4680
+ }
4681
+ /** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
4682
+ headMoveNoticeStored(agentName, mounts, messageId) {
4683
+ for (const name of mounts) {
4684
+ const head = this.directHeads.get(name)?.get(agentName);
4685
+ if (head)
4686
+ head.notice = messageId;
4687
+ }
4688
+ }
4689
+ /**
4690
+ * How the module asks whether an agent's history holds a message, as the
4691
+ * framework judges its own notices (`cm.getMessage(id) !== null`): handed
4692
+ * over once, when the module is added. Without it, no notice is held.
4693
+ */
4694
+ useHistory(lookup) {
4695
+ this.historyLookup = lookup;
4696
+ }
4697
+ async handleDelete(input, callerAgentName) {
4063
4698
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
4064
4699
  input = { ...input, path };
4065
- if (mount.config.mode === 'read-only') {
4066
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
4067
- }
4700
+ const refusal = writeRefusal(mount);
4701
+ if (refusal)
4702
+ return { success: false, error: refusal, isError: true };
4703
+ if (mount.direct)
4704
+ return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
4068
4705
  const store = this.getStore();
4069
4706
  let committed = false;
4070
4707
  try {
@@ -4109,7 +4746,9 @@ export class WorkspaceModule {
4109
4746
  const mounts = [...this.mounts.entries()].map(([name, m]) => ({
4110
4747
  name,
4111
4748
  path: m.config.path,
4112
- mode: m.config.mode,
4749
+ // A mount configured direct says its mode where its config names
4750
+ // none, so a writable one shows as writable.
4751
+ mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
4113
4752
  ...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
4114
4753
  ...(m.config.include ? { include: [...m.config.include] } : {}),
4115
4754
  }));
@@ -4263,16 +4902,26 @@ export class WorkspaceModule {
4263
4902
  // starts there: past the walk's cap, a walk of the whole scope can stop
4264
4903
  // before it gets there. In the mount a pattern's first segment names, the
4265
4904
  // pattern is read mount-prefixed (prefixedGlob), from its rest's directory
4266
- // within `path`.
4905
+ // within `path`. A `path` that directory can't overlap leaves no file the
4906
+ // pattern can match, so the glob is refused, as grep's is, saying how
4907
+ // the pattern is read, rather than answered with no matches; but a path
4908
+ // in another mount's directory is that mount's, and is said so, as grep
4909
+ // says it. Only a `path` can do either, and it names one mount, so
4910
+ // nothing has been searched yet.
4267
4911
  const patternDir = literalDirOf(input.pattern);
4268
- for (const { mount, relativePath } of mountsToSearch) {
4912
+ for (const { mount, relativePath, moved, root } of mountsToSearch) {
4269
4913
  const prefixed = prefixedGlob(input.pattern, mount.config.name);
4270
4914
  let start;
4271
4915
  let matched;
4272
4916
  if (prefixed) {
4273
4917
  const dir = deeperOf(relativePath, prefixed.dir);
4274
- if (dir === null)
4275
- continue;
4918
+ if (dir === null) {
4919
+ if (this.holderOf(mount.config.name, relativePath, false, moved)) {
4920
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
4921
+ continue;
4922
+ }
4923
+ return { success: false, error: await this.disjointRefusal('glob', input.path, input.pattern, true, mount, relativePath, root), isError: true };
4924
+ }
4276
4925
  start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
4277
4926
  matched = (path) => prefixed.regex.test(path);
4278
4927
  }
@@ -4307,11 +4956,19 @@ export class WorkspaceModule {
4307
4956
  return { success: false, error: `The grep did nothing: ${e instanceof Error ? e.message : String(e)}.`, isError: true };
4308
4957
  }
4309
4958
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4310
- // The filter matches mount-relative paths, so every file it lets through
4311
- // lies beneath its literal directory, and the walk starts at the deeper of
4312
- // that and `path`. In the mount a filter's first segment names, its
4313
- // mount-prefixed reading takes the filter's place (below).
4959
+ // The filter matches mount-relative paths, from the mount's root whatever
4960
+ // the `path`, so every file it lets through lies beneath its literal
4961
+ // directory, and the walk starts at the deeper of that and `path`. In the
4962
+ // mount a filter's first segment names, its mount-prefixed reading takes
4963
+ // the filter's place (below). One with no "/" also matches a file's name,
4964
+ // wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
4965
+ // file through, where tested against the path alone it let through only
4966
+ // those at the mount's root. The path is still tried, so a "**" in such a
4967
+ // filter spans directories as it did, and nothing it let through before
4968
+ // is lost. A filter read mount-prefixed always holds a "/" after the
4969
+ // mount's name, so it never takes the name test.
4314
4970
  const filterDir = input.glob ? literalDirOf(input.glob) : '';
4971
+ const byName = fileGlob !== null && !input.glob.includes('/');
4315
4972
  const contextBefore = input.contextBefore ?? 0;
4316
4973
  const contextAfter = input.contextAfter ?? 0;
4317
4974
  const mountsToSearch = input.path
@@ -4328,8 +4985,27 @@ export class WorkspaceModule {
4328
4985
  const results = [];
4329
4986
  const skipped = [];
4330
4987
  const incomplete = [];
4331
- const search = (content) => {
4332
- const lines = linesOf(content);
4988
+ // Lines as read shows them (fileText): a CRLF end's CR is left out, for
4989
+ // matching as well as display, unless the line's own text ends in a CR, so
4990
+ // `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
4991
+ // the line copies into an edit as read's do. ripgrep's --crlf still prints
4992
+ // the line with its CR.
4993
+ //
4994
+ // Whether a file has CRLF ends or a BOM is a fact about the file, not a
4995
+ // line, so each searched file that has either is kept here by its path,
4996
+ // for the note on a pattern that names a CR or U+FEFF to name it. A
4997
+ // conflict's disk version is kept, for each, only where the workspace's
4998
+ // copy of its path doesn't have it, and is named as that version, since
4999
+ // read doesn't show it.
5000
+ const crlfFiles = new Map();
5001
+ const bomFiles = new Map();
5002
+ const search = (text) => {
5003
+ const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5004
+ const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
5005
+ if (lineEndings !== undefined && !crlfFiles.has(text.file))
5006
+ crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
5007
+ if (bom && !bomFiles.has(text.file))
5008
+ bomFiles.set(text.file, text.version);
4333
5009
  const found = [];
4334
5010
  for (let i = 0; i < lines.length; i++) {
4335
5011
  if (regex.test(lines[i])) {
@@ -4342,7 +5018,7 @@ export class WorkspaceModule {
4342
5018
  found.push(match);
4343
5019
  }
4344
5020
  }
4345
- return found;
5021
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
4346
5022
  };
4347
5023
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
4348
5024
  // A path named in another mount's directory, as holderOf resolves it,
@@ -4355,37 +5031,73 @@ export class WorkspaceModule {
4355
5031
  }
4356
5032
  // In the mount a filter's first segment names, the filter is read
4357
5033
  // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
4358
- // a filter whose directories don't overlap leave no file the filter can
4359
- // let through, so nothing is searched.
5034
+ // a filter whose directories, in the reading this mount takes, don't
5035
+ // overlap leave no file the filter can let through, so the grep is
5036
+ // refused, saying how the filter is read, rather than answered with no
5037
+ // matches: written relative to the `path`, as glob's pattern is, it
5038
+ // names nothing there. Only a `path` can do this, and it names one
5039
+ // mount, so nothing has been searched yet.
4360
5040
  const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
4361
5041
  const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
4362
- if (deeper === null)
4363
- continue;
5042
+ if (deeper === null) {
5043
+ return { success: false, error: await this.disjointRefusal('grep', input.path, input.glob, prefixed !== null, mount, relativePath, root), isError: true };
5044
+ }
4364
5045
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
4365
5046
  const searched = (text) => {
4366
- const matches = search(text.text);
4367
- if (matches.length > 0)
4368
- results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
5047
+ const hits = search(text);
5048
+ if (hits.matches.length > 0)
5049
+ results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
4369
5050
  };
4370
5051
  const kept = prefixed
4371
5052
  ? (path) => prefixed.regex.test(path)
4372
- : (path) => fileGlob === null || fileGlob.test(path);
5053
+ : (path) => fileGlob === null || fileGlob.test(path)
5054
+ || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
4373
5055
  const found = mount.direct
4374
5056
  ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
4375
5057
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
4376
5058
  skipped.push(...found.skipped);
4377
5059
  incomplete.push(...found.incomplete);
4378
5060
  }
4379
- return {
4380
- success: true,
4381
- data: {
4382
- pattern: input.pattern,
4383
- results,
4384
- totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
4385
- ...(skipped.length > 0 ? { skipped } : {}),
4386
- ...(incomplete.length > 0 ? { incomplete } : {}),
4387
- },
5061
+ const answer = {
5062
+ pattern: input.pattern,
5063
+ results,
5064
+ totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
5065
+ // A pattern written for a CR can't find a CRLF end's, nor one written
5066
+ // for U+FEFF a BOM, which lines as read shows them leave out, so the
5067
+ // answer says why it didn't, and names the files that have them.
5068
+ ...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
5069
+ ...(skipped.length > 0 ? { skipped } : {}),
5070
+ ...(incomplete.length > 0 ? { incomplete } : {}),
4388
5071
  };
5072
+ return textAnswer(grepText(answer, contextBefore), { ...answer });
5073
+ }
5074
+ /**
5075
+ * Why a grep or a glob whose `path` (`given`, in the mount at
5076
+ * `relativePath`) and filter or pattern can't overlap did nothing. grep's
5077
+ * filter is matched from the mount's root, and in the mount its first
5078
+ * segment names, either tool's is read mount-prefixed (`prefixed`), from
5079
+ * that mount's root; either way no file at or beneath the path can pass
5080
+ * it. Where the path isn't a file, it names the one that reaches beneath
5081
+ * it, the one meant if it was written relative to the path, mount-prefixed
5082
+ * as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
5083
+ * `?`, which matches each of them, and since the search stays beneath the
5084
+ * path, a `?` that would match a sibling's name too takes nothing in from
5085
+ * outside it. One with an empty, `.` or `..` segment gets no suggestion,
5086
+ * since no path holds such a segment. Segments are read as written, so one
5087
+ * a brace group splits around isn't told apart.
5088
+ */
5089
+ async disjointRefusal(tool, given, filter, prefixed, mount, relativePath, root) {
5090
+ const name = mount.config.name;
5091
+ const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
5092
+ const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
5093
+ + (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
5094
+ if (await this.namesFile(mount, relativePath, root))
5095
+ return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
5096
+ const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
5097
+ if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..'))
5098
+ return within;
5099
+ const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
5100
+ return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
4389
5101
  }
4390
5102
  /**
4391
5103
  * The texts a 'chronicle' mount's grep searches beneath `start`, or in the
@@ -4475,7 +5187,7 @@ export class WorkspaceModule {
4475
5187
  const entry = store.treeGet(mount.treeStateId, report.path);
4476
5188
  const blob = entry ? store.getBlob(entry.blobHash) : null;
4477
5189
  if (blob) {
4478
- each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
5190
+ each({ file, state: report.state, version: 'workspace', bytes: blob });
4479
5191
  }
4480
5192
  else if (entry) {
4481
5193
  skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
@@ -4491,7 +5203,7 @@ export class WorkspaceModule {
4491
5203
  const changed = report.conflict?.diskChangedSinceRecorded === true;
4492
5204
  const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
4493
5205
  if (diskBlob) {
4494
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
5206
+ each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
4495
5207
  }
4496
5208
  if (disk?.stored && !diskBlob) {
4497
5209
  skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
@@ -4541,7 +5253,7 @@ export class WorkspaceModule {
4541
5253
  else if (looksBinary(read.head))
4542
5254
  skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
4543
5255
  else
4544
- each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
5256
+ each({ file, version: 'disk', bytes: read.bytes });
4545
5257
  });
4546
5258
  return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
4547
5259
  }
@@ -4557,7 +5269,7 @@ export class WorkspaceModule {
4557
5269
  if (mount.direct) {
4558
5270
  // A direct mount keeps nothing to count: its kind, and the ignore
4559
5271
  // list its walks go by, whose default no config shows.
4560
- status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
5272
+ status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
4561
5273
  continue;
4562
5274
  }
4563
5275
  // What the workspace's history holds outside the mount's include isn't the mount's,
@@ -4571,7 +5283,7 @@ export class WorkspaceModule {
4571
5283
  const currentBranch = store.currentBranch();
4572
5284
  status[name] = {
4573
5285
  path: mount.config.path,
4574
- mode: mount.config.mode,
5286
+ mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
4575
5287
  // A mount configured direct runs as 'chronicle' this session
4576
5288
  // (chooseKinds), by the direct kind's ignore list where its config
4577
5289
  // names none.
@@ -4746,8 +5458,9 @@ export class WorkspaceModule {
4746
5458
  mountsToMaterialize = [{ name: input.mount, mount: m }];
4747
5459
  }
4748
5460
  else {
5461
+ // Not a mount running direct, whose writes are on disk already.
4749
5462
  mountsToMaterialize = [...this.mounts.entries()]
4750
- .filter(([, m]) => m.config.mode === 'read-write')
5463
+ .filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
4751
5464
  .map(([name, mount]) => ({ name, mount }));
4752
5465
  }
4753
5466
  // Branch guard, scoped to the mounts actually being materialized: a
@@ -4766,6 +5479,12 @@ export class WorkspaceModule {
4766
5479
  const guarded = [];
4767
5480
  let proceeded = 0;
4768
5481
  for (const { name, mount } of mountsToMaterialize) {
5482
+ // Only a writable direct mount the caller named gets here: it keeps no
5483
+ // tree to push. A read-only one is named as any read-only mount is.
5484
+ if (mount.direct && mount.config.mode !== 'read-only') {
5485
+ blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
5486
+ continue;
5487
+ }
4769
5488
  if (mount.config.mode === 'read-only') {
4770
5489
  // Only a mount the caller named gets here read-only (a bare
4771
5490
  // materialize takes the read-write ones): say why nothing was written.
@@ -4884,7 +5603,8 @@ export class WorkspaceModule {
4884
5603
  return [];
4885
5604
  const store = this.getStore();
4886
5605
  const mount = this.mounts.get(mountName);
4887
- if (!mount || mount.config.mode === 'read-only')
5606
+ // A direct mount keeps no tree to push: disk is its only copy.
5607
+ if (!mount || mount.config.mode === 'read-only' || mount.direct)
4888
5608
  return [];
4889
5609
  // Reset tracking — we're deliberately materializing on the new branch
4890
5610
  mount.lastMaterializedBranchId = null;
@@ -5108,9 +5828,9 @@ export class WorkspaceModule {
5108
5828
  * on disk (a shell command's files, a deletion) is in the workspace when the
5109
5829
  * agent next looks. On a mount over its walkCap, that holds for the first
5110
5830
  * walkCap entries in the walk's order, which every batch's scan takes; files
5111
- * past them come in through syncs and the listings that reach them, while a
5112
- * read takes in only a file the workspace doesn't hold yet. A scan that
5113
- * outlasts the deadline stops holding the round: the miss is recorded on
5831
+ * past them come in through syncs, the listings that reach them, and a read
5832
+ * of the path, which takes disk's change in first as a listing does. A scan
5833
+ * that outlasts the deadline stops holding the round: the miss is recorded on
5114
5834
  * the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
5115
5835
  * event, and the scan continues, deciding on the branch selected when it
5116
5836
  * applies.
@@ -5338,16 +6058,33 @@ const UNREADABLE_INTENT = "its workspace intent can't be read; a sync of the pat
5338
6058
  function watchOf(config) {
5339
6059
  return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
5340
6060
  }
6061
+ /**
6062
+ * Why a write, edit, deletion or peer module's writeBinary to this mount is
6063
+ * refused, or null where it may go ahead: a read-only mount, and a mount
6064
+ * configured direct that runs as 'chronicle' this session (chooseKinds),
6065
+ * since the store holds changes there that disk lacks. A write there would
6066
+ * stay in the store, beside an agent told the mount is its checkout, and
6067
+ * add to what keeps it off direct; refused, what's held only shrinks, and
6068
+ * the next start once none is held runs it direct.
6069
+ */
6070
+ function writeRefusal(mount) {
6071
+ const name = mount.config.name;
6072
+ if (mount.config.mode === 'read-only')
6073
+ return `Mount "${name}" is read-only`;
6074
+ if (mount.config.backend === 'direct' && !mount.direct) {
6075
+ return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6076
+ + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6077
+ + `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct`;
6078
+ }
6079
+ return null;
6080
+ }
5341
6081
  /**
5342
6082
  * What a direct mount's config sets that would never act there, each with
5343
- * the way out (MountConfig.backend): a direct mount is read-only, and has no
5344
- * watcher to tune, no change event to wake on and no push to make.
6083
+ * the way out (MountConfig.backend): a direct mount has no watcher to tune,
6084
+ * no change event to wake on and no push to make. Either mode is its own.
5345
6085
  */
5346
6086
  function directRefusals(mount) {
5347
6087
  const refused = [];
5348
- if (mount.mode !== 'read-only') {
5349
- refused.push(mount.mode === undefined ? "mode must be 'read-only' (left out, it's 'read-write')" : `mode must be 'read-only', not ${shownValue(mount.mode)}`);
5350
- }
5351
6088
  if (mount.watch !== undefined && mount.watch !== 'never')
5352
6089
  refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
5353
6090
  for (const key of ['watchDebounceMs', 'watchRootPollMs']) {
@@ -5361,6 +6098,146 @@ function directRefusals(mount) {
5361
6098
  refused.push('autoMaterialize would never push (leave it out)');
5362
6099
  return refused;
5363
6100
  }
6101
+ /** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
6102
+ const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
6103
+ /**
6104
+ * A saved record as saveDirectHeads writes it, or a throw naming what isn't:
6105
+ * a list of entries, each a mount's name, root and agents, and each agent's
6106
+ * branch, head and, where set, notice. Checked whole, carried entries
6107
+ * included, so a record of another shape is refused at load rather than
6108
+ * carried into the next save.
6109
+ */
6110
+ function checkedHeads(value) {
6111
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
6112
+ if (!Array.isArray(value))
6113
+ throw new Error("the record isn't of the shape saved");
6114
+ for (const saved of value) {
6115
+ if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
6116
+ throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
6117
+ }
6118
+ for (const [agent, head] of Object.entries(saved.agents)) {
6119
+ if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
6120
+ || (head.notice !== undefined && typeof head.notice !== 'string')) {
6121
+ throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
6122
+ }
6123
+ }
6124
+ }
6125
+ return value;
6126
+ }
6127
+ /** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
6128
+ function canonicalHeads(snapshot) {
6129
+ return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
6130
+ const { branchId, after, notice } = agents[agent];
6131
+ return [agent, branchId, after, notice ?? null];
6132
+ })]).sort((a, b) => {
6133
+ const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
6134
+ return x < y ? -1 : x > y ? 1 : 0;
6135
+ }));
6136
+ }
6137
+ /**
6138
+ * How many times a direct edit reads and plans a file another writer keeps
6139
+ * changing: enough to land on top of a writer that changes it now and then,
6140
+ * such as a formatter or a build, and bounded for one that never stops.
6141
+ */
6142
+ const DIRECT_EDIT_ATTEMPTS = 3;
6143
+ /** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
6144
+ function directReason(error) {
6145
+ if (error instanceof FileChanged)
6146
+ return 'it changed on disk while this call was being applied';
6147
+ // A deletion's root check (unlinkContained) has a store-backed mount's
6148
+ // words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
6149
+ if (error instanceof Error && error.message === ROOT_REPLACED)
6150
+ return ROOT_MOVED;
6151
+ return error instanceof Error ? error.message : String(error);
6152
+ }
6153
+ /** "a", "a and b", or "a, b and c". */
6154
+ function listed(items) {
6155
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
6156
+ }
6157
+ /**
6158
+ * The note for a direct write that gave a hard-linked path a file of its
6159
+ * own: the other links keep the old text. Null for a file with none.
6160
+ */
6161
+ function linksNote(path, staged, tool) {
6162
+ const n = staged.otherLinks;
6163
+ if (n <= 0)
6164
+ return null;
6165
+ return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
6166
+ + `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
6167
+ }
6168
+ /**
6169
+ * A refusal of openContainedFile's as a direct tool gives it: its message,
6170
+ * and for one that names only the path, its errno too, as a direct read's
6171
+ * answer gives it (bytesOnDisk).
6172
+ */
6173
+ function containedRefusal(err, path) {
6174
+ const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6175
+ return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
6176
+ }
6177
+ /**
6178
+ * The notice that writes an agent made direct remain on disk from
6179
+ * turns the agent's history no longer holds. `git status` stands beside
6180
+ * `git diff` because a departed turn's new file is untracked, which `git
6181
+ * diff` doesn't show; and the warning says "unstaged", since `git checkout
6182
+ * -- <file>` puts back a file's staged text, so a staged change survives it.
6183
+ */
6184
+ function headMoveText(mounts) {
6185
+ const one = mounts.length === 1;
6186
+ return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6187
+ + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6188
+ + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6189
+ }
6190
+ /**
6191
+ * One file's edits planned against its bytes, as both kinds of mount plan
6192
+ * them: its text decoded, every edit matched in it (edit-plan.ts), and the
6193
+ * result held to the mount's size limit. Each problem found goes into
6194
+ * `problems`, and then, as for a file named only by edits whose own input
6195
+ * was refused, there's no plan (null).
6196
+ */
6197
+ function planTargetEdits(tool, target, bytes, hints, maxSize, counts, problems) {
6198
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
6199
+ // the text back would change bytes the edits never named.
6200
+ const file = decodeForEdit(bytes);
6201
+ if (!file) {
6202
+ problems.push({
6203
+ kind: 'file',
6204
+ edits: target.edits,
6205
+ path: target.path,
6206
+ text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
6207
+ + 'Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.',
6208
+ whole: false,
6209
+ });
6210
+ return null;
6211
+ }
6212
+ if (!target.specs.length)
6213
+ return null;
6214
+ const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
6215
+ if ('problems' in planned) {
6216
+ // One at a time: a call of many edits can have more problems
6217
+ // than a spread passes as arguments.
6218
+ for (const problem of planned.problems)
6219
+ problems.push({ ...problem, path: target.path });
6220
+ return null;
6221
+ }
6222
+ // The planner refuses a file its counts take past the limit before
6223
+ // building it, from the least its bytes can be; one it built past
6224
+ // the limit, as its CRs or multi-byte characters can take it, is
6225
+ // refused here.
6226
+ if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
6227
+ const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
6228
+ problems.push({
6229
+ kind: 'file',
6230
+ edits: target.edits,
6231
+ path: target.path,
6232
+ text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
6233
+ + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
6234
+ + "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
6235
+ whole: false,
6236
+ });
6237
+ return null;
6238
+ }
6239
+ return planned.plan;
6240
+ }
5364
6241
  /**
5365
6242
  * Whether a tree entry is what a change leaves: for a set, an entry with its
5366
6243
  * blob, size and mode; for a removal, none.
@@ -5442,15 +6319,43 @@ function storedButUnfinished(tool, path, outcome, note = null) {
5442
6319
  return { success: false, isError: true, error: stored + rest + (note !== null ? ` ${note}` : '') };
5443
6320
  }
5444
6321
  /**
5445
- * A text's lines, as read and grep number them: a final line end ends the
5446
- * last line rather than starting an empty one, so "a\nb\n" is two lines,
5447
- * and an empty text has none.
6322
+ * What a read says about the copy it shows, when that isn't simply the one the
6323
+ * workspace last agreed on with disk: disk's newer text, taken in first; the
6324
+ * workspace's copy, because disk's couldn't be checked or disk has none that
6325
+ * anything accounts for; or a draft in conflict with disk's change. A file
6326
+ * the workspace didn't hold has no older copy, so taking it in needs no word.
6327
+ * A conflict's note names the ways to settle it that work on the path's mount.
5448
6328
  */
5449
- function linesOf(content) {
5450
- const lines = content.split('\n');
5451
- if (lines.at(-1) === '')
5452
- lines.pop();
5453
- return lines;
6329
+ function whichCopyRead(report, tookChange, readOnly) {
6330
+ if (tookChange) {
6331
+ return ['Disk\'s copy had changed since the workspace last agreed with it, so it was taken in first, and this read shows that text.'];
6332
+ }
6333
+ if (report?.state === 'unverified') {
6334
+ return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
6335
+ }
6336
+ if (report?.state === 'disk-missing-provenance-unknown') {
6337
+ return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
6338
+ }
6339
+ if (report?.state === 'conflict') {
6340
+ const kind = report.conflict?.kind ?? 'both-changed';
6341
+ // A sync takes disk's state as it is now, which a recorded conflict's kind
6342
+ // doesn't follow: where disk has no copy, the sync takes its deletion, and
6343
+ // the workspace's copy goes with it. Where disk has one, the sync keeps it
6344
+ // and gives up the workspace's, taking disk's in only where the workspace
6345
+ // can hold it (not binary or too large). A read-only mount can hold a
6346
+ // copy disk doesn't, such as one a branch switch selected, but a
6347
+ // materialize, forced or not, writes nothing to it.
6348
+ const sync = report.diskNow === 'absent'
6349
+ ? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6350
+ : 'Sync the path to keep disk\'s copy and give up the workspace\'s';
6351
+ const force = readOnly
6352
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6353
+ : report.diskNow === 'absent'
6354
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6355
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
6356
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
6357
+ }
6358
+ return [];
5454
6359
  }
5455
6360
  /**
5456
6361
  * The least each tool's whole-number fields may be, beside their schemas,
@@ -5517,6 +6422,81 @@ function inputProblem(schema, input, minimums = {}) {
5517
6422
  }
5518
6423
  return null;
5519
6424
  }
6425
+ /** How many files a grep note names before it gives the count of the rest. */
6426
+ const NOTE_FILES_MAX = 20;
6427
+ /**
6428
+ * The files a grep note names, in the order grep searched them, as its
6429
+ * results are: at most NOTE_FILES_MAX of them, then how many more and how to
6430
+ * see them. Each is named by its mount-prefixed path, with what `detail` says
6431
+ * of it.
6432
+ */
6433
+ function namedFiles(files, detail) {
6434
+ const names = [...files.keys()];
6435
+ const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name))}`);
6436
+ const rest = names.length - shown.length;
6437
+ return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
6438
+ }
6439
+ /** What a note says of a conflict's disk version, which read doesn't show. */
6440
+ const ofVersion = (version) => version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
6441
+ /**
6442
+ * grep's note for a pattern that names a character its lines leave out, where
6443
+ * a file it searched has one: it says how many files searched have it and
6444
+ * names them, so a search for a CR or a BOM still finds the files that have
6445
+ * them. The CR note gives each file's ends, CRLF or mixed, and either note
6446
+ * names a conflict's disk version as that version. The note says a
6447
+ * character read shows it in the workspace's copy, since grep also searches a
6448
+ * conflict's disk version, which read doesn't show. A direct mount keeps no
6449
+ * workspace copy, and its read shows disk's file, so on a host with one
6450
+ * (`direct`) the note names that too, as the tools' descriptions mention
6451
+ * direct mounts only on such a host.
6452
+ */
6453
+ function leftOutNote(pattern, crlfFiles, bomFiles, direct) {
6454
+ if (crlfFiles.size === 0 && bomFiles.size === 0)
6455
+ return {};
6456
+ const named = namedLeftOut(pattern);
6457
+ const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
6458
+ const count = (n, one, many) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
6459
+ const notes = [
6460
+ named.cr && crlfFiles.size > 0
6461
+ ? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
6462
+ + `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
6463
+ + `which leaves out a CRLF end's CR unless the line's own text ends in a CR. read with offsetChars shows every CR in the workspace's copy${onDisk}.`
6464
+ : null,
6465
+ named.bom && bomFiles.size > 0
6466
+ ? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
6467
+ + `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
6468
+ + `and a BOM isn't part of the first line. read with offsetChars shows any BOM ${direct ? `in the workspace's copy${onDisk}` : 'the workspace\'s copy has'}.`
6469
+ : null,
6470
+ ].filter((n) => n !== null);
6471
+ return notes.length ? { note: notes.join(' ') } : {};
6472
+ }
6473
+ /**
6474
+ * Which of the characters grep's lines leave out a pattern names: a CR, as a
6475
+ * literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
6476
+ * before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
6477
+ * before a literal one. An escape counts only where its backslash isn't itself
6478
+ * escaped. A pattern compiled without flags can match a CR other ways too,
6479
+ * such as `\15` in a pattern of fewer than 15 groups or a class range that
6480
+ * spans U+000D, and those go unnoticed.
6481
+ */
6482
+ function namedLeftOut(pattern) {
6483
+ const named = { cr: false, bom: false };
6484
+ for (let i = 0; i < pattern.length; i++) {
6485
+ if (pattern[i] === '\r')
6486
+ named.cr = true;
6487
+ else if (pattern[i] === '\uFEFF')
6488
+ named.bom = true;
6489
+ else if (pattern[i] === '\\') {
6490
+ const next = pattern.slice(i + 1, i + 6);
6491
+ if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next))
6492
+ named.cr = true;
6493
+ else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next))
6494
+ named.bom = true;
6495
+ i++;
6496
+ }
6497
+ }
6498
+ return named;
6499
+ }
5520
6500
  /**
5521
6501
  * A glob's reading in the mount its first segment names, in place of the
5522
6502
  * pattern as written there. Every path the tools take and return is