@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
package/src/framework.ts CHANGED
@@ -52,6 +52,8 @@ import { ChannelRegistry } from './mcpl/channel-registry.js';
52
52
  import { safeSlice } from './safe-slice.js';
53
53
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
54
54
  import { EventGate } from './gate/event-gate.js';
55
+ import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
56
+ import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
55
57
  import type { McplServerConnection } from './mcpl/server-connection.js';
56
58
  import type {
57
59
  McplServerConfig,
@@ -120,6 +122,44 @@ class DefaultErrorPolicy implements ErrorPolicy {
120
122
  /** Default sync interval in milliseconds */
121
123
  const DEFAULT_SYNC_INTERVAL_MS = 1000;
122
124
 
125
+ /**
126
+ * Extract fields the EventGate cares about from a ProcessEvent. The set of
127
+ * event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
128
+ * (modules can define CustomEvents), so we read by name rather than match the
129
+ * discriminant. Centralizing this lets new gate-visible fields be added in one
130
+ * place instead of retyping the narrowing at every call site.
131
+ */
132
+ function extractGateFields(event: ProcessEvent): {
133
+ content: string;
134
+ metadata: Record<string, unknown>;
135
+ mount?: string;
136
+ paths?: string[];
137
+ } {
138
+ const rec = event as unknown as Record<string, unknown>;
139
+ const rawContent = rec.content;
140
+ let content = '';
141
+ if (typeof rawContent === 'string') {
142
+ content = rawContent;
143
+ } else if (Array.isArray(rawContent)) {
144
+ // ContentBlock[] — concatenate text blocks so gate content filters match
145
+ content = rawContent
146
+ .filter((b): b is { type: 'text'; text: string } =>
147
+ !!b && typeof b === 'object' && (b as { type?: unknown }).type === 'text'
148
+ && typeof (b as { text?: unknown }).text === 'string'
149
+ )
150
+ .map(b => b.text)
151
+ .join('\n');
152
+ }
153
+ const metadata = (rec.metadata && typeof rec.metadata === 'object')
154
+ ? rec.metadata as Record<string, unknown>
155
+ : {};
156
+ const mount = typeof rec.mount === 'string' ? rec.mount : undefined;
157
+ const paths = Array.isArray(rec.paths)
158
+ ? rec.paths.filter((p): p is string => typeof p === 'string')
159
+ : undefined;
160
+ return { content, metadata, mount, paths };
161
+ }
162
+
123
163
  /**
124
164
  * The main agent framework.
125
165
  */
@@ -173,6 +213,9 @@ export class AgentFramework {
173
213
  // EventGate (null when FrameworkConfig.gate is omitted)
174
214
  private eventGate: EventGate | null = null;
175
215
 
216
+ // Session-level token usage tracking (always-on)
217
+ private usageTracker: UsageTracker;
218
+
176
219
  private constructor(
177
220
  store: JsStore,
178
221
  ownsStore: boolean,
@@ -192,6 +235,9 @@ export class AgentFramework {
192
235
  this.processLoggingPersist = processLoggingPersist;
193
236
  this.processLoggingBroadcast = processLoggingBroadcast;
194
237
  this.queue = new ProcessQueueImpl();
238
+ this.usageTracker = new UsageTracker({
239
+ emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
240
+ });
195
241
 
196
242
  // Initialize module registry with callbacks
197
243
  this.moduleRegistry = new ModuleRegistry(store, this.queue, {
@@ -276,6 +322,9 @@ export class AgentFramework {
276
322
  processLoggingBroadcast
277
323
  );
278
324
 
325
+ // Restore persisted usage data (if any) from prior session
326
+ framework.restoreUsageState();
327
+
279
328
  // Create agents
280
329
  for (const agentConfig of config.agents) {
281
330
  await framework.createAgent(agentConfig);
@@ -530,6 +579,36 @@ export class AgentFramework {
530
579
  return this.membrane;
531
580
  }
532
581
 
582
+ getSessionUsage(): SessionUsageSnapshot {
583
+ return this.usageTracker.getSnapshot();
584
+ }
585
+
586
+ private restoreUsageState(): void {
587
+ try {
588
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
589
+ if (data && typeof data === 'object' && (data as any).usage) {
590
+ const restored = (data as any).usage as PersistedUsageState;
591
+ this.usageTracker = new UsageTracker({
592
+ emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
593
+ restored,
594
+ });
595
+ }
596
+ } catch {
597
+ // No prior state or corrupt — start fresh (already initialized in constructor)
598
+ }
599
+ }
600
+
601
+ private persistUsageState(): void {
602
+ try {
603
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
604
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
605
+ state.usage = this.usageTracker.toJSON();
606
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
607
+ } catch {
608
+ // Non-fatal — usage tracking is best-effort
609
+ }
610
+ }
611
+
533
612
  /**
534
613
  * Create an ephemeral agent that is NOT registered in the main event loop.
535
614
  *
@@ -1422,13 +1501,11 @@ export class AgentFramework {
1422
1501
 
1423
1502
  // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry)
1424
1503
  if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') {
1425
- const content = 'content' in event ? String((event as { content: unknown }).content) : '';
1426
1504
  const decision = this.eventGate.evaluate({
1427
- content,
1505
+ ...extractGateFields(event),
1428
1506
  eventType: event.type,
1429
1507
  serverId: source,
1430
1508
  channelId: '',
1431
- metadata: 'metadata' in event ? (event as { metadata: Record<string, unknown> }).metadata : {},
1432
1509
  });
1433
1510
  if (!decision.trigger) return;
1434
1511
  }
@@ -1823,12 +1900,13 @@ export class AgentFramework {
1823
1900
  const speechContent = hadToolCalls ? [] : allText;
1824
1901
  const thoughts = hadToolCalls ? allText : [];
1825
1902
 
1826
- const tokenUsage = response.usage
1903
+ const du = response.details?.usage;
1904
+ const tokenUsage = du
1827
1905
  ? {
1828
- input: response.usage.inputTokens,
1829
- output: response.usage.outputTokens,
1830
- cacheCreation: response.details?.usage?.cacheCreationTokens,
1831
- cacheRead: response.details?.usage?.cacheReadTokens,
1906
+ input: du.inputTokens,
1907
+ output: du.outputTokens,
1908
+ cacheCreation: du.cacheCreationTokens,
1909
+ cacheRead: du.cacheReadTokens,
1832
1910
  }
1833
1911
  : undefined;
1834
1912
  this.emitTrace({
@@ -1838,6 +1916,16 @@ export class AgentFramework {
1838
1916
  tokenUsage,
1839
1917
  });
1840
1918
 
1919
+ if (du) {
1920
+ this.usageTracker.onInferenceCompleted(agent.name, {
1921
+ inputTokens: du.inputTokens,
1922
+ outputTokens: du.outputTokens,
1923
+ cacheCreationTokens: du.cacheCreationTokens,
1924
+ cacheReadTokens: du.cacheReadTokens,
1925
+ }, du.estimatedCost ? { total: du.estimatedCost.total, currency: du.estimatedCost.currency } : undefined);
1926
+ this.persistUsageState();
1927
+ }
1928
+
1841
1929
  // Log inference
1842
1930
  this.logInference({
1843
1931
  timestamp: startTime,
@@ -1942,6 +2030,8 @@ export class AgentFramework {
1942
2030
  tokenUsage: {
1943
2031
  input: event.usage.inputTokens,
1944
2032
  output: event.usage.outputTokens,
2033
+ cacheCreation: event.usage.cacheCreationTokens,
2034
+ cacheRead: event.usage.cacheReadTokens,
1945
2035
  },
1946
2036
  });
1947
2037
  break;
@@ -2390,6 +2480,12 @@ export class AgentFramework {
2390
2480
 
2391
2481
  for (const config of serverConfigs) {
2392
2482
  try {
2483
+ // Record per-server channel subscription policy before the server
2484
+ // registers channels — handleRegister fires during the handshake.
2485
+ if (config.channelSubscription !== undefined && this.channelRegistry) {
2486
+ this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
2487
+ }
2488
+
2393
2489
  const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
2394
2490
 
2395
2491
  // Wire event listeners then flush any events that arrived during the
@@ -76,6 +76,8 @@ interface CompiledPolicy {
76
76
  filterRegex?: RegExp;
77
77
  sourceRegex?: RegExp;
78
78
  channelRegex?: RegExp;
79
+ mountRegex?: RegExp;
80
+ pathRegex?: RegExp;
79
81
  }
80
82
 
81
83
  interface PolicyStats {
@@ -170,6 +172,8 @@ function validatePolicy(raw: unknown): GatePolicy {
170
172
  if (Array.isArray(m.scope)) match.scope = m.scope.filter(s => typeof s === 'string');
171
173
  if (typeof m.source === 'string') match.source = m.source;
172
174
  if (typeof m.channel === 'string') match.channel = m.channel;
175
+ if (typeof m.mount === 'string') match.mount = m.mount;
176
+ if (typeof m.pathGlob === 'string') match.pathGlob = m.pathGlob;
173
177
  if (m.filter && typeof m.filter === 'object') {
174
178
  const f = m.filter as Record<string, unknown>;
175
179
  if ((f.type === 'text' || f.type === 'regex') && typeof f.pattern === 'string') {
@@ -210,6 +214,12 @@ export class EventGate {
210
214
  // Per-policy stats
211
215
  private stats = new Map<string, PolicyStats>();
212
216
 
217
+ // Observability counters for events that didn't match any policy
218
+ private totalEvaluations = 0;
219
+ private defaultTriggered = 0;
220
+ private defaultSkipped = 0;
221
+ private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
222
+
213
223
  // Dependency-injected callbacks
214
224
  private emitTrace: (event: TraceEventLike) => void;
215
225
  private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record<string, unknown>) => unknown;
@@ -230,13 +240,44 @@ export class EventGate {
230
240
  this.requestInferenceFn = opts.requestInference;
231
241
  this.getAgentNamesFn = opts.getAgentNames;
232
242
 
233
- // Seed config file if it doesn't exist
234
- if (opts.initialConfig && !existsSync(this.configPath)) {
243
+ // Seed or reconcile the on-disk config.
244
+ //
245
+ // First start: write `initialConfig` verbatim.
246
+ // Subsequent starts: if the recipe/config source declares policies that
247
+ // aren't yet in gate.json (e.g. the user edited the recipe between
248
+ // sessions), append them by name. Existing policies are left alone so
249
+ // user edits via `workspace--edit _config/gate.json` survive.
250
+ // The `default` field is not reconciled — it's an explicit operator
251
+ // choice and a recipe change shouldn't silently flip live wake
252
+ // behavior for a session that's already been running.
253
+ if (opts.initialConfig) {
235
254
  try {
236
255
  mkdirSync(dirname(this.configPath), { recursive: true });
237
- writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
256
+ if (!existsSync(this.configPath)) {
257
+ writeFileSync(this.configPath, JSON.stringify(opts.initialConfig, null, 2) + '\n');
258
+ } else {
259
+ const existing = this.loadConfig();
260
+ if (existing) {
261
+ const existingNames = new Set(existing.policies.map(p => p.name));
262
+ const missing = opts.initialConfig.policies.filter(p => !existingNames.has(p.name));
263
+ if (missing.length > 0) {
264
+ const reconciled: GateConfig = {
265
+ ...existing,
266
+ policies: [...existing.policies, ...missing],
267
+ };
268
+ writeFileSync(this.configPath, JSON.stringify(reconciled, null, 2) + '\n');
269
+ opts.emitTrace({
270
+ type: 'gate:config-reconciled',
271
+ configPath: this.configPath,
272
+ addedPolicies: missing.map(p => p.name),
273
+ timestamp: Date.now(),
274
+ });
275
+ }
276
+ }
277
+ }
238
278
  } catch (err) {
239
- this.configErrors.push(`Failed to seed gate.json: ${err}`);
279
+ const msg = err instanceof Error ? err.message : String(err);
280
+ this.configErrors.push(`Failed to seed/reconcile gate.json: ${msg}`);
240
281
  }
241
282
  }
242
283
 
@@ -286,6 +327,12 @@ export class EventGate {
286
327
  if (policy.match.channel) {
287
328
  compiled.channelRegex = compileGlob(policy.match.channel);
288
329
  }
330
+ if (policy.match.mount) {
331
+ compiled.mountRegex = compileGlob(policy.match.mount);
332
+ }
333
+ if (policy.match.pathGlob) {
334
+ compiled.pathRegex = compileGlob(policy.match.pathGlob);
335
+ }
289
336
  return compiled;
290
337
  });
291
338
  }
@@ -368,6 +415,21 @@ export class EventGate {
368
415
  return false;
369
416
  }
370
417
 
418
+ // Mount check (workspace fs events)
419
+ if (compiled.mountRegex) {
420
+ if (!info.mount || !compiled.mountRegex.test(info.mount)) {
421
+ return false;
422
+ }
423
+ }
424
+
425
+ // Path glob check — matches if ANY path matches
426
+ if (compiled.pathRegex) {
427
+ const paths = info.paths;
428
+ if (!paths || paths.length === 0) return false;
429
+ const anyMatch = paths.some(p => compiled.pathRegex!.test(p));
430
+ if (!anyMatch) return false;
431
+ }
432
+
371
433
  // Content filter check
372
434
  if (match.filter) {
373
435
  if (match.filter.type === 'text') {
@@ -394,9 +456,38 @@ export class EventGate {
394
456
  this.reloadIfChanged();
395
457
 
396
458
  const policy = this.matchPolicy(info);
459
+ const decision = this.computeDecision(policy, info);
460
+
461
+ this.totalEvaluations++;
462
+ if (!policy) {
463
+ const bucket = this.defaultByEventType.get(info.eventType)
464
+ ?? { triggered: 0, skipped: 0 };
465
+ if (decision.trigger) {
466
+ this.defaultTriggered++;
467
+ bucket.triggered++;
468
+ } else {
469
+ this.defaultSkipped++;
470
+ bucket.skipped++;
471
+ }
472
+ this.defaultByEventType.set(info.eventType, bucket);
473
+ }
474
+
475
+ this.emitTrace({
476
+ type: 'gate:decision',
477
+ eventType: info.eventType,
478
+ serverId: info.serverId || undefined,
479
+ channelId: info.channelId || undefined,
480
+ matchedPolicy: decision.policyName,
481
+ trigger: decision.trigger,
482
+ behavior: typeof decision.behavior === 'object' ? `debounce:${decision.behavior.debounce}` : decision.behavior,
483
+ timestamp: Date.now(),
484
+ });
485
+
486
+ return decision;
487
+ }
397
488
 
489
+ private computeDecision(policy: GatePolicy | null, info: GateEventInfo): GateDecision {
398
490
  if (!policy) {
399
- // No policy matched — use default
400
491
  const trigger = (this.config.default ?? 'always') === 'always';
401
492
  return { trigger, policyName: null, behavior: this.config.default ?? 'always' };
402
493
  }
@@ -407,7 +498,7 @@ export class EventGate {
407
498
  policyStats.lastMatchTimestamp = Date.now();
408
499
  this.stats.set(policy.name, policyStats);
409
500
 
410
- // Emit trace
501
+ // Legacy trace kept for backward compatibility with existing consumers.
411
502
  this.emitTrace({
412
503
  type: 'gate:policy-matched',
413
504
  policyName: policy.name,
@@ -599,6 +690,9 @@ export class EventGate {
599
690
  return result;
600
691
  });
601
692
 
693
+ const byEventType: Record<string, { triggered: number; skipped: number }> = {};
694
+ for (const [k, v] of this.defaultByEventType) byEventType[k] = { ...v };
695
+
602
696
  return {
603
697
  configPath: this.configPath,
604
698
  configSource: this.configSource,
@@ -606,6 +700,12 @@ export class EventGate {
606
700
  default: this.config.default ?? 'always',
607
701
  policies,
608
702
  errors: [...this.configErrors],
703
+ totalEvaluations: this.totalEvaluations,
704
+ defaultDecisions: {
705
+ triggered: this.defaultTriggered,
706
+ skipped: this.defaultSkipped,
707
+ byEventType,
708
+ },
609
709
  };
610
710
  }
611
711
 
package/src/gate/types.ts CHANGED
@@ -30,6 +30,16 @@ export interface GatePolicyMatch {
30
30
  channel?: string;
31
31
  /** Content text filter. */
32
32
  filter?: { type: 'text' | 'regex'; pattern: string };
33
+ /**
34
+ * Mount name to match (exact or glob with *). Populated for workspace
35
+ * filesystem events (workspace:created/modified/deleted).
36
+ */
37
+ mount?: string;
38
+ /**
39
+ * Glob applied against each path in the event. Matches if ANY path matches.
40
+ * Supports `*` wildcard; not full gitignore syntax.
41
+ */
42
+ pathGlob?: string;
33
43
  }
34
44
 
35
45
  // ---------------------------------------------------------------------------
@@ -87,6 +97,10 @@ export interface GateEventInfo {
87
97
  serverId: string;
88
98
  channelId: string;
89
99
  metadata?: Record<string, unknown>;
100
+ /** Workspace mount name (for workspace:* events). */
101
+ mount?: string;
102
+ /** Mount-prefixed paths touched (for workspace:* events). */
103
+ paths?: string[];
90
104
  }
91
105
 
92
106
  /** Per-policy runtime statistics (for gate:status). */
@@ -109,4 +123,18 @@ export interface GateStatus {
109
123
  default: 'always' | 'skip';
110
124
  policies: GatePolicyStats[];
111
125
  errors: string[];
126
+ /** Total events the gate has evaluated since startup. */
127
+ totalEvaluations: number;
128
+ /**
129
+ * Count of events that fell through to `default` (no policy matched),
130
+ * broken down by whether they triggered or were skipped. Useful for
131
+ * spotting events that are arriving but silently dropped — if a policy
132
+ * appears to never fire (matchCount 0) and this count is growing for the
133
+ * same eventType, the event is reaching the gate but being dropped.
134
+ */
135
+ defaultDecisions: {
136
+ triggered: number;
137
+ skipped: number;
138
+ byEventType: Record<string, { triggered: number; skipped: number }>;
139
+ };
112
140
  }
package/src/index.ts CHANGED
@@ -52,6 +52,10 @@ export type {
52
52
  // Note: AgentInfo, MessageEditedEvent, MessageRemovedEvent intentionally not re-exported
53
53
  // from api/types.js to avoid conflicts with ./types/index.js
54
54
 
55
+ // Usage tracking
56
+ export { UsageTracker } from './usage/index.js';
57
+ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent } from './usage/index.js';
58
+
55
59
  // EventGate
56
60
  export { EventGate } from './gate/index.js';
57
61
  export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior } from './gate/index.js';
@@ -195,6 +195,13 @@ export class ChannelRegistry {
195
195
  /** Per-channel typing indicator timers. */
196
196
  private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
197
197
 
198
+ /**
199
+ * Per-server channel auto-open policy. Populated by the framework from
200
+ * each McplServerConfig at connect time; default ('auto') applies when
201
+ * unset.
202
+ */
203
+ private subscriptionPolicies = new Map<string, 'auto' | 'manual' | string[]>();
204
+
198
205
  constructor(
199
206
  serverRegistry: McplServerRegistry,
200
207
  featureSetManager: FeatureSetManager,
@@ -212,6 +219,14 @@ export class ChannelRegistry {
212
219
  this.shouldTriggerInference = options?.shouldTriggerInference;
213
220
  }
214
221
 
222
+ /**
223
+ * Set the channel auto-open policy for a server. Called by the framework
224
+ * when each MCPL server is registered.
225
+ */
226
+ setSubscriptionPolicy(serverId: string, policy: 'auto' | 'manual' | string[]): void {
227
+ this.subscriptionPolicies.set(serverId, policy);
228
+ }
229
+
215
230
  // ==========================================================================
216
231
  // Handler Methods (called from framework.ts wireMcplEvents)
217
232
  // ==========================================================================
@@ -339,7 +354,7 @@ export class ChannelRegistry {
339
354
  textContent,
340
355
  {
341
356
  ...message.metadata,
342
- eventType: 'channel:incoming',
357
+ eventType: 'mcpl:channel-incoming',
343
358
  serverId,
344
359
  channelId: message.channelId,
345
360
  messageId: message.messageId,
@@ -574,7 +589,23 @@ export class ChannelRegistry {
574
589
  const server = this.serverRegistry.getServer(serverId);
575
590
  if (!server) return;
576
591
 
592
+ const policy = this.subscriptionPolicies.get(serverId) ?? 'auto';
593
+ if (policy === 'manual') {
594
+ this.emitTraceFn({
595
+ type: 'mcpl:channels-auto-open-skipped',
596
+ serverId,
597
+ reason: 'manual',
598
+ count: channels.length,
599
+ });
600
+ return;
601
+ }
602
+
603
+ const allowList = Array.isArray(policy) ? new Set(policy) : null;
604
+ let opened = 0;
605
+
577
606
  for (const channel of channels) {
607
+ if (allowList && !allowList.has(channel.id)) continue;
608
+
578
609
  const key = `${serverId}:${channel.id}`;
579
610
  try {
580
611
  await server.sendChannelsOpen({
@@ -585,6 +616,7 @@ export class ChannelRegistry {
585
616
  if (entry) {
586
617
  entry.open = true;
587
618
  }
619
+ opened++;
588
620
  } catch (err) {
589
621
  this.emitTraceFn({
590
622
  type: 'mcpl:channel-open-failed',
@@ -594,6 +626,18 @@ export class ChannelRegistry {
594
626
  });
595
627
  }
596
628
  }
629
+
630
+ // Surface the common typo failure mode: allow-list set but nothing
631
+ // matched. Without this trace a one-char typo in a channel id looks
632
+ // identical to "MCPL didn't register the channel".
633
+ if (allowList && opened === 0 && channels.length > 0) {
634
+ this.emitTraceFn({
635
+ type: 'mcpl:channels-allow-list-empty',
636
+ serverId,
637
+ allowList: [...allowList],
638
+ registered: channels.map(c => c.id),
639
+ });
640
+ }
597
641
  }
598
642
 
599
643
  // ==========================================================================
@@ -195,7 +195,7 @@ export class PushHandler {
195
195
  serverId,
196
196
  featureSet: params.featureSet,
197
197
  eventId: params.eventId,
198
- eventType: 'push:event',
198
+ eventType: 'mcpl:push-event',
199
199
  ...(params.origin ?? {}),
200
200
  };
201
201
  triggerInference = this.shouldTriggerInference(textContent, metadata);
package/src/mcpl/types.ts CHANGED
@@ -222,6 +222,22 @@ export interface McplServerConfig {
222
222
  * Must not collide with any module name or another server's prefix.
223
223
  */
224
224
  toolPrefix?: string;
225
+
226
+ /**
227
+ * How channels registered by this server are opened.
228
+ * - `'auto'` (default) — every registered channel is auto-opened; the host
229
+ * receives all traffic. Matches prior behavior. Right for agents that
230
+ * should listen passively (e.g. a frontdesk clerk).
231
+ * - `'manual'` — registered channels are listed but not opened. The agent
232
+ * must call the `channel_open` tool to start receiving messages. Right
233
+ * for focused agents that should not be flooded with chatter from
234
+ * servers that advertise many public channels (e.g. Zulip's full stream
235
+ * list).
236
+ * - `string[]` — allow-list of channel ids; only matching channels are
237
+ * auto-opened. Ids are server-scoped (the raw id the server registers,
238
+ * e.g. `"zulip:tracker-miner-f"`).
239
+ */
240
+ channelSubscription?: 'auto' | 'manual' | string[];
225
241
  }
226
242
 
227
243
  // ============================================================================