@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
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { constants as fsConstants } from 'node:fs';
9
- import type { Stats } from 'node:fs';
9
+ import type { BigIntStats, Stats } from 'node:fs';
10
10
  import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
11
11
  import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
12
12
  import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
@@ -45,6 +45,9 @@ import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-
45
45
  import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
46
46
  import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
47
47
  import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
48
+ import { type FileText, fileText } from './text.js';
49
+ import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
50
+ import { type GrepAnswer, grepText, textAnswer } from './result-text.js';
48
51
  import {
49
52
  type MountRuntime,
50
53
  type PassOptions,
@@ -61,6 +64,23 @@ import {
61
64
  reconcilePass,
62
65
  settleAdoptionBeforeMutation,
63
66
  } from './reconcile.js';
67
+ import { EffectFailed, unlinkContained } from './effects.js';
68
+ import { RecordJournal } from '../../record-journal.js';
69
+ import {
70
+ type FileIdentity,
71
+ type Staged,
72
+ FileChanged,
73
+ ROOT_MOVED,
74
+ WriteRefused,
75
+ checkUnchanged,
76
+ directWriteTestSeam,
77
+ discard,
78
+ identityOf,
79
+ placedStill,
80
+ renameInto,
81
+ sameIdentity,
82
+ stageContained,
83
+ } from './direct-write.js';
64
84
 
65
85
  /** Default for how long the after-batch scan may hold the agent's next inference. */
66
86
  const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
@@ -74,6 +94,8 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
74
94
  const CATCH_UP_BOUND_MS = 30_000;
75
95
  /** Why a call is refused once the module has stopped. */
76
96
  const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
97
+ /** writeBinary's refusal when the store's branch is no longer the one its file is for. */
98
+ const BRANCH_LEFT = 'the workspace had left the branch this file is for';
77
99
  /** The workspace's own deadline must end before the framework's hook bound. */
78
100
  const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
79
101
  /** A direct mount's ignore list when its config names none (MountConfig.ignore). */
@@ -88,6 +110,8 @@ const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git',
88
110
  const DIRECT_READS_AT_ONCE = 16;
89
111
  /** Why sync passes over a direct mount, as its result's `skipped` says. */
90
112
  const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
113
+ /** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
114
+ const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
91
115
 
92
116
  export type {
93
117
  WorkspaceConfig,
@@ -983,6 +1007,28 @@ export class WorkspaceModule implements Module {
983
1007
  */
984
1008
  private notDirect = new Map<string, Array<{ path: string; what: string }>>();
985
1009
 
1010
+ /**
1011
+ * Each agent's latest direct write on each mount it wrote direct, P, with
1012
+ * the notice of its departure from it, N (DirectHead), by mount, then by
1013
+ * agent. Saved at stop() in a global record (headsJournal), not the
1014
+ * module's branch state: they describe disk, which doesn't rewind with a
1015
+ * branch, so a restart on another branch, such as one an offline rollback
1016
+ * made, still finds them.
1017
+ */
1018
+ private directHeads = new Map<string, Map<string, DirectHead>>();
1019
+
1020
+ /** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
1021
+ private headsJournal: RecordJournal<never, DirectHeadsSnapshot> | null = null;
1022
+
1023
+ /** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
1024
+ private loadedHeads: string | null = null;
1025
+
1026
+ /** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
1027
+ private carriedHeads: DirectHeadsEntry[] = [];
1028
+
1029
+ /** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
1030
+ private historyLookup: ((agentName: string, messageId: string) => boolean) | null = null;
1031
+
986
1032
  constructor(config: WorkspaceConfig) {
987
1033
  for (const mount of config.mounts) {
988
1034
  const cap: unknown = mount.walkCap;
@@ -1116,10 +1162,64 @@ export class WorkspaceModule implements Module {
1116
1162
  this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
1117
1163
 
1118
1164
  this.chooseKinds();
1165
+ this.loadDirectHeads(store);
1119
1166
  this.applySavedState();
1120
1167
  this.ensureRunning();
1121
1168
  }
1122
1169
 
1170
+ /**
1171
+ * The positions stop() last saved (directHeads), before any call can
1172
+ * write: each saved entry is a mount's positions at one root, and a mount
1173
+ * this session configures direct gets the entry at its own root. Every
1174
+ * other entry, a mount configured at another root, not direct, or not at
1175
+ * all, is kept for the next save (carriedHeads), since its writes are
1176
+ * still on disk beneath that root; a mount removed for good, and each
1177
+ * root a mount has moved from, leave that small entry behind. A record
1178
+ * the store can't read is logged, and this session starts with none and
1179
+ * saves none, so the record stands for a session that can read it. One
1180
+ * read but not of the shape saved is logged too, and this session starts
1181
+ * with none and its stop replaces it, so a damaged record doesn't keep
1182
+ * every later session from saving. Either way, a move away from the
1183
+ * writes before it goes untold, as after a crash.
1184
+ */
1185
+ private loadDirectHeads(store: JsStore): void {
1186
+ const journal = new RecordJournal<never, DirectHeadsSnapshot>(store, { type: DIRECT_HEADS_RECORD_TYPE });
1187
+ const restored = new Map<string, Map<string, DirectHead>>();
1188
+ const carried: DirectHeadsEntry[] = [];
1189
+ let loaded: unknown;
1190
+ try {
1191
+ loaded = journal.load().snapshot;
1192
+ } catch (err) {
1193
+ console.error('[workspace] could not read the saved direct-write positions:', err);
1194
+ return;
1195
+ }
1196
+ let snapshot: DirectHeadsSnapshot;
1197
+ let text: string | null;
1198
+ try {
1199
+ snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
1200
+ text = canonicalHeads(snapshot);
1201
+ } catch (err) {
1202
+ 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);
1203
+ snapshot = [];
1204
+ text = null;
1205
+ }
1206
+ for (const saved of snapshot) {
1207
+ // Only a direct mount's writes are the agent's on disk, and a
1208
+ // store-backed one's disk follows its store.
1209
+ const mount = this.mounts.get(saved.mount);
1210
+ if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
1211
+ carried.push(saved);
1212
+ continue;
1213
+ }
1214
+ restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) =>
1215
+ [agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
1216
+ }
1217
+ this.headsJournal = journal;
1218
+ this.loadedHeads = text;
1219
+ this.carriedHeads = carried;
1220
+ for (const [name, heads] of restored) this.directHeads.set(name, heads);
1221
+ }
1222
+
1123
1223
  /**
1124
1224
  * Whether each mount configured direct runs direct: not while the store
1125
1225
  * holds changes there that disk lacks, on the branch selected then, so
@@ -1612,8 +1712,8 @@ export class WorkspaceModule implements Module {
1612
1712
  const message = (one
1613
1713
  ? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
1614
1714
  : "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
1615
- "sync its path to take disk's copy, or configure the mount 'chronicle' and read-write, materialize it, " +
1616
- `follow the answer, and restore its direct config. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1715
+ "sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
1716
+ `and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
1617
1717
  `${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
1618
1718
  console.warn(`[workspace] ${message}`);
1619
1719
  this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
@@ -1683,18 +1783,58 @@ export class WorkspaceModule implements Module {
1683
1783
  }
1684
1784
  this.ctx.setState(state);
1685
1785
  }
1786
+ this.saveDirectHeads();
1686
1787
  this.ctx = null;
1687
1788
  }
1688
1789
 
1689
- /** Mount names + modes, and each mount's include where it sets one, for
1690
- * peer callers (framework spill/journal paths) that need a mount for files
1691
- * of their own, the first writable one without an include
1692
- * (firstWritableMountName), without reaching into private state. */
1693
- getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[] }> {
1790
+ /**
1791
+ * Save directHeads as one global record, the latest of which the next
1792
+ * start loads (loadDirectHeads): each mount's positions with the root
1793
+ * they were made under, and the entries this session didn't take up
1794
+ * (carriedHeads), unchanged. Only when that differs from what the
1795
+ * start loaded, so a session that changed nothing, or a store no direct
1796
+ * write reached, adds no record, and always where the record read wasn't
1797
+ * of the shape saved, which this replaces. Nothing where the store couldn't
1798
+ * read the record, so it stands. A save that fails is logged, and the next
1799
+ * start finds the positions of the last save that landed, as after a
1800
+ * crash.
1801
+ */
1802
+ private saveDirectHeads(): void {
1803
+ if (!this.headsJournal) return;
1804
+ const snapshot: DirectHeadsSnapshot = [
1805
+ ...this.carriedHeads,
1806
+ ...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
1807
+ mount: name,
1808
+ root: resolve(this.mounts.get(name)!.config.path),
1809
+ agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
1810
+ })),
1811
+ ];
1812
+ const text = canonicalHeads(snapshot);
1813
+ if (text === this.loadedHeads) return;
1814
+ try {
1815
+ this.headsJournal.checkpoint(snapshot);
1816
+ this.loadedHeads = text;
1817
+ } catch (err) {
1818
+ console.error('[workspace] could not save the direct-write positions:', err);
1819
+ }
1820
+ }
1821
+
1822
+ /** Mount names + modes, each mount's include where it sets one, and
1823
+ * `backend: 'direct'` for one configured direct, whether it runs direct
1824
+ * this session or, while the store holds work there disk lacks, as
1825
+ * 'chronicle': either way it's a directory agents write directly when it
1826
+ * runs direct, whose files an undo leaves. For the
1827
+ * framework's tool-result spills, saved payloads and fetched references,
1828
+ * which need a mount for files of their own: the first writable one
1829
+ * without an include that isn't direct (firstWritableMountName), without
1830
+ * reaching into private state. */
1831
+ getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[]; backend?: 'direct' }> {
1694
1832
  return [...this.mounts.values()].map((m) => ({
1695
1833
  name: m.config.name,
1696
- mode: m.config.mode === 'read-only' ? 'read-only' : 'read-write',
1834
+ // Read-only where this session takes no writes there (writeRefusal).
1835
+ mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
1697
1836
  ...(m.config.include ? { include: [...m.config.include] } : {}),
1837
+ ...(m.config.backend === 'direct' ? { backend: 'direct' as const } : {}),
1698
1838
  }));
1699
1839
  }
1700
1840
 
@@ -1702,10 +1842,19 @@ export class WorkspaceModule implements Module {
1702
1842
  // Tool Definitions
1703
1843
  // ==========================================================================
1704
1844
 
1845
+ /**
1846
+ * Whether any of this host's mounts runs direct. The tools' descriptions
1847
+ * and grep's notes mention direct mounts only on such a host, so both ask
1848
+ * this one question.
1849
+ */
1850
+ private anyRunsDirect(): boolean {
1851
+ return [...this.mounts.values()].some((m) => m.direct);
1852
+ }
1853
+
1705
1854
  getTools(): ToolDefinition[] {
1706
1855
  // Where a mount runs direct, four descriptions say what that changes.
1707
1856
  // A host with none keeps them as they were, and its residents' caches.
1708
- const direct = [...this.mounts.values()].some((m) => m.direct);
1857
+ const direct = this.anyRunsDirect();
1709
1858
  const lsDirect = direct
1710
1859
  ? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
1711
1860
  + '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.'
@@ -1716,6 +1865,23 @@ export class WorkspaceModule implements Module {
1716
1865
  : '';
1717
1866
  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.' : '';
1718
1867
  const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
1868
+ // Where a direct mount is writable, the four writing tools say what a
1869
+ // write is there: on disk when it answers, and left there by a head move.
1870
+ const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
1871
+ const writeDirect = writable
1872
+ ? ' 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.'
1873
+ : '';
1874
+ const editDirect = writable
1875
+ ? " 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 "
1876
+ + 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
1877
+ : '';
1878
+ const editManyDirect = writable
1879
+ ? " 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: "
1880
+ + "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
1881
+ + 'An undo or other head move leaves them on disk.'
1882
+ : '';
1883
+ 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.' : '';
1884
+ 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.' : '';
1719
1885
  return [
1720
1886
  {
1721
1887
  name: 'read',
@@ -1749,7 +1915,7 @@ export class WorkspaceModule implements Module {
1749
1915
  },
1750
1916
  {
1751
1917
  name: 'write',
1752
- description: 'Create or overwrite a file in the workspace.',
1918
+ description: 'Create or overwrite a file in the workspace.' + writeDirect,
1753
1919
  inputSchema: {
1754
1920
  type: 'object' as const,
1755
1921
  properties: {
@@ -1770,7 +1936,7 @@ export class WorkspaceModule implements Module {
1770
1936
  + "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
1771
1937
  + 'A change disk made to the file is taken in first, and the answer says so. '
1772
1938
  + 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
1773
- + 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
1939
+ + 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
1774
1940
  inputSchema: {
1775
1941
  type: 'object' as const,
1776
1942
  properties: {
@@ -1792,7 +1958,8 @@ export class WorkspaceModule implements Module {
1792
1958
  + "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
1793
1959
  + 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
1794
1960
  + "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
1795
- + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows.",
1961
+ + "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
1962
+ + editManyDirect,
1796
1963
  inputSchema: {
1797
1964
  type: 'object' as const,
1798
1965
  properties: {
@@ -1821,7 +1988,7 @@ export class WorkspaceModule implements Module {
1821
1988
  name: 'delete',
1822
1989
  description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
1823
1990
  + 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
1824
- + 'or a sync of the path restores it.',
1991
+ + 'or a sync of the path restores it.' + deleteDirect,
1825
1992
  inputSchema: {
1826
1993
  type: 'object' as const,
1827
1994
  properties: {
@@ -1874,7 +2041,7 @@ export class WorkspaceModule implements Module {
1874
2041
  properties: {
1875
2042
  pattern: { type: 'string', description: 'Regular expression pattern' },
1876
2043
  path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
1877
- 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' },
2044
+ 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")' },
1878
2045
  contextBefore: { type: 'integer', description: 'Context lines before match' },
1879
2046
  contextAfter: { type: 'integer', description: 'Context lines after match' },
1880
2047
  },
@@ -1896,7 +2063,7 @@ export class WorkspaceModule implements Module {
1896
2063
  description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1897
2064
  + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
1898
2065
  + 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
1899
- + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
2066
+ + 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
1900
2067
  inputSchema: {
1901
2068
  type: 'object' as const,
1902
2069
  properties: {
@@ -2027,21 +2194,26 @@ export class WorkspaceModule implements Module {
2027
2194
  return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
2028
2195
  }
2029
2196
  const input = call.input as Record<string, unknown>;
2197
+ // The most of this call's result that can reach its caller whole: past
2198
+ // it, a model's result spills into a file, and a script gets
2199
+ // ResultTooLarge in its place. A call made in process carries none and
2200
+ // takes the data itself, so its read pages by its line limit alone.
2201
+ const resultMaxChars = call.resultMaxChars;
2030
2202
  const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
2031
2203
  if (generated) {
2032
2204
  if (call.name === 'read' && call.callerAgentName === generated.agentName) {
2033
- return await this.handleRead(input as unknown as ReadInput, generated.read());
2205
+ return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
2034
2206
  }
2035
2207
  return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
2036
2208
  }
2037
2209
 
2038
2210
  switch (call.name) {
2039
- case 'read': return await this.handleRead(input as unknown as ReadInput);
2211
+ case 'read': return await this.handleRead(input as unknown as ReadInput, resultMaxChars);
2040
2212
  case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
2041
- case 'write': return await this.handleWrite(input as unknown as WriteInput);
2042
- case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars);
2213
+ case 'write': return await this.handleWrite(input as unknown as WriteInput, call.callerAgentName);
2214
+ case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars, call.callerAgentName);
2043
2215
  case 'edit_many': return await this.handleEditMany(input as unknown as EditManyInput, call.resultMaxChars, call.callerAgentName);
2044
- case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
2216
+ case 'delete': return await this.handleDelete(input as unknown as DeleteInput, call.callerAgentName);
2045
2217
  case 'ls': return await this.handleLs(input as unknown as LsInput);
2046
2218
  case 'glob': return await this.handleGlob(input as unknown as GlobInput);
2047
2219
  case 'grep': return await this.handleGrep(input as unknown as GrepInput);
@@ -2182,6 +2354,12 @@ export class WorkspaceModule implements Module {
2182
2354
  * withheld original this way, for the branch its stub is on
2183
2355
  * (agent-framework #277).
2184
2356
  *
2357
+ * On a mount running direct, the bytes go to disk as a direct `write`'s
2358
+ * do, whole or not at all, and nothing goes into the store: `branch` is
2359
+ * checked before the write starts and again just before its rename, and
2360
+ * `agentName` names the agent whose write it is for the head-move notice
2361
+ * (a write naming none tells no agent).
2362
+ *
2185
2363
  * A write whose tree entry is set but that then fails returns success
2186
2364
  * false with `data: { inChronicle: true }`: its copy on disk was refused,
2187
2365
  * the store failed after recording the entry, which skips the push where
@@ -2195,7 +2373,7 @@ export class WorkspaceModule implements Module {
2195
2373
  mountPrefixedPath: string,
2196
2374
  data: Buffer,
2197
2375
  mimeType: string,
2198
- options: { branch?: string } = {},
2376
+ options: { branch?: string; agentName?: string } = {},
2199
2377
  ): Promise<ToolResult> {
2200
2378
  if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
2201
2379
  return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
@@ -2205,7 +2383,7 @@ export class WorkspaceModule implements Module {
2205
2383
  mountPrefixedPath: string,
2206
2384
  data: Buffer,
2207
2385
  mimeType: string,
2208
- options: { branch?: string },
2386
+ options: { branch?: string; agentName?: string },
2209
2387
  ): Promise<ToolResult> {
2210
2388
  let mount: MountState;
2211
2389
  let relativePath: string;
@@ -2218,20 +2396,32 @@ export class WorkspaceModule implements Module {
2218
2396
  isError: true,
2219
2397
  };
2220
2398
  }
2221
- if (mount.config.mode === 'read-only') {
2222
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
2223
- }
2399
+ const refusal = writeRefusal(mount);
2400
+ if (refusal) return { success: false, error: refusal, isError: true };
2224
2401
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
2225
2402
  if (data.byteLength > maxSize) {
2226
2403
  return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
2227
2404
  }
2228
2405
  const store = this.getStore();
2406
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
2407
+ // as the caller's agent's write, where it names one. A branch it names
2408
+ // is checked just before the rename, since disk is no branch's.
2409
+ if (mount.direct) {
2410
+ // Checked first too, so a request already for a left branch makes no
2411
+ // directory and stages nothing.
2412
+ if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
2413
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
2414
+ }
2415
+ const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
2416
+ if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
2417
+ return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
2418
+ }
2229
2419
  return this.withMount(mount, async () => {
2230
2420
  await this.settleBeforeMutation(mount, relativePath);
2231
2421
  // Checked inside the mount's turn, after anything the write waited on:
2232
2422
  // commitToolChange sets the tree entry before it first yields.
2233
2423
  if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
2234
- return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
2424
+ return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
2235
2425
  }
2236
2426
  const blobHash = store.storeBlob(data, mimeType);
2237
2427
  // An overwrite keeps the file's mode, as the write tool's does.
@@ -2268,11 +2458,15 @@ export class WorkspaceModule implements Module {
2268
2458
  }
2269
2459
 
2270
2460
  /**
2271
- * Read a file's raw bytes from a mount (through the Chronicle tree, synced
2272
- * first like the `read` tool; on a direct mount, from disk as
2273
- * readBinaryFromDisk reads it, at most the mount's `maxFileSize`). Public
2274
- * API for the framework's synthesized `read_image` tool and other peer
2275
- * callers that need binary content.
2461
+ * Read a file's raw bytes from a mount: through the Chronicle tree, a path
2462
+ * the workspace doesn't hold observed first (ensureSynced); or, on a direct
2463
+ * mount, from disk as readBinaryFromDisk reads it, at most the mount's
2464
+ * `maxFileSize`. A path the workspace holds is read as the workspace holds
2465
+ * it, not settled as the `read` tool settles it: the tree takes in no
2466
+ * binary disk copy, so a pass over a held binary file that disk changed
2467
+ * would drop the workspace's copy. Public API for the framework's
2468
+ * synthesized `read_image` tool, where the workspace module doesn't serve
2469
+ * read_image itself, and other peer callers that need binary content.
2276
2470
  */
2277
2471
  async readBinary(
2278
2472
  mountPrefixedPath: string,
@@ -2490,8 +2684,10 @@ export class WorkspaceModule implements Module {
2490
2684
  * mount nested by its directories alone, since its real root holds this
2491
2685
  * one, is judged by the parse's nesting, so a root repointed between a
2492
2686
  * read's parse and its open can have it serve a path in that mount's
2493
- * directory. Each mount is placed as a start places it (placementOf), from
2494
- * where its real root and the directories above its configured path lie on
2687
+ * directory, and one repointed between a direct write's parse and its
2688
+ * staging can have the write land there. Each mount is placed as a start
2689
+ * places it (placementOf), from where its real root and the directories
2690
+ * above its configured path lie on
2495
2691
  * disk. For a mount configured at this mount's own path or beneath it,
2496
2692
  * that's where they lie now: beneath `rootReal` where they lie beneath this
2497
2693
  * mount's path, and the directories above this mount's path as resolving
@@ -3681,15 +3877,19 @@ export class WorkspaceModule implements Module {
3681
3877
  }
3682
3878
 
3683
3879
  /**
3684
- * Before a read: a path the workspace doesn't hold is observed under the
3685
- * three-way rule, which ingests a new disk file but never brings back a
3686
- * workspace deletion or a file absent from this branch. The pass names the
3687
- * path, as a file tool names the one it acts on: a path the mount passes
3688
- * over by design is observed, as a sync naming it would observe it (#276),
3689
- * while a path in another mount's directory is refused before this, and
3690
- * passed over by any pass. Answers the pass's report on the path, and why
3691
- * the pass decided nothing where it did (undecidedOf), which leaves disk
3692
- * unchecked. The caller holds the mount's turn.
3880
+ * Before a deletion, and for readBinary: a path the workspace doesn't hold
3881
+ * is observed under the three-way rule, which ingests a new disk file but
3882
+ * never brings back a workspace deletion or a file absent from this branch.
3883
+ * A path it holds is left alone. For a deletion, as for a write, taking
3884
+ * disk's change in first would let the push remove that change unseen; for
3885
+ * readBinary, a pass would drop a held binary file that disk changed, since
3886
+ * the tree takes in no binary disk copy. The pass names the path, as a file
3887
+ * tool names the one it acts on: a path the mount passes over by design is
3888
+ * observed, as a sync naming it would observe it (#276), while a path in
3889
+ * another mount's directory is refused before this, and passed over by any
3890
+ * pass. Answers the pass's report on the path, and why the pass decided
3891
+ * nothing where it did (undecidedOf), which leaves disk unchecked. The
3892
+ * caller holds the mount's turn.
3693
3893
  */
3694
3894
  private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
3695
3895
  const store = this.getStore();
@@ -3728,21 +3928,52 @@ export class WorkspaceModule implements Module {
3728
3928
  }
3729
3929
 
3730
3930
  /**
3731
- * A file's bytes for read, from where the mount keeps what it reads: the
3732
- * workspace's copy, taken in from disk first where it holds none
3733
- * (ensureSynced); or, on a direct mount, disk's file as it is now
3734
- * (bytesOnDisk). An error says why there are none to read.
3931
+ * A file's bytes for read, and the notes that say which copy they are. On a
3932
+ * 'chronicle' mount it's the workspace's copy, after a pass over the path
3933
+ * takes disk's change to it in first. On a direct mount it's disk's file as
3934
+ * it is now (bytesOnDisk), with no notes, since nothing else is kept. An
3935
+ * error says why there are none to read.
3735
3936
  */
3736
- private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
3737
- if (mount.direct) return this.bytesOnDisk(mount, relativePath, path);
3937
+ private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> {
3938
+ if (mount.direct) {
3939
+ const read = await this.bytesOnDisk(mount, relativePath, path);
3940
+ return 'error' in read ? read : { bytes: read.bytes, notes: [] };
3941
+ }
3738
3942
  const store = this.getStore();
3739
- return this.withMount(mount, async () => {
3740
- const { report } = await this.ensureSynced(mount, relativePath);
3943
+ return this.withMount(mount, async (): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> => {
3944
+ // A read shows the path as disk holds it now: a change on disk to a
3945
+ // file the workspace holds is taken in first, by the rule a listing
3946
+ // uses, as an edit's is. Where the stored copy is a draft and disk
3947
+ // changed too, the read shows the draft and names the conflict. The
3948
+ // pass names the path, as a file tool names the one it acts on, so a
3949
+ // path the mount passes over by design is observed, as a sync naming
3950
+ // it would observe it (#276).
3951
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
3952
+ // A pass that decided nothing (undecidedOf: the selected branch kept
3953
+ // changing) leaves no report. It's read as a copy disk couldn't be
3954
+ // checked, with its reason, so the answer says the check didn't finish.
3955
+ const undecided = undecidedOf(pass);
3956
+ const report = pass.reports.get(relativePath)
3957
+ ?? (undecided !== null ? { path: relativePath, state: 'unverified' as const, note: undecided } : undefined);
3958
+ // A check that decided nothing may well finish on the next read, as
3959
+ // grep's answer to the same says of grep.
3960
+ const again = undecided !== null ? ['Read again to check it.'] : [];
3961
+ // A mode taken in alone isn't a change to the text, so it leaves the
3962
+ // read nothing to say, as it leaves an edit nothing.
3963
+ const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
3741
3964
  const entry = store.treeGet(mount.treeStateId, relativePath);
3742
- if (!entry) return { error: this.notInWorkspace(path, report) };
3965
+ if (!entry) {
3966
+ // A pass also drops a copy disk made binary or too large, which
3967
+ // the disk-only answer describes; any other drop is disk's deletion.
3968
+ return {
3969
+ error: took('deleted') && report?.state !== 'disk-only'
3970
+ ? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
3971
+ : [this.notInWorkspace(path, report), ...again].join(' '),
3972
+ };
3973
+ }
3743
3974
  const blob = store.getBlob(entry.blobHash);
3744
3975
  if (!blob) return { error: `Blob not found for: ${path}` };
3745
- return { bytes: blob };
3976
+ return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
3746
3977
  });
3747
3978
  }
3748
3979
 
@@ -3765,8 +3996,7 @@ export class WorkspaceModule implements Module {
3765
3996
  } catch (err) {
3766
3997
  // A refusal that names only the path says why with its errno, as a
3767
3998
  // failed read below does.
3768
- const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
3769
- return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
3999
+ return { error: containedRefusal(err, path) };
3770
4000
  }
3771
4001
  try {
3772
4002
  // No more than the limit is loaded, as for a file over it at its fstat.
@@ -3788,92 +4018,38 @@ export class WorkspaceModule implements Module {
3788
4018
  // Tool Handlers
3789
4019
  // ==========================================================================
3790
4020
 
3791
- private async handleRead(input: ReadInput, generatedContent?: string): Promise<ToolResult> {
4021
+ /**
4022
+ * A read's page, held to `maxChars`, the most of this call's result that
4023
+ * can reach its caller whole. A call that carries none, made in process,
4024
+ * takes the data itself: its page holds to its line limit alone, with every
4025
+ * line whole, as it did before pages had a budget.
4026
+ */
4027
+ private async handleRead(input: ReadInput, maxChars: number | undefined, generatedContent?: string): Promise<ToolResult> {
3792
4028
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
3793
- const offsetChars = input.offsetChars ?? 0;
3794
- const limitChars = input.limitChars ?? 2000;
3795
4029
  // Each number is a whole number of at least its schema's minimum by now
3796
4030
  // (inputProblem).
3797
4031
  if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
3798
4032
  return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
3799
4033
  }
3800
- let content = generatedContent;
3801
- if (content === undefined) {
4034
+ let file: FileText;
4035
+ let notes: string[] = [];
4036
+ if (generatedContent === undefined) {
3802
4037
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3803
4038
  input = { ...input, path };
3804
4039
  const found = await this.bytesOf(mount, relativePath, path);
3805
4040
  if ('error' in found) return { success: false, error: found.error, isError: true };
3806
- content = found.bytes.toString('utf-8');
3807
- }
3808
- if (characterPaging) {
3809
- const start = Math.min(offsetChars, content.length);
3810
- const splitsPair = (at: number): boolean =>
3811
- at > 0 && at < content.length
3812
- && content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
3813
- && content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
3814
- if (splitsPair(start)) {
3815
- return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
3816
- }
3817
- let end = start + Math.min(limitChars, content.length - start);
3818
- if (splitsPair(end)) end++;
3819
- return {
3820
- success: true,
3821
- data: {
3822
- path: input.path,
3823
- totalChars: content.length,
3824
- offsetChars: start,
3825
- nextOffsetChars: end < content.length ? end : null,
3826
- content: content.slice(start, end),
3827
- },
3828
- };
3829
- }
3830
- const lines = linesOf(content);
3831
-
3832
- // Apply offset/limit. An unlimited read of a large file would inject the
3833
- // whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
3834
- // past the model context and 400s), so an omitted limit falls back to a
3835
- // default cap; the result reports total/from/to so the agent can page.
3836
- const DEFAULT_READ_LINE_LIMIT = 2000;
3837
- const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
3838
- if (startLine >= lines.length) {
3839
- // Nothing to show: an empty file, or an offset past the last line. No
3840
- // range is claimed, and the line count says where the file ends.
3841
- return {
3842
- success: true,
3843
- data: {
3844
- path: input.path,
3845
- totalLines: lines.length,
3846
- // Reading an empty file from its start is reading it whole.
3847
- ...(startLine > 0
3848
- ? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
3849
- : {}),
3850
- content: '',
3851
- },
3852
- };
4041
+ file = fileText(found.bytes);
4042
+ notes = found.notes;
4043
+ } else {
4044
+ file = fileText(Buffer.from(generatedContent, 'utf-8'));
3853
4045
  }
3854
- const effectiveLimit = input.limit ?? DEFAULT_READ_LINE_LIMIT;
3855
- const endLine = startLine + effectiveLimit;
3856
- const slice = lines.slice(startLine, endLine);
3857
- const truncated = endLine < lines.length;
3858
-
3859
- // Format with line numbers (cat -n style)
3860
- const formatted = slice
3861
- .map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
3862
- .join('\n');
3863
-
3864
- return {
3865
- success: true,
3866
- data: {
3867
- path: input.path,
3868
- totalLines: lines.length,
3869
- fromLine: startLine + 1,
3870
- toLine: Math.min(endLine, lines.length),
3871
- ...(truncated
3872
- ? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
3873
- : {}),
3874
- content: formatted,
3875
- },
3876
- };
4046
+ // An unlimited read of a large file would inject the whole thing into the
4047
+ // turn (a 2MB file ≈ 600k tokens → the request blows past the model
4048
+ // context and 400s), so an omitted limit falls back to a default, and a
4049
+ // page stops at the call's result limit where it has one.
4050
+ return characterPaging
4051
+ ? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
4052
+ : linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
3877
4053
  }
3878
4054
 
3879
4055
  private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
@@ -3925,15 +4101,14 @@ export class WorkspaceModule implements Module {
3925
4101
  }
3926
4102
  }
3927
4103
 
3928
- private async handleWrite(input: WriteInput): Promise<ToolResult> {
4104
+ private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
3929
4105
  if (LONE_SURROGATE.test(input.content)) {
3930
4106
  return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
3931
4107
  }
3932
4108
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
3933
4109
  input = { ...input, path };
3934
- if (mount.config.mode === 'read-only') {
3935
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
3936
- }
4110
+ const refusal = writeRefusal(mount);
4111
+ if (refusal) return { success: false, error: refusal, isError: true };
3937
4112
 
3938
4113
  const store = this.getStore();
3939
4114
  const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
@@ -3943,6 +4118,12 @@ export class WorkspaceModule implements Module {
3943
4118
 
3944
4119
  const buffer = Buffer.from(input.content, 'utf-8');
3945
4120
  const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
4121
+ // A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
4122
+ if (mount.direct) {
4123
+ const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
4124
+ if ('error' in written) return { success: false, isError: true, error: written.error };
4125
+ return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
4126
+ }
3946
4127
  let committed = false;
3947
4128
  let refused: string | null;
3948
4129
  let modeKept: string | null;
@@ -3966,13 +4147,13 @@ export class WorkspaceModule implements Module {
3966
4147
  return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
3967
4148
  }
3968
4149
 
3969
- private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS): Promise<ToolResult> {
4150
+ private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
3970
4151
  return this.applyEdits('edit', [{
3971
4152
  path: input.path,
3972
4153
  oldString: input.oldString,
3973
4154
  newString: input.newString,
3974
4155
  expected: input.replaceAll ? 'all' : 1,
3975
- }], maxChars);
4156
+ }], maxChars, callerAgentName);
3976
4157
  }
3977
4158
 
3978
4159
  private async handleEditMany(
@@ -4093,6 +4274,9 @@ export class WorkspaceModule implements Module {
4093
4274
  const [mount] = mounts.values();
4094
4275
  if (tool === 'edit' && problems.length) return refuse(problems);
4095
4276
  if (!mount || mounts.size > 1) return refuse(problems);
4277
+ // A direct mount's edits apply to disk's text as it is now, and land on
4278
+ // disk or don't happen (applyDirectEdits).
4279
+ if (mount.direct) return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
4096
4280
 
4097
4281
  const store = this.getStore();
4098
4282
  // Whether the commit set the call's entries, all of them at once.
@@ -4202,48 +4386,11 @@ export class WorkspaceModule implements Module {
4202
4386
  problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
4203
4387
  continue;
4204
4388
  }
4205
- // Decoding would turn each invalid sequence into U+FFFD, and writing
4206
- // the text back would change bytes the edits never named.
4207
- const file = decodeForEdit(blob);
4208
- if (!file) {
4209
- problems.push({
4210
- kind: 'file',
4211
- edits: target.edits,
4212
- path: target.path,
4213
- text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
4214
- + '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.',
4215
- whole: false,
4216
- });
4217
- continue;
4218
- }
4219
- if (!target.specs.length) continue;
4220
- const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
4221
- if ('problems' in planned) {
4222
- // One at a time: a call of many edits can have more problems
4223
- // than a spread passes as arguments.
4224
- for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
4225
- continue;
4226
- }
4227
- // The planner refuses a file its counts take past the limit before
4228
- // building it, from the least its bytes can be; one it built past
4229
- // the limit, as its CRs or multi-byte characters can take it, is
4230
- // refused here.
4231
- if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
4232
- const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
4233
- problems.push({
4234
- kind: 'file',
4235
- edits: target.edits,
4236
- path: target.path,
4237
- text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
4238
- + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
4239
- + "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.",
4240
- whole: false,
4241
- });
4242
- continue;
4243
- }
4389
+ const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
4390
+ if (plan === null) continue;
4244
4391
  plans.push({
4245
4392
  target,
4246
- plan: planned.plan,
4393
+ plan,
4247
4394
  mode: entry.mode,
4248
4395
  backOnDisk,
4249
4396
  conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
@@ -4258,15 +4405,12 @@ export class WorkspaceModule implements Module {
4258
4405
  change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
4259
4406
  })), () => { committed = true; });
4260
4407
  if (refused.size > 0) return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
4261
- return {
4262
- success: true,
4263
- data: editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4264
- path: target.path,
4265
- plan,
4266
- notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4267
- modeKept: modeKept.get(target.relativePath) ?? null,
4268
- })), given.length, maxChars),
4269
- };
4408
+ return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
4409
+ path: target.path,
4410
+ plan,
4411
+ notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
4412
+ modeKept: modeKept.get(target.relativePath) ?? null,
4413
+ })), given.length, maxChars);
4270
4414
  });
4271
4415
  } catch (error) {
4272
4416
  const message = error instanceof Error ? error.message : String(error);
@@ -4321,7 +4465,8 @@ export class WorkspaceModule implements Module {
4321
4465
  // One that names the mount's root, its bare name included, names no
4322
4466
  // file.
4323
4467
  if (where.relativePath === '') 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.`);
4324
- if (where.mount.config.mode === 'read-only') return unusable(`Mount "${where.mount.config.name}" is read-only`);
4468
+ const refusal = writeRefusal(where.mount);
4469
+ if (refusal) return unusable(refusal);
4325
4470
  const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
4326
4471
  if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
4327
4472
  return where;
@@ -4405,12 +4550,513 @@ export class WorkspaceModule implements Module {
4405
4550
  return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
4406
4551
  }
4407
4552
 
4408
- private async handleDelete(input: DeleteInput): Promise<ToolResult> {
4553
+ // ==========================================================================
4554
+ // Direct writes
4555
+ // ==========================================================================
4556
+
4557
+ /**
4558
+ * A direct mount's write of a whole file: its bytes staged beside it and
4559
+ * renamed over it (direct-write.ts), under the mount's turn, so the
4560
+ * module's own calls take turns. Answers a note when the file had other
4561
+ * hard links, which keep its old text, or why nothing was written. One
4562
+ * that lands is the caller's latest write on the mount (recordDirectWrite).
4563
+ * With `branch`, nothing is written once the store's branch is another,
4564
+ * checked with no turn of the event loop before the rename (writeBinary's).
4565
+ */
4566
+ private async writeDirect(
4567
+ mount: MountState,
4568
+ relativePath: string,
4569
+ path: string,
4570
+ bytes: Buffer,
4571
+ agentName: string | undefined,
4572
+ branch?: string,
4573
+ ): Promise<{ note: string | null } | { error: string; branchLeft?: true }> {
4574
+ return this.withMount(mount, async () => {
4575
+ const { view, rootReal } = await this.diskView(mount);
4576
+ let staged: Staged;
4577
+ try {
4578
+ staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
4579
+ } catch (error) {
4580
+ return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4581
+ }
4582
+ try {
4583
+ await directWriteTestSeam.afterStage?.([staged.path]);
4584
+ await placedStill(staged);
4585
+ // A write reads nothing first, so it replaces whatever is there.
4586
+ checkUnchanged(staged, null);
4587
+ if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
4588
+ await discard(staged);
4589
+ return { error: BRANCH_LEFT, branchLeft: true };
4590
+ }
4591
+ renameInto(staged);
4592
+ } catch (error) {
4593
+ await discard(staged);
4594
+ return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
4595
+ }
4596
+ this.recordDirectWrite(mount, agentName);
4597
+ return { note: linksNote(path, staged, 'write') };
4598
+ });
4599
+ }
4600
+
4601
+ /**
4602
+ * applyEdits on a direct mount, under the mount's turn, whose edits land
4603
+ * on disk or don't happen. Each attempt reads every file from disk as it
4604
+ * is now (directEditText), plans every edit against those bytes as on any
4605
+ * mount (planTargetEdits), stages every file beside itself, checks every
4606
+ * one unchanged since its read, and then renames each over its file in
4607
+ * turn (direct-write.ts). A file another writer changed between its read
4608
+ * and its check has the call read and planned again against its new
4609
+ * text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
4610
+ * longer apply to it, a file that changed at every attempt, or a file the
4611
+ * next read refuses as any read would (now past the size limit, say) are
4612
+ * refused, with nothing written. A rename that fails after others leaves
4613
+ * those written and the rest as they were, and the answer names each
4614
+ * file's outcome; nothing puts the written ones back.
4615
+ */
4616
+ private async applyDirectEdits(
4617
+ tool: EditTool,
4618
+ mount: MountState,
4619
+ targets: EditTarget[],
4620
+ early: EditProblem[],
4621
+ editCount: number,
4622
+ maxChars: number,
4623
+ agentName: string | undefined,
4624
+ ): Promise<ToolResult> {
4625
+ const changedMeanwhile = new Set<EditTarget>();
4626
+ return this.withMount(mount, async () => {
4627
+ for (let attempt = 1; ; attempt++) {
4628
+ const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
4629
+ if (outcome !== 'changed') return outcome;
4630
+ if (attempt === DIRECT_EDIT_ATTEMPTS) {
4631
+ const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
4632
+ // Each attempt stops at the first file it finds changed, so these are
4633
+ // the files some attempt found changed, at most one per attempt.
4634
+ return {
4635
+ success: false,
4636
+ isError: true,
4637
+ error: tool === 'edit'
4638
+ ? `${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.`
4639
+ : `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
4640
+ + `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
4641
+ };
4642
+ }
4643
+ }
4644
+ });
4645
+ }
4646
+
4647
+ /**
4648
+ * One attempt of applyDirectEdits: its answer, or 'changed' when another
4649
+ * writer changed a file since its read, with nothing written. `early` are
4650
+ * the problems found before any file was read, which a refusal names with
4651
+ * the rest. The caller holds the mount's turn.
4652
+ */
4653
+ private async attemptDirectEdits(
4654
+ tool: EditTool,
4655
+ mount: MountState,
4656
+ targets: EditTarget[],
4657
+ early: EditProblem[],
4658
+ changedMeanwhile: Set<EditTarget>,
4659
+ editCount: number,
4660
+ maxChars: number,
4661
+ agentName: string | undefined,
4662
+ ): Promise<ToolResult | 'changed'> {
4663
+ const problems = [...early];
4664
+ const read = new Map<EditTarget, { bytes: Buffer; identity: FileIdentity }>();
4665
+ for (const target of targets) {
4666
+ const got = await this.directEditText(mount, target.relativePath, target.path);
4667
+ if ('changed' in got) {
4668
+ changedMeanwhile.add(target);
4669
+ return 'changed';
4670
+ }
4671
+ if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
4672
+ else read.set(target, got);
4673
+ }
4674
+ // Two paths that reach one file, through a link this mount follows, or
4675
+ // as two hard links (a disk that folds case has its two spellings made
4676
+ // one path by the parse, short of a directory link on the way, past
4677
+ // which they're refused here as one file named twice): each
4678
+ // would be planned against the same text and renamed over the same file
4679
+ // or split from it, so one's edits would be lost or kept apart from the
4680
+ // other's. They're refused. One file read twice has one identity here
4681
+ // unless a writer changed it between the reads, and the check before the
4682
+ // renames then sees that and reads again.
4683
+ const byIdentity = new Map<string, EditTarget>();
4684
+ for (const [target, { identity }] of read) {
4685
+ const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
4686
+ const other = byIdentity.get(key);
4687
+ if (!other) {
4688
+ byIdentity.set(key, target);
4689
+ continue;
4690
+ }
4691
+ problems.push({
4692
+ kind: 'file',
4693
+ edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
4694
+ path: other.path,
4695
+ 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`,
4696
+ whole: true,
4697
+ });
4698
+ }
4699
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4700
+ const hints = hintBudget();
4701
+ // As much as the largest file the call names holds, at least, so the
4702
+ // budget is the same whatever order its files are planned in.
4703
+ let largest = maxSize;
4704
+ for (const { bytes } of read.values()) largest = Math.max(largest, bytes.length);
4705
+ const counts = countBudget(largest);
4706
+ const plans: Array<{ target: EditTarget; plan: FilePlan }> = [];
4707
+ for (const target of targets) {
4708
+ const got = read.get(target);
4709
+ if (!got) continue;
4710
+ const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
4711
+ if (plan !== null) plans.push({ target, plan });
4712
+ }
4713
+ // A file read again for another writer's change says so, whichever way
4714
+ // its edits went.
4715
+ const reread = (target: EditTarget, verb: string): string | null => !changedMeanwhile.has(target) ? null
4716
+ : `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
4717
+ + `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
4718
+ const refuse = (found: EditProblem[]): ToolResult => ({
4719
+ success: false,
4720
+ isError: true,
4721
+ error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note): note is string => note !== null), maxChars, 'wrote'),
4722
+ });
4723
+ if (problems.length) return refuse(problems);
4724
+
4725
+ const { view, rootReal } = await this.diskView(mount);
4726
+ const staged: Array<{ target: EditTarget; staged: Staged }> = [];
4727
+ const discardAll = async (from = 0): Promise<void> => {
4728
+ for (const { staged: file } of staged.slice(from)) await discard(file);
4729
+ };
4730
+ // A refusal before any file is renamed says so where the call names several.
4731
+ const nothingWritten = (target: EditTarget, error: unknown): ToolResult => refuse([{
4732
+ kind: 'file',
4733
+ edits: target.edits,
4734
+ path: target.path,
4735
+ text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
4736
+ + (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
4737
+ whole: true,
4738
+ }]);
4739
+ for (const { target, plan } of plans) {
4740
+ try {
4741
+ staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
4742
+ } catch (error) {
4743
+ await discardAll();
4744
+ if (error instanceof FileChanged) {
4745
+ changedMeanwhile.add(target);
4746
+ return 'changed';
4747
+ }
4748
+ return nothingWritten(target, error);
4749
+ }
4750
+ }
4751
+ try {
4752
+ await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
4753
+ } catch (error) {
4754
+ await discardAll();
4755
+ throw error;
4756
+ }
4757
+ for (const { target, staged: file } of staged) {
4758
+ try {
4759
+ await placedStill(file);
4760
+ } catch (error) {
4761
+ await discardAll();
4762
+ return nothingWritten(target, error);
4763
+ }
4764
+ }
4765
+ // Every file is checked, then every file renamed, with no turn of the
4766
+ // event loop between: a change to any of them before its check is seen
4767
+ // before any is written, and one landing between its check and the last
4768
+ // rename isn't (checkUnchanged).
4769
+ for (const { target, staged: file } of staged) {
4770
+ try {
4771
+ checkUnchanged(file, read.get(target)!);
4772
+ } catch (error) {
4773
+ await discardAll();
4774
+ if (error instanceof FileChanged) {
4775
+ changedMeanwhile.add(target);
4776
+ return 'changed';
4777
+ }
4778
+ return nothingWritten(target, error);
4779
+ }
4780
+ }
4781
+ for (const [i, { target, staged: file }] of staged.entries()) {
4782
+ try {
4783
+ renameInto(file);
4784
+ } catch (error) {
4785
+ if (i === 0) {
4786
+ await discardAll();
4787
+ return nothingWritten(target, error);
4788
+ }
4789
+ // Read with no turn of the event loop since the last rename.
4790
+ this.recordDirectWrite(mount, agentName);
4791
+ await discardAll(i);
4792
+ return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
4793
+ }
4794
+ }
4795
+ this.recordDirectWrite(mount, agentName);
4796
+ return editReceipt(tool, staged.map(({ target, staged: file }) => ({
4797
+ path: target.path,
4798
+ plan: plans.find((p) => p.target === target)!.plan,
4799
+ notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
4800
+ modeKept: null,
4801
+ })), editCount, maxChars);
4802
+ }
4803
+
4804
+ /**
4805
+ * The answer to an edit_many call on a direct mount whose rename of its
4806
+ * `failed`th file failed after the ones before it were written: what was
4807
+ * written, the failure, and that nothing puts them back, then each file's
4808
+ * outcome, within `maxChars`, with the hard links a written one split
4809
+ * from, as a full answer names them (linksNote).
4810
+ */
4811
+ private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
4812
+ // The failed file is named among the outcomes, so the head stays as
4813
+ // long as its reason, whatever the paths' lengths.
4814
+ const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
4815
+ + "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:";
4816
+ 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'}`);
4817
+ const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
4818
+ return fitted(head, parts, notes, maxChars, 'written');
4819
+ }
4820
+
4821
+ /**
4822
+ * A direct mount's file as an edit reads it: its bytes on disk as they are
4823
+ * now, through the mount's boundary as read reaches them
4824
+ * (openContainedFile), with the file's identity when the read began, which
4825
+ * the write checks it against just before its rename. A file changed while
4826
+ * it was read is `changed`, so the call reads it again. What the
4827
+ * workspace doesn't show as text, it doesn't edit either: a file over the
4828
+ * mount's size limit, and a binary one.
4829
+ */
4830
+ private async directEditText(
4831
+ mount: MountState,
4832
+ relativePath: string,
4833
+ path: string,
4834
+ ): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
4835
+ const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
4836
+ const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
4837
+ let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
4838
+ try {
4839
+ opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
4840
+ if (fileStat.size > maxSize) throw new Error(over);
4841
+ });
4842
+ } catch (err) {
4843
+ if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
4844
+ return { error: containedRefusal(err, path) };
4845
+ }
4846
+ try {
4847
+ const before = identityOf(await opened.handle.stat({ bigint: true }));
4848
+ const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
4849
+ if (more) return { error: over };
4850
+ if (looksBinary(bytes)) return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
4851
+ if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
4852
+ return { bytes, identity: before };
4853
+ } catch (err) {
4854
+ const errno = errnoOf(err);
4855
+ return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
4856
+ } finally {
4857
+ await opened.handle.close();
4858
+ }
4859
+ }
4860
+
4861
+ /**
4862
+ * A direct mount's deletion: the file the read tools reach, by their
4863
+ * refusals (openContainedFile), unlinked through the mount's boundary as
4864
+ * a push's deletion unlinks one (unlinkContained). A followed link is the
4865
+ * entry removed, as there. One that lands is the caller's latest write on
4866
+ * the mount (recordDirectWrite).
4867
+ */
4868
+ private async deleteDirect(mount: MountState, relativePath: string, path: string, agentName: string | undefined): Promise<ToolResult> {
4869
+ return this.withMount(mount, async (): Promise<ToolResult> => {
4870
+ // One root for the call, by its identity as the call starts: the
4871
+ // open's checks and the unlink's own refuse any other directory there,
4872
+ // in ROOT_MOVED's words, so a root replaced after this stat is refused
4873
+ // rather than taken, but in the instant between the unlink's last
4874
+ // check and the unlink, which Node can't close (effects.ts's header).
4875
+ // One that can't be read now is refused here.
4876
+ const disk = await this.diskView(mount);
4877
+ let rootStat: BigIntStats;
4878
+ try {
4879
+ rootStat = await stat(disk.rootReal, { bigint: true });
4880
+ } catch (err) {
4881
+ const code = errnoOf(err);
4882
+ const why = code === 'ENOENT' || code === 'ENOTDIR'
4883
+ ? `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`
4884
+ : code === 'EACCES' || code === 'EPERM'
4885
+ ? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
4886
+ : `the mount root can't be read (${code ?? String(err)})`;
4887
+ return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
4888
+ }
4889
+ const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
4890
+ const { rootReal } = disk;
4891
+ try {
4892
+ const opened = await this.openContainedFile(mount, relativePath, path);
4893
+ await opened.handle.close();
4894
+ } catch (err) {
4895
+ // The open's own words for a replaced root speak of reading.
4896
+ if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
4897
+ return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
4898
+ }
4899
+ return { success: false, isError: true, error: containedRefusal(err, path) };
4900
+ }
4901
+ // The unlink is awaited, unlike a write's rename, so where the head
4902
+ // was just before it says whether the head moved while it ran.
4903
+ const since = this.positionNow();
4904
+ try {
4905
+ await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
4906
+ } catch (error) {
4907
+ // A failure after the unlink reached the file's entry can't say
4908
+ // whether the file is gone, so it's taken as the agent's write too.
4909
+ if (error instanceof EffectFailed && error.touched) {
4910
+ this.recordDirectWrite(mount, agentName, since);
4911
+ 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.` };
4912
+ }
4913
+ return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
4914
+ }
4915
+ this.recordDirectWrite(mount, agentName, since);
4916
+ return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
4917
+ });
4918
+ }
4919
+
4920
+ /**
4921
+ * After a direct write lands: the agent's latest write on the mount, P,
4922
+ * moves to it, where the agent's history holds P or the notice of its
4923
+ * departure from P, N (historyHoldsWrites), and N clears. Otherwise P
4924
+ * stays where it is: the head moved away from it under the call's turn,
4925
+ * and the next start's notice covers what it left (headMoveNotice). P is
4926
+ * the branch and the store's head as it is now, read with no turn of the
4927
+ * event loop since the write, so no record that lands after the write is
4928
+ * taken for one before it. A deletion, whose unlink is awaited, passes
4929
+ * where the head was just before its unlink (`since`): where the branch
4930
+ * changed meanwhile, the head moved under the call, and P is set there,
4931
+ * so the next start judges the deletion by the branch its turn was on,
4932
+ * unless the history doesn't hold P already, whose notice is owed. A write no agent
4933
+ * made tells no one. The store's reads here come after the write landed,
4934
+ * so one that throws is logged and P stays, as for a history that can't
4935
+ * be read, and the write still answers that it landed.
4936
+ */
4937
+ private recordDirectWrite(mount: MountState, agentName: string | undefined, since?: DirectPosition | null): void {
4938
+ if (agentName === undefined) return;
4939
+ try {
4940
+ const store = this.getStore();
4941
+ let heads = this.directHeads.get(mount.config.name);
4942
+ if (!heads) this.directHeads.set(mount.config.name, (heads = new Map()));
4943
+ const head = heads.get(agentName);
4944
+ const branchId = store.currentBranch().id;
4945
+ if (since && since.branchId !== branchId) {
4946
+ if (!head || this.historyHoldsWrites(agentName, head) === true) heads.set(agentName, { ...since, notice: null });
4947
+ return;
4948
+ }
4949
+ if (head && this.historyHoldsWrites(agentName, head) !== true) return;
4950
+ heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
4951
+ } catch (err) {
4952
+ console.error('[workspace] could not record a direct write\'s position:', err);
4953
+ }
4954
+ }
4955
+
4956
+ /** The store's branch and head now, or null where the store can't say. */
4957
+ private positionNow(): DirectPosition | null {
4958
+ try {
4959
+ const store = this.getStore();
4960
+ return { branchId: store.currentBranch().id, after: store.currentSequence() };
4961
+ } catch {
4962
+ return null;
4963
+ }
4964
+ }
4965
+
4966
+ /**
4967
+ * Whether an agent's history holds its latest direct write on a mount
4968
+ * (holdsPosition), or the notice of its departure from it, judged by the
4969
+ * notice's own presence there, through the lookup the framework hands the
4970
+ * module (useHistory); null where that lookup threw, which is logged. A
4971
+ * history that can't be read decides nothing: a write takes it as not
4972
+ * held, so its position stays and the notice stays owed, and a start
4973
+ * gives no notice, which may not be true of a history it can't read, and
4974
+ * leaves the next start to decide (headMoveNotice).
4975
+ */
4976
+ private historyHoldsWrites(agentName: string, head: DirectHead): boolean | null {
4977
+ if (this.holdsPosition(head)) return true;
4978
+ if (head.notice === null) return false;
4979
+ try {
4980
+ return this.historyLookup?.(agentName, head.notice) === true;
4981
+ } catch (err) {
4982
+ console.error('[workspace] history lookup for the head-move notice failed:', err);
4983
+ return null;
4984
+ }
4985
+ }
4986
+
4987
+ /**
4988
+ * Whether the store's current branch holds what came before a direct
4989
+ * write: it's the write's branch, or continues it with every fork point on
4990
+ * the way after `after`, the store's head when the write landed. A fork at
4991
+ * exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
4992
+ * which is the head at the write when nothing was recorded in the turn
4993
+ * before it. A missing branch or one without fork metadata holds nothing,
4994
+ * as for the materialize guard (isLinearContinuation).
4995
+ */
4996
+ private holdsPosition({ branchId, after }: DirectHead): boolean {
4997
+ const store = this.getStore();
4998
+ const current = store.currentBranch();
4999
+ if (current.id === branchId) return true;
5000
+ const byId = new Map(store.listBranches().map((b) => [b.id, b]));
5001
+ const visited = new Set<string>();
5002
+ let cursor = byId.get(current.id);
5003
+ while (cursor && !visited.has(cursor.id)) {
5004
+ visited.add(cursor.id);
5005
+ if (cursor.id === branchId) return true;
5006
+ if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after) return false;
5007
+ cursor = byId.get(cursor.parentId);
5008
+ }
5009
+ return false;
5010
+ }
5011
+
5012
+ /**
5013
+ * The framework's check at every start of an agent's turn, opening,
5014
+ * continuing or resuming: the notice due for each mount the agent wrote
5015
+ * direct where its history holds neither its latest write there nor the
5016
+ * notice of its departure from it (historyHoldsWrites), with those
5017
+ * mounts' names; or null, as on a head that hasn't moved. The framework
5018
+ * stores the text in the agent's history before the turn's compile and
5019
+ * hands its message id back (headMoveNoticeStored).
5020
+ */
5021
+ headMoveNotice(agentName: string): { text: string; mounts: string[] } | null {
5022
+ if (!this.store) return null;
5023
+ const due: string[] = [];
5024
+ // Every mount where the agent wrote direct, whether it runs direct this
5025
+ // session or as 'chronicle': the writes are on disk either way.
5026
+ for (const name of this.mounts.keys()) {
5027
+ const head = this.directHeads.get(name)?.get(agentName);
5028
+ if (!head) continue;
5029
+ const held = this.historyHoldsWrites(agentName, head);
5030
+ // A history that can't be read decides nothing at this start.
5031
+ if (held === null) return null;
5032
+ if (!held) due.push(name);
5033
+ }
5034
+ return due.length ? { text: headMoveText(due), mounts: due } : null;
5035
+ }
5036
+
5037
+ /** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
5038
+ headMoveNoticeStored(agentName: string, mounts: readonly string[], messageId: string): void {
5039
+ for (const name of mounts) {
5040
+ const head = this.directHeads.get(name)?.get(agentName);
5041
+ if (head) head.notice = messageId;
5042
+ }
5043
+ }
5044
+
5045
+ /**
5046
+ * How the module asks whether an agent's history holds a message, as the
5047
+ * framework judges its own notices (`cm.getMessage(id) !== null`): handed
5048
+ * over once, when the module is added. Without it, no notice is held.
5049
+ */
5050
+ useHistory(lookup: (agentName: string, messageId: string) => boolean): void {
5051
+ this.historyLookup = lookup;
5052
+ }
5053
+
5054
+ private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
4409
5055
  const { mount, relativePath, path } = await this.parseFilePath(input.path);
4410
5056
  input = { ...input, path };
4411
- if (mount.config.mode === 'read-only') {
4412
- return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
4413
- }
5057
+ const refusal = writeRefusal(mount);
5058
+ if (refusal) return { success: false, error: refusal, isError: true };
5059
+ if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
4414
5060
 
4415
5061
  const store = this.getStore();
4416
5062
  let committed = false;
@@ -4458,7 +5104,9 @@ export class WorkspaceModule implements Module {
4458
5104
  const mounts = [...this.mounts.entries()].map(([name, m]) => ({
4459
5105
  name,
4460
5106
  path: m.config.path,
4461
- mode: m.config.mode,
5107
+ // A mount configured direct says its mode where its config names
5108
+ // none, so a writable one shows as writable.
5109
+ mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
4462
5110
  ...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
4463
5111
  ...(m.config.include ? { include: [...m.config.include] } : {}),
4464
5112
  }));
@@ -4619,15 +5267,26 @@ export class WorkspaceModule implements Module {
4619
5267
  // starts there: past the walk's cap, a walk of the whole scope can stop
4620
5268
  // before it gets there. In the mount a pattern's first segment names, the
4621
5269
  // pattern is read mount-prefixed (prefixedGlob), from its rest's directory
4622
- // within `path`.
5270
+ // within `path`. A `path` that directory can't overlap leaves no file the
5271
+ // pattern can match, so the glob is refused, as grep's is, saying how
5272
+ // the pattern is read, rather than answered with no matches; but a path
5273
+ // in another mount's directory is that mount's, and is said so, as grep
5274
+ // says it. Only a `path` can do either, and it names one mount, so
5275
+ // nothing has been searched yet.
4623
5276
  const patternDir = literalDirOf(input.pattern);
4624
- for (const { mount, relativePath } of mountsToSearch) {
5277
+ for (const { mount, relativePath, moved, root } of mountsToSearch) {
4625
5278
  const prefixed = prefixedGlob(input.pattern, mount.config.name);
4626
5279
  let start: string;
4627
5280
  let matched: (path: string) => boolean;
4628
5281
  if (prefixed) {
4629
5282
  const dir = deeperOf(relativePath, prefixed.dir);
4630
- if (dir === null) continue;
5283
+ if (dir === null) {
5284
+ if (this.holderOf(mount.config.name, relativePath, false, moved)) {
5285
+ incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
5286
+ continue;
5287
+ }
5288
+ return { success: false, error: await this.disjointRefusal('glob', input.path!, input.pattern, true, mount, relativePath, root), isError: true };
5289
+ }
4631
5290
  start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
4632
5291
  matched = (path: string) => prefixed.regex.test(path);
4633
5292
  } else {
@@ -4662,11 +5321,19 @@ export class WorkspaceModule implements Module {
4662
5321
  }
4663
5322
 
4664
5323
  const fileGlob = input.glob ? globToRegex(input.glob) : null;
4665
- // The filter matches mount-relative paths, so every file it lets through
4666
- // lies beneath its literal directory, and the walk starts at the deeper of
4667
- // that and `path`. In the mount a filter's first segment names, its
4668
- // mount-prefixed reading takes the filter's place (below).
5324
+ // The filter matches mount-relative paths, from the mount's root whatever
5325
+ // the `path`, so every file it lets through lies beneath its literal
5326
+ // directory, and the walk starts at the deeper of that and `path`. In the
5327
+ // mount a filter's first segment names, its mount-prefixed reading takes
5328
+ // the filter's place (below). One with no "/" also matches a file's name,
5329
+ // wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
5330
+ // file through, where tested against the path alone it let through only
5331
+ // those at the mount's root. The path is still tried, so a "**" in such a
5332
+ // filter spans directories as it did, and nothing it let through before
5333
+ // is lost. A filter read mount-prefixed always holds a "/" after the
5334
+ // mount's name, so it never takes the name test.
4669
5335
  const filterDir = input.glob ? literalDirOf(input.glob) : '';
5336
+ const byName = fileGlob !== null && !input.glob!.includes('/');
4670
5337
  const contextBefore = input.contextBefore ?? 0;
4671
5338
  const contextAfter = input.contextAfter ?? 0;
4672
5339
 
@@ -4683,12 +5350,30 @@ export class WorkspaceModule implements Module {
4683
5350
  }
4684
5351
 
4685
5352
  type GrepMatch = { line: number; text: string; context?: string[] };
4686
- const results: Array<{ file: string; state?: string; version: GrepText['version']; matches: GrepMatch[] }> = [];
5353
+ type Searched = { lineEndings?: 'CRLF' | 'mixed'; matches: GrepMatch[] };
5354
+ const results: Array<{ file: string; state?: string; version: GrepText['version'] } & Searched> = [];
4687
5355
  const skipped: GrepSkip[] = [];
4688
5356
  const incomplete: Array<{ path: string; reason: string }> = [];
4689
5357
 
4690
- const search = (content: string): GrepMatch[] => {
4691
- const lines = linesOf(content);
5358
+ // Lines as read shows them (fileText): a CRLF end's CR is left out, for
5359
+ // matching as well as display, unless the line's own text ends in a CR, so
5360
+ // `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
5361
+ // the line copies into an edit as read's do. ripgrep's --crlf still prints
5362
+ // the line with its CR.
5363
+ //
5364
+ // Whether a file has CRLF ends or a BOM is a fact about the file, not a
5365
+ // line, so each searched file that has either is kept here by its path,
5366
+ // for the note on a pattern that names a CR or U+FEFF to name it. A
5367
+ // conflict's disk version is kept, for each, only where the workspace's
5368
+ // copy of its path doesn't have it, and is named as that version, since
5369
+ // read doesn't show it.
5370
+ const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
5371
+ const bomFiles = new Map<string, GrepText['version']>();
5372
+ const search = (text: GrepText): Searched => {
5373
+ const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
5374
+ const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
5375
+ if (lineEndings !== undefined && !crlfFiles.has(text.file)) crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
5376
+ if (bom && !bomFiles.has(text.file)) bomFiles.set(text.file, text.version);
4692
5377
  const found: GrepMatch[] = [];
4693
5378
  for (let i = 0; i < lines.length; i++) {
4694
5379
  if (regex.test(lines[i])) {
@@ -4701,7 +5386,7 @@ export class WorkspaceModule implements Module {
4701
5386
  found.push(match);
4702
5387
  }
4703
5388
  }
4704
- return found;
5389
+ return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
4705
5390
  };
4706
5391
 
4707
5392
  for (const { mount, relativePath, moved, root } of mountsToSearch) {
@@ -4715,19 +5400,26 @@ export class WorkspaceModule implements Module {
4715
5400
  }
4716
5401
  // In the mount a filter's first segment names, the filter is read
4717
5402
  // mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
4718
- // a filter whose directories don't overlap leave no file the filter can
4719
- // let through, so nothing is searched.
5403
+ // a filter whose directories, in the reading this mount takes, don't
5404
+ // overlap leave no file the filter can let through, so the grep is
5405
+ // refused, saying how the filter is read, rather than answered with no
5406
+ // matches: written relative to the `path`, as glob's pattern is, it
5407
+ // names nothing there. Only a `path` can do this, and it names one
5408
+ // mount, so nothing has been searched yet.
4720
5409
  const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
4721
5410
  const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
4722
- if (deeper === null) continue;
5411
+ if (deeper === null) {
5412
+ return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
5413
+ }
4723
5414
  const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
4724
5415
  const searched = (text: GrepText): void => {
4725
- const matches = search(text.text);
4726
- if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, matches });
5416
+ const hits = search(text);
5417
+ if (hits.matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
4727
5418
  };
4728
5419
  const kept = prefixed
4729
5420
  ? (path: string): boolean => prefixed.regex.test(path)
4730
- : (path: string): boolean => fileGlob === null || fileGlob.test(path);
5421
+ : (path: string): boolean => fileGlob === null || fileGlob.test(path)
5422
+ || (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
4731
5423
  const found = mount.direct
4732
5424
  ? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
4733
5425
  : await this.textsFromStore(mount, relativePath, start, kept, searched);
@@ -4735,16 +5427,45 @@ export class WorkspaceModule implements Module {
4735
5427
  incomplete.push(...found.incomplete);
4736
5428
  }
4737
5429
 
4738
- return {
4739
- success: true,
4740
- data: {
4741
- pattern: input.pattern,
4742
- results,
4743
- totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
4744
- ...(skipped.length > 0 ? { skipped } : {}),
4745
- ...(incomplete.length > 0 ? { incomplete } : {}),
4746
- },
5430
+ const answer: GrepAnswer = {
5431
+ pattern: input.pattern,
5432
+ results,
5433
+ totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
5434
+ // A pattern written for a CR can't find a CRLF end's, nor one written
5435
+ // for U+FEFF a BOM, which lines as read shows them leave out, so the
5436
+ // answer says why it didn't, and names the files that have them.
5437
+ ...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
5438
+ ...(skipped.length > 0 ? { skipped } : {}),
5439
+ ...(incomplete.length > 0 ? { incomplete } : {}),
4747
5440
  };
5441
+ return textAnswer(grepText(answer, contextBefore), { ...answer });
5442
+ }
5443
+
5444
+ /**
5445
+ * Why a grep or a glob whose `path` (`given`, in the mount at
5446
+ * `relativePath`) and filter or pattern can't overlap did nothing. grep's
5447
+ * filter is matched from the mount's root, and in the mount its first
5448
+ * segment names, either tool's is read mount-prefixed (`prefixed`), from
5449
+ * that mount's root; either way no file at or beneath the path can pass
5450
+ * it. Where the path isn't a file, it names the one that reaches beneath
5451
+ * it, the one meant if it was written relative to the path, mount-prefixed
5452
+ * as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
5453
+ * `?`, which matches each of them, and since the search stays beneath the
5454
+ * path, a `?` that would match a sibling's name too takes nothing in from
5455
+ * outside it. One with an empty, `.` or `..` segment gets no suggestion,
5456
+ * since no path holds such a segment. Segments are read as written, so one
5457
+ * a brace group splits around isn't told apart.
5458
+ */
5459
+ private async disjointRefusal(tool: 'grep' | 'glob', given: string, filter: string, prefixed: boolean, mount: MountState, relativePath: string, root: string): Promise<string> {
5460
+ const name = mount.config.name;
5461
+ const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
5462
+ const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
5463
+ + (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
5464
+ if (await this.namesFile(mount, relativePath, root)) return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
5465
+ const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
5466
+ if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) return within;
5467
+ const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
5468
+ return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
4748
5469
  }
4749
5470
 
4750
5471
  /**
@@ -4838,7 +5559,7 @@ export class WorkspaceModule implements Module {
4838
5559
  const entry = store.treeGet(mount.treeStateId, report.path);
4839
5560
  const blob = entry ? store.getBlob(entry.blobHash) : null;
4840
5561
  if (blob) {
4841
- each({ file, state: report.state, version: 'workspace', text: blob.toString('utf-8') });
5562
+ each({ file, state: report.state, version: 'workspace', bytes: blob });
4842
5563
  } else if (entry) {
4843
5564
  skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
4844
5565
  }
@@ -4852,7 +5573,7 @@ export class WorkspaceModule implements Module {
4852
5573
  const changed = report.conflict?.diskChangedSinceRecorded === true;
4853
5574
  const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
4854
5575
  if (diskBlob) {
4855
- each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', text: diskBlob.toString('utf-8') });
5576
+ each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
4856
5577
  }
4857
5578
  if (disk?.stored && !diskBlob) {
4858
5579
  skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
@@ -4905,7 +5626,7 @@ export class WorkspaceModule implements Module {
4905
5626
  const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
4906
5627
  if (read.bytes === null) skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
4907
5628
  else if (looksBinary(read.head)) skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
4908
- else each({ file, version: 'disk', text: read.bytes.toString('utf-8') });
5629
+ else each({ file, version: 'disk', bytes: read.bytes });
4909
5630
  });
4910
5631
  return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
4911
5632
  }
@@ -4922,7 +5643,7 @@ export class WorkspaceModule implements Module {
4922
5643
  if (mount.direct) {
4923
5644
  // A direct mount keeps nothing to count: its kind, and the ignore
4924
5645
  // list its walks go by, whose default no config shows.
4925
- status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
5646
+ status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
4926
5647
  continue;
4927
5648
  }
4928
5649
  // What the workspace's history holds outside the mount's include isn't the mount's,
@@ -4937,7 +5658,7 @@ export class WorkspaceModule implements Module {
4937
5658
  const currentBranch = store.currentBranch();
4938
5659
  status[name] = {
4939
5660
  path: mount.config.path,
4940
- mode: mount.config.mode,
5661
+ mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
4941
5662
  // A mount configured direct runs as 'chronicle' this session
4942
5663
  // (chooseKinds), by the direct kind's ignore list where its config
4943
5664
  // names none.
@@ -5109,8 +5830,9 @@ export class WorkspaceModule implements Module {
5109
5830
  }
5110
5831
  mountsToMaterialize = [{ name: input.mount, mount: m }];
5111
5832
  } else {
5833
+ // Not a mount running direct, whose writes are on disk already.
5112
5834
  mountsToMaterialize = [...this.mounts.entries()]
5113
- .filter(([, m]) => m.config.mode === 'read-write')
5835
+ .filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
5114
5836
  .map(([name, mount]) => ({ name, mount }));
5115
5837
  }
5116
5838
 
@@ -5131,6 +5853,12 @@ export class WorkspaceModule implements Module {
5131
5853
  let proceeded = 0;
5132
5854
 
5133
5855
  for (const { name, mount } of mountsToMaterialize) {
5856
+ // Only a writable direct mount the caller named gets here: it keeps no
5857
+ // tree to push. A read-only one is named as any read-only mount is.
5858
+ if (mount.direct && mount.config.mode !== 'read-only') {
5859
+ blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
5860
+ continue;
5861
+ }
5134
5862
  if (mount.config.mode === 'read-only') {
5135
5863
  // Only a mount the caller named gets here read-only (a bare
5136
5864
  // materialize takes the read-write ones): say why nothing was written.
@@ -5243,7 +5971,8 @@ export class WorkspaceModule implements Module {
5243
5971
  if (!this.running()) return [];
5244
5972
  const store = this.getStore();
5245
5973
  const mount = this.mounts.get(mountName);
5246
- if (!mount || mount.config.mode === 'read-only') return [];
5974
+ // A direct mount keeps no tree to push: disk is its only copy.
5975
+ if (!mount || mount.config.mode === 'read-only' || mount.direct) return [];
5247
5976
 
5248
5977
  // Reset tracking — we're deliberately materializing on the new branch
5249
5978
  mount.lastMaterializedBranchId = null;
@@ -5470,9 +6199,9 @@ export class WorkspaceModule implements Module {
5470
6199
  * on disk (a shell command's files, a deletion) is in the workspace when the
5471
6200
  * agent next looks. On a mount over its walkCap, that holds for the first
5472
6201
  * walkCap entries in the walk's order, which every batch's scan takes; files
5473
- * past them come in through syncs and the listings that reach them, while a
5474
- * read takes in only a file the workspace doesn't hold yet. A scan that
5475
- * outlasts the deadline stops holding the round: the miss is recorded on
6202
+ * past them come in through syncs, the listings that reach them, and a read
6203
+ * of the path, which takes disk's change in first as a listing does. A scan
6204
+ * that outlasts the deadline stops holding the round: the miss is recorded on
5476
6205
  * the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
5477
6206
  * event, and the scan continues, deciding on the branch selected when it
5478
6207
  * applies.
@@ -5631,13 +6360,14 @@ export class WorkspaceModule implements Module {
5631
6360
 
5632
6361
  /**
5633
6362
  * A text grep searches (textsFromStore, textsFromDisk): its mount-prefixed
5634
- * file, which copy it is, and on a 'chronicle' mount, the path's state.
6363
+ * file, which copy it is, on a 'chronicle' mount the path's state, and its
6364
+ * bytes, whose lines grep matches as read shows them (fileText).
5635
6365
  */
5636
6366
  interface GrepText {
5637
6367
  file: string;
5638
6368
  state?: string;
5639
6369
  version: 'workspace' | 'conflicting-disk' | 'recorded-disk' | 'disk';
5640
- text: string;
6370
+ bytes: Buffer;
5641
6371
  }
5642
6372
 
5643
6373
  /** A file, or a copy of one, grep doesn't search, with why. */
@@ -5736,16 +6466,33 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
5736
6466
  return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
5737
6467
  }
5738
6468
 
6469
+ /**
6470
+ * Why a write, edit, deletion or peer module's writeBinary to this mount is
6471
+ * refused, or null where it may go ahead: a read-only mount, and a mount
6472
+ * configured direct that runs as 'chronicle' this session (chooseKinds),
6473
+ * since the store holds changes there that disk lacks. A write there would
6474
+ * stay in the store, beside an agent told the mount is its checkout, and
6475
+ * add to what keeps it off direct; refused, what's held only shrinks, and
6476
+ * the next start once none is held runs it direct.
6477
+ */
6478
+ function writeRefusal(mount: MountState): string | null {
6479
+ const name = mount.config.name;
6480
+ if (mount.config.mode === 'read-only') return `Mount "${name}" is read-only`;
6481
+ if (mount.config.backend === 'direct' && !mount.direct) {
6482
+ return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
6483
+ + `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
6484
+ + `(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`;
6485
+ }
6486
+ return null;
6487
+ }
6488
+
5739
6489
  /**
5740
6490
  * What a direct mount's config sets that would never act there, each with
5741
- * the way out (MountConfig.backend): a direct mount is read-only, and has no
5742
- * watcher to tune, no change event to wake on and no push to make.
6491
+ * the way out (MountConfig.backend): a direct mount has no watcher to tune,
6492
+ * no change event to wake on and no push to make. Either mode is its own.
5743
6493
  */
5744
6494
  function directRefusals(mount: MountConfig): string[] {
5745
6495
  const refused: string[] = [];
5746
- if (mount.mode !== 'read-only') {
5747
- 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)}`);
5748
- }
5749
6496
  if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
5750
6497
  for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
5751
6498
  if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);
@@ -5756,6 +6503,124 @@ function directRefusals(mount: MountConfig): string[] {
5756
6503
  return refused;
5757
6504
  }
5758
6505
 
6506
+ /**
6507
+ * An agent's latest direct write on a mount, P: the store's branch, and its
6508
+ * head when the write landed (`after`); and the id of the message that last
6509
+ * told the agent its history no longer holds P, N, or null.
6510
+ */
6511
+ interface DirectHead {
6512
+ branchId: string;
6513
+ after: number;
6514
+ notice: string | null;
6515
+ }
6516
+
6517
+ /** A store position: a branch and its head. */
6518
+ type DirectPosition = Pick<DirectHead, 'branchId' | 'after'>;
6519
+
6520
+ /** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
6521
+ const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
6522
+
6523
+ /** One mount's positions at one root (resolved, as DiskAgreement keeps a root), by agent, with N only where it's set. */
6524
+ interface DirectHeadsEntry {
6525
+ mount: string;
6526
+ root: string;
6527
+ agents: Record<string, { branchId: string; after: number; notice?: string }>;
6528
+ }
6529
+
6530
+ /** directHeads as saved: an entry per mount and root its agents wrote direct under. */
6531
+ type DirectHeadsSnapshot = DirectHeadsEntry[];
6532
+
6533
+ /**
6534
+ * A saved record as saveDirectHeads writes it, or a throw naming what isn't:
6535
+ * a list of entries, each a mount's name, root and agents, and each agent's
6536
+ * branch, head and, where set, notice. Checked whole, carried entries
6537
+ * included, so a record of another shape is refused at load rather than
6538
+ * carried into the next save.
6539
+ */
6540
+ function checkedHeads(value: unknown): DirectHeadsSnapshot {
6541
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
6542
+ if (!Array.isArray(value)) throw new Error("the record isn't of the shape saved");
6543
+ for (const saved of value as unknown[]) {
6544
+ if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
6545
+ throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
6546
+ }
6547
+ for (const [agent, head] of Object.entries(saved.agents)) {
6548
+ if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
6549
+ || (head.notice !== undefined && typeof head.notice !== 'string')) {
6550
+ throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
6551
+ }
6552
+ }
6553
+ }
6554
+ return value as DirectHeadsSnapshot;
6555
+ }
6556
+
6557
+ /** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
6558
+ function canonicalHeads(snapshot: DirectHeadsSnapshot): string {
6559
+ return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
6560
+ const { branchId, after, notice } = agents[agent]!;
6561
+ return [agent, branchId, after, notice ?? null];
6562
+ })]).sort((a, b) => {
6563
+ const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
6564
+ return x < y ? -1 : x > y ? 1 : 0;
6565
+ }));
6566
+ }
6567
+
6568
+ /**
6569
+ * How many times a direct edit reads and plans a file another writer keeps
6570
+ * changing: enough to land on top of a writer that changes it now and then,
6571
+ * such as a formatter or a build, and bounded for one that never stops.
6572
+ */
6573
+ const DIRECT_EDIT_ATTEMPTS = 3;
6574
+
6575
+ /** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
6576
+ function directReason(error: unknown): string {
6577
+ if (error instanceof FileChanged) return 'it changed on disk while this call was being applied';
6578
+ // A deletion's root check (unlinkContained) has a store-backed mount's
6579
+ // words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
6580
+ if (error instanceof Error && error.message === ROOT_REPLACED) return ROOT_MOVED;
6581
+ return error instanceof Error ? error.message : String(error);
6582
+ }
6583
+
6584
+ /** "a", "a and b", or "a, b and c". */
6585
+ function listed(items: readonly string[]): string {
6586
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
6587
+ }
6588
+
6589
+ /**
6590
+ * The note for a direct write that gave a hard-linked path a file of its
6591
+ * own: the other links keep the old text. Null for a file with none.
6592
+ */
6593
+ function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): string | null {
6594
+ const n = staged.otherLinks;
6595
+ if (n <= 0) return null;
6596
+ return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
6597
+ + `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
6598
+ }
6599
+
6600
+ /**
6601
+ * A refusal of openContainedFile's as a direct tool gives it: its message,
6602
+ * and for one that names only the path, its errno too, as a direct read's
6603
+ * answer gives it (bytesOnDisk).
6604
+ */
6605
+ function containedRefusal(err: unknown, path: string): string {
6606
+ const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
6607
+ return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
6608
+ }
6609
+
6610
+ /**
6611
+ * The notice that writes an agent made direct remain on disk from
6612
+ * turns the agent's history no longer holds. `git status` stands beside
6613
+ * `git diff` because a departed turn's new file is untracked, which `git
6614
+ * diff` doesn't show; and the warning says "unstaged", since `git checkout
6615
+ * -- <file>` puts back a file's staged text, so a staged change survives it.
6616
+ */
6617
+ function headMoveText(mounts: readonly string[]): string {
6618
+ const one = mounts.length === 1;
6619
+ return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
6620
+ + `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
6621
+ + 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
6622
+ }
6623
+
5759
6624
  /** A change commitToolChanges makes to one path's tree entry. */
5760
6625
  type ToolChange = { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string };
5761
6626
 
@@ -5770,6 +6635,64 @@ interface EditTarget {
5770
6635
  specs: EditSpec[];
5771
6636
  }
5772
6637
 
6638
+ /**
6639
+ * One file's edits planned against its bytes, as both kinds of mount plan
6640
+ * them: its text decoded, every edit matched in it (edit-plan.ts), and the
6641
+ * result held to the mount's size limit. Each problem found goes into
6642
+ * `problems`, and then, as for a file named only by edits whose own input
6643
+ * was refused, there's no plan (null).
6644
+ */
6645
+ function planTargetEdits(
6646
+ tool: EditTool,
6647
+ target: EditTarget,
6648
+ bytes: Buffer,
6649
+ hints: ReturnType<typeof hintBudget>,
6650
+ maxSize: number,
6651
+ counts: ReturnType<typeof countBudget>,
6652
+ problems: EditProblem[],
6653
+ ): FilePlan | null {
6654
+ // Decoding would turn each invalid sequence into U+FFFD, and writing
6655
+ // the text back would change bytes the edits never named.
6656
+ const file = decodeForEdit(bytes);
6657
+ if (!file) {
6658
+ problems.push({
6659
+ kind: 'file',
6660
+ edits: target.edits,
6661
+ path: target.path,
6662
+ text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
6663
+ + '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.',
6664
+ whole: false,
6665
+ });
6666
+ return null;
6667
+ }
6668
+ if (!target.specs.length) return null;
6669
+ const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
6670
+ if ('problems' in planned) {
6671
+ // One at a time: a call of many edits can have more problems
6672
+ // than a spread passes as arguments.
6673
+ for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
6674
+ return null;
6675
+ }
6676
+ // The planner refuses a file its counts take past the limit before
6677
+ // building it, from the least its bytes can be; one it built past
6678
+ // the limit, as its CRs or multi-byte characters can take it, is
6679
+ // refused here.
6680
+ if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
6681
+ const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
6682
+ problems.push({
6683
+ kind: 'file',
6684
+ edits: target.edits,
6685
+ path: target.path,
6686
+ text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
6687
+ + `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
6688
+ + "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.",
6689
+ whole: false,
6690
+ });
6691
+ return null;
6692
+ }
6693
+ return planned.plan;
6694
+ }
6695
+
5773
6696
  /**
5774
6697
  * Whether a tree entry is what a change leaves: for a set, an entry with its
5775
6698
  * blob, size and mode; for a removal, none.
@@ -5861,14 +6784,43 @@ function storedButUnfinished(
5861
6784
  }
5862
6785
 
5863
6786
  /**
5864
- * A text's lines, as read and grep number them: a final line end ends the
5865
- * last line rather than starting an empty one, so "a\nb\n" is two lines,
5866
- * and an empty text has none.
6787
+ * What a read says about the copy it shows, when that isn't simply the one the
6788
+ * workspace last agreed on with disk: disk's newer text, taken in first; the
6789
+ * workspace's copy, because disk's couldn't be checked or disk has none that
6790
+ * anything accounts for; or a draft in conflict with disk's change. A file
6791
+ * the workspace didn't hold has no older copy, so taking it in needs no word.
6792
+ * A conflict's note names the ways to settle it that work on the path's mount.
5867
6793
  */
5868
- function linesOf(content: string): string[] {
5869
- const lines = content.split('\n');
5870
- if (lines.at(-1) === '') lines.pop();
5871
- return lines;
6794
+ function whichCopyRead(report: PathReport | undefined, tookChange: boolean, readOnly: boolean): string[] {
6795
+ if (tookChange) {
6796
+ 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.'];
6797
+ }
6798
+ if (report?.state === 'unverified') {
6799
+ return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
6800
+ }
6801
+ if (report?.state === 'disk-missing-provenance-unknown') {
6802
+ return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
6803
+ }
6804
+ if (report?.state === 'conflict') {
6805
+ const kind = report.conflict?.kind ?? 'both-changed';
6806
+ // A sync takes disk's state as it is now, which a recorded conflict's kind
6807
+ // doesn't follow: where disk has no copy, the sync takes its deletion, and
6808
+ // the workspace's copy goes with it. Where disk has one, the sync keeps it
6809
+ // and gives up the workspace's, taking disk's in only where the workspace
6810
+ // can hold it (not binary or too large). A read-only mount can hold a
6811
+ // copy disk doesn't, such as one a branch switch selected, but a
6812
+ // materialize, forced or not, writes nothing to it.
6813
+ const sync = report.diskNow === 'absent'
6814
+ ? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
6815
+ : 'Sync the path to keep disk\'s copy and give up the workspace\'s';
6816
+ const force = readOnly
6817
+ ? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
6818
+ : report.diskNow === 'absent'
6819
+ ? ', or materialize it with force to write the workspace\'s copy back to disk.'
6820
+ : ', or materialize it with force to write the workspace\'s over disk\'s.';
6821
+ return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
6822
+ }
6823
+ return [];
5872
6824
  }
5873
6825
 
5874
6826
  /**
@@ -5939,6 +6891,87 @@ function inputProblem(
5939
6891
  return null;
5940
6892
  }
5941
6893
 
6894
+ /** How many files a grep note names before it gives the count of the rest. */
6895
+ const NOTE_FILES_MAX = 20;
6896
+
6897
+ /**
6898
+ * The files a grep note names, in the order grep searched them, as its
6899
+ * results are: at most NOTE_FILES_MAX of them, then how many more and how to
6900
+ * see them. Each is named by its mount-prefixed path, with what `detail` says
6901
+ * of it.
6902
+ */
6903
+ function namedFiles<T>(files: Map<string, T>, detail: (value: T) => string): string {
6904
+ const names = [...files.keys()];
6905
+ const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name)!)}`);
6906
+ const rest = names.length - shown.length;
6907
+ return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
6908
+ }
6909
+
6910
+ /** What a note says of a conflict's disk version, which read doesn't show. */
6911
+ const ofVersion = (version: GrepText['version']): string =>
6912
+ version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
6913
+
6914
+ /**
6915
+ * grep's note for a pattern that names a character its lines leave out, where
6916
+ * a file it searched has one: it says how many files searched have it and
6917
+ * names them, so a search for a CR or a BOM still finds the files that have
6918
+ * them. The CR note gives each file's ends, CRLF or mixed, and either note
6919
+ * names a conflict's disk version as that version. The note says a
6920
+ * character read shows it in the workspace's copy, since grep also searches a
6921
+ * conflict's disk version, which read doesn't show. A direct mount keeps no
6922
+ * workspace copy, and its read shows disk's file, so on a host with one
6923
+ * (`direct`) the note names that too, as the tools' descriptions mention
6924
+ * direct mounts only on such a host.
6925
+ */
6926
+ function leftOutNote(
6927
+ pattern: string,
6928
+ crlfFiles: Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>,
6929
+ bomFiles: Map<string, GrepText['version']>,
6930
+ direct: boolean,
6931
+ ): { note?: string } {
6932
+ if (crlfFiles.size === 0 && bomFiles.size === 0) return {};
6933
+ const named = namedLeftOut(pattern);
6934
+ const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
6935
+ const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
6936
+ const notes = [
6937
+ named.cr && crlfFiles.size > 0
6938
+ ? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
6939
+ + `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
6940
+ + `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}.`
6941
+ : null,
6942
+ named.bom && bomFiles.size > 0
6943
+ ? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
6944
+ + `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
6945
+ + `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'}.`
6946
+ : null,
6947
+ ].filter((n) => n !== null);
6948
+ return notes.length ? { note: notes.join(' ') } : {};
6949
+ }
6950
+
6951
+ /**
6952
+ * Which of the characters grep's lines leave out a pattern names: a CR, as a
6953
+ * literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
6954
+ * before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
6955
+ * before a literal one. An escape counts only where its backslash isn't itself
6956
+ * escaped. A pattern compiled without flags can match a CR other ways too,
6957
+ * such as `\15` in a pattern of fewer than 15 groups or a class range that
6958
+ * spans U+000D, and those go unnoticed.
6959
+ */
6960
+ function namedLeftOut(pattern: string): { cr: boolean; bom: boolean } {
6961
+ const named = { cr: false, bom: false };
6962
+ for (let i = 0; i < pattern.length; i++) {
6963
+ if (pattern[i] === '\r') named.cr = true;
6964
+ else if (pattern[i] === '\uFEFF') named.bom = true;
6965
+ else if (pattern[i] === '\\') {
6966
+ const next = pattern.slice(i + 1, i + 6);
6967
+ if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next)) named.cr = true;
6968
+ else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next)) named.bom = true;
6969
+ i++;
6970
+ }
6971
+ }
6972
+ return named;
6973
+ }
6974
+
5942
6975
  /**
5943
6976
  * A glob's reading in the mount its first segment names, in place of the
5944
6977
  * pattern as written there. Every path the tools take and return is