@animalabs/agent-framework 0.21.1 → 0.22.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 (271) hide show
  1. package/README.md +43 -0
  2. package/dist/src/agent.d.ts +17 -4
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +102 -42
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/py-runner.d.ts +22 -1
  7. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.js +64 -9
  9. package/dist/src/code-execution/py-runner.js.map +1 -1
  10. package/dist/src/code-execution/runtime-py.d.ts +4 -0
  11. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  12. package/dist/src/code-execution/runtime-py.js +215 -38
  13. package/dist/src/code-execution/runtime-py.js.map +1 -1
  14. package/dist/src/framework.d.ts +250 -39
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +1602 -371
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts +21 -0
  19. package/dist/src/gate/event-gate.d.ts.map +1 -1
  20. package/dist/src/gate/event-gate.js +40 -7
  21. package/dist/src/gate/event-gate.js.map +1 -1
  22. package/dist/src/index.d.ts +5 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +26 -3
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/conversation-router.d.ts +28 -1
  30. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  31. package/dist/src/mcpl/conversation-router.js +17 -0
  32. package/dist/src/mcpl/conversation-router.js.map +1 -1
  33. package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
  34. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  35. package/dist/src/mcpl/feature-set-manager.js +31 -7
  36. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  37. package/dist/src/mcpl/index.d.ts +2 -0
  38. package/dist/src/mcpl/index.d.ts.map +1 -1
  39. package/dist/src/mcpl/index.js +1 -0
  40. package/dist/src/mcpl/index.js.map +1 -1
  41. package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
  42. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  43. package/dist/src/mcpl/prose-stream-router.js +85 -22
  44. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  45. package/dist/src/mcpl/push-coalescer.d.ts +32 -4
  46. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  47. package/dist/src/mcpl/push-coalescer.js +46 -13
  48. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  49. package/dist/src/mcpl/push-handler.d.ts +5 -3
  50. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-handler.js +41 -11
  52. package/dist/src/mcpl/push-handler.js.map +1 -1
  53. package/dist/src/mcpl/server-connection.d.ts +4 -0
  54. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  55. package/dist/src/mcpl/server-connection.js +45 -8
  56. package/dist/src/mcpl/server-connection.js.map +1 -1
  57. package/dist/src/mcpl/server-registry.d.ts +5 -2
  58. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  59. package/dist/src/mcpl/server-registry.js +25 -7
  60. package/dist/src/mcpl/server-registry.js.map +1 -1
  61. package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
  62. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  63. package/dist/src/mcpl/tool-lifecycle.js +47 -1
  64. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  65. package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
  66. package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
  67. package/dist/src/mcpl/tool-pattern-check.js +79 -0
  68. package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
  69. package/dist/src/mcpl/types.d.ts +15 -5
  70. package/dist/src/mcpl/types.d.ts.map +1 -1
  71. package/dist/src/mcpl/types.js.map +1 -1
  72. package/dist/src/mcpl/visible-content.d.ts +39 -0
  73. package/dist/src/mcpl/visible-content.d.ts.map +1 -0
  74. package/dist/src/mcpl/visible-content.js +54 -0
  75. package/dist/src/mcpl/visible-content.js.map +1 -0
  76. package/dist/src/module-registry.d.ts +81 -3
  77. package/dist/src/module-registry.d.ts.map +1 -1
  78. package/dist/src/module-registry.js +257 -16
  79. package/dist/src/module-registry.js.map +1 -1
  80. package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
  81. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
  82. package/dist/src/modules/workspace/branch-intent.js +83 -0
  83. package/dist/src/modules/workspace/branch-intent.js.map +1 -0
  84. package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
  85. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
  86. package/dist/src/modules/workspace/disk-agreement.js +259 -0
  87. package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
  88. package/dist/src/modules/workspace/effects.d.ts +84 -0
  89. package/dist/src/modules/workspace/effects.d.ts.map +1 -0
  90. package/dist/src/modules/workspace/effects.js +368 -0
  91. package/dist/src/modules/workspace/effects.js.map +1 -0
  92. package/dist/src/modules/workspace/index.d.ts +155 -17
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +1059 -424
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/observe.d.ts +278 -0
  97. package/dist/src/modules/workspace/observe.d.ts.map +1 -0
  98. package/dist/src/modules/workspace/observe.js +805 -0
  99. package/dist/src/modules/workspace/observe.js.map +1 -0
  100. package/dist/src/modules/workspace/reconcile.d.ts +224 -0
  101. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
  102. package/dist/src/modules/workspace/reconcile.js +901 -0
  103. package/dist/src/modules/workspace/reconcile.js.map +1 -0
  104. package/dist/src/modules/workspace/sync.d.ts +6 -59
  105. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/sync.js +6 -257
  107. package/dist/src/modules/workspace/sync.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +77 -10
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/record-journal.d.ts +127 -0
  112. package/dist/src/record-journal.d.ts.map +1 -0
  113. package/dist/src/record-journal.js +202 -0
  114. package/dist/src/record-journal.js.map +1 -0
  115. package/dist/src/shutdown.d.ts +13 -0
  116. package/dist/src/shutdown.d.ts.map +1 -0
  117. package/dist/src/shutdown.js +46 -0
  118. package/dist/src/shutdown.js.map +1 -0
  119. package/dist/src/silent-heartbeat.d.ts +74 -0
  120. package/dist/src/silent-heartbeat.d.ts.map +1 -0
  121. package/dist/src/silent-heartbeat.js +77 -0
  122. package/dist/src/silent-heartbeat.js.map +1 -0
  123. package/dist/src/tool-presentation.d.ts +50 -0
  124. package/dist/src/tool-presentation.d.ts.map +1 -0
  125. package/dist/src/tool-presentation.js +235 -0
  126. package/dist/src/tool-presentation.js.map +1 -0
  127. package/dist/src/tool-result-guard.d.ts +125 -6
  128. package/dist/src/tool-result-guard.d.ts.map +1 -1
  129. package/dist/src/tool-result-guard.js +310 -35
  130. package/dist/src/tool-result-guard.js.map +1 -1
  131. package/dist/src/tool-result-history.d.ts +13 -0
  132. package/dist/src/tool-result-history.d.ts.map +1 -1
  133. package/dist/src/tool-result-history.js +21 -1
  134. package/dist/src/tool-result-history.js.map +1 -1
  135. package/dist/src/types/agent.d.ts +18 -2
  136. package/dist/src/types/agent.d.ts.map +1 -1
  137. package/dist/src/types/events.d.ts +4 -0
  138. package/dist/src/types/events.d.ts.map +1 -1
  139. package/dist/src/types/framework.d.ts +12 -0
  140. package/dist/src/types/framework.d.ts.map +1 -1
  141. package/dist/src/types/module.d.ts +14 -0
  142. package/dist/src/types/module.d.ts.map +1 -1
  143. package/dist/src/types/trace.d.ts +73 -0
  144. package/dist/src/types/trace.d.ts.map +1 -1
  145. package/dist/test/batched-wake-locus.test.d.ts +2 -0
  146. package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
  147. package/dist/test/batched-wake-locus.test.js +166 -0
  148. package/dist/test/batched-wake-locus.test.js.map +1 -0
  149. package/dist/test/code-execution.test.js +386 -2
  150. package/dist/test/code-execution.test.js.map +1 -1
  151. package/dist/test/component-defaults.test.d.ts +2 -0
  152. package/dist/test/component-defaults.test.d.ts.map +1 -0
  153. package/dist/test/component-defaults.test.js +54 -0
  154. package/dist/test/component-defaults.test.js.map +1 -0
  155. package/dist/test/conversation-routing.test.js +57 -1
  156. package/dist/test/conversation-routing.test.js.map +1 -1
  157. package/dist/test/ephemeral-settle.test.js +3 -0
  158. package/dist/test/ephemeral-settle.test.js.map +1 -1
  159. package/dist/test/event-gate.test.js +118 -0
  160. package/dist/test/event-gate.test.js.map +1 -1
  161. package/dist/test/explicit-prose-routing.test.js +977 -0
  162. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  163. package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
  164. package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
  165. package/dist/test/helpers/pytc-stop-rows.js +143 -0
  166. package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
  167. package/dist/test/mcpl-coalescing.test.js +19 -6
  168. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  169. package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
  170. package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
  171. package/dist/test/mcpl-config-diagnostics.test.js +449 -0
  172. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
  173. package/dist/test/mcpl-empty-content.test.d.ts +2 -0
  174. package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
  175. package/dist/test/mcpl-empty-content.test.js +368 -0
  176. package/dist/test/mcpl-empty-content.test.js.map +1 -0
  177. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
  178. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  179. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
  180. package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
  181. package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
  182. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
  183. package/dist/test/prose-stream-router.test.js +41 -0
  184. package/dist/test/prose-stream-router.test.js.map +1 -1
  185. package/dist/test/record-journal.test.d.ts +6 -0
  186. package/dist/test/record-journal.test.d.ts.map +1 -0
  187. package/dist/test/record-journal.test.js +269 -0
  188. package/dist/test/record-journal.test.js.map +1 -0
  189. package/dist/test/save-recent-image.test.js +102 -0
  190. package/dist/test/save-recent-image.test.js.map +1 -1
  191. package/dist/test/shutdown-errors.test.d.ts +2 -0
  192. package/dist/test/shutdown-errors.test.d.ts.map +1 -0
  193. package/dist/test/shutdown-errors.test.js +662 -0
  194. package/dist/test/shutdown-errors.test.js.map +1 -0
  195. package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
  196. package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
  197. package/dist/test/silent-heartbeat-rows.test.js +451 -0
  198. package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
  199. package/dist/test/silent-heartbeat.test.js +49 -38
  200. package/dist/test/silent-heartbeat.test.js.map +1 -1
  201. package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
  202. package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
  203. package/dist/test/stop-with-work-in-flight.test.js +672 -0
  204. package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
  205. package/dist/test/tool-batch-hook.test.d.ts +8 -0
  206. package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
  207. package/dist/test/tool-batch-hook.test.js +275 -0
  208. package/dist/test/tool-batch-hook.test.js.map +1 -0
  209. package/dist/test/tool-presentation.test.d.ts +2 -0
  210. package/dist/test/tool-presentation.test.d.ts.map +1 -0
  211. package/dist/test/tool-presentation.test.js +268 -0
  212. package/dist/test/tool-presentation.test.js.map +1 -0
  213. package/dist/test/tool-result-guard.test.js +2330 -18
  214. package/dist/test/tool-result-guard.test.js.map +1 -1
  215. package/dist/test/tool-result-history.test.js +2 -1
  216. package/dist/test/tool-result-history.test.js.map +1 -1
  217. package/dist/test/turn-trigger-provenance.test.js +35 -0
  218. package/dist/test/turn-trigger-provenance.test.js.map +1 -1
  219. package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
  220. package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
  221. package/dist/test/unconvertible-tool-result.test.js +358 -0
  222. package/dist/test/unconvertible-tool-result.test.js.map +1 -0
  223. package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
  224. package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
  225. package/dist/test/workspace-materialize-freshness.test.js +252 -0
  226. package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
  227. package/dist/test/workspace-three-way.test.d.ts +12 -0
  228. package/dist/test/workspace-three-way.test.d.ts.map +1 -0
  229. package/dist/test/workspace-three-way.test.js +3079 -0
  230. package/dist/test/workspace-three-way.test.js.map +1 -0
  231. package/dist/tsconfig.tsbuildinfo +1 -1
  232. package/package.json +2 -2
  233. package/src/agent.ts +120 -44
  234. package/src/code-execution/py-runner.ts +66 -9
  235. package/src/code-execution/runtime-py.ts +215 -38
  236. package/src/framework.ts +1661 -363
  237. package/src/gate/event-gate.ts +59 -7
  238. package/src/index.ts +11 -0
  239. package/src/mcpl/channel-registry.ts +24 -3
  240. package/src/mcpl/conversation-router.ts +34 -1
  241. package/src/mcpl/feature-set-manager.ts +36 -11
  242. package/src/mcpl/index.ts +2 -0
  243. package/src/mcpl/prose-stream-router.ts +79 -23
  244. package/src/mcpl/push-coalescer.ts +62 -13
  245. package/src/mcpl/push-handler.ts +41 -12
  246. package/src/mcpl/server-connection.ts +41 -10
  247. package/src/mcpl/server-registry.ts +24 -7
  248. package/src/mcpl/tool-lifecycle.ts +77 -2
  249. package/src/mcpl/tool-pattern-check.ts +106 -0
  250. package/src/mcpl/types.ts +15 -5
  251. package/src/mcpl/visible-content.ts +57 -0
  252. package/src/module-registry.ts +252 -16
  253. package/src/modules/workspace/branch-intent.ts +123 -0
  254. package/src/modules/workspace/disk-agreement.ts +325 -0
  255. package/src/modules/workspace/effects.ts +362 -0
  256. package/src/modules/workspace/index.ts +1058 -446
  257. package/src/modules/workspace/observe.ts +839 -0
  258. package/src/modules/workspace/reconcile.ts +1136 -0
  259. package/src/modules/workspace/sync.ts +6 -336
  260. package/src/modules/workspace/types.ts +75 -10
  261. package/src/record-journal.ts +246 -0
  262. package/src/shutdown.ts +49 -0
  263. package/src/silent-heartbeat.ts +141 -0
  264. package/src/tool-presentation.ts +188 -0
  265. package/src/tool-result-guard.ts +335 -33
  266. package/src/tool-result-history.ts +25 -1
  267. package/src/types/agent.ts +18 -2
  268. package/src/types/events.ts +4 -0
  269. package/src/types/framework.ts +12 -0
  270. package/src/types/module.ts +15 -0
  271. package/src/types/trace.ts +80 -0
@@ -7,12 +7,13 @@
7
7
 
8
8
  import { constants as fsConstants } from 'node:fs';
9
9
  import type { Stats } from 'node:fs';
10
- import { open, readFile, stat, access, writeFile, unlink, mkdir, lstat, realpath } from 'node:fs/promises';
11
- import { join, resolve, relative, dirname, sep } from 'node:path';
10
+ import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
11
+ import { join, resolve, relative, sep } from 'node:path';
12
12
  import type { JsStore } from '@animalabs/chronicle';
13
13
  import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
14
14
  import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
15
15
  import type {
16
+ AgentActionScan,
16
17
  WorkspaceConfig,
17
18
  MountConfig,
18
19
  MountState,
@@ -35,7 +36,31 @@ import type {
35
36
  } from './types.js';
36
37
  import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
37
38
  import { MountWatcher, type FsChange } from './watcher.js';
38
- import { syncFromFs, materializeToFs, hashContent, isBinary, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
39
+ import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
40
+ import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
41
+ import { BranchIntents } from './branch-intent.js';
42
+ import { filesystemTrustsCtime, locateRoot } from './observe.js';
43
+ import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
44
+ import {
45
+ type MountRuntime,
46
+ type PassOptions,
47
+ type PassResult,
48
+ type PathReport,
49
+ type PushOptions,
50
+ type PushResult,
51
+ type Scope,
52
+ type TreeOp,
53
+ pushPaths,
54
+ reconcilePass,
55
+ settleAdoptionBeforeMutation,
56
+ } from './reconcile.js';
57
+
58
+ /** Default for how long the after-batch scan may hold the agent's next inference. */
59
+ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
60
+ /** Why a call is refused once the module has stopped. */
61
+ const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
62
+ /** The workspace's own deadline must end before the framework's hook bound. */
63
+ const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
39
64
 
40
65
  export type {
41
66
  WorkspaceConfig,
@@ -603,9 +628,22 @@ export class WorkspaceModule implements Module {
603
628
 
604
629
  private ctx: ModuleContext | null = null;
605
630
  private store: JsStore | null = null;
631
+ /** Set once stop has drained and saved, cleared by start (running()). */
632
+ private stopped = false;
606
633
  private config: WorkspaceConfig;
607
634
  private mounts = new Map<string, MountState>();
608
635
  private watchers = new Map<string, MountWatcher>();
636
+ /** What disk last agreed with (global records); created with the store. */
637
+ private agreement: DiskAgreement | null = null;
638
+ /** Branch-local intent per mount. */
639
+ private intents = new Map<string, BranchIntents>();
640
+ /** Each mount's turn: the tail of its serialized passes and writes. */
641
+ private mountTurns = new Map<string, Promise<void>>();
642
+ /** Whether an over-bound agentActionScanDeadlineMs has been warned about. */
643
+ private warnedScanDeadline = false;
644
+ /** Whether a mount root's filesystem maintains ctime, by canonical root. */
645
+ /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
646
+ private ctimeTrust = new Map<string, string | null>();
609
647
  /** Persisted state decoded in start(), held until mounts exist to apply it
610
648
  * to — in the Host ordering start() runs before initStore() creates the
611
649
  * mounts, so restoration must be second-callback-safe like watcher
@@ -651,40 +689,119 @@ export class WorkspaceModule implements Module {
651
689
  initStore(store: JsStore): void {
652
690
  this.store = store;
653
691
 
654
- // Register tree states for each mount
692
+ // Register tree states for each mount: the workspace tree, and the
693
+ // branch-local intent that qualifies it.
655
694
  for (const mount of this.config.mounts) {
656
695
  const treeStateId = `workspace/${mount.name}/tree`;
657
- try {
658
- store.registerState({
659
- id: treeStateId,
660
- strategy: 'tree',
661
- deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
662
- fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
663
- });
664
- } catch {
665
- // State already registered (restart scenario)
696
+ const intentTreeStateId = `workspace/${mount.name}/intent`;
697
+ for (const id of [treeStateId, intentTreeStateId]) {
698
+ try {
699
+ store.registerState({
700
+ id,
701
+ strategy: 'tree',
702
+ deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
703
+ fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
704
+ });
705
+ } catch {
706
+ // State already registered (restart scenario)
707
+ }
666
708
  }
667
709
 
668
710
  const mountState: MountState = {
669
711
  config: mount,
670
712
  treeStateId,
713
+ intentTreeStateId,
671
714
  lastMaterializedSeq: 0,
672
715
  suppressedPaths: new Set(),
673
716
  initialSyncDone: false,
674
717
  lastMaterializedBranchId: null,
675
- materializedHashes: new Map(),
676
718
  watcherReadyAt: null,
677
719
  watcherError: null,
678
720
  };
679
721
  this.mounts.set(mount.name, mountState);
722
+ this.intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
680
723
  }
681
724
 
725
+ // What disk last agreed with, per mount and path: global records, so a
726
+ // branch switch never rewinds it.
727
+ this.agreement = new DiskAgreement(store);
728
+ this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
729
+
682
730
  this.applySavedState();
683
731
  this.ensureRunning();
684
732
  }
685
733
 
734
+ // ==========================================================================
735
+ // Reconciliation plumbing
736
+ // ==========================================================================
737
+
738
+ /**
739
+ * Run `fn` with the mount to itself: observation, decision and effect never
740
+ * interleave with another pass or a tool write on the same mount (a disk
741
+ * observation taken before an autoMaterialize write and applied after it
742
+ * would ingest old bytes over the new entry). Not re-entrant: code holding
743
+ * a mount's turn calls the unlocked helpers.
744
+ */
745
+ private withMount<T>(mount: MountState, fn: () => Promise<T>): Promise<T> {
746
+ const name = mount.config.name;
747
+ const previous = this.mountTurns.get(name) ?? Promise.resolve();
748
+ const run = previous.then(fn, fn);
749
+ this.mountTurns.set(name, run.then(() => undefined, () => undefined));
750
+ return run;
751
+ }
752
+
753
+ private agreementOrThrow(): DiskAgreement {
754
+ if (!this.agreement) throw new Error('Workspace store not initialized');
755
+ return this.agreement;
756
+ }
757
+
758
+ /** The view reconciliation needs of a mount, refreshed per pass. */
759
+ private async runtime(mount: MountState): Promise<MountRuntime> {
760
+ const root = resolve(mount.config.path);
761
+ const rootReal = await realpath(root).catch(() => root);
762
+ let trustedDev = this.ctimeTrust.get(rootReal);
763
+ if (trustedDev === undefined) {
764
+ const dev = (await stat(rootReal, { bigint: true }).catch(() => null))?.dev;
765
+ trustedDev = dev !== undefined && (await filesystemTrustsCtime(rootReal)) ? dev.toString() : null;
766
+ this.ctimeTrust.set(rootReal, trustedDev);
767
+ }
768
+ return {
769
+ name: mount.config.name,
770
+ view: {
771
+ root,
772
+ rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
773
+ followSymlinks: mount.config.followSymlinks === true,
774
+ maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
775
+ ignore: mount.config.ignore ?? [],
776
+ },
777
+ rootReal,
778
+ ctimeTrustedDev: trustedDev,
779
+ treeStateId: mount.treeStateId,
780
+ intents: this.intents.get(mount.config.name)!,
781
+ readOnly: mount.config.mode === 'read-only',
782
+ };
783
+ }
784
+
785
+ /** One disk→store pass; the caller holds the mount's turn. Emits its events. */
786
+ private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
787
+ const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
788
+ if (scope.kind === 'dir' && scope.dir === '' && scope.recursive) mount.initialSyncDone = true;
789
+ this.emitFsEvents(mount.config.name, result.ops, result.newConflicts);
790
+ return result;
791
+ }
792
+
793
+ /** One store→disk push; the caller holds the mount's turn. */
794
+ private async pushUnlocked(mount: MountState, paths: string[], opts: PushOptions = {}): Promise<PushResult> {
795
+ const watcher = this.watchers.get(mount.config.name);
796
+ return pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), paths, {
797
+ ...opts,
798
+ beforeEffect: (p) => watcher?.suppress(p),
799
+ });
800
+ }
801
+
686
802
  async start(ctx: ModuleContext): Promise<void> {
687
803
  this.ctx = ctx;
804
+ this.stopped = false;
688
805
 
689
806
  // Decode persisted state if restarting; application waits until mounts
690
807
  // exist (see applySavedState) — in the Host ordering they don't yet.
@@ -709,9 +826,18 @@ export class WorkspaceModule implements Module {
709
826
 
710
827
  for (const [name, meta] of Object.entries(this.savedState.mounts)) {
711
828
  const mount = this.mounts.get(name);
712
- if (mount) {
829
+ // A mount materialized this session already (a call between initStore
830
+ // and start) has a pin and watermark newer than anything saved.
831
+ if (mount && mount.lastMaterializedBranchId === null && mount.lastMaterializedSeq === 0) {
713
832
  mount.lastMaterializedSeq = meta.lastMaterializedSeq;
714
833
  mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
834
+ // A store that ran #169's stop-time baselines (#109) saved them here
835
+ // too. They aren't carried over as evidence: #169 recorded no root,
836
+ // so nothing binds them to the directory they describe, and an empty
837
+ // mountpoint at the next start would read as every file deleted.
838
+ // Such a store gains its evidence from its first pass instead: a
839
+ // file disk agrees with is recorded, and anything else is a conflict
840
+ // or unknown provenance, never an adoption.
715
841
  // watcherReadyAt intentionally not restored — each session must
716
842
  // observe its own watcher attach, otherwise a stale timestamp
717
843
  // would hide a new-session attach failure.
@@ -855,12 +981,10 @@ export class WorkspaceModule implements Module {
855
981
  watcher.start();
856
982
  this.watchers.set(name, watcher);
857
983
 
858
- // Chokidar is started with ignoreInitial:true, so files already on
859
- // disk at session start would be invisible. Trigger one syncFromFs
860
- // pass — syncFromFs diffs disk against the tree state, so only files
861
- // new-to-this-session's-tree fire workspace:created events. Fresh
862
- // sessions see the existing catalog; restarts only see what appeared
863
- // while the session was down.
984
+ // Chokidar is started with ignoreInitial:true, so changes made while
985
+ // the session was down would be invisible. One full pass catches up
986
+ // under the three-way rule: only real differences produce tree
987
+ // changes and events.
864
988
  void this.initialScan(name);
865
989
  }
866
990
 
@@ -878,9 +1002,9 @@ export class WorkspaceModule implements Module {
878
1002
  if (!store || !mount) return;
879
1003
 
880
1004
  try {
881
- const result = await syncFromFs(store, mount);
882
- mount.initialSyncDone = true;
883
- this.emitFsEvents(mountName, result.synced, result.conflicts);
1005
+ // A full pass under the three-way rule: deletions on disk are confirmed
1006
+ // where nothing newer is pending, and nothing unvisited is touched.
1007
+ await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }));
884
1008
  } catch (err) {
885
1009
  const msg = err instanceof Error ? err.message : String(err);
886
1010
  this.ctx?.pushEvent({
@@ -892,6 +1016,24 @@ export class WorkspaceModule implements Module {
892
1016
  }
893
1017
 
894
1018
  async stop(): Promise<void> {
1019
+ // No new watcher-driven passes, then let every pass and write finish
1020
+ // before the store goes away — including any queued behind the ones in
1021
+ // flight (a tool call, a scan continuing past its deadline): drained
1022
+ // until no new tail appears.
1023
+ for (const watcher of this.watchers.values()) {
1024
+ await watcher.stop();
1025
+ }
1026
+ this.watchers.clear();
1027
+ for (;;) {
1028
+ const tails = [...this.mountTurns.values()];
1029
+ await Promise.all(tails);
1030
+ const now = [...this.mountTurns.values()];
1031
+ if (now.length === tails.length && now.every((tail, i) => tail === tails[i])) break;
1032
+ }
1033
+ // Nothing is queued now, and nothing more is taken: what follows saves
1034
+ // the pins and watermarks as the last work left them.
1035
+ this.stopped = true;
1036
+
895
1037
  // Persist state
896
1038
  if (this.ctx) {
897
1039
  const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
@@ -906,12 +1048,6 @@ export class WorkspaceModule implements Module {
906
1048
  }
907
1049
  this.ctx.setState(state);
908
1050
  }
909
-
910
- // Stop watchers
911
- for (const watcher of this.watchers.values()) {
912
- await watcher.stop();
913
- }
914
- this.watchers.clear();
915
1051
  this.ctx = null;
916
1052
  }
917
1053
 
@@ -988,7 +1124,9 @@ export class WorkspaceModule implements Module {
988
1124
  },
989
1125
  {
990
1126
  name: 'delete',
991
- description: 'Delete a file from the workspace.',
1127
+ description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
1128
+ + 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
1129
+ + 'or a sync of the path restores it.',
992
1130
  inputSchema: {
993
1131
  type: 'object' as const,
994
1132
  properties: {
@@ -999,7 +1137,14 @@ export class WorkspaceModule implements Module {
999
1137
  },
1000
1138
  {
1001
1139
  name: 'ls',
1002
- description: 'List directory contents from the workspace tree.',
1140
+ description: 'List a directory, checked against disk first. Each file has a state: synced; '
1141
+ + 'workspace-draft (the workspace has changes disk lacks); workspace-deleted (deleted in the workspace, '
1142
+ + 'still on disk); not-in-branch (on disk, absent from this branch\'s workspace); disk-only (binary or over '
1143
+ + 'the size limit, never stored — shown with size and image type); conflict, with its kind (disk and the '
1144
+ + 'workspace both changed; disk holds a different copy and nothing records which is newer; or a write was '
1145
+ + 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace; '
1146
+ + 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
1147
+ + 'the listing could not see.',
1003
1148
  inputSchema: {
1004
1149
  type: 'object' as const,
1005
1150
  properties: {
@@ -1010,7 +1155,7 @@ export class WorkspaceModule implements Module {
1010
1155
  },
1011
1156
  {
1012
1157
  name: 'glob',
1013
- description: 'Find files matching a glob pattern in the workspace.',
1158
+ description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.',
1014
1159
  inputSchema: {
1015
1160
  type: 'object' as const,
1016
1161
  properties: {
@@ -1022,7 +1167,11 @@ export class WorkspaceModule implements Module {
1022
1167
  },
1023
1168
  {
1024
1169
  name: 'grep',
1025
- description: 'Search file contents with a regex pattern.',
1170
+ description: 'Search stored file text with a regex pattern, checked against disk first. Each result says '
1171
+ + 'which version matched: the workspace\'s (with its state); the disk version kept with a conflict '
1172
+ + '(conflicting-disk); or, once disk has changed since the conflict was recorded, that recorded version '
1173
+ + '(recorded-disk). Disk versions it could not search are listed as skipped: disk-only files (binary or '
1174
+ + 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.',
1026
1175
  inputSchema: {
1027
1176
  type: 'object' as const,
1028
1177
  properties: {
@@ -1047,24 +1196,45 @@ export class WorkspaceModule implements Module {
1047
1196
  },
1048
1197
  {
1049
1198
  name: 'materialize',
1050
- description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk.',
1199
+ description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
1200
+ + 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.',
1051
1201
  inputSchema: {
1052
1202
  type: 'object' as const,
1053
1203
  properties: {
1054
- path: { type: 'string', description: 'Specific path to materialize (optional — defaults to all changed)' },
1204
+ path: { type: 'string', description: 'Specific path to materialize (optional — defaults to what disk still owes: workspace edits, conflicts, workspace deletions, and files never checked against disk)' },
1055
1205
  mount: { type: 'string', description: 'Specific mount (optional)' },
1056
- force: { type: 'boolean', description: 'Materialize even if the current branch has diverged from the branch last written to disk (default false). Only needed for genuine divergence — descendant branches pass automatically.' },
1206
+ force: { type: 'boolean', description: 'Overwrite files whose disk copy changed since it last agreed with the workspace (another writer, or a conflict), and materialize even if the current branch has diverged from the branch last written to disk (default false). Without it, divergent files are skipped and listed. A file the workspace has not changed is left as disk has it unless you name its path.' },
1207
+ applyDeletions: { type: 'boolean', description: 'Also delete from disk the files deleted in the workspace whose disk copy is one the workspace holds (default false). With force, also those whose disk copy changed since.' },
1057
1208
  },
1058
1209
  },
1059
1210
  },
1060
1211
  {
1061
1212
  name: 'sync',
1062
- description: 'Pull filesystem state into the workspace. Detects user changes on disk.',
1213
+ description: 'Bring disk changes into the workspace. Without a path, every mount (or the given one) is '
1214
+ + 'rechecked in full and nothing pending in the workspace is discarded. With a path (file or directory), '
1215
+ + 'the workspace takes disk\'s state there explicitly: it restores a workspace-deleted file, resolves '
1216
+ + 'conflicts toward disk, replaces a workspace draft, and removes a workspace file disk does not have. '
1217
+ + 'Each workspace change it gives up is listed under `discarded`, with the state it had and, as `op`, '
1218
+ + 'what that did to the workspace copy (absent when it changed none: a deletion or conflict given up for '
1219
+ + 'a disk copy the workspace can\'t hold). '
1220
+ + 'A mount whose root is not the directory disk last agreed with (an unmounted drive, a replaced '
1221
+ + 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
1222
+ + 'directory replaced on purpose, or a drive back under a new device number, never for one still '
1223
+ + 'unplugged, whose empty mountpoint would become the root.',
1063
1224
  inputSchema: {
1064
1225
  type: 'object' as const,
1065
1226
  properties: {
1066
- path: { type: 'string', description: 'Specific path to sync (optional — defaults to all)' },
1227
+ path: { type: 'string', description: 'Specific path to take from disk (optional — defaults to a full recheck)' },
1067
1228
  mount: { type: 'string', description: 'Specific mount (optional)' },
1229
+ acceptRoot: {
1230
+ type: 'boolean',
1231
+ description: 'Take each mount\'s root as it is now when it is not the directory disk last agreed with: '
1232
+ + 'a directory replaced on purpose, or a drive that came back under a new device number (default false). '
1233
+ + 'What was recorded about the old one is set aside, so every file is compared afresh: a file disk '
1234
+ + 'and the workspace agree on agrees again, a differing one is a conflict, and a workspace file the '
1235
+ + 'root lacks stays in the workspace. Not with path. Leave it off while a drive is merely unplugged: '
1236
+ + 'its empty mountpoint would become the root.',
1237
+ },
1068
1238
  },
1069
1239
  },
1070
1240
  },
@@ -1075,9 +1245,49 @@ export class WorkspaceModule implements Module {
1075
1245
  // Tool Dispatch
1076
1246
  // ==========================================================================
1077
1247
 
1248
+ private generatedTextFiles = new Map<string, {read: () => string; agentName: string}>();
1249
+
1250
+ /** Compare the same normalized physical path for registration and tool access. */
1251
+ private generatedFileKey(path: string): string {
1252
+ const slash = path.indexOf('/');
1253
+ const mountName = slash < 0 ? path : path.slice(0, slash);
1254
+ const mount = this.config.mounts.find(m => m.name === mountName);
1255
+ if (!mount) throw new Error(`Unknown mount: "${mountName}"`);
1256
+ const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1));
1257
+ if (!isContainedPath(mount.path, resolved)) throw new Error(`Path traversal detected: "${path}"`);
1258
+ return resolved;
1259
+ }
1260
+
1261
+ private findGeneratedTextFile(path: string) {
1262
+ if (!this.generatedTextFiles.size) return undefined;
1263
+ try { return this.generatedTextFiles.get(this.generatedFileKey(path)); }
1264
+ catch {
1265
+ // Not an alias of a valid generated file. Let the selected tool return
1266
+ // its established validation error (including image-specific errors).
1267
+ return undefined;
1268
+ }
1269
+ }
1270
+
1271
+ /** Generated files bypass stored blobs so every read reflects current definitions. */
1272
+ registerGeneratedTextFile(path: string, read: () => string, agentName: string): void {
1273
+ const [mount, ...parts] = path.split('/');
1274
+ if (!this.config.mounts.some(m => m.name === mount) || !parts.length
1275
+ || parts.some(p => !p || p === '.' || p === '..') || this.generatedTextFiles.has(this.generatedFileKey(path)))
1276
+ throw new Error(`Duplicate/invalid generated path: ${path}`);
1277
+ this.generatedTextFiles.set(this.generatedFileKey(path), {read, agentName});
1278
+ }
1279
+
1078
1280
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
1281
+ if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1079
1282
  try {
1080
1283
  const input = call.input as Record<string, unknown>;
1284
+ const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
1285
+ if (generated) {
1286
+ if (call.callerAgentName !== generated.agentName) return {success:false,isError:true,error:'Generated file belongs to another agent'};
1287
+ if (call.name !== 'read') return {success:false,isError:true,error:'Generated file is read-only; edit the presentation configuration instead'};
1288
+ return await this.handleRead(input as unknown as ReadInput, generated.read());
1289
+ }
1290
+
1081
1291
  switch (call.name) {
1082
1292
  case 'read': return await this.handleRead(input as unknown as ReadInput);
1083
1293
  case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
@@ -1197,12 +1407,29 @@ export class WorkspaceModule implements Module {
1197
1407
  * mount, through the same Chronicle-tree + auto-materialize path as the
1198
1408
  * `write` tool. Public API for the framework's synthesized `save_image`
1199
1409
  * tool and other peer callers that hold bytes rather than text.
1410
+ *
1411
+ * `branch`, when given, ties the write to that branch: the store's current
1412
+ * branch is checked where the tree entry is set, nothing yielding between
1413
+ * the check and the entry, so the file's tree entry lands on that branch or
1414
+ * the write is refused, with `data: { branchLeft: true }`. A mount has one
1415
+ * directory on disk, so with autoMaterialize the file's copy there is
1416
+ * written like any other write's. The tool-result guard writes a
1417
+ * withheld original this way, for the branch its stub is on
1418
+ * (agent-framework #277).
1419
+ *
1420
+ * A write whose tree entry is set but that then fails returns success
1421
+ * false with `data: { inChronicle: true }`: its copy on disk was refused, or
1422
+ * something after the entry (recording its intent, the event, the push)
1423
+ * threw. The workspace's tools read the file from the tree, so it is there
1424
+ * for them, on the branch that was current when the entry was set.
1200
1425
  */
1201
1426
  async writeBinary(
1202
1427
  mountPrefixedPath: string,
1203
1428
  data: Buffer,
1204
1429
  mimeType: string,
1430
+ options: { branch?: string } = {},
1205
1431
  ): Promise<ToolResult> {
1432
+ if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
1206
1433
  let mount: MountState;
1207
1434
  let relativePath: string;
1208
1435
  try {
@@ -1222,24 +1449,36 @@ export class WorkspaceModule implements Module {
1222
1449
  return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
1223
1450
  }
1224
1451
  const store = this.getStore();
1225
- const blobHash = store.storeBlob(data, mimeType);
1226
- store.treeSet(mount.treeStateId, relativePath, {
1227
- blobHash,
1228
- size: data.byteLength,
1229
- mode: 0o644,
1230
- });
1231
- const materializeError = await this.autoMaterialize(mount, relativePath, 'write', data);
1232
- if (materializeError) {
1452
+ return this.withMount(mount, async () => {
1453
+ await this.settleBeforeMutation(mount, relativePath);
1454
+ // Checked inside the mount's turn, after anything the write waited on:
1455
+ // commitToolChange sets the tree entry before it first yields.
1456
+ if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
1457
+ return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
1458
+ }
1459
+ const blobHash = store.storeBlob(data, mimeType);
1460
+ let committed = false;
1461
+ let failure: string | null;
1462
+ try {
1463
+ const materializeError = await this.commitToolChange(mount, relativePath,
1464
+ { kind: 'set', blobHash, size: data.byteLength }, () => { committed = true; });
1465
+ failure = materializeError === null ? null
1466
+ : `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`;
1467
+ } catch (error) {
1468
+ if (!committed) throw error;
1469
+ failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
1470
+ }
1471
+ if (failure !== null) {
1472
+ // The file is in the Chronicle tree, where the workspace's tools read
1473
+ // it. `inChronicle` lets a caller that needs the file readable tell
1474
+ // this from a write that never happened (agent-framework #281).
1475
+ return { success: false, error: failure, isError: true, data: { inChronicle: true } };
1476
+ }
1233
1477
  return {
1234
- success: false,
1235
- error: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`,
1236
- isError: true,
1478
+ success: true,
1479
+ data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
1237
1480
  };
1238
- }
1239
- return {
1240
- success: true,
1241
- data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
1242
- };
1481
+ });
1243
1482
  }
1244
1483
 
1245
1484
  /**
@@ -1250,6 +1489,7 @@ export class WorkspaceModule implements Module {
1250
1489
  async readBinary(
1251
1490
  mountPrefixedPath: string,
1252
1491
  ): Promise<{ data: Buffer } | { error: string }> {
1492
+ if (!this.running()) return { error: NOT_RUNNING };
1253
1493
  let mount: MountState;
1254
1494
  let relativePath: string;
1255
1495
  try {
@@ -1258,12 +1498,14 @@ export class WorkspaceModule implements Module {
1258
1498
  return { error: error instanceof Error ? error.message : String(error) };
1259
1499
  }
1260
1500
  const store = this.getStore();
1261
- await this.ensureSynced(mount, relativePath);
1262
- const entry = store.treeGet(mount.treeStateId, relativePath);
1263
- if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
1264
- const blob = store.getBlob(entry.blobHash);
1265
- if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
1266
- return { data: blob };
1501
+ return this.withMount(mount, async () => {
1502
+ await this.ensureSynced(mount, relativePath);
1503
+ const entry = store.treeGet(mount.treeStateId, relativePath);
1504
+ if (!entry) return { error: `File not found: ${mountPrefixedPath}` };
1505
+ const blob = store.getBlob(entry.blobHash);
1506
+ if (!blob) return { error: `Blob not found for: ${mountPrefixedPath}` };
1507
+ return { data: blob };
1508
+ });
1267
1509
  }
1268
1510
 
1269
1511
  /**
@@ -1332,6 +1574,18 @@ export class WorkspaceModule implements Module {
1332
1574
  return { mount, relativePath };
1333
1575
  }
1334
1576
 
1577
+ /**
1578
+ * Whether the module takes work: stop hasn't finished. Work admitted while
1579
+ * stop drains still runs; once stop has saved the pins and watermarks,
1580
+ * nothing more changes them, so it takes nothing until it starts again. A
1581
+ * call before start is taken, as it always was (a generated file's read
1582
+ * needs no store at all): in the host's order, start runs first, and
1583
+ * initStore applies the saved pins.
1584
+ */
1585
+ private running(): boolean {
1586
+ return !this.stopped;
1587
+ }
1588
+
1335
1589
  private getStore(): JsStore {
1336
1590
  if (!this.store) throw new Error('WorkspaceModule: store not initialized. Call initStore() first.');
1337
1591
  return this.store;
@@ -1653,55 +1907,70 @@ export class WorkspaceModule implements Module {
1653
1907
  }
1654
1908
 
1655
1909
  /**
1656
- * Persist a single write/edit/delete to disk when the mount opts in via
1657
- * `autoMaterialize`. Required for cross-agent pipelines — another agent's
1658
- * chokidar watcher on the same directory only sees real filesystem events.
1659
- * The local watcher's `suppress()` absorbs the echo so we don't self-wake.
1910
+ * Commit a tool's write or delete to the workspace tree, with the branch
1911
+ * intent it implies (store origin for a path disk never agreed with; a
1912
+ * tombstone for a deletion), and push it to disk when the mount opts in via
1913
+ * `autoMaterialize` — required for cross-agent pipelines, since another
1914
+ * agent's watcher on the same directory only sees real filesystem events.
1915
+ * The push follows materialize's rule: a disk copy that changed since it
1916
+ * last agreed is not overwritten or unlinked, and the refusal says why.
1660
1917
  *
1661
- * Returns an error string on failure so callers can surface it in the tool
1662
- * result. The autoMaterialize contract is specifically "disk is the source
1663
- * of truth for downstream agents", so a silent disk-write failure with a
1664
- * Chronicle commit would defeat the whole purpose. No-op cases (flag off,
1665
- * read-only mount) return null (success).
1918
+ * Returns the reason when the change reached the workspace but not disk, so
1919
+ * callers surface it in the tool result: the autoMaterialize contract is
1920
+ * that disk is the source of truth for downstream agents. The caller holds
1921
+ * the mount's turn and has settled the path first (settleBeforeMutation).
1666
1922
  */
1667
- private async autoMaterialize(
1923
+ private async commitToolChange(
1668
1924
  mount: MountState,
1669
1925
  relativePath: string,
1670
- op: 'write' | 'delete',
1671
- content?: Buffer,
1926
+ change: { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string },
1927
+ onCommitted?: () => void,
1672
1928
  ): Promise<string | null> {
1673
- if (!mount.config.autoMaterialize) return null;
1674
- if (mount.config.mode === 'read-only') return null;
1675
-
1676
- const absolutePath = join(mount.config.path, relativePath);
1677
- const watcher = this.watchers.get(mount.config.name);
1678
- watcher?.suppress(relativePath);
1679
-
1680
- try {
1681
- if (op === 'write' && content) {
1682
- await mkdir(dirname(absolutePath), { recursive: true });
1683
- await writeFile(absolutePath, content);
1684
- mount.materializedHashes.set(relativePath, hashContent(content));
1685
- } else if (op === 'delete') {
1686
- try {
1687
- await unlink(absolutePath);
1688
- } catch (err) {
1689
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
1690
- }
1691
- mount.materializedHashes.delete(relativePath);
1692
- }
1693
- return null;
1694
- } catch (err) {
1695
- const msg = err instanceof Error ? err.message : String(err);
1696
- this.ctx?.pushEvent({
1697
- type: 'workspace:materialize-failed',
1698
- mount: mount.config.name,
1699
- path: relativePath,
1700
- op,
1701
- error: msg,
1702
- } as ProcessEvent);
1703
- return msg;
1929
+ const store = this.getStore();
1930
+ const intents = this.intents.get(mount.config.name)!;
1931
+ // The mutation's precondition, enforced here, synchronously with the
1932
+ // commit: whatever settleBeforeMutation's pass could or couldn't settle.
1933
+ settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
1934
+ const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
1935
+ if (change.kind === 'set') {
1936
+ store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
1937
+ // A later intent update or disk push can throw; the tree entry is
1938
+ // already set, on the branch current now. If the active branch changes
1939
+ // during the push, that branch's tree has the file and the new one may
1940
+ // not.
1941
+ onCommitted?.();
1942
+ intents.update(relativePath, (cur) => {
1943
+ const next = { ...cur };
1944
+ delete next.tombstone;
1945
+ // Disk has never agreed with this path: a missing disk copy is a draft
1946
+ // not yet materialized, not a deletion.
1947
+ if (!known) next.origin = 'store';
1948
+ return next;
1949
+ });
1950
+ } else {
1951
+ store.treeRemove(mount.treeStateId, relativePath);
1952
+ intents.update(relativePath, (cur) => {
1953
+ const next = { ...cur, tombstone: { hash: change.previousHash } };
1954
+ delete next.origin;
1955
+ return next;
1956
+ });
1704
1957
  }
1958
+ if (!mount.config.autoMaterialize || mount.config.mode === 'read-only') return null;
1959
+
1960
+ const pushed = await this.pushUnlocked(mount, [relativePath], {
1961
+ applyDeletions: change.kind === 'remove',
1962
+ branchId: store.currentBranch().id,
1963
+ });
1964
+ const refused = pushed.skipped.find((s) => s.path === relativePath);
1965
+ if (!refused) return null;
1966
+ this.ctx?.pushEvent({
1967
+ type: 'workspace:materialize-failed',
1968
+ mount: mount.config.name,
1969
+ path: relativePath,
1970
+ op: change.kind === 'set' ? 'write' : 'delete',
1971
+ error: refused.reason,
1972
+ } as ProcessEvent);
1973
+ return refused.reason;
1705
1974
  }
1706
1975
 
1707
1976
  // ==========================================================================
@@ -1709,36 +1978,49 @@ export class WorkspaceModule implements Module {
1709
1978
  // ==========================================================================
1710
1979
 
1711
1980
  /**
1712
- * Ensure a file is synced from filesystem if not yet in tree (lazy sync).
1981
+ * Before a tool reads or changes a path's workspace entry, a pending intent
1982
+ * on that path is settled by a pass over it, so the tool works from what
1983
+ * disk now holds (an edit sees a disk change the settled adoption took in).
1984
+ * The pass may not settle it (a branch that keeps changing, an unreadable
1985
+ * disk copy); the precondition that matters is enforced again,
1986
+ * synchronously, where the change commits (commitToolChange). The caller
1987
+ * holds the mount's turn.
1713
1988
  */
1714
- private async ensureSynced(mount: MountState, relativePath: string): Promise<void> {
1715
- const store = this.getStore();
1716
- const existing = store.treeGet(mount.treeStateId, relativePath);
1717
- if (existing) return; // Already in tree
1989
+ private async settleBeforeMutation(mount: MountState, relativePath: string): Promise<void> {
1990
+ if (this.agreementOrThrow().get(mount.config.name, relativePath)?.kind !== 'pending') return;
1991
+ await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
1992
+ }
1718
1993
 
1719
- // Try to read from filesystem
1720
- const absolutePath = join(mount.config.path, relativePath);
1721
- try {
1722
- const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
1723
- const fileStat = await stat(absolutePath);
1724
- if (!fileStat.isFile() || fileStat.size > maxSize) return;
1725
-
1726
- const buffer = await readFile(absolutePath);
1727
- // Binaries are never synced into the tree (mirrors syncFromFs): the old
1728
- // utf-8 round-trip here silently replaced non-UTF-8 bytes with U+FFFD,
1729
- // permanently mangling images in the blob store. Binary reads are served
1730
- // straight from disk (readImageFromFilesystem / read_image fallback).
1731
- if (isBinary(buffer)) return;
1732
- const content = buffer.toString('utf-8');
1733
- const blobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
1734
-
1735
- store.treeSet(mount.treeStateId, relativePath, {
1736
- blobHash,
1737
- size: buffer.length,
1738
- mode: 0o644,
1739
- });
1740
- } catch {
1741
- // File doesn't exist on disk — that's fine
1994
+ /**
1995
+ * Before a read: a path the workspace doesn't hold is observed under the
1996
+ * three-way rule, which ingests a new disk file but never brings back a
1997
+ * workspace deletion or a file absent from this branch. The caller holds
1998
+ * the mount's turn.
1999
+ */
2000
+ private async ensureSynced(mount: MountState, relativePath: string): Promise<PathReport | undefined> {
2001
+ const store = this.getStore();
2002
+ if (store.treeGet(mount.treeStateId, relativePath)) return undefined;
2003
+ const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
2004
+ return pass.reports.get(relativePath);
2005
+ }
2006
+
2007
+ /** Why the workspace holds no entry at a path, when disk explains it. */
2008
+ private notInWorkspace(path: string, report: PathReport | undefined): string {
2009
+ switch (report?.state) {
2010
+ case 'workspace-deleted':
2011
+ return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
2012
+ case 'not-in-branch':
2013
+ return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
2014
+ case 'disk-only':
2015
+ return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
2016
+ (report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
2017
+ case 'conflict':
2018
+ return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
2019
+ 'Sync this path to take the disk version.';
2020
+ case 'unverified':
2021
+ return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
2022
+ default:
2023
+ return `File not found: ${path}`;
1742
2024
  }
1743
2025
  }
1744
2026
 
@@ -1746,7 +2028,7 @@ export class WorkspaceModule implements Module {
1746
2028
  // Tool Handlers
1747
2029
  // ==========================================================================
1748
2030
 
1749
- private async handleRead(input: ReadInput): Promise<ToolResult> {
2031
+ private async handleRead(input: ReadInput, generatedContent?: string): Promise<ToolResult> {
1750
2032
  const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
1751
2033
  const offsetChars = input.offsetChars ?? 0;
1752
2034
  const limitChars = input.limitChars ?? 2000;
@@ -1760,22 +2042,21 @@ export class WorkspaceModule implements Module {
1760
2042
  return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
1761
2043
  }
1762
2044
  }
1763
- const { mount, relativePath } = this.parsePath(input.path);
1764
- const store = this.getStore();
1765
-
1766
- await this.ensureSynced(mount, relativePath);
1767
-
1768
- const entry = store.treeGet(mount.treeStateId, relativePath);
1769
- if (!entry) {
1770
- return { success: false, error: `File not found: ${input.path}`, isError: true };
1771
- }
1772
-
1773
- const blob = store.getBlob(entry.blobHash);
1774
- if (!blob) {
1775
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2045
+ let content = generatedContent;
2046
+ if (content === undefined) {
2047
+ const { mount, relativePath } = this.parsePath(input.path);
2048
+ const store = this.getStore();
2049
+ const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => {
2050
+ const report = await this.ensureSynced(mount, relativePath);
2051
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2052
+ if (!entry) return { error: this.notInWorkspace(input.path, report) };
2053
+ const blob = store.getBlob(entry.blobHash);
2054
+ if (!blob) return { error: `Blob not found for: ${input.path}` };
2055
+ return { text: blob.toString('utf-8') };
2056
+ });
2057
+ if ('error' in found) return { success: false, error: found.error, isError: true };
2058
+ content = found.text;
1776
2059
  }
1777
-
1778
- const content = blob.toString('utf-8');
1779
2060
  if (characterPaging) {
1780
2061
  const start = Math.min(offsetChars, content.length);
1781
2062
  const splitsPair = (at: number): boolean =>
@@ -1916,13 +2197,11 @@ export class WorkspaceModule implements Module {
1916
2197
  }
1917
2198
 
1918
2199
  const buffer = Buffer.from(input.content, 'utf-8');
1919
- const blobHash = store.storeBlob(buffer, 'text/plain');
1920
- store.treeSet(mount.treeStateId, relativePath, {
1921
- blobHash,
1922
- size: Buffer.byteLength(input.content),
1923
- mode: 0o644,
2200
+ const materializeError = await this.withMount(mount, async () => {
2201
+ await this.settleBeforeMutation(mount, relativePath);
2202
+ const blobHash = store.storeBlob(buffer, 'text/plain');
2203
+ return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength });
1924
2204
  });
1925
- const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
1926
2205
  if (materializeError) {
1927
2206
  return {
1928
2207
  success: false,
@@ -1948,62 +2227,65 @@ export class WorkspaceModule implements Module {
1948
2227
  }
1949
2228
 
1950
2229
  const store = this.getStore();
1951
- await this.ensureSynced(mount, relativePath);
2230
+ return this.withMount(mount, async (): Promise<ToolResult> => {
2231
+ const report = await this.ensureSynced(mount, relativePath);
2232
+ await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry
1952
2233
 
1953
- const entry = store.treeGet(mount.treeStateId, relativePath);
1954
- if (!entry) {
1955
- return { success: false, error: `File not found: ${input.path}`, isError: true };
1956
- }
2234
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2235
+ if (!entry) {
2236
+ return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2237
+ }
1957
2238
 
1958
- const blob = store.getBlob(entry.blobHash);
1959
- if (!blob) {
1960
- return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
1961
- }
2239
+ const blob = store.getBlob(entry.blobHash);
2240
+ if (!blob) {
2241
+ return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
2242
+ }
1962
2243
 
1963
- let content = blob.toString('utf-8');
2244
+ let content = blob.toString('utf-8');
1964
2245
 
1965
- // Validate uniqueness
1966
- if (!input.replaceAll) {
1967
- const count = content.split(input.oldString).length - 1;
1968
- if (count === 0) {
1969
- return { success: false, error: `String not found in ${input.path}`, isError: true };
2246
+ // Validate uniqueness
2247
+ if (!input.replaceAll) {
2248
+ const count = content.split(input.oldString).length - 1;
2249
+ if (count === 0) {
2250
+ return { success: false, error: `String not found in ${input.path}`, isError: true };
2251
+ }
2252
+ if (count > 1) {
2253
+ return {
2254
+ success: false,
2255
+ error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
2256
+ isError: true,
2257
+ };
2258
+ }
1970
2259
  }
1971
- if (count > 1) {
2260
+
2261
+ content = input.replaceAll
2262
+ ? content.replaceAll(input.oldString, input.newString)
2263
+ : content.replace(input.oldString, input.newString);
2264
+
2265
+ const newBuffer = Buffer.from(content, 'utf-8');
2266
+ const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
2267
+ const materializeError = await this.commitToolChange(mount, relativePath, {
2268
+ kind: 'set',
2269
+ blobHash: newBlobHash,
2270
+ size: newBuffer.byteLength,
2271
+ mode: entry.mode,
2272
+ });
2273
+ if (materializeError) {
1972
2274
  return {
1973
2275
  success: false,
1974
- error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
2276
+ error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
1975
2277
  isError: true,
1976
2278
  };
1977
2279
  }
1978
- }
1979
2280
 
1980
- content = input.replaceAll
1981
- ? content.replaceAll(input.oldString, input.newString)
1982
- : content.replace(input.oldString, input.newString);
1983
-
1984
- const newBuffer = Buffer.from(content, 'utf-8');
1985
- const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
1986
- store.treeSet(mount.treeStateId, relativePath, {
1987
- blobHash: newBlobHash,
1988
- size: Buffer.byteLength(content),
1989
- mode: entry.mode,
1990
- });
1991
- const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
1992
- if (materializeError) {
1993
2281
  return {
1994
- success: false,
1995
- error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
1996
- isError: true,
2282
+ success: true,
2283
+ data: {
2284
+ path: input.path,
2285
+ size: newBuffer.byteLength,
2286
+ },
1997
2287
  };
1998
- }
1999
-
2000
- return {
2001
- success: true,
2002
- data: {
2003
- path: input.path,
2004
- size: Buffer.byteLength(content),
2005
- },
2006
- };
2288
+ });
2007
2289
  }
2008
2290
 
2009
2291
  private async handleDelete(input: DeleteInput): Promise<ToolResult> {
@@ -2013,22 +2295,26 @@ export class WorkspaceModule implements Module {
2013
2295
  }
2014
2296
 
2015
2297
  const store = this.getStore();
2016
- const entry = store.treeGet(mount.treeStateId, relativePath);
2017
- if (!entry) {
2018
- return { success: false, error: `File not found: ${input.path}`, isError: true };
2019
- }
2298
+ return this.withMount(mount, async (): Promise<ToolResult> => {
2299
+ const report = await this.ensureSynced(mount, relativePath);
2300
+ await this.settleBeforeMutation(mount, relativePath);
2301
+ const entry = store.treeGet(mount.treeStateId, relativePath);
2302
+ if (!entry) {
2303
+ return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
2304
+ }
2020
2305
 
2021
- store.treeRemove(mount.treeStateId, relativePath);
2022
- const materializeError = await this.autoMaterialize(mount, relativePath, 'delete');
2023
- if (materializeError) {
2024
- return {
2025
- success: false,
2026
- error: `Removed from Chronicle but failed to unlink "${input.path}" from disk: ${materializeError}. Downstream agents will still see the stale file until manual cleanup.`,
2027
- isError: true,
2028
- };
2029
- }
2306
+ const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash });
2307
+ if (materializeError) {
2308
+ return {
2309
+ success: false,
2310
+ error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` +
2311
+ 'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.',
2312
+ isError: true,
2313
+ };
2314
+ }
2030
2315
 
2031
- return { success: true, data: { path: input.path, deleted: true } };
2316
+ return { success: true, data: { path: input.path, deleted: true } };
2317
+ });
2032
2318
  }
2033
2319
 
2034
2320
  private async handleLs(input: LsInput): Promise<ToolResult> {
@@ -2045,63 +2331,55 @@ export class WorkspaceModule implements Module {
2045
2331
  }
2046
2332
 
2047
2333
  const { mount, relativePath } = this.parsePath(input.path);
2334
+ const recursive = input.recursive === true;
2048
2335
 
2049
- // Ensure initial sync — always sync on first access regardless of watch mode,
2050
- // so that ls/glob/grep see filesystem contents even for unwatched mounts
2051
- if (!mount.initialSyncDone) {
2052
- await syncFromFs(store, mount);
2053
- mount.initialSyncDone = true;
2054
- }
2336
+ // Every listing checks the paths it shows against disk first, under the
2337
+ // three-way rule, whatever the mount's watch mode.
2338
+ return this.withMount(mount, async (): Promise<ToolResult> => {
2339
+ const pass = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive });
2340
+ const reports = [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path));
2341
+ const incomplete = pass.incomplete.length > 0 ? { incomplete: pass.incomplete } : {};
2055
2342
 
2056
- const prefix = relativePath ? relativePath + '/' : '';
2057
- const entries = store.treeList(mount.treeStateId, prefix || undefined);
2343
+ if (recursive) {
2344
+ return {
2345
+ success: true,
2346
+ data: {
2347
+ path: input.path,
2348
+ entries: reports.map((r) => ({ path: r.path, ...stateView(r) })),
2349
+ count: reports.length,
2350
+ ...incomplete,
2351
+ },
2352
+ };
2353
+ }
2058
2354
 
2059
- if (input.recursive) {
2355
+ // Non-recursive: the scope's files, and the directories beneath it that
2356
+ // disk or the workspace has.
2357
+ const prefix = relativePath ? relativePath + '/' : '';
2358
+ const dirNames = new Set(pass.dirs.map((d) => d.slice(prefix.length)));
2359
+ for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
2360
+ const slash = entry.path.indexOf('/', prefix.length);
2361
+ if (slash >= 0) dirNames.add(entry.path.slice(prefix.length, slash));
2362
+ }
2363
+ const children: Array<Record<string, unknown>> = [
2364
+ ...[...dirNames].sort().map((name) => ({ name, type: 'directory' })),
2365
+ ...reports.map((r) => ({ name: r.path.slice(prefix.length), type: 'file', ...stateView(r) })),
2366
+ ];
2060
2367
  return {
2061
2368
  success: true,
2062
2369
  data: {
2063
2370
  path: input.path,
2064
- entries: entries.map(e => ({
2065
- path: e.path,
2066
- size: e.size,
2067
- })),
2068
- count: entries.length,
2371
+ entries: children,
2372
+ count: children.length,
2373
+ ...incomplete,
2069
2374
  },
2070
2375
  };
2071
- }
2072
-
2073
- // Non-recursive: deduplicate to show immediate children only
2074
- const seen = new Set<string>();
2075
- const children: Array<{ name: string; type: 'file' | 'directory' }> = [];
2076
-
2077
- for (const entry of entries) {
2078
- const rest = entry.path.slice(prefix.length);
2079
- const slashIdx = rest.indexOf('/');
2080
- if (slashIdx >= 0) {
2081
- const dirName = rest.slice(0, slashIdx);
2082
- if (!seen.has(dirName)) {
2083
- seen.add(dirName);
2084
- children.push({ name: dirName, type: 'directory' });
2085
- }
2086
- } else {
2087
- children.push({ name: rest, type: 'file' });
2088
- }
2089
- }
2090
-
2091
- return {
2092
- success: true,
2093
- data: {
2094
- path: input.path,
2095
- entries: children,
2096
- count: children.length,
2097
- },
2098
- };
2376
+ });
2099
2377
  }
2100
2378
 
2101
2379
  private async handleGlob(input: GlobInput): Promise<ToolResult> {
2102
- const store = this.getStore();
2103
2380
  const regex = globToRegex(input.pattern);
2104
- const matches: string[] = [];
2381
+ const matches: Array<Record<string, unknown>> = [];
2382
+ const incomplete: Array<{ path: string; reason: string }> = [];
2105
2383
 
2106
2384
  // Search across mounts
2107
2385
  const mountsToSearch = input.path
@@ -2109,20 +2387,17 @@ export class WorkspaceModule implements Module {
2109
2387
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2110
2388
 
2111
2389
  for (const { mount, relativePath } of mountsToSearch) {
2112
- if (!mount.initialSyncDone) {
2113
- await syncFromFs(store, mount);
2114
- mount.initialSyncDone = true;
2115
- }
2116
-
2117
- const prefix = relativePath ? relativePath + '/' : undefined;
2118
- const entries = store.treeList(mount.treeStateId, prefix);
2119
-
2120
- for (const entry of entries) {
2121
- const testPath = relativePath ? entry.path.slice(relativePath.length + 1) : entry.path;
2390
+ const pass = await this.withMount(mount, () =>
2391
+ this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
2392
+ for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
2393
+ const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
2122
2394
  if (regex.test(testPath)) {
2123
- matches.push(`${mount.config.name}/${entry.path}`);
2395
+ matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) });
2124
2396
  }
2125
2397
  }
2398
+ for (const region of pass.incomplete) {
2399
+ incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
2400
+ }
2126
2401
  }
2127
2402
 
2128
2403
  return {
@@ -2131,6 +2406,7 @@ export class WorkspaceModule implements Module {
2131
2406
  pattern: input.pattern,
2132
2407
  matches,
2133
2408
  count: matches.length,
2409
+ ...(incomplete.length > 0 ? { incomplete } : {}),
2134
2410
  },
2135
2411
  };
2136
2412
  }
@@ -2152,61 +2428,107 @@ export class WorkspaceModule implements Module {
2152
2428
  ? [this.parsePath(input.path)]
2153
2429
  : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
2154
2430
 
2155
- const results: Array<{ file: string; matches: Array<{ line: number; text: string; context?: string[] }> }> = [];
2156
-
2157
- for (const { mount, relativePath } of mountsToSearch) {
2158
- if (!mount.initialSyncDone) {
2159
- await syncFromFs(store, mount);
2160
- mount.initialSyncDone = true;
2161
- }
2162
-
2163
- // If `path` points at a single FILE, grep just that file. Otherwise treat
2164
- // `path` as a directory prefix (the original behaviour). Previously a file
2165
- // path produced an empty prefix like "notes.md/" and matched nothing, so
2166
- // grepping a specific file silently returned zero results.
2167
- let entries: Array<{ path: string; blobHash: string }>;
2168
- const fileNode = relativePath
2169
- ? (store.treeGet(mount.treeStateId, relativePath) as { blobHash?: string } | null)
2170
- : null;
2171
- if (fileNode && fileNode.blobHash) {
2172
- entries = [{ path: relativePath, blobHash: fileNode.blobHash }];
2173
- } else {
2174
- const prefix = relativePath ? relativePath + '/' : undefined;
2175
- entries = store.treeList(mount.treeStateId, prefix);
2431
+ type GrepMatch = { line: number; text: string; context?: string[] };
2432
+ const results: Array<{ file: string; state: string; version: 'workspace' | 'conflicting-disk' | 'recorded-disk'; matches: GrepMatch[] }> = [];
2433
+ const skipped: Array<{ file: string; reason: string; size?: number; mimeType?: string }> = [];
2434
+ const incomplete: Array<{ path: string; reason: string }> = [];
2435
+
2436
+ const search = (content: string): GrepMatch[] => {
2437
+ const lines = content.split('\n');
2438
+ const found: GrepMatch[] = [];
2439
+ for (let i = 0; i < lines.length; i++) {
2440
+ if (regex.test(lines[i])) {
2441
+ const match: GrepMatch = { line: i + 1, text: lines[i] };
2442
+ if (contextBefore > 0 || contextAfter > 0) {
2443
+ const start = Math.max(0, i - contextBefore);
2444
+ const end = Math.min(lines.length, i + contextAfter + 1);
2445
+ match.context = lines.slice(start, end);
2446
+ }
2447
+ found.push(match);
2448
+ }
2176
2449
  }
2450
+ return found;
2451
+ };
2177
2452
 
2178
- for (const entry of entries) {
2179
- if (fileGlob && !fileGlob.test(entry.path)) continue;
2453
+ for (const { mount, relativePath } of mountsToSearch) {
2454
+ await this.withMount(mount, async () => {
2455
+ // A `path` naming a single file greps just that file; otherwise it is a
2456
+ // directory prefix. (A file path once produced the prefix "notes.md/"
2457
+ // and silently matched nothing.)
2458
+ const fileScope = relativePath !== '' && (
2459
+ store.treeGet(mount.treeStateId, relativePath) !== null ||
2460
+ await lstat(join(mount.config.path, relativePath)).then((s) => !s.isDirectory(), () => false)
2461
+ );
2462
+ // What follows reads the tree and branch intent to go with the pass's
2463
+ // reports, synchronously. They must be the branch the pass decided
2464
+ // on: one selected in between would label its content with the other
2465
+ // branch's states. So a pass whose branch is no longer selected runs
2466
+ // again, and a branch that keeps changing leaves the scope unsearched.
2467
+ let pass: PassResult | null = null;
2468
+ for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
2469
+ const next = await this.passUnlocked(mount, fileScope
2470
+ ? { kind: 'paths', paths: [relativePath] }
2471
+ : { kind: 'dir', dir: relativePath, recursive: true });
2472
+ if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next;
2473
+ }
2474
+ if (pass === null) {
2475
+ incomplete.push({
2476
+ path: `${mount.config.name}/${relativePath}`,
2477
+ reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
2478
+ });
2479
+ return;
2480
+ }
2481
+ for (const region of pass.incomplete) {
2482
+ incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
2483
+ }
2180
2484
 
2181
- const blob = store.getBlob(entry.blobHash);
2182
- if (!blob) continue;
2183
-
2184
- const content = blob.toString('utf-8');
2185
- const lines = content.split('\n');
2186
- const fileMatches: Array<{ line: number; text: string; context?: string[] }> = [];
2187
-
2188
- for (let i = 0; i < lines.length; i++) {
2189
- if (regex.test(lines[i])) {
2190
- const match: { line: number; text: string; context?: string[] } = {
2191
- line: i + 1,
2192
- text: lines[i],
2193
- };
2194
- if (contextBefore > 0 || contextAfter > 0) {
2195
- const start = Math.max(0, i - contextBefore);
2196
- const end = Math.min(lines.length, i + contextAfter + 1);
2197
- match.context = lines.slice(start, end);
2485
+ const intents = this.intents.get(mount.config.name)!;
2486
+ for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
2487
+ if (fileGlob && !fileGlob.test(report.path)) continue;
2488
+ const file = `${mount.config.name}/${report.path}`;
2489
+ if (report.state === 'disk-only') {
2490
+ skipped.push({
2491
+ file,
2492
+ reason: 'disk-only: binary or over the size limit, never stored',
2493
+ ...(report.size !== undefined ? { size: report.size } : {}),
2494
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
2495
+ });
2496
+ continue;
2497
+ }
2498
+ // The workspace's version, labelled with the path's state.
2499
+ const entry = store.treeGet(mount.treeStateId, report.path);
2500
+ const blob = entry ? store.getBlob(entry.blobHash) : null;
2501
+ if (blob) {
2502
+ const found = search(blob.toString('utf-8'));
2503
+ if (found.length > 0) results.push({ file, state: report.state, version: 'workspace', matches: found });
2504
+ } else if (entry) {
2505
+ skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
2506
+ }
2507
+ if (report.state !== 'conflict') continue;
2508
+ // A conflict's disk side. The version recorded with the conflict is
2509
+ // disk's only while disk still holds it; once disk has changed
2510
+ // since, it is labelled as recorded, and a file disk holds now
2511
+ // (never stored) is listed as not searched. A binary or oversize
2512
+ // disk version was never stored either.
2513
+ const disk = intents.get(report.path)?.conflict?.disk;
2514
+ const changed = report.conflict?.diskChangedSinceRecorded === true;
2515
+ const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
2516
+ if (diskBlob) {
2517
+ const found = search(diskBlob.toString('utf-8'));
2518
+ if (found.length > 0) results.push({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', matches: found });
2519
+ }
2520
+ if (disk?.stored && !diskBlob) {
2521
+ skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
2522
+ }
2523
+ if (changed) {
2524
+ if (report.diskNow === 'file') {
2525
+ skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
2198
2526
  }
2199
- fileMatches.push(match);
2527
+ } else if (disk && !disk.stored) {
2528
+ skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
2200
2529
  }
2201
2530
  }
2202
-
2203
- if (fileMatches.length > 0) {
2204
- results.push({
2205
- file: `${mount.config.name}/${entry.path}`,
2206
- matches: fileMatches,
2207
- });
2208
- }
2209
- }
2531
+ });
2210
2532
  }
2211
2533
 
2212
2534
  return {
@@ -2215,20 +2537,33 @@ export class WorkspaceModule implements Module {
2215
2537
  pattern: input.pattern,
2216
2538
  results,
2217
2539
  totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
2540
+ ...(skipped.length > 0 ? { skipped } : {}),
2541
+ ...(incomplete.length > 0 ? { incomplete } : {}),
2218
2542
  },
2219
2543
  };
2220
2544
  }
2221
2545
 
2222
- private async handleStatus(_input: StatusInput): Promise<ToolResult> {
2546
+ private async handleStatus(input: StatusInput): Promise<ToolResult> {
2223
2547
  const store = this.getStore();
2224
2548
  const status: Record<string, unknown> = {};
2549
+ if (input.mount !== undefined && !this.mounts.has(input.mount)) {
2550
+ return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
2551
+ }
2225
2552
 
2226
2553
  for (const [name, mount] of this.mounts) {
2554
+ if (input.mount !== undefined && name !== input.mount) continue;
2227
2555
  const entries = store.treeList(mount.treeStateId);
2228
2556
  const currentSeq = store.currentSequence();
2229
- const changes = mount.lastMaterializedSeq > 0
2230
- ? store.treeDiff(mount.treeStateId, mount.lastMaterializedSeq, currentSeq)
2231
- : [];
2557
+ const intents = this.intents.get(name)!.list();
2558
+ const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
2559
+ // A workspace deletion is pending until a push confirms it on disk:
2560
+ // left there without applyDeletions, or unlinked but unconfirmed.
2561
+ const deletionPaths = intents.filter(([, bi]) => bi.tombstone).map(([p]) => p);
2562
+ // Pending is what disk owes by the evidence: every entry it doesn't
2563
+ // show on disk (a refused or failed push stays owed), conflicts and
2564
+ // deletions. An entry from before the evidence isn't counted until a
2565
+ // listing or a materialize checks it: unchecked isn't unpushed.
2566
+ const pending = new Set([...conflictPaths, ...deletionPaths, ...this.entriesByEvidence(mount).owed]);
2232
2567
 
2233
2568
  const currentBranch = store.currentBranch();
2234
2569
  status[name] = {
@@ -2238,24 +2573,90 @@ export class WorkspaceModule implements Module {
2238
2573
  fileCount: entries.length,
2239
2574
  lastMaterializedSeq: mount.lastMaterializedSeq,
2240
2575
  currentSeq,
2241
- pendingChanges: changes.length,
2576
+ pendingChanges: pending.size,
2577
+ conflicts: conflictPaths.length,
2578
+ pendingWorkspaceDeletions: deletionPaths.length,
2242
2579
  initialSyncDone: mount.initialSyncDone,
2243
2580
  currentBranch: currentBranch.name,
2244
2581
  lastMaterializedBranch: mount.lastMaterializedBranchId,
2245
2582
  canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
2583
+ ...(mount.lastAgentActionScan ? { lastAgentActionScan: mount.lastAgentActionScan } : {}),
2246
2584
  };
2247
2585
  }
2248
2586
 
2249
2587
  return { success: true, data: status };
2250
2588
  }
2251
2589
 
2590
+ /**
2591
+ * What a materialize of `mount` should consider: a named file, or every
2592
+ * tracked path beneath a named directory; otherwise what the evidence says
2593
+ * disk still owes, the entries never checked against disk, recorded
2594
+ * conflicts, and workspace deletions still on disk (applied with
2595
+ * `applyDeletions`, listed without it). The watermark plays no part: it is
2596
+ * lost at an unclean restart, and an entry the evidence shows on disk has
2597
+ * nothing to push. If disk changed since, a sync adopts that; a bare
2598
+ * materialize, forced or not, never reverts it (naming the path with force
2599
+ * does). `inPlace` says whether any tree entry was left out for that reason.
2600
+ */
2601
+ private materializeSelection(mount: MountState, explicitPath: string): { paths: string[]; inPlace: boolean } {
2602
+ const store = this.getStore();
2603
+ const intents = new Map(this.intents.get(mount.config.name)!.list(explicitPath));
2604
+ const selected = new Set<string>();
2605
+
2606
+ if (explicitPath !== '') {
2607
+ if (store.treeGet(mount.treeStateId, explicitPath)) selected.add(explicitPath);
2608
+ for (const e of store.treeList(mount.treeStateId, explicitPath + '/')) selected.add(e.path);
2609
+ for (const [p, bi] of intents) if (bi.tombstone || bi.conflict) selected.add(p);
2610
+ return { paths: [...selected], inPlace: false };
2611
+ }
2612
+
2613
+ const { owed, unchecked, agreed } = this.entriesByEvidence(mount);
2614
+ for (const path of [...owed, ...unchecked]) selected.add(path);
2615
+ for (const [p, bi] of intents) if (bi.conflict || bi.tombstone) selected.add(p);
2616
+ return { paths: [...selected], inPlace: agreed > 0 };
2617
+ }
2618
+
2619
+ /**
2620
+ * The workspace entries by what the evidence says of disk. `owed`: P
2621
+ * differs from the entry (a draft, or a push still pending), or there is no
2622
+ * P and the entry came from the workspace (a draft disk never had).
2623
+ * `unchecked`: no P and no workspace origin, an entry from before the
2624
+ * evidence; a push agrees it, writes it where disk lacks it, or refuses it
2625
+ * as a conflict. `agreed`: how many have P = S, so disk holds them. Status
2626
+ * counts the owed as pending; a materialize without a path takes up the
2627
+ * owed and the unchecked.
2628
+ */
2629
+ private entriesByEvidence(mount: MountState): { owed: string[]; unchecked: string[]; agreed: number } {
2630
+ const store = this.getStore();
2631
+ const agreement = this.agreementOrThrow();
2632
+ const name = mount.config.name;
2633
+ const intents = this.intents.get(name)!;
2634
+ const owed: string[] = [];
2635
+ const unchecked: string[] = [];
2636
+ let agreed = 0;
2637
+ for (const e of store.treeList(mount.treeStateId)) {
2638
+ const p = agreement.get(name, e.path);
2639
+ if (p === undefined) (intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
2640
+ else if (p.kind !== 'content' || p.hash !== e.blobHash) owed.push(e.path);
2641
+ else agreed++;
2642
+ }
2643
+ return { owed, unchecked, agreed };
2644
+ }
2645
+
2252
2646
  private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
2253
2647
  const store = this.getStore();
2254
2648
 
2255
2649
  const allWritten: Array<{ mount: string; path: string }> = [];
2650
+ const allDeleted: Array<{ mount: string; path: string }> = [];
2651
+ const pendingDeletions: Array<{ mount: string; path: string }> = [];
2652
+
2653
+ let explicit: { mount: MountState; relativePath: string } | null = null;
2654
+ if (input.path) explicit = this.parsePath(input.path);
2256
2655
 
2257
2656
  let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
2258
- if (input.mount) {
2657
+ if (explicit) {
2658
+ mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
2659
+ } else if (input.mount) {
2259
2660
  const m = this.mounts.get(input.mount);
2260
2661
  if (!m) {
2261
2662
  return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
@@ -2270,65 +2671,94 @@ export class WorkspaceModule implements Module {
2270
2671
  // Branch guard, scoped to the mounts actually being materialized: a
2271
2672
  // linear continuation (current branch descends from the pinned branch at
2272
2673
  // or after the pinned seq) passes; genuine divergence refuses unless
2273
- // force. One mount's stale pin must never block another mount.
2674
+ // force. One mount's stale pin must never block another mount. It is
2675
+ // judged inside the mount's turn, on the same branch the selection is
2676
+ // made on: a materialize queued behind a pass would otherwise select on
2677
+ // whatever branch is current when its turn comes, unguarded. The push
2678
+ // then writes nothing if the branch changes after that.
2274
2679
  const blocked: Array<{ mount: string; reason: string }> = [];
2275
- for (const { name, mount } of mountsToMaterialize) {
2276
- const reason = this.mountMaterializeBlockReason(store, mount);
2277
- if (!reason) continue;
2278
- if (input.force) {
2279
- // Disk reflects another line of history, so an incremental diff from
2280
- // the pinned seq is meaningless — reset tracking and re-materialize
2281
- // the full tree, exactly like materializeMount() after a deliberate
2282
- // branch switch.
2283
- mount.lastMaterializedSeq = 0;
2284
- mount.lastMaterializedBranchId = null;
2285
- } else {
2286
- blocked.push({ mount: name, reason });
2287
- }
2288
- }
2289
- mountsToMaterialize = mountsToMaterialize.filter(
2290
- ({ name }) => !blocked.some((b) => b.mount === name),
2291
- );
2292
- if (mountsToMaterialize.length === 0 && blocked.length > 0) {
2293
- return {
2294
- success: false,
2295
- error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
2296
- isError: true,
2297
- };
2298
- }
2680
+ const guarded: string[] = [];
2681
+ let proceeded = 0;
2299
2682
 
2300
2683
  for (const { name, mount } of mountsToMaterialize) {
2301
-
2302
- let paths: string[] | undefined;
2303
- if (input.path) {
2304
- const { relativePath } = this.parsePath(input.path);
2305
- paths = relativePath ? [relativePath] : undefined;
2684
+ if (mount.config.mode === 'read-only') {
2685
+ // Only a mount the caller named gets here read-only (a bare
2686
+ // materialize takes the read-write ones): say why nothing was written.
2687
+ blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' });
2688
+ continue;
2306
2689
  }
2307
-
2308
- // Suppress watcher for paths we're about to write
2309
- const watcher = this.watchers.get(name);
2310
- const written = await materializeToFs(store, mount, paths);
2311
-
2312
- for (const p of written) {
2313
- watcher?.suppress(p);
2314
- allWritten.push({ mount: name, path: p });
2690
+ const turn = await this.withMount(mount, async (): Promise<{ blocked: string } | { pushed: PushResult; inPlace: boolean }> => {
2691
+ const branchId = store.currentBranch().id;
2692
+ const reason = this.mountMaterializeBlockReason(store, mount);
2693
+ // Force passes a divergence. The selection follows the evidence, which
2694
+ // is per path and kept across branches, so nothing needs resetting:
2695
+ // the push re-pins to this branch.
2696
+ if (reason && !input.force) return { blocked: reason };
2697
+ const { paths, inPlace } = this.materializeSelection(mount, explicit?.relativePath ?? '');
2698
+ return {
2699
+ inPlace,
2700
+ pushed: await this.pushUnlocked(mount, paths, {
2701
+ force: input.force, named: explicit !== null, applyDeletions: input.applyDeletions, branchId,
2702
+ }),
2703
+ };
2704
+ });
2705
+ if ('blocked' in turn) {
2706
+ blocked.push({ mount: name, reason: turn.blocked });
2707
+ guarded.push(name);
2708
+ continue;
2709
+ }
2710
+ proceeded++;
2711
+ const pushed = turn.pushed;
2712
+
2713
+ for (const p of pushed.written) allWritten.push({ mount: name, path: p });
2714
+ for (const p of pushed.deleted) allDeleted.push({ mount: name, path: p });
2715
+ for (const p of pushed.pendingDeletions) pendingDeletions.push({ mount: name, path: p });
2716
+ // Refusals ride the same skipped list as branch blocks: divergence must
2717
+ // be visible, not resolved silently either way.
2718
+ for (const s of pushed.skipped) {
2719
+ blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` });
2315
2720
  }
2316
2721
 
2722
+ // What disk still owes is kept by its evidence, so the watermark and
2723
+ // the pin only feed the branch guard. Both are the push's own: what it
2724
+ // planned from is what disk now reflects, whatever branch was selected
2725
+ // while it wrote. A push that planned nothing (the selected branch
2726
+ // changed after its paths were chosen) left disk as it was, so it
2727
+ // leaves both as they were too.
2728
+ if (pushed.branchId === undefined || pushed.sequence === undefined) continue;
2729
+ mount.lastMaterializedSeq = pushed.sequence;
2317
2730
  // Track which branch we materialized on. Re-pin on a clean empty
2318
2731
  // materialize too (previously-pinned mount, nothing pending): disk
2319
2732
  // already reflects the current branch's tree, and leaving the old pin
2320
2733
  // would keep force required forever after a cross-branch materialize
2321
- // that happened to write nothing.
2322
- if (written.length > 0 || mount.lastMaterializedBranchId !== null) {
2323
- mount.lastMaterializedBranchId = store.currentBranch().id;
2734
+ // that happened to write nothing. A first materialize pins once files
2735
+ // of this branch are in place, whether it found them on disk or the
2736
+ // evidence shows them there: disk holds this branch's tree.
2737
+ if (pushed.written.length > 0 || pushed.unchanged.length > 0 || turn.inPlace || mount.lastMaterializedBranchId !== null) {
2738
+ mount.lastMaterializedBranchId = pushed.branchId;
2324
2739
  }
2325
2740
  }
2326
2741
 
2742
+ if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
2743
+ return {
2744
+ success: false,
2745
+ error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
2746
+ isError: true,
2747
+ };
2748
+ }
2749
+
2327
2750
  return {
2328
2751
  success: true,
2329
2752
  data: {
2330
2753
  materialized: allWritten,
2331
2754
  count: allWritten.length,
2755
+ ...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
2756
+ ...(pendingDeletions.length > 0
2757
+ ? {
2758
+ workspaceDeletionsLeftOnDisk: pendingDeletions,
2759
+ note: 'Files deleted in the workspace were left on disk; materialize with applyDeletions to remove them.',
2760
+ }
2761
+ : {}),
2332
2762
  ...(blocked.length > 0 ? { skipped: blocked } : {}),
2333
2763
  },
2334
2764
  };
@@ -2340,6 +2770,7 @@ export class WorkspaceModule implements Module {
2340
2770
  * Resets branch tracking to allow cross-branch materialization.
2341
2771
  */
2342
2772
  async materializeMount(mountName: string): Promise<string[]> {
2773
+ if (!this.running()) return [];
2343
2774
  const store = this.getStore();
2344
2775
  const mount = this.mounts.get(mountName);
2345
2776
  if (!mount || mount.config.mode === 'read-only') return [];
@@ -2348,58 +2779,119 @@ export class WorkspaceModule implements Module {
2348
2779
  mount.lastMaterializedBranchId = null;
2349
2780
  mount.lastMaterializedSeq = 0;
2350
2781
 
2351
- const watcher = this.watchers.get(mountName);
2352
- const written = await materializeToFs(store, mount);
2353
- for (const p of written) {
2354
- watcher?.suppress(p);
2355
- }
2356
- if (written.length > 0) {
2357
- mount.lastMaterializedBranchId = store.currentBranch().id;
2782
+ // force: this path only runs after a deliberate undo/redo/branch switch
2783
+ // on the framework's own _config mount — restoring disk to the branch
2784
+ // state IS the operator intent, so the freshness guard yields. named:
2785
+ // the whole tree is that deliberate scope, entries without evidence too.
2786
+ const pushed = await this.withMount(mount, () => {
2787
+ const branchId = store.currentBranch().id;
2788
+ return this.pushUnlocked(mount, store.treeList(mount.treeStateId).map((e) => e.path), { force: true, named: true, branchId });
2789
+ });
2790
+ // A push that planned nothing leaves the reset tracking as it is.
2791
+ if (pushed.branchId !== undefined && pushed.sequence !== undefined) {
2792
+ mount.lastMaterializedSeq = pushed.sequence;
2793
+ if (pushed.written.length > 0 || pushed.unchanged.length > 0) {
2794
+ mount.lastMaterializedBranchId = pushed.branchId;
2795
+ }
2358
2796
  }
2359
- return written;
2797
+ return pushed.written;
2360
2798
  }
2361
2799
 
2362
2800
  private async handleSync(input: SyncInput): Promise<ToolResult> {
2363
2801
  const store = this.getStore();
2364
- const allResults: Array<{ mount: string; synced: string[]; conflicts: ConflictInfo[] }> = [];
2802
+ // A path sync adopts on the branch it was asked on, read before any await.
2803
+ const askedOn = store.currentBranch().id;
2804
+ const allResults: Array<{
2805
+ mount: string;
2806
+ synced: string[];
2807
+ conflicts: Array<{ path: string; kind: string; diskCopy: string }>;
2808
+ /** A path sync's workspace changes given up for disk's state, each with the state it had,
2809
+ * and what that did to the workspace copy (absent when nothing). */
2810
+ discarded?: Array<{ path: string; was: string; op?: string }>;
2811
+ }> = [];
2365
2812
  const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
2366
-
2367
- let mountsToSync: Array<{ name: string; mount: MountState }>;
2368
- if (input.mount) {
2813
+ const allIncomplete: Array<{ mount: string; path: string; reason: string }> = [];
2814
+ const rootsAccepted: string[] = [];
2815
+ if (input.acceptRoot && input.path) {
2816
+ return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
2817
+ }
2818
+
2819
+ // A path names its own mount; otherwise the given mount, or every mount.
2820
+ let targets: Array<{ name: string; mount: MountState; relativePath: string }>;
2821
+ if (input.path) {
2822
+ const { mount, relativePath } = this.parsePath(input.path);
2823
+ targets = [{ name: mount.config.name, mount, relativePath }];
2824
+ } else if (input.mount) {
2369
2825
  const m = this.mounts.get(input.mount);
2370
2826
  if (!m) {
2371
2827
  return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
2372
2828
  }
2373
- mountsToSync = [{ name: input.mount, mount: m }];
2829
+ targets = [{ name: input.mount, mount: m, relativePath: '' }];
2374
2830
  } else {
2375
- mountsToSync = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount }));
2831
+ targets = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount, relativePath: '' }));
2376
2832
  }
2377
2833
 
2378
- for (const { name, mount } of mountsToSync) {
2379
-
2380
- let paths: string[] | undefined;
2381
- if (input.path) {
2382
- const { relativePath } = this.parsePath(input.path);
2383
- // Empty relativePath means mount root — sync entire mount, not a single path
2384
- paths = relativePath ? [relativePath] : undefined;
2385
- }
2386
-
2387
- const result = await syncFromFs(store, mount, paths);
2388
- mount.initialSyncDone = true;
2834
+ for (const { name, mount, relativePath } of targets) {
2835
+ const pass = await this.withMount(mount, async () => {
2836
+ if (input.acceptRoot && await this.acceptRootUnlocked(mount)) rootsAccepted.push(name);
2837
+ if (!relativePath) {
2838
+ // A full recheck: every file rehashed, nothing pending discarded.
2839
+ return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }, { rehash: true });
2840
+ }
2841
+ // An explicit path: the workspace takes disk's state there.
2842
+ const prefix = relativePath + '/';
2843
+ const isDir =
2844
+ await lstat(join(mount.config.path, relativePath)).then((s) => s.isDirectory(), () => false) ||
2845
+ store.treeList(mount.treeStateId, prefix).length > 0 ||
2846
+ this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
2847
+ this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
2848
+ const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn };
2849
+ if (!isDir) return this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
2850
+ const beneath = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }, opts);
2851
+ // Something at the path itself is no part of a directory scope: a file
2852
+ // disk has where the workspace holds a directory, or a workspace file
2853
+ // where disk has a directory. Its own pass takes disk's state there
2854
+ // too, after what lies beneath, so a file never sits over entries it
2855
+ // replaced.
2856
+ const onDisk = await lstat(join(mount.config.path, relativePath)).catch(() => null);
2857
+ const atPath = (onDisk !== null && !onDisk.isDirectory())
2858
+ || store.treeGet(mount.treeStateId, relativePath) !== null
2859
+ || this.agreementOrThrow().get(name, relativePath) !== undefined
2860
+ || this.intents.get(name)!.get(relativePath) !== null;
2861
+ if (!atPath) return beneath;
2862
+ const itself = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
2863
+ return {
2864
+ ops: [...beneath.ops, ...itself.ops],
2865
+ discarded: [...beneath.discarded, ...itself.discarded],
2866
+ newConflicts: [...beneath.newConflicts, ...itself.newConflicts],
2867
+ reports: new Map([...beneath.reports, ...itself.reports]),
2868
+ incomplete: [...beneath.incomplete, ...itself.incomplete],
2869
+ dirs: beneath.dirs,
2870
+ branchId: itself.branchId ?? beneath.branchId,
2871
+ };
2872
+ });
2389
2873
 
2390
- if (result.synced.length > 0 || result.conflicts.length > 0) {
2874
+ const conflicts = [...pass.reports.values()]
2875
+ .filter((r) => r.state === 'conflict')
2876
+ .map((r) => ({ path: r.path, kind: r.conflict?.kind ?? 'both-changed', diskCopy: r.conflict?.diskCopy ?? 'referenced' }));
2877
+ if (pass.ops.length > 0 || conflicts.length > 0 || pass.discarded.length > 0) {
2391
2878
  allResults.push({
2392
2879
  mount: name,
2393
- synced: result.synced.map(s => s.path),
2394
- conflicts: result.conflicts,
2880
+ synced: pass.ops.map((o) => o.path),
2881
+ conflicts,
2882
+ ...(pass.discarded.length > 0 ? { discarded: pass.discarded } : {}),
2395
2883
  });
2396
- this.emitFsEvents(name, result.synced, result.conflicts);
2397
2884
  }
2398
- // Skips were previously computed and dropped, so "nothing synced" and
2399
- // "your file was refused" looked identical from the outside. Say which.
2400
- for (const s of result.skipped) {
2401
- allSkipped.push({ mount: name, path: s.path, reason: s.reason });
2885
+ // Say why a path wasn't taken in, so "nothing synced" and "your file was
2886
+ // refused" don't look identical from the outside.
2887
+ for (const r of pass.reports.values()) {
2888
+ if (r.state === 'unverified') {
2889
+ allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
2890
+ } else if (r.state === 'disk-only') {
2891
+ allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
2892
+ }
2402
2893
  }
2894
+ for (const region of pass.incomplete) allIncomplete.push({ mount: name, ...region });
2403
2895
  }
2404
2896
 
2405
2897
  return {
@@ -2409,10 +2901,30 @@ export class WorkspaceModule implements Module {
2409
2901
  totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
2410
2902
  totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
2411
2903
  ...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
2904
+ ...(allIncomplete.length > 0 ? { incomplete: allIncomplete } : {}),
2905
+ ...(rootsAccepted.length > 0 ? { rootsAccepted } : {}),
2412
2906
  },
2413
2907
  };
2414
2908
  }
2415
2909
 
2910
+ /**
2911
+ * Take the mount's root as it is now (acceptRoot), when it is a directory
2912
+ * other than the one disk last agreed under. Its evidence is set aside:
2913
+ * it described another directory. Returns whether a root was accepted.
2914
+ */
2915
+ private async acceptRootUnlocked(mount: MountState): Promise<boolean> {
2916
+ const runtime = await this.runtime(mount);
2917
+ const found = await locateRoot({ ...runtime.view, rootIdentity: undefined }, runtime.rootReal);
2918
+ if ('reason' in found) return false; // still missing: the pass says so
2919
+ const agreement = this.agreementOrThrow();
2920
+ const recorded = agreement.rootIdentity(mount.config.name);
2921
+ if (recorded && sameRootIdentity(recorded, found.identity)) return false;
2922
+ if (recorded) agreement.acceptRoot(mount.config.name, found.identity);
2923
+ else agreement.recordRootIdentity(mount.config.name, found.identity);
2924
+ agreement.barrier();
2925
+ return true;
2926
+ }
2927
+
2416
2928
  // ==========================================================================
2417
2929
  // Internal: Filesystem Change Handling
2418
2930
  // ==========================================================================
@@ -2420,74 +2932,163 @@ export class WorkspaceModule implements Module {
2420
2932
  /**
2421
2933
  * Handle filesystem changes detected by watcher (watch: 'always' mode).
2422
2934
  *
2423
- * The watcher carries the op per path. For created/modified we call
2424
- * syncFromFs to pull content into the tree; for deleted we strip the tree
2425
- * entry directly (the file is gone, nothing to read). We emit one event per
2426
- * op type.
2935
+ * The watcher's ops only choose which paths to look at: each path is
2936
+ * observed again and decided under the three-way rule, so an event that
2937
+ * arrives late or out of order can't delete or revert anything by itself.
2427
2938
  */
2428
2939
  private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
2429
- const store = this.store;
2430
2940
  const mount = this.mounts.get(mountName);
2431
- if (!store || !mount) return;
2941
+ if (!this.store || !this.running() || !mount) return;
2942
+ const paths = [...new Set(changes.map((c) => c.path))];
2943
+ if (paths.length === 0) return;
2944
+ await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'paths', paths }));
2945
+ }
2432
2946
 
2433
- const touchedPaths = changes.filter(c => c.op !== 'deleted').map(c => c.path);
2434
- const deletedPaths = changes.filter(c => c.op === 'deleted').map(c => c.path);
2947
+ /**
2948
+ * After an agent's completed tool batch, before anything continues its turn:
2949
+ * scan every `watch: 'on-agent-action'` mount, so what the batch's tools did
2950
+ * on disk (a shell command's files, a deletion) is in the workspace when the
2951
+ * agent next looks. A scan that outlasts the deadline stops holding the
2952
+ * round: the miss is recorded on the mount's status and pushed as a
2953
+ * `workspace:agent-action-scan-incomplete` event, and the scan continues,
2954
+ * deciding on the branch selected when it applies.
2955
+ */
2956
+ async onToolBatchComplete(_agentName?: string): Promise<void> {
2957
+ // Every agent's workspace is the store's current branch, so the agent
2958
+ // that ran the batch doesn't choose what is scanned.
2959
+ const mounts = [...this.mounts.values()].filter((m) => m.config.watch === 'on-agent-action');
2960
+ if (mounts.length === 0 || !this.store || !this.running()) return;
2961
+ const deadlineMs = this.agentActionScanDeadlineMs();
2962
+ await Promise.all(mounts.map(async (mount) => {
2963
+ const runningLate = mount.agentActionScanLate !== undefined;
2964
+ const scan = this.agentActionScan(mount);
2965
+ if (runningLate) {
2966
+ // An earlier batch's scan outlasted its deadline and still runs: this
2967
+ // round isn't held as well. Its own scan is queued after that one,
2968
+ // and records its outcome when it finishes. It is late from the
2969
+ // start, so a batch that comes while it runs isn't held either.
2970
+ this.agentActionScanLateUntil(mount, scan);
2971
+ this.agentActionScanMissed(mount, 'an earlier scan is still running past its deadline; this batch is scanned after it');
2972
+ return;
2973
+ }
2974
+ let timer: ReturnType<typeof setTimeout> | undefined;
2975
+ const deadline = new Promise<'late'>((resolveDeadline) => {
2976
+ timer = setTimeout(() => resolveDeadline('late'), deadlineMs);
2977
+ });
2978
+ const outcome = await Promise.race([scan.settled.then(() => 'done' as const), deadline]);
2979
+ clearTimeout(timer);
2980
+ if (outcome === 'late') {
2981
+ this.agentActionScanLateUntil(mount, scan);
2982
+ this.agentActionScanMissed(mount, `deadline exceeded (${deadlineMs} ms); the scan continues`);
2983
+ }
2984
+ }));
2985
+ }
2435
2986
 
2436
- const syncResult = touchedPaths.length > 0
2437
- ? await syncFromFs(store, mount, touchedPaths)
2438
- : { synced: [], conflicts: [], skipped: [] };
2987
+ /**
2988
+ * Mark a scan late, and the mount as running late until it settles. The
2989
+ * marker follows the newest late scan: a mount's scans run one after
2990
+ * another, so the newest is the last to settle, and a batch that comes
2991
+ * while any late scan of the chain still runs isn't held.
2992
+ */
2993
+ private agentActionScanLateUntil(mount: MountState, scan: AgentActionScan): void {
2994
+ scan.late = true;
2995
+ const settled = scan.settled;
2996
+ mount.agentActionScanLate = settled;
2997
+ void settled.then(() => { if (mount.agentActionScanLate === settled) mount.agentActionScanLate = undefined; });
2998
+ }
2439
2999
 
2440
- // Strip deleted entries from the tree so downstream consumers see a
2441
- // coherent state. syncFromFs would also do this if we passed the paths in,
2442
- // but the op from the watcher is authoritative — skip the access() probe.
2443
- for (const p of deletedPaths) {
2444
- const existing = store.treeGet(mount.treeStateId, p);
2445
- if (existing) {
2446
- store.treeRemove(mount.treeStateId, p);
2447
- syncResult.synced.push({ path: p, op: 'deleted' });
2448
- }
2449
- }
3000
+ /** A batch's scan missed its deadline, or failed: said on status and as an event. */
3001
+ private agentActionScanMissed(mount: MountState, reason: string): void {
3002
+ mount.lastAgentActionScan = { at: Date.now(), complete: false, withinDeadline: false, reason };
3003
+ this.ctx?.pushEvent({ type: 'workspace:agent-action-scan-incomplete', mount: mount.config.name, reason } as ProcessEvent);
3004
+ }
2450
3005
 
2451
- this.emitFsEvents(mountName, syncResult.synced, syncResult.conflicts);
3006
+ /**
3007
+ * The mount's after-batch scan: one may run and one wait behind it. A batch
3008
+ * that comes while one waits shares it, since it starts after the batch's
3009
+ * tools ran. Each scan records its own outcome when it settles: on status
3010
+ * while it is the mount's newest scan (a scan queued behind it describes a
3011
+ * later batch), and as an event for regions it couldn't observe (the file
3012
+ * cap, an unreadable or changing region, a branch that kept changing) or a
3013
+ * failure, whichever scan it is.
3014
+ */
3015
+ private agentActionScan(mount: MountState): AgentActionScan {
3016
+ if (mount.agentActionScanWaiting) return mount.agentActionScanWaiting;
3017
+ const scan: AgentActionScan = { late: false, settled: Promise.resolve() };
3018
+ const pass = this.withMount(mount, () => {
3019
+ if (mount.agentActionScanWaiting === scan) mount.agentActionScanWaiting = undefined;
3020
+ return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
3021
+ });
3022
+ scan.settled = pass.then(
3023
+ (result) => {
3024
+ if (result.incomplete.length > 0) {
3025
+ this.ctx?.pushEvent({
3026
+ type: 'workspace:agent-action-scan-incomplete',
3027
+ mount: mount.config.name,
3028
+ reason: 'the scan finished, but some regions could not be observed',
3029
+ incomplete: result.incomplete,
3030
+ } as ProcessEvent);
3031
+ }
3032
+ if (mount.agentActionScanLatest !== scan) return;
3033
+ const incomplete = result.incomplete.length > 0 ? { incomplete: result.incomplete } : {};
3034
+ mount.lastAgentActionScan = scan.late
3035
+ ? { at: Date.now(), complete: true, withinDeadline: false, reason: 'finished after the deadline', ...incomplete }
3036
+ : { at: Date.now(), complete: true, withinDeadline: true, ...incomplete };
3037
+ },
3038
+ (err: unknown) => {
3039
+ const reason = `scan failed: ${err instanceof Error ? err.message : String(err)}`;
3040
+ if (mount.agentActionScanLatest === scan) this.agentActionScanMissed(mount, reason);
3041
+ else this.ctx?.pushEvent({ type: 'workspace:agent-action-scan-incomplete', mount: mount.config.name, reason } as ProcessEvent);
3042
+ },
3043
+ );
3044
+ mount.agentActionScanWaiting = scan;
3045
+ mount.agentActionScanLatest = scan;
3046
+ return scan;
3047
+ }
3048
+
3049
+ /** The scan deadline, kept below the framework's hook bound (warned once). */
3050
+ private agentActionScanDeadlineMs(): number {
3051
+ const configured = this.config.agentActionScanDeadlineMs ?? AGENT_ACTION_SCAN_DEADLINE_MS;
3052
+ if (configured <= AGENT_ACTION_SCAN_DEADLINE_MAX_MS) return configured;
3053
+ if (!this.warnedScanDeadline) {
3054
+ this.warnedScanDeadline = true;
3055
+ console.warn(
3056
+ `[workspace] agentActionScanDeadlineMs ${configured} leaves less than 1 s before the framework's ` +
3057
+ `${TOOL_BATCH_HOOK_TIMEOUT_MS} ms bound on a tool-batch hook; using ${AGENT_ACTION_SCAN_DEADLINE_MAX_MS} ms`,
3058
+ );
3059
+ }
3060
+ return AGENT_ACTION_SCAN_DEADLINE_MAX_MS;
2452
3061
  }
2453
3062
 
2454
3063
  /**
2455
- * Group synced paths by op and push one ProcessEvent per non-empty op.
3064
+ * Push one ProcessEvent per op for a pass's tree changes and new conflicts.
3065
+ * A conflict rides the event for the disk change behind it (modified, or
3066
+ * deleted for a disk deletion of a file with newer workspace changes).
2456
3067
  */
2457
3068
  private emitFsEvents(
2458
3069
  mountName: string,
2459
- synced: Array<{ path: string; op: WorkspaceFsOp }>,
2460
- conflicts: ConflictInfo[],
3070
+ ops: Array<{ path: string; op: TreeOp }>,
3071
+ newConflicts: Array<{ path: string; op: TreeOp }> = [],
2461
3072
  ): void {
2462
- if (!this.ctx || synced.length === 0) return;
2463
-
2464
- const byOp = new Map<WorkspaceFsOp, string[]>();
2465
- for (const { path, op } of synced) {
2466
- const list = byOp.get(op) ?? [];
2467
- list.push(`${mountName}/${path}`);
2468
- byOp.set(op, list);
2469
- }
2470
-
2471
- // Conflicts by definition require a prior tree entry (sync detected that
2472
- // the agent-side version diverged from the filesystem baseline), which
2473
- // means the path is being re-synced as a modification. Attach conflicts
2474
- // only to the workspace:modified event, intersected with its paths — a
2475
- // created/deleted batch can't carry a meaningful conflict.
2476
- const conflictsByPath = new Map<string, ConflictInfo>();
2477
- for (const c of conflicts) conflictsByPath.set(c.path, c);
2478
-
2479
- for (const [op, paths] of byOp) {
2480
- const type = opToEventType(op);
2481
- let eventConflicts: string[] | undefined;
2482
- if (op === 'modified' && conflictsByPath.size > 0) {
2483
- const matched = paths.filter(p => conflictsByPath.has(p.slice(mountName.length + 1)));
2484
- if (matched.length > 0) eventConflicts = matched;
2485
- }
3073
+ if (!this.ctx || (ops.length === 0 && newConflicts.length === 0)) return;
3074
+
3075
+ const byOp = new Map<WorkspaceFsOp, { paths: string[]; conflicts: string[] }>();
3076
+ const add = (op: TreeOp, path: string, conflict: boolean): void => {
3077
+ const group = byOp.get(op) ?? { paths: [], conflicts: [] };
3078
+ const full = `${mountName}/${path}`;
3079
+ if (!group.paths.includes(full)) group.paths.push(full);
3080
+ if (conflict) group.conflicts.push(full);
3081
+ byOp.set(op, group);
3082
+ };
3083
+ for (const { path, op } of ops) add(op, path, false);
3084
+ for (const { path, op } of newConflicts) add(op, path, true);
3085
+
3086
+ for (const [op, { paths, conflicts }] of byOp) {
2486
3087
  const event = {
2487
- type,
3088
+ type: opToEventType(op),
2488
3089
  paths,
2489
3090
  mount: mountName,
2490
- ...(eventConflicts ? { conflicts: eventConflicts } : {}),
3091
+ ...(conflicts.length > 0 ? { conflicts } : {}),
2491
3092
  } as WorkspaceCreatedEvent | WorkspaceModifiedEvent | WorkspaceDeletedEvent;
2492
3093
  this.ctx.pushEvent(event as ProcessEvent);
2493
3094
  }
@@ -2495,6 +3096,17 @@ export class WorkspaceModule implements Module {
2495
3096
 
2496
3097
  }
2497
3098
 
3099
+ /** What a listing shows of a path beyond its name. */
3100
+ function stateView(report: PathReport): Record<string, unknown> {
3101
+ return {
3102
+ state: report.state,
3103
+ ...(report.size !== undefined ? { size: report.size } : {}),
3104
+ ...(report.mimeType ? { mimeType: report.mimeType } : {}),
3105
+ ...(report.conflict ? { conflict: report.conflict } : {}),
3106
+ ...(report.note ? { note: report.note } : {}),
3107
+ };
3108
+ }
3109
+
2498
3110
  // ==========================================================================
2499
3111
  // Utilities
2500
3112
  // ==========================================================================