@animalabs/agent-framework 0.5.7 → 0.6.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 (116) hide show
  1. package/dist/src/framework.d.ts +67 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +469 -26
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +44 -1
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +152 -8
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  10. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  11. package/dist/src/gate/gate-script-worker.js +69 -0
  12. package/dist/src/gate/gate-script-worker.js.map +1 -0
  13. package/dist/src/gate/gate-script.d.ts +44 -0
  14. package/dist/src/gate/gate-script.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script.js +164 -0
  16. package/dist/src/gate/gate-script.js.map +1 -0
  17. package/dist/src/gate/types.d.ts +18 -3
  18. package/dist/src/gate/types.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +43 -1
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +82 -8
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  24. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  25. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  26. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  27. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  28. package/dist/src/mcpl/push-handler.js +1 -0
  29. package/dist/src/mcpl/push-handler.js.map +1 -1
  30. package/dist/src/mcpl/server-connection.d.ts +35 -30
  31. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  32. package/dist/src/mcpl/server-connection.js +145 -206
  33. package/dist/src/mcpl/server-connection.js.map +1 -1
  34. package/dist/src/mcpl/transport.d.ts +98 -0
  35. package/dist/src/mcpl/transport.d.ts.map +1 -0
  36. package/dist/src/mcpl/transport.js +281 -0
  37. package/dist/src/mcpl/transport.js.map +1 -0
  38. package/dist/src/mcpl/types.d.ts +42 -2
  39. package/dist/src/mcpl/types.d.ts.map +1 -1
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/module-registry.d.ts +2 -0
  42. package/dist/src/module-registry.d.ts.map +1 -1
  43. package/dist/src/module-registry.js +9 -2
  44. package/dist/src/module-registry.js.map +1 -1
  45. package/dist/src/prose-segments.d.ts +18 -0
  46. package/dist/src/prose-segments.d.ts.map +1 -0
  47. package/dist/src/prose-segments.js +39 -0
  48. package/dist/src/prose-segments.js.map +1 -0
  49. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  50. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  51. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  52. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  53. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  54. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  55. package/dist/src/runtime/liveness-watchdog.js +106 -0
  56. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  57. package/dist/src/types/agent.d.ts +10 -0
  58. package/dist/src/types/agent.d.ts.map +1 -1
  59. package/dist/src/types/framework.d.ts +14 -0
  60. package/dist/src/types/framework.d.ts.map +1 -1
  61. package/dist/src/types/module.d.ts +12 -0
  62. package/dist/src/types/module.d.ts.map +1 -1
  63. package/dist/test/channel-registry-routing.test.js +108 -1
  64. package/dist/test/channel-registry-routing.test.js.map +1 -1
  65. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  66. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  67. package/dist/test/checkpoint-attribution.test.js +40 -0
  68. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  69. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  70. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  71. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  72. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  73. package/dist/test/event-gate-script.test.d.ts +2 -0
  74. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  75. package/dist/test/event-gate-script.test.js +95 -0
  76. package/dist/test/event-gate-script.test.js.map +1 -0
  77. package/dist/test/event-gate-tags.test.d.ts +2 -0
  78. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  79. package/dist/test/event-gate-tags.test.js +76 -0
  80. package/dist/test/event-gate-tags.test.js.map +1 -0
  81. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  82. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  83. package/dist/test/liveness-watchdog.test.js +78 -0
  84. package/dist/test/liveness-watchdog.test.js.map +1 -0
  85. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  86. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  87. package/dist/test/mcpl-ws-transport.test.js +155 -0
  88. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  89. package/dist/test/prose-segments.test.d.ts +2 -0
  90. package/dist/test/prose-segments.test.d.ts.map +1 -0
  91. package/dist/test/prose-segments.test.js +52 -0
  92. package/dist/test/prose-segments.test.js.map +1 -0
  93. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  94. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  95. package/dist/test/trunk-channel-routing.test.js +175 -0
  96. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/framework.ts +509 -24
  100. package/src/gate/event-gate.ts +153 -8
  101. package/src/gate/gate-script-worker.ts +73 -0
  102. package/src/gate/gate-script.ts +180 -0
  103. package/src/gate/types.ts +19 -3
  104. package/src/mcpl/channel-registry.ts +112 -8
  105. package/src/mcpl/checkpoint-manager.ts +16 -0
  106. package/src/mcpl/push-handler.ts +1 -0
  107. package/src/mcpl/server-connection.ts +153 -224
  108. package/src/mcpl/transport.ts +307 -0
  109. package/src/mcpl/types.ts +49 -2
  110. package/src/module-registry.ts +13 -2
  111. package/src/prose-segments.ts +42 -0
  112. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  113. package/src/runtime/liveness-watchdog.ts +122 -0
  114. package/src/types/agent.ts +10 -0
  115. package/src/types/framework.ts +15 -0
  116. package/src/types/module.ts +13 -0
@@ -12,7 +12,8 @@
12
12
  */
13
13
 
14
14
  import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
15
- import { dirname } from 'node:path';
15
+ import { dirname, join } from 'node:path';
16
+ import { GateScript } from './gate-script.js';
16
17
 
17
18
  import type { ToolDefinition } from '../types/events.js';
18
19
  import type {
@@ -89,6 +90,9 @@ interface CompiledPolicy {
89
90
  channelRegex?: RegExp;
90
91
  mountRegex?: RegExp;
91
92
  pathRegex?: RegExp;
93
+ tagsAnyRe?: RegExp[];
94
+ tagsAllRe?: RegExp[];
95
+ tagsNoneRe?: RegExp[];
92
96
  }
93
97
 
94
98
  interface PolicyStats {
@@ -170,8 +174,8 @@ function validateConfig(raw: unknown): GateConfig {
170
174
  const obj = raw as Record<string, unknown>;
171
175
 
172
176
  const defaultBehavior = obj.default ?? 'always';
173
- if (defaultBehavior !== 'always' && defaultBehavior !== 'skip') {
174
- throw new Error(`gate.json "default" must be "always" or "skip", got: ${defaultBehavior}`);
177
+ if (defaultBehavior !== 'always' && defaultBehavior !== 'defer' && defaultBehavior !== 'skip') {
178
+ throw new Error(`gate.json "default" must be "always", "defer", or "skip", got: ${defaultBehavior}`);
175
179
  }
176
180
 
177
181
  const policies: GatePolicy[] = [];
@@ -197,7 +201,7 @@ function validatePolicy(raw: unknown): GatePolicy {
197
201
 
198
202
  // Validate behavior
199
203
  let behavior: GateBehavior;
200
- if (obj.behavior === 'always' || obj.behavior === 'skip') {
204
+ if (obj.behavior === 'always' || obj.behavior === 'defer' || obj.behavior === 'skip') {
201
205
  behavior = obj.behavior;
202
206
  } else if (obj.behavior && typeof obj.behavior === 'object') {
203
207
  const b = obj.behavior as Record<string, unknown>;
@@ -247,7 +251,7 @@ function validatePolicy(raw: unknown): GatePolicy {
247
251
  }
248
252
  behavior = { passive_sample: out };
249
253
  } else {
250
- throw new Error(`Policy "${obj.name}": unknown behavior — expected always | skip | { debounce } | { rate_limit } | { passive_sample }`);
254
+ throw new Error(`Policy "${obj.name}": unknown behavior — expected always | defer | skip | { debounce } | { rate_limit } | { passive_sample }`);
251
255
  }
252
256
  } else {
253
257
  behavior = 'always';
@@ -266,6 +270,14 @@ function validatePolicy(raw: unknown): GatePolicy {
266
270
  const fields = m.metadataTrue.filter((s): s is string => typeof s === 'string' && s.length > 0);
267
271
  if (fields.length > 0) match.metadataTrue = fields;
268
272
  }
273
+ const tagList = (v: unknown): string[] | undefined => {
274
+ if (!Array.isArray(v)) return undefined;
275
+ const out = v.filter((s): s is string => typeof s === 'string' && s.length > 0);
276
+ return out.length > 0 ? out : undefined;
277
+ };
278
+ const tagsAny = tagList(m.tagsAny); if (tagsAny) match.tagsAny = tagsAny;
279
+ const tagsAll = tagList(m.tagsAll); if (tagsAll) match.tagsAll = tagsAll;
280
+ const tagsNone = tagList(m.tagsNone); if (tagsNone) match.tagsNone = tagsNone;
269
281
  if (m.filter && typeof m.filter === 'object') {
270
282
  const f = m.filter as Record<string, unknown>;
271
283
  if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
@@ -299,6 +311,8 @@ function validatePolicy(raw: unknown): GatePolicy {
299
311
  export class EventGate {
300
312
  private configPath: string;
301
313
  private config: GateConfig;
314
+ /** Optional agent-authored programmable gate (gate.js). */
315
+ private gateScript: GateScript;
302
316
  private compiledPolicies: CompiledPolicy[] = [];
303
317
  private configMtime: number = 0;
304
318
  private lastReloadCheck: number = 0;
@@ -363,6 +377,8 @@ export class EventGate {
363
377
  getAgentNames: () => string[];
364
378
  /** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
365
379
  now?: () => number;
380
+ /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
381
+ scriptTimeoutMs?: number;
366
382
  }) {
367
383
  this.configPath = opts.configPath;
368
384
  this.privilegedUsersPath = opts.privilegedUsersPath;
@@ -417,6 +433,14 @@ export class EventGate {
417
433
  // Load config
418
434
  this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG };
419
435
  this.compiledPolicies = this.compilePolicies(this.config);
436
+
437
+ // Programmable gate: an optional agent-authored `gate.js` next to gate.json.
438
+ // Absent ⇒ inactive (zero overhead beyond a throttled stat check).
439
+ this.gateScript = new GateScript(
440
+ join(dirname(this.configPath), 'gate.js'),
441
+ opts.scriptTimeoutMs ?? 50,
442
+ this.now,
443
+ );
420
444
  }
421
445
 
422
446
  // =========================================================================
@@ -466,6 +490,9 @@ export class EventGate {
466
490
  if (policy.match.pathGlob) {
467
491
  compiled.pathRegex = compileGlob(policy.match.pathGlob);
468
492
  }
493
+ if (policy.match.tagsAny) compiled.tagsAnyRe = policy.match.tagsAny.map(compileGlob);
494
+ if (policy.match.tagsAll) compiled.tagsAllRe = policy.match.tagsAll.map(compileGlob);
495
+ if (policy.match.tagsNone) compiled.tagsNoneRe = policy.match.tagsNone.map(compileGlob);
469
496
  return compiled;
470
497
  });
471
498
  }
@@ -534,6 +561,105 @@ export class EventGate {
534
561
  }
535
562
  }
536
563
 
564
+ // =========================================================================
565
+ // Runtime policy mutation (backs wake_add_rule / wake_remove_rule)
566
+ // =========================================================================
567
+
568
+ /**
569
+ * Add or replace (upsert) a single policy in gate.json and apply it live.
570
+ *
571
+ * The raw policy is validated with the SAME `validatePolicy` used when loading
572
+ * gate.json — an invalid shape throws (nothing is written) and the caller
573
+ * surfaces it as a tool error. On success the policy is persisted to gate.json
574
+ * (so it survives restart and is visible to `workspace--edit _config/gate.json`)
575
+ * and the in-memory config is swapped in immediately, so the rule takes effect
576
+ * without waiting for the throttled mtime reload.
577
+ *
578
+ * The freshest on-disk config is re-read first, so a concurrent operator edit
579
+ * isn't clobbered. A policy with the same `name` is replaced IN PLACE (order
580
+ * preserved); otherwise it is appended, or prepended when `position:'prepend'`
581
+ * — first match wins, so a wake rule that must beat a broad defer/debounce
582
+ * belongs at the front.
583
+ */
584
+ addPolicy(rawPolicy: unknown, options?: { position?: 'append' | 'prepend' }): GatePolicy {
585
+ const policy = validatePolicy(rawPolicy);
586
+ const current = this.loadConfig() ?? this.config;
587
+ const policies = [...current.policies];
588
+ const idx = policies.findIndex((p) => p.name === policy.name);
589
+ if (idx >= 0) {
590
+ policies[idx] = policy;
591
+ } else if (options?.position === 'prepend') {
592
+ policies.unshift(policy);
593
+ } else {
594
+ policies.push(policy);
595
+ }
596
+ this.writeConfig({ ...current, policies });
597
+ this.emitTrace({
598
+ type: 'gate:policy-added',
599
+ configPath: this.configPath,
600
+ policyName: policy.name,
601
+ replaced: idx >= 0,
602
+ timestamp: Date.now(),
603
+ });
604
+ return policy;
605
+ }
606
+
607
+ /**
608
+ * Remove a policy by name from gate.json and clear its live state. Any pending
609
+ * debounce batch is delivered first (matching reloadIfChanged's removal path,
610
+ * so buffered events aren't silently dropped). Returns false if no policy of
611
+ * that name exists (nothing written). Freshest on-disk config is re-read first
612
+ * (see addPolicy).
613
+ */
614
+ removePolicy(name: string): boolean {
615
+ const current = this.loadConfig() ?? this.config;
616
+ if (!current.policies.some((p) => p.name === name)) {
617
+ return false;
618
+ }
619
+ // Deliver any pending debounce batch, then drop timers/buckets/counters.
620
+ this.clearPolicyState(name, { deliverPendingDebounce: true });
621
+ this.stats.delete(name);
622
+ const policies = current.policies.filter((p) => p.name !== name);
623
+ this.writeConfig({ ...current, policies });
624
+ this.emitTrace({
625
+ type: 'gate:policy-removed',
626
+ configPath: this.configPath,
627
+ policyName: name,
628
+ timestamp: Date.now(),
629
+ });
630
+ return true;
631
+ }
632
+
633
+ /**
634
+ * List the current policy names (freshest on-disk view). Cheap helper for
635
+ * tools/UX that want to show what's active without the full status payload.
636
+ */
637
+ listPolicyNames(): string[] {
638
+ const current = this.loadConfig() ?? this.config;
639
+ return current.policies.map((p) => p.name);
640
+ }
641
+
642
+ /**
643
+ * Persist a config to gate.json and apply it in memory together: write the
644
+ * file, swap in the compiled policies, and sync `configMtime` to the new file
645
+ * so `reloadIfChanged` doesn't redundantly re-parse identical content. Throws
646
+ * if the write fails (disk/permission) — callers surface it as a tool error.
647
+ */
648
+ private writeConfig(config: GateConfig): void {
649
+ mkdirSync(dirname(this.configPath), { recursive: true });
650
+ writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
651
+ this.config = config;
652
+ this.compiledPolicies = this.compilePolicies(config);
653
+ this.configSource = 'file';
654
+ this.configErrors = [];
655
+ this.lastReloadTimestamp = Date.now();
656
+ try {
657
+ this.configMtime = statSync(this.configPath).mtimeMs;
658
+ } catch {
659
+ // A failed stat only risks one redundant reload; keep the old mtime.
660
+ }
661
+ }
662
+
537
663
  // =========================================================================
538
664
  // Policy matching — first match wins
539
665
  // =========================================================================
@@ -604,6 +730,16 @@ export class EventGate {
604
730
  if (!anyTrue) return false;
605
731
  }
606
732
 
733
+ // Tag matching (MCPL RFC-001). Patterns are globs; `tags` is the event's
734
+ // (implication-expanded) tag set. A pattern "matches" if it matches ANY tag.
735
+ if (compiled.tagsAnyRe || compiled.tagsAllRe || compiled.tagsNoneRe) {
736
+ const tags = info.tags ?? [];
737
+ const patternHits = (re: RegExp): boolean => tags.some(t => re.test(t));
738
+ if (compiled.tagsAnyRe && !compiled.tagsAnyRe.some(patternHits)) return false;
739
+ if (compiled.tagsAllRe && !compiled.tagsAllRe.every(patternHits)) return false;
740
+ if (compiled.tagsNoneRe && compiled.tagsNoneRe.some(patternHits)) return false;
741
+ }
742
+
607
743
  return true;
608
744
  }
609
745
 
@@ -643,7 +779,12 @@ export class EventGate {
643
779
  // Privileged author — fall through to normal policy evaluation.
644
780
  }
645
781
 
646
- const policy = this.matchPolicy(info);
782
+ // Programmable gate (gate.js) takes precedence when present and returns a
783
+ // behavior; null/undefined (inactive, error, or a deliberate pass) falls
784
+ // through to the declarative gate.json policies below.
785
+ const scripted = this.gateScript.evaluate(info);
786
+ const policy: GatePolicy | null =
787
+ scripted != null ? { name: 'gate.js', match: {}, behavior: scripted } : this.matchPolicy(info);
647
788
  const decision = this.computeDecision(policy, info);
648
789
 
649
790
  this.totalEvaluations++;
@@ -696,8 +837,9 @@ export class EventGate {
696
837
  timestamp: this.now(),
697
838
  });
698
839
 
699
- if (policy.behavior === 'skip') {
700
- return { trigger: false, policyName: policy.name, behavior: 'skip' };
840
+ if (policy.behavior === 'defer' || policy.behavior === 'skip') {
841
+ // Report the configured name faithfully ('defer' or legacy 'skip').
842
+ return { trigger: false, policyName: policy.name, behavior: policy.behavior };
701
843
  }
702
844
 
703
845
  if (policy.behavior === 'always') {
@@ -854,6 +996,7 @@ export class EventGate {
854
996
  serverId: (metadata.serverId as string) ?? '',
855
997
  channelId: (metadata.channelId as string) ?? '',
856
998
  metadata,
999
+ tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : undefined,
857
1000
  });
858
1001
  return decision.trigger;
859
1002
  };
@@ -1124,6 +1267,7 @@ export class EventGate {
1124
1267
  configSource: this.configSource,
1125
1268
  lastReloadTimestamp: this.lastReloadTimestamp,
1126
1269
  default: this.config.default ?? 'always',
1270
+ script: this.gateScript.status(),
1127
1271
  policies,
1128
1272
  errors: [...this.configErrors],
1129
1273
  totalEvaluations: this.totalEvaluations,
@@ -1140,6 +1284,7 @@ export class EventGate {
1140
1284
  // =========================================================================
1141
1285
 
1142
1286
  dispose(): void {
1287
+ this.gateScript.dispose();
1143
1288
  for (const state of this.debounceTimers.values()) {
1144
1289
  clearTimeout(state.timer);
1145
1290
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Worker that runs the agent-authored `gate.js` decision function.
3
+ *
4
+ * The agent is trusted (it already has shell + filesystem), so this is NOT a
5
+ * security sandbox — it's a *robustness* boundary: running the script on a
6
+ * separate thread lets the main thread time it out (Atomics.wait) so an
7
+ * accidental infinite loop degrades to "fall through to declarative policies"
8
+ * instead of freezing the agent's own event loop.
9
+ *
10
+ * Protocol:
11
+ * - on startup we import `gate.js` and post `{ type: 'ready', error? }`.
12
+ * - per event the parent posts `{ type: 'eval', event }`; we run the script
13
+ * and write the JSON result into the shared buffer, flip the control word,
14
+ * and notify. The parent is blocked in Atomics.wait until then (or times out).
15
+ *
16
+ * `gate.js` shape (ESM): `export default (event) => behavior | null` where
17
+ * behavior is a GateBehavior ('always' | 'defer' | { debounce } | …) and null
18
+ * means "let the declarative gate.json policies decide". Sync or async.
19
+ */
20
+ import { parentPort, workerData } from 'node:worker_threads';
21
+ import { pathToFileURL } from 'node:url';
22
+
23
+ interface WorkerData {
24
+ scriptPath: string;
25
+ sab: SharedArrayBuffer;
26
+ }
27
+ const { scriptPath, sab } = workerData as WorkerData;
28
+ const ctrl = new Int32Array(sab, 0, 2); // [0]=status (0 pending, 1 done), [1]=result byte length
29
+ const data = new Uint8Array(sab, 8);
30
+ const enc = new TextEncoder();
31
+
32
+ type DecideFn = (event: unknown) => unknown;
33
+ let fn: DecideFn | null = null;
34
+
35
+ // Load eagerly and announce readiness, so the parent doesn't time out (and
36
+ // pointlessly respawn) while a cold dynamic import is still in flight.
37
+ void (async () => {
38
+ let error: string | null = null;
39
+ try {
40
+ const mod = (await import(pathToFileURL(scriptPath).href)) as Record<string, unknown>;
41
+ const f = (mod.default ?? mod.decide ?? mod.gate) as unknown;
42
+ if (typeof f !== 'function') {
43
+ error = 'gate.js must export a default function: (event) => behavior | null';
44
+ } else {
45
+ fn = f as DecideFn;
46
+ }
47
+ } catch (e) {
48
+ error = (e as Error)?.message ?? String(e);
49
+ }
50
+ parentPort?.postMessage({ type: 'ready', error });
51
+ })();
52
+
53
+ function writeResult(obj: unknown): void {
54
+ const bytes = enc.encode(JSON.stringify(obj));
55
+ const n = Math.min(bytes.length, data.length);
56
+ data.set(bytes.subarray(0, n));
57
+ Atomics.store(ctrl, 1, n);
58
+ Atomics.store(ctrl, 0, 1);
59
+ Atomics.notify(ctrl, 0);
60
+ }
61
+
62
+ parentPort?.on('message', (msg: { type?: string; event?: unknown }) => {
63
+ if (msg?.type !== 'eval') return;
64
+ void (async () => {
65
+ if (!fn) return writeResult({ behavior: null }); // load failed → fall through
66
+ try {
67
+ const r = await fn(msg.event); // sync OR async decision functions
68
+ writeResult({ behavior: r ?? null });
69
+ } catch (e) {
70
+ writeResult({ error: (e as Error)?.message ?? String(e) });
71
+ }
72
+ })();
73
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * GateScript — runs an agent-authored `gate.js` decision function on a worker
3
+ * thread, synchronously from the gate's perspective, with a hard timeout.
4
+ *
5
+ * The agent is trusted (shell + fs already), so this is a robustness seatbelt,
6
+ * not a security sandbox: a script that throws falls through to the declarative
7
+ * policies (and the error is surfaced via status()); a script that hangs is
8
+ * timed out (Atomics.wait), the wedged worker is terminated and respawned, and
9
+ * the event falls through — so a bad rule can't brick the agent's own attention.
10
+ *
11
+ * Sync model: the main thread posts the event then blocks in Atomics.wait until
12
+ * the worker writes the result into shared memory (or the timeout elapses).
13
+ * Node permits Atomics.wait on the main thread, so evaluate() stays synchronous
14
+ * and the rest of the EventGate is unchanged.
15
+ */
16
+ import { Worker } from 'node:worker_threads';
17
+ import { existsSync, statSync } from 'node:fs';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ import type { GateBehavior, GateEventInfo } from './types.js';
21
+
22
+ const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'gate-script-worker.js');
23
+ const SAB_SIZE = 16_384;
24
+ const RELOAD_THROTTLE_MS = 1000;
25
+
26
+ export interface GateScriptStatus {
27
+ active: boolean;
28
+ ready: boolean;
29
+ path: string;
30
+ runs: number;
31
+ errors: number;
32
+ timeouts: number;
33
+ lastError: string | null;
34
+ }
35
+
36
+ interface ScriptResult {
37
+ behavior?: GateBehavior | null;
38
+ error?: string;
39
+ }
40
+
41
+ export class GateScript {
42
+ private worker?: Worker;
43
+ private readonly sab = new SharedArrayBuffer(SAB_SIZE);
44
+ private readonly ctrl = new Int32Array(this.sab, 0, 2);
45
+ private readonly data = new Uint8Array(this.sab, 8);
46
+ private readonly dec = new TextDecoder();
47
+ private mtime = 0;
48
+ private lastCheck = 0;
49
+ private active = false;
50
+ private ready = false;
51
+ private lastError: string | null = null;
52
+ private runs = 0;
53
+ private errors = 0;
54
+ private timeouts = 0;
55
+
56
+ constructor(
57
+ private readonly scriptPath: string,
58
+ private readonly timeoutMs = 50,
59
+ private readonly now: () => number = Date.now,
60
+ ) {
61
+ this.reloadIfChanged();
62
+ }
63
+
64
+ /** Hot-reload: (re)spawn the worker when gate.js appears or changes; stop it
65
+ * when the file is removed. Throttled like the gate's config reload. */
66
+ private reloadIfChanged(): void {
67
+ const t = this.now();
68
+ if (this.lastCheck !== 0 && t - this.lastCheck < RELOAD_THROTTLE_MS) return;
69
+ this.lastCheck = t;
70
+ if (!existsSync(this.scriptPath)) {
71
+ if (this.active) this.stop();
72
+ return;
73
+ }
74
+ let m = 0;
75
+ try {
76
+ m = statSync(this.scriptPath).mtimeMs;
77
+ } catch {
78
+ return;
79
+ }
80
+ if (this.active && m === this.mtime) return;
81
+ this.mtime = m;
82
+ this.spawn();
83
+ }
84
+
85
+ private spawn(): void {
86
+ this.stop();
87
+ try {
88
+ const w = new Worker(WORKER_PATH, {
89
+ workerData: { scriptPath: this.scriptPath, sab: this.sab },
90
+ });
91
+ w.on('message', (m: { type?: string; error?: string | null }) => {
92
+ if (m?.type === 'ready') {
93
+ this.ready = true; // load finished (with or without a load error)
94
+ if (m.error) this.lastError = m.error;
95
+ }
96
+ });
97
+ w.on('error', (e) => {
98
+ this.lastError = e.message;
99
+ });
100
+ w.unref(); // never keep the process alive on the gate script's account
101
+ this.worker = w;
102
+ this.active = true;
103
+ this.lastError = null;
104
+ } catch (e) {
105
+ this.lastError = (e as Error).message;
106
+ this.active = false;
107
+ }
108
+ }
109
+
110
+ private stop(): void {
111
+ if (this.worker) {
112
+ void this.worker.terminate().catch(() => {});
113
+ this.worker = undefined;
114
+ }
115
+ this.active = false;
116
+ this.ready = false;
117
+ }
118
+
119
+ /**
120
+ * Evaluate the script for one event.
121
+ * - returns a GateBehavior → the script decided
122
+ * - returns null → fall through to the declarative policies
123
+ * - returns undefined → script inactive / errored / timed out (fall through)
124
+ */
125
+ evaluate(event: GateEventInfo): GateBehavior | null | undefined {
126
+ this.reloadIfChanged();
127
+ // Inactive, or the worker hasn't finished importing gate.js yet → fall
128
+ // through (don't time-out/respawn during a cold start).
129
+ if (!this.active || !this.worker || !this.ready) return undefined;
130
+
131
+ Atomics.store(this.ctrl, 0, 0);
132
+ try {
133
+ this.worker.postMessage({ type: 'eval', event });
134
+ } catch (e) {
135
+ this.lastError = (e as Error).message;
136
+ return undefined;
137
+ }
138
+
139
+ const r = Atomics.wait(this.ctrl, 0, 0, this.timeoutMs);
140
+ this.runs++;
141
+ if (r === 'timed-out') {
142
+ this.timeouts++;
143
+ this.lastError = `gate.js timed out (>${this.timeoutMs}ms) — terminated and restarted`;
144
+ this.spawn(); // kill the wedged worker, fresh one for next time
145
+ return undefined;
146
+ }
147
+
148
+ const len = Atomics.load(this.ctrl, 1);
149
+ let parsed: ScriptResult;
150
+ try {
151
+ parsed = JSON.parse(this.dec.decode(this.data.subarray(0, len))) as ScriptResult;
152
+ } catch {
153
+ this.errors++;
154
+ this.lastError = 'gate.js produced an unreadable result';
155
+ return undefined;
156
+ }
157
+ if (parsed.error) {
158
+ this.errors++;
159
+ this.lastError = parsed.error;
160
+ return undefined;
161
+ }
162
+ return parsed.behavior ?? null;
163
+ }
164
+
165
+ status(): GateScriptStatus {
166
+ return {
167
+ active: this.active,
168
+ ready: this.ready,
169
+ path: this.scriptPath,
170
+ runs: this.runs,
171
+ errors: this.errors,
172
+ timeouts: this.timeouts,
173
+ lastError: this.lastError,
174
+ };
175
+ }
176
+
177
+ dispose(): void {
178
+ this.stop();
179
+ }
180
+ }
package/src/gate/types.ts CHANGED
@@ -52,7 +52,8 @@ export interface PassiveSampleConfig {
52
52
  /** How a matching policy affects inference triggering. */
53
53
  export type GateBehavior =
54
54
  | 'always' // Trigger inference immediately
55
- | 'skip' // Don't trigger inference (event still enters context)
55
+ | 'defer' // Don't trigger inference (event still enters context). Preferred name.
56
+ | 'skip' // Legacy alias for 'defer' (still accepted).
56
57
  | { debounce: number } // Batch events per-policy, deliver after delay (ms)
57
58
  | { rate_limit: RateLimitConfig } // Token bucket per `keyBy`; deny when empty
58
59
  | { passive_sample: PassiveSampleConfig }; // Fire every Nth match
@@ -88,6 +89,17 @@ export interface GatePolicyMatch {
88
89
  * matches if any of those is set. Empty/omitted = no metadata constraint.
89
90
  */
90
91
  metadataTrue?: string[];
92
+ /**
93
+ * Tag matching (MCPL RFC-001 event tags). Globs allowed (e.g. `robotics:*`).
94
+ * Evaluated against the event's (implication-expanded) tag set:
95
+ * tagsAny — match if ANY listed pattern matches an event tag
96
+ * tagsAll — match only if EVERY listed pattern matches some event tag
97
+ * tagsNone — match only if NO listed pattern matches any event tag
98
+ * Combined with the other fields by AND, like the rest of the match.
99
+ */
100
+ tagsAny?: string[];
101
+ tagsAll?: string[];
102
+ tagsNone?: string[];
91
103
  }
92
104
 
93
105
  // ---------------------------------------------------------------------------
@@ -116,7 +128,7 @@ export interface GatePolicy {
116
128
  export interface GateConfig {
117
129
  policies: GatePolicy[];
118
130
  /** Behavior when no policy matches. Default: 'always'. */
119
- default?: 'always' | 'skip';
131
+ default?: 'always' | 'defer' | 'skip';
120
132
  }
121
133
 
122
134
  // ---------------------------------------------------------------------------
@@ -163,6 +175,8 @@ export interface GateEventInfo {
163
175
  mount?: string;
164
176
  /** Mount-prefixed paths touched (for workspace:* events). */
165
177
  paths?: string[];
178
+ /** Event tags (MCPL RFC-001), already implication-expanded by the host. */
179
+ tags?: string[];
166
180
  }
167
181
 
168
182
  /** Per-policy runtime statistics (for gate:status). */
@@ -192,7 +206,9 @@ export interface GateStatus {
192
206
  configPath: string;
193
207
  configSource: 'file' | 'initial' | 'default';
194
208
  lastReloadTimestamp: number | null;
195
- default: 'always' | 'skip';
209
+ default: 'always' | 'defer' | 'skip';
210
+ /** Status of the optional programmable gate (gate.js). */
211
+ script: import('./gate-script.js').GateScriptStatus;
196
212
  policies: GatePolicyStats[];
197
213
  errors: string[];
198
214
  /** Total events the gate has evaluated since startup. */