@animalabs/agent-framework 0.2.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 (58) hide show
  1. package/dist/src/framework.d.ts +8 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +42 -2
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/index.d.ts +1 -0
  6. package/dist/src/index.d.ts.map +1 -1
  7. package/dist/src/index.js +2 -0
  8. package/dist/src/index.js.map +1 -1
  9. package/dist/src/mcpl/channel-registry.d.ts +5 -2
  10. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  11. package/dist/src/mcpl/channel-registry.js +61 -5
  12. package/dist/src/mcpl/channel-registry.js.map +1 -1
  13. package/dist/src/mcpl/server-connection.d.ts +17 -2
  14. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  15. package/dist/src/mcpl/server-connection.js +66 -3
  16. package/dist/src/mcpl/server-connection.js.map +1 -1
  17. package/dist/src/mcpl/tool-policy.d.ts +24 -0
  18. package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
  19. package/dist/src/mcpl/tool-policy.js +46 -0
  20. package/dist/src/mcpl/tool-policy.js.map +1 -0
  21. package/dist/src/mcpl/types.d.ts +16 -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 +7 -0
  25. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  26. package/dist/src/modules/workspace/index.js +36 -0
  27. package/dist/src/modules/workspace/index.js.map +1 -1
  28. package/dist/src/modules/workspace/types.d.ts +11 -0
  29. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  30. package/dist/src/modules/workspace/types.js.map +1 -1
  31. package/dist/src/modules/workspace/watcher.d.ts +13 -1
  32. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  33. package/dist/src/modules/workspace/watcher.js +27 -1
  34. package/dist/src/modules/workspace/watcher.js.map +1 -1
  35. package/dist/src/types/trace.d.ts +4 -0
  36. package/dist/src/types/trace.d.ts.map +1 -1
  37. package/dist/test/channel-registry-typing.test.d.ts +2 -0
  38. package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
  39. package/dist/test/channel-registry-typing.test.js +82 -0
  40. package/dist/test/channel-registry-typing.test.js.map +1 -0
  41. package/dist/test/tool-policy.test.d.ts +2 -0
  42. package/dist/test/tool-policy.test.d.ts.map +1 -0
  43. package/dist/test/tool-policy.test.js +51 -0
  44. package/dist/test/tool-policy.test.js.map +1 -0
  45. package/dist/test/workspace-watcher.test.js +28 -1
  46. package/dist/test/workspace-watcher.test.js.map +1 -1
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
  49. package/src/framework.ts +44 -2
  50. package/src/index.ts +3 -0
  51. package/src/mcpl/channel-registry.ts +75 -7
  52. package/src/mcpl/server-connection.ts +78 -3
  53. package/src/mcpl/tool-policy.ts +52 -0
  54. package/src/mcpl/types.ts +18 -0
  55. package/src/modules/workspace/index.ts +43 -3
  56. package/src/modules/workspace/types.ts +11 -0
  57. package/src/modules/workspace/watcher.ts +40 -0
  58. package/src/types/trace.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/framework.ts CHANGED
@@ -51,6 +51,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
51
51
  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
+ import { isToolAllowed } from './mcpl/tool-policy.js';
54
55
  import { EventGate } from './gate/event-gate.js';
55
56
  import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
56
57
  import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
@@ -473,6 +474,16 @@ export class AgentFramework {
473
474
  this.traceListeners.push(listener);
474
475
  }
475
476
 
477
+ /**
478
+ * Public accessor for the MCPL channel registry.
479
+ * Null when no MCPL servers are configured.
480
+ * Modules that need channel-level operations (typing indicators, default publish channel,
481
+ * etc.) obtain them here.
482
+ */
483
+ get channels(): ChannelRegistry | null {
484
+ return this.channelRegistry;
485
+ }
486
+
476
487
  /**
477
488
  * Remove a trace event listener.
478
489
  */
@@ -2101,6 +2112,13 @@ export class AgentFramework {
2101
2112
  }
2102
2113
  const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
2103
2114
  const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
2115
+ if (!isToolAllowed(toolName, config)) {
2116
+ return {
2117
+ success: false,
2118
+ error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
2119
+ isError: true,
2120
+ };
2121
+ }
2104
2122
  try {
2105
2123
  const result = await server.sendToolsCall(toolName, call.input as Record<string, unknown>);
2106
2124
  return {
@@ -2457,10 +2475,10 @@ export class AgentFramework {
2457
2475
  (event) => this.pushEvent(event),
2458
2476
  (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
2459
2477
  {
2460
- sendTypingFn: (serverId, channelId) => {
2478
+ sendTypingFn: (serverId, channelId, metadata, op) => {
2461
2479
  const server = this.mcplServerRegistry!.getServer(serverId);
2462
2480
  if (server) {
2463
- server.sendChannelsTyping(channelId);
2481
+ server.sendChannelsTyping(channelId, metadata, op);
2464
2482
  }
2465
2483
  },
2466
2484
  shouldTriggerInference: triggerFilter,
@@ -2547,6 +2565,12 @@ export class AgentFramework {
2547
2565
  * Push events and inference requests are deferred to Steps 6/7.
2548
2566
  */
2549
2567
  private wireMcplEvents(connection: McplServerConnection): void {
2568
+ // Forward subprocess stderr lines as trace events so consumers (conhost,
2569
+ // log sinks, TUI badges) can persist and surface them.
2570
+ connection.on('stderr', (params: { line: string }) => {
2571
+ this.emitTrace({ type: 'mcpl:server-stderr', serverId: connection.id, line: params.line });
2572
+ });
2573
+
2550
2574
  // Handle dynamic feature set changes from server
2551
2575
  connection.on('feature-sets-changed', (params: FeatureSetsChangedParams) => {
2552
2576
  this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
@@ -2644,6 +2668,7 @@ export class AgentFramework {
2644
2668
  try {
2645
2669
  const result = await server.sendToolsList();
2646
2670
  for (const tool of result.tools) {
2671
+ if (!isToolAllowed(tool.name, config)) continue;
2647
2672
  // MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
2648
2673
  const schema = tool.inputSchema as import('./types/index.js').ToolDefinition['inputSchema'];
2649
2674
  tools.push({
@@ -2733,6 +2758,23 @@ export class AgentFramework {
2733
2758
  return;
2734
2759
  }
2735
2760
 
2761
+ const config = this.mcplServerConfigs.get(serverId);
2762
+ if (!isToolAllowed(toolName, config)) {
2763
+ this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: 'denied by tool policy' });
2764
+ this.pushEvent({
2765
+ type: 'tool-result',
2766
+ callId: call.id,
2767
+ agentName,
2768
+ moduleName: `mcpl:${serverId}`,
2769
+ result: {
2770
+ success: false,
2771
+ error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
2772
+ isError: true,
2773
+ },
2774
+ });
2775
+ return;
2776
+ }
2777
+
2736
2778
  this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
2737
2779
  const startTime = Date.now();
2738
2780
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
package/src/index.ts CHANGED
@@ -60,6 +60,9 @@ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent
60
60
  export { EventGate } from './gate/index.js';
61
61
  export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior } from './gate/index.js';
62
62
 
63
+ // MCPL channel registry (exposed for modules that need channel-level operations)
64
+ export { ChannelRegistry } from './mcpl/index.js';
65
+
63
66
  // Re-export commonly used types from dependencies
64
67
  export type { ContextManager, ContextStrategy, TokenBudget } from '@animalabs/context-manager';
65
68
  export { PassthroughStrategy, AutobiographicalStrategy, KnowledgeStrategy } from '@animalabs/context-manager';
@@ -38,6 +38,21 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js
38
38
 
39
39
  const TYPING_INTERVAL_MS = 7_000;
40
40
 
41
+ function shallowEqualRecord(
42
+ a: Record<string, unknown> | undefined,
43
+ b: Record<string, unknown> | undefined,
44
+ ): boolean {
45
+ if (a === b) return true;
46
+ if (!a || !b) return false;
47
+ const keysA = Object.keys(a);
48
+ const keysB = Object.keys(b);
49
+ if (keysA.length !== keysB.length) return false;
50
+ for (const k of keysA) {
51
+ if (a[k] !== b[k]) return false;
52
+ }
53
+ return true;
54
+ }
55
+
41
56
  // ============================================================================
42
57
  // Internal Types
43
58
  // ============================================================================
@@ -179,7 +194,12 @@ export class ChannelRegistry {
179
194
  private featureSetManager: FeatureSetManager;
180
195
  private pushEventFn: (event: ProcessEvent) => void;
181
196
  private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
182
- private sendTypingFn?: (serverId: string, channelId: string) => void;
197
+ private sendTypingFn?: (
198
+ serverId: string,
199
+ channelId: string,
200
+ metadata?: Record<string, unknown>,
201
+ op?: 'start' | 'stop',
202
+ ) => void;
183
203
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
184
204
 
185
205
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
@@ -195,6 +215,10 @@ export class ChannelRegistry {
195
215
  /** Per-channel typing indicator timers. */
196
216
  private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
197
217
 
218
+ /** Per-channel typing metadata — carried on the 7s refresh so the target
219
+ * server keeps getting the same routing hints (e.g. Zulip topic). */
220
+ private typingMetadata = new Map<string, Record<string, unknown>>();
221
+
198
222
  /**
199
223
  * Per-server channel auto-open policy. Populated by the framework from
200
224
  * each McplServerConfig at connect time; default ('auto') applies when
@@ -208,7 +232,12 @@ export class ChannelRegistry {
208
232
  pushEventFn: (event: ProcessEvent) => void,
209
233
  emitTraceFn: (event: { type: string; [key: string]: unknown }) => void,
210
234
  options?: ChannelRegistryOptions & {
211
- sendTypingFn?: (serverId: string, channelId: string) => void;
235
+ sendTypingFn?: (
236
+ serverId: string,
237
+ channelId: string,
238
+ metadata?: Record<string, unknown>,
239
+ op?: 'start' | 'stop',
240
+ ) => void;
212
241
  },
213
242
  ) {
214
243
  this.serverRegistry = serverRegistry;
@@ -413,9 +442,27 @@ export class ChannelRegistry {
413
442
  *
414
443
  * No-op if already typing on this channel.
415
444
  */
416
- startTyping(channelId: string): void {
445
+ startTyping(channelId: string, metadata?: Record<string, unknown>): void {
446
+ const metadataChanged =
447
+ metadata !== undefined &&
448
+ !shallowEqualRecord(this.typingMetadata.get(channelId), metadata);
449
+ if (metadata) {
450
+ this.typingMetadata.set(channelId, metadata);
451
+ }
452
+
417
453
  if (this.typingIntervals.has(channelId)) {
418
- return; // Already typing
454
+ // Already typing. If the caller supplied new routing metadata (e.g. the
455
+ // relevant Zulip topic just moved because a newer message arrived),
456
+ // dispatch an immediate refresh so the server sees the new routing
457
+ // within this request instead of waiting up to TYPING_INTERVAL_MS for
458
+ // the next tick.
459
+ if (metadataChanged) {
460
+ const entry = this.findChannelEntry(channelId);
461
+ if (entry) {
462
+ this.sendTypingNotification(entry.serverId, channelId);
463
+ }
464
+ }
465
+ return;
419
466
  }
420
467
 
421
468
  // Find the channel entry and its server
@@ -427,7 +474,9 @@ export class ChannelRegistry {
427
474
  // Send typing immediately
428
475
  this.sendTypingNotification(entry.serverId, channelId);
429
476
 
430
- // Set up interval
477
+ // Set up interval — pulls the latest metadata on each tick so mid-stream
478
+ // updates (e.g. a newer incoming message switching the relevant topic)
479
+ // take effect on the next refresh.
431
480
  const interval = setInterval(() => {
432
481
  this.sendTypingNotification(entry.serverId, channelId);
433
482
  }, TYPING_INTERVAL_MS);
@@ -447,13 +496,32 @@ export class ChannelRegistry {
447
496
  if (interval) {
448
497
  clearInterval(interval);
449
498
  this.typingIntervals.delete(channelId);
499
+ // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
500
+ // clear the indicator immediately rather than waiting for auto-expire.
501
+ // Metadata still carries the routing hint so the stop hits the same
502
+ // topic/thread as the start. Guarded by `interval`: matches the
503
+ // global-clear branch's semantics, and keeps defensive stopTyping(ch)
504
+ // calls from spamming stops at a server that never saw a start.
505
+ const entry = this.findChannelEntry(channelId);
506
+ if (entry && this.sendTypingFn) {
507
+ this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
508
+ }
450
509
  }
510
+ this.typingMetadata.delete(channelId);
451
511
  } else {
452
- // Clear all typing intervals
512
+ // Clear all typing intervals and dispatch stop for each known channel
513
+ const channels = Array.from(this.typingIntervals.keys());
453
514
  for (const interval of this.typingIntervals.values()) {
454
515
  clearInterval(interval);
455
516
  }
456
517
  this.typingIntervals.clear();
518
+ if (this.sendTypingFn) {
519
+ for (const id of channels) {
520
+ const entry = this.findChannelEntry(id);
521
+ if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop');
522
+ }
523
+ }
524
+ this.typingMetadata.clear();
457
525
  }
458
526
  }
459
527
 
@@ -652,7 +720,7 @@ export class ChannelRegistry {
652
720
  */
653
721
  private sendTypingNotification(serverId: string, channelId: string): void {
654
722
  if (this.sendTypingFn) {
655
- this.sendTypingFn(serverId, channelId);
723
+ this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
656
724
  }
657
725
  // TODO: When server-connection exposes a public sendNotification or
658
726
  // sendTyping method, wire it here directly instead of using a callback.
@@ -95,6 +95,9 @@ export class McplServerConnection extends EventEmitter {
95
95
  private reconnectIntervalMs = 5000;
96
96
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
97
97
 
98
+ /** Swaps the line handler on the current child's stderr pipe. Re-bound on reconnect. */
99
+ private rebindStderr: ((next: (line: string) => void) => void) | null = null;
100
+
98
101
  /**
99
102
  * Private constructor. Use `McplServerConnection.connect()` instead.
100
103
  */
@@ -151,6 +154,33 @@ export class McplServerConnection extends EventEmitter {
151
154
  // Static factory
152
155
  // ==========================================================================
153
156
 
157
+ /**
158
+ * Wire stderr forwarding on a child process. Lines are forwarded to the
159
+ * supplied callback. The callback can be swapped at any time via `rebind`
160
+ * (used during reconnect, when we want already-wired pipes to target a new
161
+ * emitter).
162
+ */
163
+ private static wireStderrForwarding(
164
+ child: ChildProcess,
165
+ initialLineHandler: (line: string) => void,
166
+ ): { rebind: (next: (line: string) => void) => void } {
167
+ let onLine = initialLineHandler;
168
+ let carry = '';
169
+ child.stderr?.on('data', (chunk: Buffer) => {
170
+ const text = carry + chunk.toString('utf8');
171
+ const lines = text.split('\n');
172
+ carry = lines.pop() ?? '';
173
+ for (const line of lines) {
174
+ if (line.length > 0) onLine(line);
175
+ }
176
+ });
177
+ return {
178
+ rebind(next) {
179
+ onLine = next;
180
+ },
181
+ };
182
+ }
183
+
154
184
  /**
155
185
  * Spawn a server process, perform the MCP initialize handshake with MCPL
156
186
  * capability negotiation, and return a ready-to-use connection.
@@ -166,6 +196,14 @@ export class McplServerConnection extends EventEmitter {
166
196
  env: { ...process.env, ...config.env },
167
197
  });
168
198
 
199
+ // Capture child stderr so it isn't silently dropped. During the handshake
200
+ // window (before the instance exists) lines are buffered; the buffer is
201
+ // drained once we have an instance to emit 'stderr' events on.
202
+ const earlyStderrBuffer: string[] = [];
203
+ const attachStderr = McplServerConnection.wireStderrForwarding(child, (line) =>
204
+ earlyStderrBuffer.push(line),
205
+ );
206
+
169
207
  // Fail fast if the process exits before the handshake completes
170
208
  const earlyExitPromise = new Promise<never>((_resolve, reject) => {
171
209
  child.on('error', (err) => {
@@ -251,6 +289,16 @@ export class McplServerConnection extends EventEmitter {
251
289
 
252
290
  const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
253
291
 
292
+ // Swap the stderr handler to emit events on the instance, then drain
293
+ // anything that arrived during the handshake window. Keep the rebind
294
+ // handle on the instance so reconnect can re-target it later.
295
+ connection.rebindStderr = attachStderr.rebind;
296
+ attachStderr.rebind((line: string) => connection.emit('stderr', { line }));
297
+ for (const line of earlyStderrBuffer) {
298
+ connection.emit('stderr', { line });
299
+ }
300
+ earlyStderrBuffer.length = 0;
301
+
254
302
  // Store config for reconnection
255
303
  if (config.reconnect) {
256
304
  connection.config = config;
@@ -395,9 +443,21 @@ export class McplServerConnection extends EventEmitter {
395
443
  this.sendNotification(McplMethod.ChannelsOutgoingComplete, params as unknown as Record<string, unknown>);
396
444
  }
397
445
 
398
- /** Send `channels/typing` notification (best-effort). */
399
- sendChannelsTyping(channelId: string): void {
400
- this.sendNotification(McplMethod.ChannelsTyping, { channelId });
446
+ /** Send `channels/typing` notification (best-effort).
447
+ * `metadata` is opaque routing hints for the server — e.g. Zulip uses
448
+ * `topic` to target a specific thread; other servers ignore what they don't
449
+ * recognize. Typically sourced from the most recent incoming message's
450
+ * metadata on the same channel.
451
+ * `op` defaults to 'start'; pass 'stop' to clear an active indicator
452
+ * immediately rather than waiting for server-side auto-expire. */
453
+ sendChannelsTyping(
454
+ channelId: string,
455
+ metadata?: Record<string, unknown>,
456
+ op: 'start' | 'stop' = 'start',
457
+ ): void {
458
+ const params: Record<string, unknown> = { channelId, op };
459
+ if (metadata) params.metadata = metadata;
460
+ this.sendNotification(McplMethod.ChannelsTyping, params);
401
461
  }
402
462
 
403
463
  // ==========================================================================
@@ -499,6 +559,21 @@ export class McplServerConnection extends EventEmitter {
499
559
  this.nextRequestId = 1;
500
560
  this.pendingRequests.clear();
501
561
 
562
+ // Re-target stderr forwarding from the throwaway `fresh` instance to `this`.
563
+ //
564
+ // Note: this follows the same "transfer internals, leak listeners on the
565
+ // dead instance" pattern as the message-routing and lifecycle rewiring
566
+ // below — `fresh`'s own readline 'line' handler and process 'exit'
567
+ // handler stay registered on the transferred child, short-circuited by
568
+ // `fresh.closed = true` set in extractInternals(). Rebinding fresh's
569
+ // stderr listener (rather than removing it) keeps that invariant: if we
570
+ // ever clean up the message-routing duplication, fresh's stderr data
571
+ // listener closure-holds onLine and would need the same teardown.
572
+ if (fresh.rebindStderr) {
573
+ fresh.rebindStderr((line: string) => this.emit('stderr', { line }));
574
+ this.rebindStderr = fresh.rebindStderr;
575
+ }
576
+
502
577
  // Re-wire message routing and lifecycle on the new process
503
578
  this.setupMessageRouting();
504
579
  this.setupLifecycle();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tool allow/deny policy for MCPL servers.
3
+ *
4
+ * `enabledTools` and `disabledTools` on McplServerConfig hold bare tool names
5
+ * (no toolPrefix) with `*` substring wildcards. This module owns the predicate
6
+ * used at both tool-list time and dispatch time.
7
+ *
8
+ * Semantics:
9
+ * - No fields set → all tools allowed.
10
+ * - Only enabledTools set → tool must match at least one pattern.
11
+ * - Only disabledTools set → tool must NOT match any pattern.
12
+ * - Both set → must match enabledTools AND not match disabledTools.
13
+ * (deny wins on overlap.)
14
+ */
15
+
16
+ /**
17
+ * Compile a pattern with `*` substring wildcards into a regex.
18
+ * `*` matches any run of characters (including empty); other characters are literal.
19
+ */
20
+ function compilePattern(pattern: string): RegExp {
21
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
22
+ return new RegExp(`^${escaped}$`);
23
+ }
24
+
25
+ function anyMatch(patterns: string[], name: string): boolean {
26
+ for (const p of patterns) {
27
+ if (compilePattern(p).test(name)) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export interface ToolPolicy {
33
+ enabledTools?: string[];
34
+ disabledTools?: string[];
35
+ }
36
+
37
+ /**
38
+ * Returns true if `bareToolName` (server-native name, no prefix) is allowed
39
+ * under the supplied policy.
40
+ */
41
+ export function isToolAllowed(bareToolName: string, policy: ToolPolicy | undefined): boolean {
42
+ if (!policy) return true;
43
+ const { enabledTools, disabledTools } = policy;
44
+
45
+ if (disabledTools && disabledTools.length > 0 && anyMatch(disabledTools, bareToolName)) {
46
+ return false;
47
+ }
48
+ if (enabledTools && enabledTools.length > 0) {
49
+ return anyMatch(enabledTools, bareToolName);
50
+ }
51
+ return true;
52
+ }
package/src/mcpl/types.ts CHANGED
@@ -180,6 +180,24 @@ export interface McplServerConfig {
180
180
  /** Feature sets to explicitly disable on connect */
181
181
  disabledFeatureSets?: string[];
182
182
 
183
+ /**
184
+ * Tool allow-list (bare tool names as the server exports them, no toolPrefix).
185
+ * Supports `*` as a substring wildcard (e.g. `read_*`, `*_file`, `*`).
186
+ * If set, only tools matching at least one pattern are exposed.
187
+ * `disabledTools` takes precedence on conflict.
188
+ *
189
+ * Filter is applied in two places: at tool-list time (model never sees the
190
+ * tool) and at dispatch time (call rejected with a tool-result error, in
191
+ * case the model imitates a prior call from message history).
192
+ */
193
+ enabledTools?: string[];
194
+
195
+ /**
196
+ * Tool deny-list (bare tool names, same wildcard syntax as enabledTools).
197
+ * Wins over enabledTools on conflict.
198
+ */
199
+ disabledTools?: string[];
200
+
183
201
  /** Scope configurations per feature set */
184
202
  scopes?: Record<string, ScopeConfig>;
185
203
 
@@ -121,6 +121,8 @@ export class WorkspaceModule implements Module {
121
121
  initialSyncDone: false,
122
122
  lastMaterializedBranchId: null,
123
123
  materializedHashes: new Map(),
124
+ watcherReadyAt: null,
125
+ watcherError: null,
124
126
  };
125
127
  this.mounts.set(mount.name, mountState);
126
128
  }
@@ -140,6 +142,9 @@ export class WorkspaceModule implements Module {
140
142
  if (mount) {
141
143
  mount.lastMaterializedSeq = meta.lastMaterializedSeq;
142
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.
143
148
  }
144
149
  }
145
150
  }
@@ -161,9 +166,27 @@ export class WorkspaceModule implements Module {
161
166
 
162
167
  const watchMode = mount.config.watch ?? 'always';
163
168
  if (watchMode === 'always') {
164
- const watcher = new MountWatcher(mount.config, (changes) => {
165
- this.handleFsChanges(name, changes);
166
- });
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
+ );
167
190
  watcher.start();
168
191
  this.watchers.set(name, watcher);
169
192
 
@@ -212,6 +235,8 @@ export class WorkspaceModule implements Module {
212
235
  state.mounts[name] = {
213
236
  lastMaterializedSeq: mount.lastMaterializedSeq,
214
237
  lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined,
238
+ watcherReadyAt: mount.watcherReadyAt,
239
+ watcherError: mount.watcherError,
215
240
  };
216
241
  }
217
242
  this.ctx.setState(state);
@@ -406,6 +431,21 @@ export class WorkspaceModule implements Module {
406
431
  // Path Resolution
407
432
  // ==========================================================================
408
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
+
409
449
  /**
410
450
  * Parse a mount-prefixed path into (mountName, relativePath).
411
451
  */
@@ -94,6 +94,15 @@ export interface MountState {
94
94
  lastMaterializedBranchId: string | null;
95
95
  /** Per-file hash at time of last materialization — baseline for conflict detection */
96
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;
97
106
  }
98
107
 
99
108
  /**
@@ -104,6 +113,8 @@ export interface WorkspaceModuleState {
104
113
  mounts: Record<string, {
105
114
  lastMaterializedSeq: number;
106
115
  lastMaterializedBranchId?: string;
116
+ watcherReadyAt?: number | null;
117
+ watcherError?: string | null;
107
118
  }>;
108
119
  /** Branch ID considered "active" for materialization */
109
120
  activeBranchId?: string;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { watch, type FSWatcher } from 'chokidar';
9
+ import { mkdirSync } from 'node:fs';
9
10
  import { type MountConfig } from './types.js';
10
11
 
11
12
  export type FsOp = 'created' | 'modified' | 'deleted';
@@ -19,6 +20,18 @@ export interface WatcherEvents {
19
20
  onChange(changes: FsChange[]): void;
20
21
  }
21
22
 
23
+ /**
24
+ * Optional lifecycle callbacks. Without these, chokidar's `ready` and `error`
25
+ * events are silently dropped — which is how a failed attach can look
26
+ * identical to an empty directory in the recorded state.
27
+ */
28
+ export interface WatcherLifecycle {
29
+ /** Fires once, when chokidar completes its initial scan. */
30
+ onReady?: () => void;
31
+ /** Fires on any chokidar-reported error (ENOENT, EACCES, platform faults). */
32
+ onError?: (err: Error) => void;
33
+ }
34
+
22
35
  /**
23
36
  * Manages filesystem watching for a single mount.
24
37
  * Handles debouncing, ignore patterns, and write suppression.
@@ -34,14 +47,17 @@ export class MountWatcher {
34
47
  private readonly debounceMs: number;
35
48
  private readonly config: MountConfig;
36
49
  private readonly onChangeCallback: (changes: FsChange[]) => void;
50
+ private readonly lifecycle: WatcherLifecycle;
37
51
 
38
52
  constructor(
39
53
  config: MountConfig,
40
54
  onChange: (changes: FsChange[]) => void,
55
+ lifecycle: WatcherLifecycle = {},
41
56
  ) {
42
57
  this.config = config;
43
58
  this.debounceMs = config.watchDebounceMs ?? 300;
44
59
  this.onChangeCallback = onChange;
60
+ this.lifecycle = lifecycle;
45
61
  }
46
62
 
47
63
  /**
@@ -50,6 +66,21 @@ export class MountWatcher {
50
66
  start(): void {
51
67
  if (this.watcher) return;
52
68
 
69
+ // Read-write mounts: ensure the path exists before chokidar attaches.
70
+ // chokidar 4's fs.watch-based backend silently fails to fire events when
71
+ // the watched path OR any of its parent directories don't exist at
72
+ // subscribe time (see case C in the WSL/Linux repro). `ready` still fires,
73
+ // so the failure is indistinguishable from an empty directory without
74
+ // this defensive mkdir. Read-only mounts are left alone — creating
75
+ // directories a user asked us only to read would be surprising.
76
+ if (this.config.mode === 'read-write') {
77
+ try {
78
+ mkdirSync(this.config.path, { recursive: true });
79
+ } catch (err) {
80
+ this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
81
+ }
82
+ }
83
+
53
84
  const ignored = this.config.ignore ?? [];
54
85
 
55
86
  this.watcher = watch(this.config.path, {
@@ -79,6 +110,15 @@ export class MountWatcher {
79
110
  this.watcher.on('add', handleEvent('created'));
80
111
  this.watcher.on('change', handleEvent('modified'));
81
112
  this.watcher.on('unlink', handleEvent('deleted'));
113
+
114
+ if (this.lifecycle.onReady) {
115
+ this.watcher.once('ready', this.lifecycle.onReady);
116
+ }
117
+ if (this.lifecycle.onError) {
118
+ this.watcher.on('error', (err) => {
119
+ this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err)));
120
+ });
121
+ }
82
122
  }
83
123
 
84
124
  /**
@@ -206,6 +206,13 @@ export type TraceEvent =
206
206
  agentName: string;
207
207
  fromBranch: string;
208
208
  toBranch: string;
209
+ })
210
+
211
+ // MCPL subprocess stderr (one trace per line, for receipts when things break)
212
+ | (TraceEventBase & {
213
+ type: 'mcpl:server-stderr';
214
+ serverId: string;
215
+ line: string;
209
216
  });
210
217
 
211
218
  /**