@animalabs/agent-framework 0.1.0 → 0.3.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 (102) hide show
  1. package/dist/src/framework.d.ts +13 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +133 -10
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +5 -0
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +104 -6
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/types.d.ts +31 -0
  10. package/dist/src/gate/types.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +3 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +4 -0
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/mcpl/channel-registry.d.ts +16 -2
  16. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  17. package/dist/src/mcpl/channel-registry.js +101 -6
  18. package/dist/src/mcpl/channel-registry.js.map +1 -1
  19. package/dist/src/mcpl/push-handler.js +1 -1
  20. package/dist/src/mcpl/push-handler.js.map +1 -1
  21. package/dist/src/mcpl/server-connection.d.ts +17 -2
  22. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  23. package/dist/src/mcpl/server-connection.js +66 -3
  24. package/dist/src/mcpl/server-connection.js.map +1 -1
  25. package/dist/src/mcpl/tool-policy.d.ts +24 -0
  26. package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
  27. package/dist/src/mcpl/tool-policy.js +46 -0
  28. package/dist/src/mcpl/tool-policy.js.map +1 -0
  29. package/dist/src/mcpl/types.d.ts +31 -0
  30. package/dist/src/mcpl/types.d.ts.map +1 -1
  31. package/dist/src/mcpl/types.js.map +1 -1
  32. package/dist/src/modules/workspace/index.d.ts +43 -1
  33. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/index.js +246 -32
  35. package/dist/src/modules/workspace/index.js.map +1 -1
  36. package/dist/src/modules/workspace/sync.d.ts +7 -2
  37. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  38. package/dist/src/modules/workspace/sync.js +2 -2
  39. package/dist/src/modules/workspace/sync.js.map +1 -1
  40. package/dist/src/modules/workspace/types.d.ts +55 -3
  41. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  42. package/dist/src/modules/workspace/types.js +8 -1
  43. package/dist/src/modules/workspace/types.js.map +1 -1
  44. package/dist/src/modules/workspace/watcher.d.ts +40 -3
  45. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  46. package/dist/src/modules/workspace/watcher.js +80 -13
  47. package/dist/src/modules/workspace/watcher.js.map +1 -1
  48. package/dist/src/types/trace.d.ts +20 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/src/usage/index.d.ts +3 -0
  51. package/dist/src/usage/index.d.ts.map +1 -0
  52. package/dist/src/usage/index.js +2 -0
  53. package/dist/src/usage/index.js.map +1 -0
  54. package/dist/src/usage/types.d.ts +28 -0
  55. package/dist/src/usage/types.d.ts.map +1 -0
  56. package/dist/src/usage/types.js +2 -0
  57. package/dist/src/usage/types.js.map +1 -0
  58. package/dist/src/usage/usage-tracker.d.ts +33 -0
  59. package/dist/src/usage/usage-tracker.d.ts.map +1 -0
  60. package/dist/src/usage/usage-tracker.js +115 -0
  61. package/dist/src/usage/usage-tracker.js.map +1 -0
  62. package/dist/test/channel-registry-typing.test.d.ts +2 -0
  63. package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
  64. package/dist/test/channel-registry-typing.test.js +82 -0
  65. package/dist/test/channel-registry-typing.test.js.map +1 -0
  66. package/dist/test/event-gate.test.js +135 -16
  67. package/dist/test/event-gate.test.js.map +1 -1
  68. package/dist/test/mcpl-gate-roundtrip.test.d.ts +2 -0
  69. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +1 -0
  70. package/dist/test/mcpl-gate-roundtrip.test.js +140 -0
  71. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -0
  72. package/dist/test/tool-policy.test.d.ts +2 -0
  73. package/dist/test/tool-policy.test.d.ts.map +1 -0
  74. package/dist/test/tool-policy.test.js +51 -0
  75. package/dist/test/tool-policy.test.js.map +1 -0
  76. package/dist/test/usage-tracker.test.d.ts +2 -0
  77. package/dist/test/usage-tracker.test.d.ts.map +1 -0
  78. package/dist/test/usage-tracker.test.js +129 -0
  79. package/dist/test/usage-tracker.test.js.map +1 -0
  80. package/dist/test/workspace-watcher.test.d.ts +2 -0
  81. package/dist/test/workspace-watcher.test.d.ts.map +1 -0
  82. package/dist/test/workspace-watcher.test.js +117 -0
  83. package/dist/test/workspace-watcher.test.js.map +1 -0
  84. package/dist/tsconfig.tsbuildinfo +1 -1
  85. package/package.json +3 -3
  86. package/src/framework.ts +148 -10
  87. package/src/gate/event-gate.ts +106 -6
  88. package/src/gate/types.ts +28 -0
  89. package/src/index.ts +7 -0
  90. package/src/mcpl/channel-registry.ts +120 -8
  91. package/src/mcpl/push-handler.ts +1 -1
  92. package/src/mcpl/server-connection.ts +78 -3
  93. package/src/mcpl/tool-policy.ts +52 -0
  94. package/src/mcpl/types.ts +34 -0
  95. package/src/modules/workspace/index.ts +269 -38
  96. package/src/modules/workspace/sync.ts +11 -4
  97. package/src/modules/workspace/types.ts +70 -3
  98. package/src/modules/workspace/watcher.ts +104 -15
  99. package/src/types/trace.ts +26 -1
  100. package/src/usage/index.ts +2 -0
  101. package/src/usage/types.ts +27 -0
  102. package/src/usage/usage-tracker.ts +141 -0
@@ -5,8 +5,8 @@
5
5
  * auto-sync between real filesystem and Chronicle, and manual materialization.
6
6
  */
7
7
 
8
- import { readFile, stat, access } from 'node:fs/promises';
9
- import { join, resolve, relative } from 'node:path';
8
+ import { readFile, stat, access, writeFile, unlink, mkdir } from 'node:fs/promises';
9
+ import { join, resolve, relative, dirname } from 'node:path';
10
10
  import type { JsStore } from '@animalabs/chronicle';
11
11
  import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
12
12
  import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
@@ -25,9 +25,13 @@ import type {
25
25
  StatusInput,
26
26
  MaterializeInput,
27
27
  SyncInput,
28
- WorkspaceChangedEvent,
28
+ WorkspaceCreatedEvent,
29
+ WorkspaceModifiedEvent,
30
+ WorkspaceDeletedEvent,
31
+ WorkspaceFsOp,
29
32
  } from './types.js';
30
- import { MountWatcher } from './watcher.js';
33
+ import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
34
+ import { MountWatcher, type FsChange } from './watcher.js';
31
35
  import { syncFromFs, materializeToFs, hashContent, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
32
36
 
33
37
  export type {
@@ -85,6 +89,12 @@ export class WorkspaceModule implements Module {
85
89
 
86
90
  /**
87
91
  * Inject the Chronicle store. Must be called after framework creation.
92
+ *
93
+ * Host ordering is: `AgentFramework.create()` calls `module.start(ctx)`
94
+ * before the framework is fully returned, and the host (e.g. conhost) wires
95
+ * the store via `initStore(store)` afterwards. Neither half has everything
96
+ * it needs on its own, so watcher setup runs in whichever callback fires
97
+ * second. `ensureRunning()` gates on `ctx && mounts-populated`.
88
98
  */
89
99
  initStore(store: JsStore): void {
90
100
  this.store = store;
@@ -111,9 +121,13 @@ export class WorkspaceModule implements Module {
111
121
  initialSyncDone: false,
112
122
  lastMaterializedBranchId: null,
113
123
  materializedHashes: new Map(),
124
+ watcherReadyAt: null,
125
+ watcherError: null,
114
126
  };
115
127
  this.mounts.set(mount.name, mountState);
116
128
  }
129
+
130
+ this.ensureRunning();
117
131
  }
118
132
 
119
133
  async start(ctx: ModuleContext): Promise<void> {
@@ -128,24 +142,64 @@ export class WorkspaceModule implements Module {
128
142
  if (mount) {
129
143
  mount.lastMaterializedSeq = meta.lastMaterializedSeq;
130
144
  mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
145
+ // watcherReadyAt intentionally not restored — each session must
146
+ // observe its own watcher attach, otherwise a stale timestamp
147
+ // would hide a new-session attach failure.
131
148
  }
132
149
  }
133
150
  }
134
151
  }
135
152
 
136
- // Start watchers for 'always' mode mounts
153
+ this.ensureRunning();
154
+ }
155
+
156
+ /**
157
+ * Start chokidar watchers and emit workspace:mounted events.
158
+ * Idempotent: safe to call from both start() and initStore(), fires once
159
+ * per mount regardless of which runs second.
160
+ */
161
+ private ensureRunning(): void {
162
+ if (!this.ctx || this.mounts.size === 0) return;
163
+
137
164
  for (const [name, mount] of this.mounts) {
165
+ if (this.watchers.has(name)) continue;
166
+
138
167
  const watchMode = mount.config.watch ?? 'always';
139
168
  if (watchMode === 'always') {
140
- const watcher = new MountWatcher(mount.config, (paths) => {
141
- this.handleFsChanges(name, paths);
142
- });
169
+ const watcher = new MountWatcher(
170
+ mount.config,
171
+ (changes) => {
172
+ this.handleFsChanges(name, changes);
173
+ },
174
+ {
175
+ onReady: () => {
176
+ mount.watcherReadyAt = Date.now();
177
+ mount.watcherError = null;
178
+ },
179
+ onError: (err) => {
180
+ mount.watcherError = err.message;
181
+ this.ctx?.pushEvent({
182
+ type: 'workspace:watcher-error',
183
+ mount: name,
184
+ path: mount.config.path,
185
+ error: err.message,
186
+ } as ProcessEvent);
187
+ },
188
+ },
189
+ );
143
190
  watcher.start();
144
191
  this.watchers.set(name, watcher);
192
+
193
+ // Chokidar is started with ignoreInitial:true, so files already on
194
+ // disk at session start would be invisible. Trigger one syncFromFs
195
+ // pass — syncFromFs diffs disk against the tree state, so only files
196
+ // new-to-this-session's-tree fire workspace:created events. Fresh
197
+ // sessions see the existing catalog; restarts only see what appeared
198
+ // while the session was down.
199
+ void this.initialScan(name);
145
200
  }
146
201
 
147
- // Emit mounted event
148
- this.ctx?.pushEvent({
202
+ this.ctx.pushEvent({
149
203
  type: 'workspace:mounted',
150
204
  mount: name,
151
205
  path: mount.config.path,
@@ -153,6 +207,25 @@ export class WorkspaceModule implements Module {
153
207
  }
154
208
  }
155
209
 
210
+ private async initialScan(mountName: string): Promise<void> {
211
+ const store = this.store;
212
+ const mount = this.mounts.get(mountName);
213
+ if (!store || !mount) return;
214
+
215
+ try {
216
+ const result = await syncFromFs(store, mount);
217
+ mount.initialSyncDone = true;
218
+ this.emitFsEvents(mountName, result.synced, result.conflicts);
219
+ } catch (err) {
220
+ const msg = err instanceof Error ? err.message : String(err);
221
+ this.ctx?.pushEvent({
222
+ type: 'workspace:initial-scan-failed',
223
+ mount: mountName,
224
+ error: msg,
225
+ } as ProcessEvent);
226
+ }
227
+ }
228
+
156
229
  async stop(): Promise<void> {
157
230
  // Persist state
158
231
  if (this.ctx) {
@@ -162,6 +235,8 @@ export class WorkspaceModule implements Module {
162
235
  state.mounts[name] = {
163
236
  lastMaterializedSeq: mount.lastMaterializedSeq,
164
237
  lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined,
238
+ watcherReadyAt: mount.watcherReadyAt,
239
+ watcherError: mount.watcherError,
165
240
  };
166
241
  }
167
242
  this.ctx.setState(state);
@@ -330,14 +405,47 @@ export class WorkspaceModule implements Module {
330
405
  }
331
406
  }
332
407
 
333
- async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
334
- return {};
408
+ async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
409
+ // Wake agents on filesystem events when the originating mount opted in.
410
+ // The EventGate still has final say — gate policies can further filter by
411
+ // mount name, path glob, or op type without requiring a recipe change.
412
+ if (!(WORKSPACE_FS_EVENT_TYPES as readonly string[]).includes(event.type)) {
413
+ return {};
414
+ }
415
+ const mountName = (event as { mount?: string }).mount;
416
+ if (!mountName) return {};
417
+ const mount = this.mounts.get(mountName);
418
+ if (!mount) return {};
419
+
420
+ const flag = mount.config.wakeOnChange;
421
+ if (!flag) return {};
422
+
423
+ const op = event.type.slice('workspace:'.length) as WorkspaceFsOp;
424
+ const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op));
425
+ if (!opAllowed) return {};
426
+
427
+ return { requestInference: true };
335
428
  }
336
429
 
337
430
  // ==========================================================================
338
431
  // Path Resolution
339
432
  // ==========================================================================
340
433
 
434
+ /**
435
+ * Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
436
+ * absolute filesystem path. Returns null if the mount is unknown or the
437
+ * resolved path escapes the mount root (path-traversal guard). Public API
438
+ * for peer modules that need to read workspace files directly.
439
+ */
440
+ resolveAbsolutePath(mountPrefixedPath: string): string | null {
441
+ try {
442
+ const { mount, relativePath } = this.parsePath(mountPrefixedPath);
443
+ return resolve(mount.config.path, relativePath);
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
341
449
  /**
342
450
  * Parse a mount-prefixed path into (mountName, relativePath).
343
451
  */
@@ -366,6 +474,58 @@ export class WorkspaceModule implements Module {
366
474
  return this.store;
367
475
  }
368
476
 
477
+ /**
478
+ * Persist a single write/edit/delete to disk when the mount opts in via
479
+ * `autoMaterialize`. Required for cross-agent pipelines — another agent's
480
+ * chokidar watcher on the same directory only sees real filesystem events.
481
+ * The local watcher's `suppress()` absorbs the echo so we don't self-wake.
482
+ *
483
+ * Returns an error string on failure so callers can surface it in the tool
484
+ * result. The autoMaterialize contract is specifically "disk is the source
485
+ * of truth for downstream agents", so a silent disk-write failure with a
486
+ * Chronicle commit would defeat the whole purpose. No-op cases (flag off,
487
+ * read-only mount) return null (success).
488
+ */
489
+ private async autoMaterialize(
490
+ mount: MountState,
491
+ relativePath: string,
492
+ op: 'write' | 'delete',
493
+ content?: Buffer,
494
+ ): Promise<string | null> {
495
+ if (!mount.config.autoMaterialize) return null;
496
+ if (mount.config.mode === 'read-only') return null;
497
+
498
+ const absolutePath = join(mount.config.path, relativePath);
499
+ const watcher = this.watchers.get(mount.config.name);
500
+ watcher?.suppress(relativePath);
501
+
502
+ try {
503
+ if (op === 'write' && content) {
504
+ await mkdir(dirname(absolutePath), { recursive: true });
505
+ await writeFile(absolutePath, content);
506
+ mount.materializedHashes.set(relativePath, hashContent(content));
507
+ } else if (op === 'delete') {
508
+ try {
509
+ await unlink(absolutePath);
510
+ } catch (err) {
511
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
512
+ }
513
+ mount.materializedHashes.delete(relativePath);
514
+ }
515
+ return null;
516
+ } catch (err) {
517
+ const msg = err instanceof Error ? err.message : String(err);
518
+ this.ctx?.pushEvent({
519
+ type: 'workspace:materialize-failed',
520
+ mount: mount.config.name,
521
+ path: relativePath,
522
+ op,
523
+ error: msg,
524
+ } as ProcessEvent);
525
+ return msg;
526
+ }
527
+ }
528
+
369
529
  // ==========================================================================
370
530
  // Lazy Sync
371
531
  // ==========================================================================
@@ -456,12 +616,21 @@ export class WorkspaceModule implements Module {
456
616
  return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
457
617
  }
458
618
 
459
- const blobHash = store.storeBlob(Buffer.from(input.content, 'utf-8'), 'text/plain');
619
+ const buffer = Buffer.from(input.content, 'utf-8');
620
+ const blobHash = store.storeBlob(buffer, 'text/plain');
460
621
  store.treeSet(mount.treeStateId, relativePath, {
461
622
  blobHash,
462
623
  size: Buffer.byteLength(input.content),
463
624
  mode: 0o644,
464
625
  });
626
+ const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
627
+ if (materializeError) {
628
+ return {
629
+ success: false,
630
+ error: `Wrote to Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
631
+ isError: true,
632
+ };
633
+ }
465
634
 
466
635
  return {
467
636
  success: true,
@@ -513,12 +682,21 @@ export class WorkspaceModule implements Module {
513
682
  ? content.replaceAll(input.oldString, input.newString)
514
683
  : content.replace(input.oldString, input.newString);
515
684
 
516
- const newBlobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
685
+ const newBuffer = Buffer.from(content, 'utf-8');
686
+ const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
517
687
  store.treeSet(mount.treeStateId, relativePath, {
518
688
  blobHash: newBlobHash,
519
689
  size: Buffer.byteLength(content),
520
690
  mode: entry.mode,
521
691
  });
692
+ const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
693
+ if (materializeError) {
694
+ return {
695
+ success: false,
696
+ error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
697
+ isError: true,
698
+ };
699
+ }
522
700
 
523
701
  return {
524
702
  success: true,
@@ -542,6 +720,14 @@ export class WorkspaceModule implements Module {
542
720
  }
543
721
 
544
722
  store.treeRemove(mount.treeStateId, relativePath);
723
+ const materializeError = await this.autoMaterialize(mount, relativePath, 'delete');
724
+ if (materializeError) {
725
+ return {
726
+ success: false,
727
+ error: `Removed from Chronicle but failed to unlink "${input.path}" from disk: ${materializeError}. Downstream agents will still see the stale file until manual cleanup.`,
728
+ isError: true,
729
+ };
730
+ }
545
731
 
546
732
  return { success: true, data: { path: input.path, deleted: true } };
547
733
  }
@@ -869,20 +1055,12 @@ export class WorkspaceModule implements Module {
869
1055
  mount.initialSyncDone = true;
870
1056
 
871
1057
  if (result.synced.length > 0 || result.conflicts.length > 0) {
872
- allResults.push({ mount: name, synced: result.synced, conflicts: result.conflicts });
873
-
874
- // Emit workspace:changed event
875
- if (this.ctx) {
876
- const event: WorkspaceChangedEvent = {
877
- type: 'workspace:changed',
878
- paths: result.synced.map(p => `${name}/${p}`),
879
- mount: name,
880
- conflicts: result.conflicts.length > 0
881
- ? result.conflicts.map(c => `${name}/${c.path}`)
882
- : undefined,
883
- };
884
- this.ctx.pushEvent(event as ProcessEvent);
885
- }
1058
+ allResults.push({
1059
+ mount: name,
1060
+ synced: result.synced.map(s => s.path),
1061
+ conflicts: result.conflicts,
1062
+ });
1063
+ this.emitFsEvents(name, result.synced, result.conflicts);
886
1064
  }
887
1065
  }
888
1066
 
@@ -902,23 +1080,76 @@ export class WorkspaceModule implements Module {
902
1080
 
903
1081
  /**
904
1082
  * Handle filesystem changes detected by watcher (watch: 'always' mode).
1083
+ *
1084
+ * The watcher carries the op per path. For created/modified we call
1085
+ * syncFromFs to pull content into the tree; for deleted we strip the tree
1086
+ * entry directly (the file is gone, nothing to read). We emit one event per
1087
+ * op type.
905
1088
  */
906
- private async handleFsChanges(mountName: string, paths: string[]): Promise<void> {
1089
+ private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
907
1090
  const store = this.store;
908
1091
  const mount = this.mounts.get(mountName);
909
1092
  if (!store || !mount) return;
910
1093
 
911
- const result = await syncFromFs(store, mount, paths);
1094
+ const touchedPaths = changes.filter(c => c.op !== 'deleted').map(c => c.path);
1095
+ const deletedPaths = changes.filter(c => c.op === 'deleted').map(c => c.path);
1096
+
1097
+ const syncResult = touchedPaths.length > 0
1098
+ ? await syncFromFs(store, mount, touchedPaths)
1099
+ : { synced: [], conflicts: [], skipped: [] };
1100
+
1101
+ // Strip deleted entries from the tree so downstream consumers see a
1102
+ // coherent state. syncFromFs would also do this if we passed the paths in,
1103
+ // but the op from the watcher is authoritative — skip the access() probe.
1104
+ for (const p of deletedPaths) {
1105
+ const existing = store.treeGet(mount.treeStateId, p);
1106
+ if (existing) {
1107
+ store.treeRemove(mount.treeStateId, p);
1108
+ syncResult.synced.push({ path: p, op: 'deleted' });
1109
+ }
1110
+ }
1111
+
1112
+ this.emitFsEvents(mountName, syncResult.synced, syncResult.conflicts);
1113
+ }
912
1114
 
913
- if ((result.synced.length > 0 || result.conflicts.length > 0) && this.ctx) {
914
- const event: WorkspaceChangedEvent = {
915
- type: 'workspace:changed',
916
- paths: result.synced.map(p => `${mountName}/${p}`),
1115
+ /**
1116
+ * Group synced paths by op and push one ProcessEvent per non-empty op.
1117
+ */
1118
+ private emitFsEvents(
1119
+ mountName: string,
1120
+ synced: Array<{ path: string; op: WorkspaceFsOp }>,
1121
+ conflicts: ConflictInfo[],
1122
+ ): void {
1123
+ if (!this.ctx || synced.length === 0) return;
1124
+
1125
+ const byOp = new Map<WorkspaceFsOp, string[]>();
1126
+ for (const { path, op } of synced) {
1127
+ const list = byOp.get(op) ?? [];
1128
+ list.push(`${mountName}/${path}`);
1129
+ byOp.set(op, list);
1130
+ }
1131
+
1132
+ // Conflicts by definition require a prior tree entry (sync detected that
1133
+ // the agent-side version diverged from the filesystem baseline), which
1134
+ // means the path is being re-synced as a modification. Attach conflicts
1135
+ // only to the workspace:modified event, intersected with its paths — a
1136
+ // created/deleted batch can't carry a meaningful conflict.
1137
+ const conflictsByPath = new Map<string, ConflictInfo>();
1138
+ for (const c of conflicts) conflictsByPath.set(c.path, c);
1139
+
1140
+ for (const [op, paths] of byOp) {
1141
+ const type = opToEventType(op);
1142
+ let eventConflicts: string[] | undefined;
1143
+ if (op === 'modified' && conflictsByPath.size > 0) {
1144
+ const matched = paths.filter(p => conflictsByPath.has(p.slice(mountName.length + 1)));
1145
+ if (matched.length > 0) eventConflicts = matched;
1146
+ }
1147
+ const event = {
1148
+ type,
1149
+ paths,
917
1150
  mount: mountName,
918
- conflicts: result.conflicts.length > 0
919
- ? result.conflicts.map(c => `${mountName}/${c.path}`)
920
- : undefined,
921
- };
1151
+ ...(eventConflicts ? { conflicts: eventConflicts } : {}),
1152
+ } as WorkspaceCreatedEvent | WorkspaceModifiedEvent | WorkspaceDeletedEvent;
922
1153
  this.ctx.pushEvent(event as ProcessEvent);
923
1154
  }
924
1155
  }
@@ -56,9 +56,16 @@ export interface ConflictInfo {
56
56
  agentBlobHash: string;
57
57
  }
58
58
 
59
+ export type SyncOp = 'created' | 'modified' | 'deleted';
60
+
61
+ export interface SyncedPath {
62
+ path: string;
63
+ op: SyncOp;
64
+ }
65
+
59
66
  export interface SyncResult {
60
- /** Paths that were synced */
61
- synced: string[];
67
+ /** Paths that were synced, tagged with the op detected against tree state */
68
+ synced: SyncedPath[];
62
69
  /** Paths that conflicted (both agent and user modified) — filesystem wins */
63
70
  conflicts: ConflictInfo[];
64
71
  /** Paths that were skipped (binary, too large, etc.) */
@@ -97,7 +104,7 @@ export async function syncFromFs(
97
104
  const existing = store.treeGet(mount.treeStateId, relativePath);
98
105
  if (existing) {
99
106
  store.treeRemove(mount.treeStateId, relativePath);
100
- result.synced.push(relativePath);
107
+ result.synced.push({ path: relativePath, op: 'deleted' });
101
108
  }
102
109
  continue;
103
110
  }
@@ -151,7 +158,7 @@ export async function syncFromFs(
151
158
  }
152
159
 
153
160
  store.treeSet(mount.treeStateId, relativePath, entry);
154
- result.synced.push(relativePath);
161
+ result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
155
162
  } catch {
156
163
  result.skipped.push(relativePath);
157
164
  }
@@ -36,6 +36,26 @@ export interface MountConfig {
36
36
  followSymlinks?: boolean;
37
37
  /** Maximum file size in bytes (default: 5MB) */
38
38
  maxFileSize?: number;
39
+ /**
40
+ * If set, filesystem changes on this mount request inference.
41
+ * - `true` — wake on any op (created | modified | deleted)
42
+ * - array — wake only for the listed ops
43
+ *
44
+ * Self-writes through the module's own tools are already suppressed by the
45
+ * watcher, so a mount shared between agents only wakes on external writes.
46
+ * The EventGate still has final say — use gate policies for path-glob
47
+ * filtering or to disable wake temporarily via hot-reload.
48
+ */
49
+ wakeOnChange?: boolean | Array<'created' | 'modified' | 'deleted'>;
50
+
51
+ /**
52
+ * If true, writes/edits/deletes via the module's tools materialize to the
53
+ * filesystem immediately (not just into Chronicle tree state). Required for
54
+ * any mount shared across agents as a communication channel — without it,
55
+ * another agent's watcher on the same directory sees nothing. The watcher
56
+ * suppresses its own echoes, so this doesn't cause self-wake loops.
57
+ */
58
+ autoMaterialize?: boolean;
39
59
  }
40
60
 
41
61
  /**
@@ -74,6 +94,15 @@ export interface MountState {
74
94
  lastMaterializedBranchId: string | null;
75
95
  /** Per-file hash at time of last materialization — baseline for conflict detection */
76
96
  materializedHashes: Map<string, string>;
97
+ /**
98
+ * Wall-clock time chokidar emitted `ready` for this mount, or null if the
99
+ * watcher hasn't finished its initial scan. null after session start =
100
+ * watcher attach silently failed; a set value with empty tree = attached
101
+ * to an empty directory.
102
+ */
103
+ watcherReadyAt: number | null;
104
+ /** Most recent chokidar error for this mount, if any. */
105
+ watcherError: string | null;
77
106
  }
78
107
 
79
108
  /**
@@ -84,6 +113,8 @@ export interface WorkspaceModuleState {
84
113
  mounts: Record<string, {
85
114
  lastMaterializedSeq: number;
86
115
  lastMaterializedBranchId?: string;
116
+ watcherReadyAt?: number | null;
117
+ watcherError?: string | null;
87
118
  }>;
88
119
  /** Branch ID considered "active" for materialization */
89
120
  activeBranchId?: string;
@@ -175,14 +206,36 @@ export interface SyncInput {
175
206
  // Events
176
207
  // ============================================================================
177
208
 
178
- export interface WorkspaceChangedEvent {
179
- type: 'workspace:changed';
209
+ export type WorkspaceFsOp = 'created' | 'modified' | 'deleted';
210
+
211
+ /**
212
+ * Fired when files in a mount are created. `paths` are mount-prefixed
213
+ * (e.g. "tickets/2026-04-20-foo.md"). Conflicts are reported when Chronicle
214
+ * sync detected divergence between the filesystem and the tree state.
215
+ */
216
+ export interface WorkspaceCreatedEvent {
217
+ type: 'workspace:created';
180
218
  paths: string[];
181
219
  mount: string;
182
220
  conflicts?: string[];
183
221
  [key: string]: unknown;
184
222
  }
185
223
 
224
+ export interface WorkspaceModifiedEvent {
225
+ type: 'workspace:modified';
226
+ paths: string[];
227
+ mount: string;
228
+ conflicts?: string[];
229
+ [key: string]: unknown;
230
+ }
231
+
232
+ export interface WorkspaceDeletedEvent {
233
+ type: 'workspace:deleted';
234
+ paths: string[];
235
+ mount: string;
236
+ [key: string]: unknown;
237
+ }
238
+
186
239
  export interface WorkspaceMountedEvent {
187
240
  type: 'workspace:mounted';
188
241
  mount: string;
@@ -197,6 +250,20 @@ export interface WorkspaceUnmountedEvent {
197
250
  }
198
251
 
199
252
  export type WorkspaceEvent =
200
- | WorkspaceChangedEvent
253
+ | WorkspaceCreatedEvent
254
+ | WorkspaceModifiedEvent
255
+ | WorkspaceDeletedEvent
201
256
  | WorkspaceMountedEvent
202
257
  | WorkspaceUnmountedEvent;
258
+
259
+ export const WORKSPACE_FS_EVENT_TYPES = [
260
+ 'workspace:created',
261
+ 'workspace:modified',
262
+ 'workspace:deleted',
263
+ ] as const;
264
+
265
+ export type WorkspaceFsEventType = typeof WORKSPACE_FS_EVENT_TYPES[number];
266
+
267
+ export function opToEventType(op: WorkspaceFsOp): WorkspaceFsEventType {
268
+ return `workspace:${op}` as WorkspaceFsEventType;
269
+ }