@animalabs/agent-framework 0.1.0 → 0.2.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 (84) hide show
  1. package/dist/src/framework.d.ts +5 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +91 -8
  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 +2 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +2 -0
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/mcpl/channel-registry.d.ts +11 -0
  16. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  17. package/dist/src/mcpl/channel-registry.js +40 -1
  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/types.d.ts +15 -0
  22. package/dist/src/mcpl/types.d.ts.map +1 -1
  23. package/dist/src/mcpl/types.js.map +1 -1
  24. package/dist/src/modules/workspace/index.d.ts +36 -1
  25. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  26. package/dist/src/modules/workspace/index.js +210 -32
  27. package/dist/src/modules/workspace/index.js.map +1 -1
  28. package/dist/src/modules/workspace/sync.d.ts +7 -2
  29. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  30. package/dist/src/modules/workspace/sync.js +2 -2
  31. package/dist/src/modules/workspace/sync.js.map +1 -1
  32. package/dist/src/modules/workspace/types.d.ts +44 -3
  33. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/types.js +8 -1
  35. package/dist/src/modules/workspace/types.js.map +1 -1
  36. package/dist/src/modules/workspace/watcher.d.ts +28 -3
  37. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  38. package/dist/src/modules/workspace/watcher.js +53 -12
  39. package/dist/src/modules/workspace/watcher.js.map +1 -1
  40. package/dist/src/types/trace.d.ts +16 -0
  41. package/dist/src/types/trace.d.ts.map +1 -1
  42. package/dist/src/usage/index.d.ts +3 -0
  43. package/dist/src/usage/index.d.ts.map +1 -0
  44. package/dist/src/usage/index.js +2 -0
  45. package/dist/src/usage/index.js.map +1 -0
  46. package/dist/src/usage/types.d.ts +28 -0
  47. package/dist/src/usage/types.d.ts.map +1 -0
  48. package/dist/src/usage/types.js +2 -0
  49. package/dist/src/usage/types.js.map +1 -0
  50. package/dist/src/usage/usage-tracker.d.ts +33 -0
  51. package/dist/src/usage/usage-tracker.d.ts.map +1 -0
  52. package/dist/src/usage/usage-tracker.js +115 -0
  53. package/dist/src/usage/usage-tracker.js.map +1 -0
  54. package/dist/test/event-gate.test.js +135 -16
  55. package/dist/test/event-gate.test.js.map +1 -1
  56. package/dist/test/mcpl-gate-roundtrip.test.d.ts +2 -0
  57. package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +1 -0
  58. package/dist/test/mcpl-gate-roundtrip.test.js +140 -0
  59. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -0
  60. package/dist/test/usage-tracker.test.d.ts +2 -0
  61. package/dist/test/usage-tracker.test.d.ts.map +1 -0
  62. package/dist/test/usage-tracker.test.js +129 -0
  63. package/dist/test/usage-tracker.test.js.map +1 -0
  64. package/dist/test/workspace-watcher.test.d.ts +2 -0
  65. package/dist/test/workspace-watcher.test.d.ts.map +1 -0
  66. package/dist/test/workspace-watcher.test.js +90 -0
  67. package/dist/test/workspace-watcher.test.js.map +1 -0
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +3 -3
  70. package/src/framework.ts +104 -8
  71. package/src/gate/event-gate.ts +106 -6
  72. package/src/gate/types.ts +28 -0
  73. package/src/index.ts +4 -0
  74. package/src/mcpl/channel-registry.ts +45 -1
  75. package/src/mcpl/push-handler.ts +1 -1
  76. package/src/mcpl/types.ts +16 -0
  77. package/src/modules/workspace/index.ts +228 -37
  78. package/src/modules/workspace/sync.ts +11 -4
  79. package/src/modules/workspace/types.ts +59 -3
  80. package/src/modules/workspace/watcher.ts +64 -15
  81. package/src/types/trace.ts +19 -1
  82. package/src/usage/index.ts +2 -0
  83. package/src/usage/types.ts +27 -0
  84. 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;
@@ -114,6 +124,8 @@ export class WorkspaceModule implements Module {
114
124
  };
115
125
  this.mounts.set(mount.name, mountState);
116
126
  }
127
+
128
+ this.ensureRunning();
117
129
  }
118
130
 
119
131
  async start(ctx: ModuleContext): Promise<void> {
@@ -133,19 +145,38 @@ export class WorkspaceModule implements Module {
133
145
  }
134
146
  }
135
147
 
136
- // Start watchers for 'always' mode mounts
148
+ this.ensureRunning();
149
+ }
150
+
151
+ /**
152
+ * Start chokidar watchers and emit workspace:mounted events.
153
+ * Idempotent: safe to call from both start() and initStore(), fires once
154
+ * per mount regardless of which runs second.
155
+ */
156
+ private ensureRunning(): void {
157
+ if (!this.ctx || this.mounts.size === 0) return;
158
+
137
159
  for (const [name, mount] of this.mounts) {
160
+ if (this.watchers.has(name)) continue;
161
+
138
162
  const watchMode = mount.config.watch ?? 'always';
139
163
  if (watchMode === 'always') {
140
- const watcher = new MountWatcher(mount.config, (paths) => {
141
- this.handleFsChanges(name, paths);
164
+ const watcher = new MountWatcher(mount.config, (changes) => {
165
+ this.handleFsChanges(name, changes);
142
166
  });
143
167
  watcher.start();
144
168
  this.watchers.set(name, watcher);
169
+
170
+ // Chokidar is started with ignoreInitial:true, so files already on
171
+ // disk at session start would be invisible. Trigger one syncFromFs
172
+ // pass — syncFromFs diffs disk against the tree state, so only files
173
+ // new-to-this-session's-tree fire workspace:created events. Fresh
174
+ // sessions see the existing catalog; restarts only see what appeared
175
+ // while the session was down.
176
+ void this.initialScan(name);
145
177
  }
146
178
 
147
- // Emit mounted event
148
- this.ctx?.pushEvent({
179
+ this.ctx.pushEvent({
149
180
  type: 'workspace:mounted',
150
181
  mount: name,
151
182
  path: mount.config.path,
@@ -153,6 +184,25 @@ export class WorkspaceModule implements Module {
153
184
  }
154
185
  }
155
186
 
187
+ private async initialScan(mountName: string): Promise<void> {
188
+ const store = this.store;
189
+ const mount = this.mounts.get(mountName);
190
+ if (!store || !mount) return;
191
+
192
+ try {
193
+ const result = await syncFromFs(store, mount);
194
+ mount.initialSyncDone = true;
195
+ this.emitFsEvents(mountName, result.synced, result.conflicts);
196
+ } catch (err) {
197
+ const msg = err instanceof Error ? err.message : String(err);
198
+ this.ctx?.pushEvent({
199
+ type: 'workspace:initial-scan-failed',
200
+ mount: mountName,
201
+ error: msg,
202
+ } as ProcessEvent);
203
+ }
204
+ }
205
+
156
206
  async stop(): Promise<void> {
157
207
  // Persist state
158
208
  if (this.ctx) {
@@ -330,8 +380,26 @@ export class WorkspaceModule implements Module {
330
380
  }
331
381
  }
332
382
 
333
- async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
334
- return {};
383
+ async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
384
+ // Wake agents on filesystem events when the originating mount opted in.
385
+ // The EventGate still has final say — gate policies can further filter by
386
+ // mount name, path glob, or op type without requiring a recipe change.
387
+ if (!(WORKSPACE_FS_EVENT_TYPES as readonly string[]).includes(event.type)) {
388
+ return {};
389
+ }
390
+ const mountName = (event as { mount?: string }).mount;
391
+ if (!mountName) return {};
392
+ const mount = this.mounts.get(mountName);
393
+ if (!mount) return {};
394
+
395
+ const flag = mount.config.wakeOnChange;
396
+ if (!flag) return {};
397
+
398
+ const op = event.type.slice('workspace:'.length) as WorkspaceFsOp;
399
+ const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op));
400
+ if (!opAllowed) return {};
401
+
402
+ return { requestInference: true };
335
403
  }
336
404
 
337
405
  // ==========================================================================
@@ -366,6 +434,58 @@ export class WorkspaceModule implements Module {
366
434
  return this.store;
367
435
  }
368
436
 
437
+ /**
438
+ * Persist a single write/edit/delete to disk when the mount opts in via
439
+ * `autoMaterialize`. Required for cross-agent pipelines — another agent's
440
+ * chokidar watcher on the same directory only sees real filesystem events.
441
+ * The local watcher's `suppress()` absorbs the echo so we don't self-wake.
442
+ *
443
+ * Returns an error string on failure so callers can surface it in the tool
444
+ * result. The autoMaterialize contract is specifically "disk is the source
445
+ * of truth for downstream agents", so a silent disk-write failure with a
446
+ * Chronicle commit would defeat the whole purpose. No-op cases (flag off,
447
+ * read-only mount) return null (success).
448
+ */
449
+ private async autoMaterialize(
450
+ mount: MountState,
451
+ relativePath: string,
452
+ op: 'write' | 'delete',
453
+ content?: Buffer,
454
+ ): Promise<string | null> {
455
+ if (!mount.config.autoMaterialize) return null;
456
+ if (mount.config.mode === 'read-only') return null;
457
+
458
+ const absolutePath = join(mount.config.path, relativePath);
459
+ const watcher = this.watchers.get(mount.config.name);
460
+ watcher?.suppress(relativePath);
461
+
462
+ try {
463
+ if (op === 'write' && content) {
464
+ await mkdir(dirname(absolutePath), { recursive: true });
465
+ await writeFile(absolutePath, content);
466
+ mount.materializedHashes.set(relativePath, hashContent(content));
467
+ } else if (op === 'delete') {
468
+ try {
469
+ await unlink(absolutePath);
470
+ } catch (err) {
471
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
472
+ }
473
+ mount.materializedHashes.delete(relativePath);
474
+ }
475
+ return null;
476
+ } catch (err) {
477
+ const msg = err instanceof Error ? err.message : String(err);
478
+ this.ctx?.pushEvent({
479
+ type: 'workspace:materialize-failed',
480
+ mount: mount.config.name,
481
+ path: relativePath,
482
+ op,
483
+ error: msg,
484
+ } as ProcessEvent);
485
+ return msg;
486
+ }
487
+ }
488
+
369
489
  // ==========================================================================
370
490
  // Lazy Sync
371
491
  // ==========================================================================
@@ -456,12 +576,21 @@ export class WorkspaceModule implements Module {
456
576
  return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
457
577
  }
458
578
 
459
- const blobHash = store.storeBlob(Buffer.from(input.content, 'utf-8'), 'text/plain');
579
+ const buffer = Buffer.from(input.content, 'utf-8');
580
+ const blobHash = store.storeBlob(buffer, 'text/plain');
460
581
  store.treeSet(mount.treeStateId, relativePath, {
461
582
  blobHash,
462
583
  size: Buffer.byteLength(input.content),
463
584
  mode: 0o644,
464
585
  });
586
+ const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
587
+ if (materializeError) {
588
+ return {
589
+ success: false,
590
+ 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.`,
591
+ isError: true,
592
+ };
593
+ }
465
594
 
466
595
  return {
467
596
  success: true,
@@ -513,12 +642,21 @@ export class WorkspaceModule implements Module {
513
642
  ? content.replaceAll(input.oldString, input.newString)
514
643
  : content.replace(input.oldString, input.newString);
515
644
 
516
- const newBlobHash = store.storeBlob(Buffer.from(content, 'utf-8'), 'text/plain');
645
+ const newBuffer = Buffer.from(content, 'utf-8');
646
+ const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
517
647
  store.treeSet(mount.treeStateId, relativePath, {
518
648
  blobHash: newBlobHash,
519
649
  size: Buffer.byteLength(content),
520
650
  mode: entry.mode,
521
651
  });
652
+ const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
653
+ if (materializeError) {
654
+ return {
655
+ success: false,
656
+ error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
657
+ isError: true,
658
+ };
659
+ }
522
660
 
523
661
  return {
524
662
  success: true,
@@ -542,6 +680,14 @@ export class WorkspaceModule implements Module {
542
680
  }
543
681
 
544
682
  store.treeRemove(mount.treeStateId, relativePath);
683
+ const materializeError = await this.autoMaterialize(mount, relativePath, 'delete');
684
+ if (materializeError) {
685
+ return {
686
+ success: false,
687
+ error: `Removed from Chronicle but failed to unlink "${input.path}" from disk: ${materializeError}. Downstream agents will still see the stale file until manual cleanup.`,
688
+ isError: true,
689
+ };
690
+ }
545
691
 
546
692
  return { success: true, data: { path: input.path, deleted: true } };
547
693
  }
@@ -869,20 +1015,12 @@ export class WorkspaceModule implements Module {
869
1015
  mount.initialSyncDone = true;
870
1016
 
871
1017
  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
- }
1018
+ allResults.push({
1019
+ mount: name,
1020
+ synced: result.synced.map(s => s.path),
1021
+ conflicts: result.conflicts,
1022
+ });
1023
+ this.emitFsEvents(name, result.synced, result.conflicts);
886
1024
  }
887
1025
  }
888
1026
 
@@ -902,23 +1040,76 @@ export class WorkspaceModule implements Module {
902
1040
 
903
1041
  /**
904
1042
  * Handle filesystem changes detected by watcher (watch: 'always' mode).
1043
+ *
1044
+ * The watcher carries the op per path. For created/modified we call
1045
+ * syncFromFs to pull content into the tree; for deleted we strip the tree
1046
+ * entry directly (the file is gone, nothing to read). We emit one event per
1047
+ * op type.
905
1048
  */
906
- private async handleFsChanges(mountName: string, paths: string[]): Promise<void> {
1049
+ private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
907
1050
  const store = this.store;
908
1051
  const mount = this.mounts.get(mountName);
909
1052
  if (!store || !mount) return;
910
1053
 
911
- const result = await syncFromFs(store, mount, paths);
1054
+ const touchedPaths = changes.filter(c => c.op !== 'deleted').map(c => c.path);
1055
+ const deletedPaths = changes.filter(c => c.op === 'deleted').map(c => c.path);
1056
+
1057
+ const syncResult = touchedPaths.length > 0
1058
+ ? await syncFromFs(store, mount, touchedPaths)
1059
+ : { synced: [], conflicts: [], skipped: [] };
1060
+
1061
+ // Strip deleted entries from the tree so downstream consumers see a
1062
+ // coherent state. syncFromFs would also do this if we passed the paths in,
1063
+ // but the op from the watcher is authoritative — skip the access() probe.
1064
+ for (const p of deletedPaths) {
1065
+ const existing = store.treeGet(mount.treeStateId, p);
1066
+ if (existing) {
1067
+ store.treeRemove(mount.treeStateId, p);
1068
+ syncResult.synced.push({ path: p, op: 'deleted' });
1069
+ }
1070
+ }
912
1071
 
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}`),
1072
+ this.emitFsEvents(mountName, syncResult.synced, syncResult.conflicts);
1073
+ }
1074
+
1075
+ /**
1076
+ * Group synced paths by op and push one ProcessEvent per non-empty op.
1077
+ */
1078
+ private emitFsEvents(
1079
+ mountName: string,
1080
+ synced: Array<{ path: string; op: WorkspaceFsOp }>,
1081
+ conflicts: ConflictInfo[],
1082
+ ): void {
1083
+ if (!this.ctx || synced.length === 0) return;
1084
+
1085
+ const byOp = new Map<WorkspaceFsOp, string[]>();
1086
+ for (const { path, op } of synced) {
1087
+ const list = byOp.get(op) ?? [];
1088
+ list.push(`${mountName}/${path}`);
1089
+ byOp.set(op, list);
1090
+ }
1091
+
1092
+ // Conflicts by definition require a prior tree entry (sync detected that
1093
+ // the agent-side version diverged from the filesystem baseline), which
1094
+ // means the path is being re-synced as a modification. Attach conflicts
1095
+ // only to the workspace:modified event, intersected with its paths — a
1096
+ // created/deleted batch can't carry a meaningful conflict.
1097
+ const conflictsByPath = new Map<string, ConflictInfo>();
1098
+ for (const c of conflicts) conflictsByPath.set(c.path, c);
1099
+
1100
+ for (const [op, paths] of byOp) {
1101
+ const type = opToEventType(op);
1102
+ let eventConflicts: string[] | undefined;
1103
+ if (op === 'modified' && conflictsByPath.size > 0) {
1104
+ const matched = paths.filter(p => conflictsByPath.has(p.slice(mountName.length + 1)));
1105
+ if (matched.length > 0) eventConflicts = matched;
1106
+ }
1107
+ const event = {
1108
+ type,
1109
+ paths,
917
1110
  mount: mountName,
918
- conflicts: result.conflicts.length > 0
919
- ? result.conflicts.map(c => `${mountName}/${c.path}`)
920
- : undefined,
921
- };
1111
+ ...(eventConflicts ? { conflicts: eventConflicts } : {}),
1112
+ } as WorkspaceCreatedEvent | WorkspaceModifiedEvent | WorkspaceDeletedEvent;
922
1113
  this.ctx.pushEvent(event as ProcessEvent);
923
1114
  }
924
1115
  }
@@ -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
  /**
@@ -175,14 +195,36 @@ export interface SyncInput {
175
195
  // Events
176
196
  // ============================================================================
177
197
 
178
- export interface WorkspaceChangedEvent {
179
- type: 'workspace:changed';
198
+ export type WorkspaceFsOp = 'created' | 'modified' | 'deleted';
199
+
200
+ /**
201
+ * Fired when files in a mount are created. `paths` are mount-prefixed
202
+ * (e.g. "tickets/2026-04-20-foo.md"). Conflicts are reported when Chronicle
203
+ * sync detected divergence between the filesystem and the tree state.
204
+ */
205
+ export interface WorkspaceCreatedEvent {
206
+ type: 'workspace:created';
180
207
  paths: string[];
181
208
  mount: string;
182
209
  conflicts?: string[];
183
210
  [key: string]: unknown;
184
211
  }
185
212
 
213
+ export interface WorkspaceModifiedEvent {
214
+ type: 'workspace:modified';
215
+ paths: string[];
216
+ mount: string;
217
+ conflicts?: string[];
218
+ [key: string]: unknown;
219
+ }
220
+
221
+ export interface WorkspaceDeletedEvent {
222
+ type: 'workspace:deleted';
223
+ paths: string[];
224
+ mount: string;
225
+ [key: string]: unknown;
226
+ }
227
+
186
228
  export interface WorkspaceMountedEvent {
187
229
  type: 'workspace:mounted';
188
230
  mount: string;
@@ -197,6 +239,20 @@ export interface WorkspaceUnmountedEvent {
197
239
  }
198
240
 
199
241
  export type WorkspaceEvent =
200
- | WorkspaceChangedEvent
242
+ | WorkspaceCreatedEvent
243
+ | WorkspaceModifiedEvent
244
+ | WorkspaceDeletedEvent
201
245
  | WorkspaceMountedEvent
202
246
  | WorkspaceUnmountedEvent;
247
+
248
+ export const WORKSPACE_FS_EVENT_TYPES = [
249
+ 'workspace:created',
250
+ 'workspace:modified',
251
+ 'workspace:deleted',
252
+ ] as const;
253
+
254
+ export type WorkspaceFsEventType = typeof WORKSPACE_FS_EVENT_TYPES[number];
255
+
256
+ export function opToEventType(op: WorkspaceFsOp): WorkspaceFsEventType {
257
+ return `workspace:${op}` as WorkspaceFsEventType;
258
+ }
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * Filesystem watcher with debounce, ignore patterns, and suppression.
3
+ *
4
+ * Events are carried through with their op type (created/modified/deleted) so
5
+ * downstream wake policies can key on creation vs. modification vs. deletion.
3
6
  */
4
7
 
5
8
  import { watch, type FSWatcher } from 'chokidar';
6
9
  import { type MountConfig } from './types.js';
7
10
 
11
+ export type FsOp = 'created' | 'modified' | 'deleted';
12
+
13
+ export interface FsChange {
14
+ path: string;
15
+ op: FsOp;
16
+ }
17
+
8
18
  export interface WatcherEvents {
9
- onChange(paths: string[]): void;
19
+ onChange(changes: FsChange[]): void;
10
20
  }
11
21
 
12
22
  /**
@@ -15,17 +25,19 @@ export interface WatcherEvents {
15
25
  */
16
26
  export class MountWatcher {
17
27
  private watcher: FSWatcher | null = null;
18
- private pendingChanges = new Set<string>();
28
+ // Most recent op seen per path within the current debounce window.
29
+ // create -> modify collapses to 'created' (single batch); delete always wins.
30
+ private pendingChanges = new Map<string, FsOp>();
19
31
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
20
32
  private suppressedPaths = new Set<string>();
21
33
  private suppressionTimers = new Map<string, ReturnType<typeof setTimeout>>();
22
34
  private readonly debounceMs: number;
23
35
  private readonly config: MountConfig;
24
- private readonly onChangeCallback: (paths: string[]) => void;
36
+ private readonly onChangeCallback: (changes: FsChange[]) => void;
25
37
 
26
38
  constructor(
27
39
  config: MountConfig,
28
- onChange: (paths: string[]) => void,
40
+ onChange: (changes: FsChange[]) => void,
29
41
  ) {
30
42
  this.config = config;
31
43
  this.debounceMs = config.watchDebounceMs ?? 300;
@@ -51,21 +63,22 @@ export class MountWatcher {
51
63
  },
52
64
  });
53
65
 
54
- const handleEvent = (filePath: string) => {
55
- // Convert absolute path to mount-relative
66
+ const handleEvent = (op: FsOp) => (filePath: string) => {
56
67
  const relative = this.toRelative(filePath);
57
68
  if (!relative) return;
58
69
 
59
- // Skip if this path is suppressed (we just wrote it)
70
+ // Skip if this path is suppressed (we just wrote it).
71
+ // Suppression covers all ops — a materialize-then-delete by the module
72
+ // should not echo either the add or the unlink.
60
73
  if (this.suppressedPaths.has(relative)) return;
61
74
 
62
- this.pendingChanges.add(relative);
75
+ this.mergeOp(relative, op);
63
76
  this.scheduleFire();
64
77
  };
65
78
 
66
- this.watcher.on('add', handleEvent);
67
- this.watcher.on('change', handleEvent);
68
- this.watcher.on('unlink', handleEvent);
79
+ this.watcher.on('add', handleEvent('created'));
80
+ this.watcher.on('change', handleEvent('modified'));
81
+ this.watcher.on('unlink', handleEvent('deleted'));
69
82
  }
70
83
 
71
84
  /**
@@ -92,12 +105,10 @@ export class MountWatcher {
92
105
  /**
93
106
  * Suppress watcher events for a path temporarily.
94
107
  * Used after materializing to avoid echo events.
95
- * After the cooldown, the path is re-checked via the recheckCallback.
96
108
  */
97
109
  suppress(relativePath: string, cooldownMs = 500): void {
98
110
  this.suppressedPaths.add(relativePath);
99
111
 
100
- // Clear existing timer for this path
101
112
  const existing = this.suppressionTimers.get(relativePath);
102
113
  if (existing) clearTimeout(existing);
103
114
 
@@ -116,6 +127,44 @@ export class MountWatcher {
116
127
  return this.suppressedPaths.has(relativePath);
117
128
  }
118
129
 
130
+ /**
131
+ * Merge a new op for a path into the pending batch. Chokidar can fire
132
+ * multiple events per path inside one debounce window; collapse them to the
133
+ * single op that reflects the net effect at end-of-window.
134
+ *
135
+ * Transitions (prev → op → result):
136
+ * ∅ → * → op
137
+ * * → deleted → deleted (trailing delete wins)
138
+ * deleted → created → modified (atomic save: unlink+rename;
139
+ * file exists with new contents)
140
+ * created → modified → created (still net-new this window)
141
+ * created → created → created
142
+ * modified → created → created (shouldn't happen, but keep
143
+ * created to avoid a false
144
+ * delete->recreate signal)
145
+ * modified → modified → modified
146
+ */
147
+ private mergeOp(path: string, op: FsOp): void {
148
+ const prev = this.pendingChanges.get(path);
149
+ if (!prev) {
150
+ this.pendingChanges.set(path, op);
151
+ return;
152
+ }
153
+ if (op === 'deleted') {
154
+ this.pendingChanges.set(path, 'deleted');
155
+ return;
156
+ }
157
+ if (prev === 'deleted' && op === 'created') {
158
+ this.pendingChanges.set(path, 'modified');
159
+ return;
160
+ }
161
+ if (prev === 'created') {
162
+ this.pendingChanges.set(path, 'created');
163
+ return;
164
+ }
165
+ this.pendingChanges.set(path, op);
166
+ }
167
+
119
168
  private scheduleFire(): void {
120
169
  if (this.debounceTimer) {
121
170
  clearTimeout(this.debounceTimer);
@@ -123,9 +172,9 @@ export class MountWatcher {
123
172
  this.debounceTimer = setTimeout(() => {
124
173
  this.debounceTimer = null;
125
174
  if (this.pendingChanges.size > 0) {
126
- const paths = [...this.pendingChanges];
175
+ const changes: FsChange[] = [...this.pendingChanges].map(([path, op]) => ({ path, op }));
127
176
  this.pendingChanges.clear();
128
- this.onChangeCallback(paths);
177
+ this.onChangeCallback(changes);
129
178
  }
130
179
  }, this.debounceMs);
131
180
  }
@@ -1,5 +1,6 @@
1
1
  import type { ProcessEvent } from './events.js';
2
2
  import type { ModuleProcessResponse } from './framework.js';
3
+ import type { SessionUsage } from '../usage/types.js';
3
4
 
4
5
  /**
5
6
  * Base for all trace events.
@@ -75,7 +76,7 @@ export type TraceEvent =
75
76
  | (TraceEventBase & {
76
77
  type: 'inference:usage';
77
78
  agentName: string;
78
- tokenUsage: { input: number; output: number };
79
+ tokenUsage: { input: number; output: number; cacheCreation?: number; cacheRead?: number };
79
80
  })
80
81
  | (TraceEventBase & {
81
82
  type: 'inference:stream_resumed';
@@ -174,6 +175,23 @@ export type TraceEvent =
174
175
  configPath: string;
175
176
  policyCount: number;
176
177
  })
178
+ | (TraceEventBase & {
179
+ type: 'gate:decision';
180
+ eventType: string;
181
+ serverId?: string;
182
+ channelId?: string;
183
+ matchedPolicy: string | null;
184
+ trigger: boolean;
185
+ behavior: string;
186
+ })
187
+
188
+ // Usage lifecycle
189
+ | (TraceEventBase & {
190
+ type: 'usage:updated';
191
+ totals: SessionUsage;
192
+ agentName: string;
193
+ inferenceCount: number;
194
+ })
177
195
 
178
196
  // Undo/redo lifecycle
179
197
  | (TraceEventBase & {
@@ -0,0 +1,2 @@
1
+ export { UsageTracker } from './usage-tracker.js';
2
+ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './types.js';