@animalabs/agent-framework 0.21.0 → 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 +3 -3
  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
@@ -1,350 +1,20 @@
1
1
  /**
2
- * Sync logic between filesystem and Chronicle tree state.
3
- *
4
- * Two directions:
5
- * - syncFromFs: filesystem → Chronicle (user changes)
6
- * - materializeToFs: Chronicle → filesystem (agent changes)
2
+ * Shared helpers for moving content between the filesystem and the Chronicle
3
+ * tree. The rules for when content moves — the three-way rule over disk, the
4
+ * store and disk-agreement evidence — live in reconcile.ts, and how disk is
5
+ * observed (containment, symlinks, walks) in observe.ts.
7
6
  */
8
7
 
9
8
  import { createHash } from 'node:crypto';
10
- import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
11
- import { join, dirname, relative, resolve, sep } from 'node:path';
12
- import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
13
- import type { MountState } from './types.js';
14
9
 
15
10
  export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
16
11
 
17
12
  /**
18
- * Resolve a relative path within a mount and verify it doesn't escape.
19
- * Returns the absolute path, or null if the path is outside the mount.
20
- */
21
- function safePath(mountPath: string, relativePath: string): string | null {
22
- // Compare with the PLATFORM separator: resolve() emits backslashes on
23
- // Windows, so a '/'-suffixed root never prefix-matches there and every
24
- // in-mount path was reported as outside the mount.
25
- const resolved = resolve(mountPath, relativePath);
26
- const root = mountPath.endsWith(sep) ? mountPath : mountPath + sep;
27
- if (resolved !== mountPath && !resolved.startsWith(root)) {
28
- return null;
29
- }
30
- return resolved;
31
- }
32
-
33
- /**
34
- * Hash file content to a full SHA-256 hex string.
35
- * Must match Chronicle's storeBlob() hash format (64-char hex).
13
+ * Hash content to a full SHA-256 hex string.
14
+ * Matches Chronicle's storeBlob() hash format (64-char hex).
36
15
  */
37
16
  export function hashContent(content: string | Buffer): string {
38
17
  const hash = createHash('sha256');
39
18
  hash.update(content);
40
19
  return hash.digest('hex');
41
20
  }
42
-
43
- /**
44
- * Check if content appears to be binary.
45
- */
46
- export interface SkippedFile {
47
- /** Relative path of the file that was not synced */
48
- path: string;
49
- /** Why, in words the resident can act on */
50
- reason: string;
51
- }
52
-
53
- export function isBinary(buffer: Buffer): boolean {
54
- // Check for null bytes in first 8KB
55
- const check = buffer.subarray(0, 8192);
56
- for (let i = 0; i < check.length; i++) {
57
- if (check[i] === 0) return true;
58
- }
59
- return false;
60
- }
61
-
62
- export interface ConflictInfo {
63
- /** Relative path of the conflicted file */
64
- path: string;
65
- /** Blob hash of the agent's version (retrievable via store.getBlob()) */
66
- agentBlobHash: string;
67
- }
68
-
69
- export type SyncOp = 'created' | 'modified' | 'deleted';
70
-
71
- export interface SyncedPath {
72
- path: string;
73
- op: SyncOp;
74
- }
75
-
76
- export interface SyncResult {
77
- /** Paths that were synced, tagged with the op detected against tree state */
78
- synced: SyncedPath[];
79
- /** Paths that conflicted (both agent and user modified) — filesystem wins */
80
- conflicts: ConflictInfo[];
81
- /** Paths that were skipped, each with the reason — a silently skipped file
82
- * is indistinguishable from one that synced cleanly, which is how a
83
- * resident ends up believing a file is in their workspace when it never
84
- * arrived. */
85
- skipped: SkippedFile[];
86
- }
87
-
88
- /**
89
- * Sync filesystem changes into Chronicle tree state.
90
- *
91
- * @param store Chronicle store
92
- * @param mount Mount state
93
- * @param paths Specific paths to sync (relative to mount). If empty, walks the directory.
94
- * @returns Sync result with synced/conflicted/skipped paths
95
- */
96
- export async function syncFromFs(
97
- store: JsStore,
98
- mount: MountState,
99
- paths?: string[],
100
- ): Promise<SyncResult> {
101
- const result: SyncResult = { synced: [], conflicts: [], skipped: [] };
102
- const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
103
-
104
- const filesToSync = paths ?? await walkDirectory(mount.config.path, mount.config.ignore ?? []);
105
-
106
- for (const relativePath of filesToSync) {
107
- const absolutePath = safePath(mount.config.path, relativePath);
108
- if (!absolutePath) {
109
- result.skipped.push({ path: relativePath, reason: 'path resolves outside the mount' });
110
- continue;
111
- }
112
-
113
- try {
114
- await access(absolutePath);
115
- } catch {
116
- // File was deleted on disk — remove from tree
117
- const existing = store.treeGet(mount.treeStateId, relativePath);
118
- if (existing) {
119
- store.treeRemove(mount.treeStateId, relativePath);
120
- result.synced.push({ path: relativePath, op: 'deleted' });
121
- }
122
- continue;
123
- }
124
-
125
- try {
126
- const fileStat = await stat(absolutePath);
127
- if (!fileStat.isFile()) continue;
128
- if (fileStat.size > maxSize) {
129
- result.skipped.push({
130
- path: relativePath,
131
- reason: `too large: ${fileStat.size} bytes > maxFileSize ${maxSize}`,
132
- });
133
- continue;
134
- }
135
-
136
- const buffer = await readFile(absolutePath);
137
- if (isBinary(buffer)) {
138
- // Disk -> store sync is text-only, at ANY size: the tree stores text
139
- // blobs. Binary that must live in the store has to be written through
140
- // writeBinary; binary that is only passing through should be read with
141
- // readBinaryFromDisk instead of synced at all.
142
- result.skipped.push({
143
- path: relativePath,
144
- reason: 'binary file — disk sync stores text only (read it from disk, or write it via a binary-aware tool)',
145
- });
146
- continue;
147
- }
148
-
149
- const content = buffer.toString('utf-8');
150
- const hash = hashContent(content);
151
-
152
- // Check current tree state
153
- const existing = store.treeGet(mount.treeStateId, relativePath);
154
-
155
- if (existing && existing.blobHash === hash) {
156
- // No change
157
- continue;
158
- }
159
-
160
- // Store blob and update tree
161
- const blobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
162
- const entry: JsTreeEntry = {
163
- blobHash,
164
- size: buffer.length,
165
- mode: 0o644,
166
- };
167
-
168
- // Conflict detection: if the file existed in the tree AND the agent has
169
- // modified it since last materialization, this is a genuine conflict
170
- // (both agent and user changed the same file).
171
- if (existing) {
172
- const baselineHash = mount.materializedHashes.get(relativePath);
173
- if (baselineHash && existing.blobHash !== baselineHash) {
174
- // Agent changed the tree entry since we last materialized — conflict.
175
- // Filesystem still wins, but agent's version is recoverable via agentBlobHash.
176
- result.conflicts.push({
177
- path: relativePath,
178
- agentBlobHash: existing.blobHash,
179
- });
180
- }
181
- }
182
-
183
- store.treeSet(mount.treeStateId, relativePath, entry);
184
- result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
185
- } catch (error) {
186
- result.skipped.push({
187
- path: relativePath,
188
- reason: error instanceof Error ? error.message : String(error),
189
- });
190
- }
191
- }
192
-
193
- return result;
194
- }
195
-
196
- /**
197
- * Materialize Chronicle tree state to filesystem.
198
- *
199
- * @param store Chronicle store
200
- * @param mount Mount state
201
- * @param paths Specific paths to materialize. If undefined, materializes all changed since last.
202
- * @returns List of paths that were written
203
- */
204
- export async function materializeToFs(
205
- store: JsStore,
206
- mount: MountState,
207
- paths?: string[],
208
- ): Promise<string[]> {
209
- if (mount.config.mode === 'read-only') {
210
- return [];
211
- }
212
-
213
- const written: string[] = [];
214
-
215
- // Get changed files since last materialization
216
- const currentSeq = store.currentSequence();
217
- let filesToMaterialize: Array<{ path: string; blobHash: string }>;
218
-
219
- if (paths) {
220
- // Materialize specific paths
221
- filesToMaterialize = [];
222
- for (const p of paths) {
223
- const entry = store.treeGet(mount.treeStateId, p);
224
- if (entry) {
225
- filesToMaterialize.push({ path: p, blobHash: entry.blobHash });
226
- }
227
- }
228
- } else if (mount.lastMaterializedSeq > 0) {
229
- // Diff since last materialization
230
- const changes = store.treeDiff(
231
- mount.treeStateId,
232
- mount.lastMaterializedSeq,
233
- currentSeq,
234
- );
235
- filesToMaterialize = changes
236
- .filter(c => c.changeType === 'added' || c.changeType === 'modified')
237
- .map(c => ({
238
- path: c.path,
239
- blobHash: c.newEntry!.blobHash,
240
- }));
241
-
242
- // Handle removals
243
- for (const change of changes) {
244
- if (change.changeType === 'removed') {
245
- // Don't delete from filesystem — just skip.
246
- // Agent removing from tree doesn't mean delete user's file.
247
- }
248
- }
249
- } else {
250
- // First materialization — materialize everything
251
- const entries = store.treeList(mount.treeStateId);
252
- filesToMaterialize = entries.map(e => ({
253
- path: e.path,
254
- blobHash: e.blobHash,
255
- }));
256
- }
257
-
258
- for (const { path: relativePath, blobHash } of filesToMaterialize) {
259
- const absolutePath = safePath(mount.config.path, relativePath);
260
- if (!absolutePath) continue; // Path traversal — skip silently
261
-
262
- const blob = store.getBlob(blobHash);
263
- if (!blob) continue;
264
-
265
- // Create parent directories
266
- await mkdir(dirname(absolutePath), { recursive: true });
267
-
268
- // Write file
269
- await writeFile(absolutePath, blob);
270
- written.push(relativePath);
271
-
272
- // Record blob hash at materialization time for conflict detection
273
- mount.materializedHashes.set(relativePath, blobHash);
274
- }
275
-
276
- mount.lastMaterializedSeq = currentSeq;
277
-
278
- return written;
279
- }
280
-
281
- /**
282
- * Walk a directory recursively, respecting ignore patterns.
283
- */
284
- async function walkDirectory(
285
- basePath: string,
286
- ignorePatterns: string[],
287
- ): Promise<string[]> {
288
- const results: string[] = [];
289
- const maxFiles = 5000; // Safety limit
290
-
291
- async function walk(dir: string) {
292
- if (results.length >= maxFiles) return;
293
-
294
- let entries;
295
- try {
296
- entries = await readdir(dir, { withFileTypes: true });
297
- } catch {
298
- return;
299
- }
300
-
301
- for (const entry of entries) {
302
- if (results.length >= maxFiles) break;
303
-
304
- const fullPath = join(dir, entry.name);
305
- // Logical workspace paths are '/'-separated everywhere (mount-prefixed
306
- // paths, chronicle tree paths); node's relative() emits backslashes on
307
- // Windows, so normalize. No-op on POSIX (sep === '/').
308
- const relativePath = relative(basePath, fullPath).split(sep).join('/');
309
-
310
- // Check ignore patterns (simple glob matching)
311
- if (shouldIgnore(relativePath, entry.name, ignorePatterns)) continue;
312
-
313
- if (entry.isFile()) {
314
- results.push(relativePath);
315
- } else if (entry.isDirectory()) {
316
- await walk(fullPath);
317
- }
318
- }
319
- }
320
-
321
- await walk(basePath);
322
- return results;
323
- }
324
-
325
- /**
326
- * Simple ignore pattern matching.
327
- */
328
- function shouldIgnore(
329
- relativePath: string,
330
- name: string,
331
- patterns: string[],
332
- ): boolean {
333
- for (const pattern of patterns) {
334
- // Exact name match (e.g., ".git", "node_modules")
335
- if (pattern === name) return true;
336
-
337
- // Simple ** glob: "node_modules/**" matches anything under node_modules
338
- if (pattern.endsWith('/**')) {
339
- const prefix = pattern.slice(0, -3);
340
- if (relativePath.startsWith(prefix + '/') || relativePath === prefix) return true;
341
- }
342
-
343
- // Extension glob: "*.pyc" matches any .pyc file
344
- if (pattern.startsWith('*.')) {
345
- const ext = pattern.slice(1);
346
- if (name.endsWith(ext)) return true;
347
- }
348
- }
349
- return false;
350
- }
@@ -18,9 +18,13 @@ export interface MountConfig {
18
18
  mode: 'read-write' | 'read-only';
19
19
  /**
20
20
  * Watch mode for filesystem changes:
21
- * - 'always': chokidar watches continuously, syncs on debounce
22
- * - 'on-agent-action': sync from filesystem after each agent tool call
23
- * - 'never': fully virtual, no automatic filesystem reads
21
+ * - 'always': chokidar watches continuously; each event re-observes its paths
22
+ * - 'on-agent-action': after each completed agent tool batch, the mount is
23
+ * scanned, and the agent's next inference waits for that scan up to
24
+ * `agentActionScanDeadlineMs`; a scan that takes longer finishes while
25
+ * the round goes on
26
+ * - 'never': no background reads; listings and lazy reads still observe
27
+ * the paths they show
24
28
  */
25
29
  watch?: 'always' | 'on-agent-action' | 'never';
26
30
  /** Debounce window in ms for watch: 'always' mode (default: 300) */
@@ -72,6 +76,19 @@ export interface WorkspaceConfig {
72
76
  deltaSnapshotEvery?: number;
73
77
  /** Full snapshot frequency for tree states (default: 10) */
74
78
  fullSnapshotEvery?: number;
79
+ /**
80
+ * How long the scan of `watch: 'on-agent-action'` mounts after a tool batch
81
+ * may hold the agent's next inference, in ms (default: 20000). A scan that
82
+ * takes longer finishes in the background; the miss is recorded on the
83
+ * mount's status and pushed as a `workspace:agent-action-scan-incomplete`
84
+ * event. So is a scan that finished without observing everything, with the
85
+ * regions it couldn't (`incomplete`). While a scan that outlasted its
86
+ * deadline still runs, later batches aren't held: their scan is queued
87
+ * after it, and the miss is recorded at once. The framework bounds every
88
+ * module's batch hook at 30 s, so a deadline above 29 s is lowered to 29 s,
89
+ * with a warning, for the workspace's own miss to come first.
90
+ */
91
+ agentActionScanDeadlineMs?: number;
75
92
  }
76
93
 
77
94
  // ============================================================================
@@ -79,23 +96,52 @@ export interface WorkspaceConfig {
79
96
  // ============================================================================
80
97
 
81
98
  /**
82
- * Per-mount runtime state (not persisted — rebuilt on start).
99
+ * Per-mount runtime state (not persisted — rebuilt on start). What disk last
100
+ * agreed with lives in the module's disk-agreement journal (global chronicle
101
+ * records), and branch-local intent in `intentTreeStateId`.
83
102
  */
84
103
  export interface MountState {
85
104
  /** The mount config */
86
105
  config: MountConfig;
87
106
  /** Tree state ID in Chronicle */
88
107
  treeStateId: string;
108
+ /** Branch-local intent (tombstones, store origin, conflicts) for this mount's paths */
109
+ intentTreeStateId: string;
89
110
  /** Sequence number of last materialization */
90
111
  lastMaterializedSeq: number;
91
112
  /** Paths currently suppressed from watcher (recently materialized) */
92
113
  suppressedPaths: Set<string>;
93
- /** Whether initial lazy sync has been completed */
114
+ /** Whether a full scan of the mount has completed this session */
94
115
  initialSyncDone: boolean;
95
116
  /** Branch ID that was active when this mount last materialized */
96
117
  lastMaterializedBranchId: string | null;
97
- /** Per-file hash at time of last materialization — baseline for conflict detection */
98
- materializedHashes: Map<string, string>;
118
+ /**
119
+ * The last on-agent-action scan, as of `at`: `complete` when it has
120
+ * finished at all, `withinDeadline` when it finished before the deadline
121
+ * released the round, `reason` for a miss or a failure, and `incomplete`
122
+ * for the regions a finished scan couldn't observe (each also pushed as a
123
+ * `workspace:agent-action-scan-incomplete` event).
124
+ */
125
+ lastAgentActionScan?: {
126
+ at: number;
127
+ complete: boolean;
128
+ withinDeadline: boolean;
129
+ reason?: string;
130
+ incomplete?: Array<{ path: string; reason: string }>;
131
+ };
132
+ /**
133
+ * The after-batch scan queued behind the mount's running work, not yet
134
+ * started: later batches share it, since it starts after their tools ran.
135
+ */
136
+ agentActionScanWaiting?: AgentActionScan;
137
+ /**
138
+ * The mount's newest after-batch scan. Only its outcome is put on
139
+ * `lastAgentActionScan`: one queued behind an older scan describes a later
140
+ * batch, which the older scan's outcome mustn't speak for.
141
+ */
142
+ agentActionScanLatest?: AgentActionScan;
143
+ /** A scan that outlasted a batch's deadline, while it still runs. */
144
+ agentActionScanLate?: Promise<unknown>;
99
145
  /**
100
146
  * Wall-clock time chokidar emitted `ready` for this mount, or null if the
101
147
  * watcher hasn't finished its initial scan. null after session start =
@@ -195,6 +241,14 @@ export interface GrepInput {
195
241
  contextAfter?: number;
196
242
  }
197
243
 
244
+ /** One after-batch scan of a mount, shared by the batches that wait for it. */
245
+ export interface AgentActionScan {
246
+ /** Whether some batch stopped waiting for it (its deadline passed, or an earlier scan still ran late). */
247
+ late: boolean;
248
+ /** Settles, never rejects, once its outcome is recorded: on status if it is the newest, as events either way. */
249
+ settled: Promise<void>;
250
+ }
251
+
198
252
  export interface StatusInput {
199
253
  /** Specific mount to check (optional — defaults to all) */
200
254
  mount?: string;
@@ -207,11 +261,18 @@ export interface MaterializeInput {
207
261
  mount?: string;
208
262
  /**
209
263
  * Materialize even when the current branch has genuinely diverged from the
210
- * branch last materialized to disk (default: false). Not needed for linear
211
- * continuations (child branches forked at or after the last materialized
212
- * point) — those pass the guard automatically.
264
+ * branch last materialized to disk, and overwrite a disk copy in conflict
265
+ * (default: false). Not needed for linear continuations (child branches
266
+ * forked at or after the last materialized point) — those pass the guard
267
+ * automatically.
213
268
  */
214
269
  force?: boolean;
270
+ /**
271
+ * Also delete from disk the files deleted in the workspace whose disk copy
272
+ * is still one the workspace holds (default: false). With `force`, also
273
+ * those whose disk copy changed since.
274
+ */
275
+ applyDeletions?: boolean;
215
276
  }
216
277
 
217
278
  export interface SyncInput {
@@ -219,6 +280,8 @@ export interface SyncInput {
219
280
  path?: string;
220
281
  /** Specific mount (optional — defaults to all) */
221
282
  mount?: string;
283
+ /** Take each mount's root as it is now, when it isn't the directory disk last agreed with. */
284
+ acceptRoot?: boolean;
222
285
  }
223
286
 
224
287
  // ============================================================================
@@ -252,6 +315,8 @@ export interface WorkspaceDeletedEvent {
252
315
  type: 'workspace:deleted';
253
316
  paths: string[];
254
317
  mount: string;
318
+ /** Paths deleted on disk whose newer workspace version was kept as a conflict. */
319
+ conflicts?: string[];
255
320
  [key: string]: unknown;
256
321
  }
257
322