@animalabs/agent-framework 0.18.0 → 0.20.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 (203) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +161 -7
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1183 -87
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +74 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +156 -10
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/index.d.ts +3 -0
  33. package/dist/src/mcpl/index.d.ts.map +1 -1
  34. package/dist/src/mcpl/index.js +3 -0
  35. package/dist/src/mcpl/index.js.map +1 -1
  36. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  37. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  38. package/dist/src/mcpl/push-coalescer.js +709 -0
  39. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  40. package/dist/src/mcpl/push-handler.d.ts +7 -2
  41. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  42. package/dist/src/mcpl/push-handler.js +43 -4
  43. package/dist/src/mcpl/push-handler.js.map +1 -1
  44. package/dist/src/mcpl/server-connection.d.ts +18 -0
  45. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  46. package/dist/src/mcpl/server-connection.js +44 -1
  47. package/dist/src/mcpl/server-connection.js.map +1 -1
  48. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  49. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  50. package/dist/src/mcpl/tool-classes.js +170 -0
  51. package/dist/src/mcpl/tool-classes.js.map +1 -0
  52. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  53. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  54. package/dist/src/mcpl/tool-glob.js +41 -0
  55. package/dist/src/mcpl/tool-glob.js.map +1 -0
  56. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  57. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  58. package/dist/src/mcpl/tool-lifecycle.js +592 -0
  59. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  60. package/dist/src/mcpl/transport.d.ts +12 -0
  61. package/dist/src/mcpl/transport.d.ts.map +1 -1
  62. package/dist/src/mcpl/transport.js +33 -1
  63. package/dist/src/mcpl/transport.js.map +1 -1
  64. package/dist/src/mcpl/types.d.ts +66 -1
  65. package/dist/src/mcpl/types.d.ts.map +1 -1
  66. package/dist/src/mcpl/types.js +6 -0
  67. package/dist/src/mcpl/types.js.map +1 -1
  68. package/dist/src/modules/discord/index.js +1 -1
  69. package/dist/src/modules/discord/index.js.map +1 -1
  70. package/dist/src/modules/history/index.d.ts +85 -0
  71. package/dist/src/modules/history/index.d.ts.map +1 -1
  72. package/dist/src/modules/history/index.js +1019 -24
  73. package/dist/src/modules/history/index.js.map +1 -1
  74. package/dist/src/modules/history/semantic.d.ts +202 -0
  75. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  76. package/dist/src/modules/history/semantic.js +418 -0
  77. package/dist/src/modules/history/semantic.js.map +1 -0
  78. package/dist/src/modules/index.d.ts +2 -1
  79. package/dist/src/modules/index.d.ts.map +1 -1
  80. package/dist/src/modules/index.js.map +1 -1
  81. package/dist/src/tool-result-guard.d.ts +82 -0
  82. package/dist/src/tool-result-guard.d.ts.map +1 -0
  83. package/dist/src/tool-result-guard.js +306 -0
  84. package/dist/src/tool-result-guard.js.map +1 -0
  85. package/dist/src/types/agent.d.ts +14 -0
  86. package/dist/src/types/agent.d.ts.map +1 -1
  87. package/dist/src/types/framework.d.ts +21 -0
  88. package/dist/src/types/framework.d.ts.map +1 -1
  89. package/dist/src/types/trace.d.ts +17 -0
  90. package/dist/src/types/trace.d.ts.map +1 -1
  91. package/dist/test/channel-registry-routing.test.js +59 -0
  92. package/dist/test/channel-registry-routing.test.js.map +1 -1
  93. package/dist/test/channels-changed-respond-first.test.d.ts +2 -0
  94. package/dist/test/channels-changed-respond-first.test.d.ts.map +1 -0
  95. package/dist/test/channels-changed-respond-first.test.js +74 -0
  96. package/dist/test/channels-changed-respond-first.test.js.map +1 -0
  97. package/dist/test/code-execution.test.js +139 -0
  98. package/dist/test/code-execution.test.js.map +1 -1
  99. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  100. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  101. package/dist/test/helpers/coalescing-fixture.js +145 -0
  102. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  103. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  104. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  105. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  106. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  107. package/dist/test/history-module-realstore.test.d.ts +2 -0
  108. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  109. package/dist/test/history-module-realstore.test.js +363 -0
  110. package/dist/test/history-module-realstore.test.js.map +1 -0
  111. package/dist/test/history-module-semantic.test.d.ts +2 -0
  112. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  113. package/dist/test/history-module-semantic.test.js +213 -0
  114. package/dist/test/history-module-semantic.test.js.map +1 -0
  115. package/dist/test/history-module-ux.test.d.ts +2 -0
  116. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  117. package/dist/test/history-module-ux.test.js +304 -0
  118. package/dist/test/history-module-ux.test.js.map +1 -0
  119. package/dist/test/history-module.test.js +25 -1
  120. package/dist/test/history-module.test.js.map +1 -1
  121. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  122. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  123. package/dist/test/history-semantic-review-findings.test.js +338 -0
  124. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  125. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  126. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  127. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  128. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  129. package/dist/test/journal-tool.test.d.ts +2 -0
  130. package/dist/test/journal-tool.test.d.ts.map +1 -0
  131. package/dist/test/journal-tool.test.js +33 -0
  132. package/dist/test/journal-tool.test.js.map +1 -0
  133. package/dist/test/mcpl-capability-grant.test.js +3 -1
  134. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  135. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  136. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  137. package/dist/test/mcpl-child-env.test.js +69 -0
  138. package/dist/test/mcpl-child-env.test.js.map +1 -0
  139. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  140. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  141. package/dist/test/mcpl-coalescing.test.js +1050 -0
  142. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  143. package/dist/test/mcpl-list-status.test.js +4 -0
  144. package/dist/test/mcpl-list-status.test.js.map +1 -1
  145. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  146. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  147. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  148. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  149. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  150. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  151. package/dist/test/mcpl-tool-classes.test.js +80 -0
  152. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  153. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  154. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  155. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  156. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  157. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  158. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  159. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  160. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  161. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  162. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  163. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  164. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  165. package/dist/test/overbudget-drain-breaker.test.js +44 -4
  166. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  167. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  168. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  169. package/dist/test/silent-heartbeat.test.js +154 -0
  170. package/dist/test/silent-heartbeat.test.js.map +1 -0
  171. package/dist/test/tool-result-guard.test.d.ts +2 -0
  172. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  173. package/dist/test/tool-result-guard.test.js +1081 -0
  174. package/dist/test/tool-result-guard.test.js.map +1 -0
  175. package/dist/test/trunk-channel-routing.test.js +67 -0
  176. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  177. package/dist/tsconfig.tsbuildinfo +1 -1
  178. package/package.json +2 -2
  179. package/src/agent.ts +128 -10
  180. package/src/api/types.ts +2 -0
  181. package/src/code-execution/py-runner.ts +34 -2
  182. package/src/code-execution/tool-definition.ts +46 -0
  183. package/src/framework.ts +1215 -91
  184. package/src/index.ts +13 -0
  185. package/src/mcpl/capability-grant.ts +54 -6
  186. package/src/mcpl/channel-registry.ts +170 -10
  187. package/src/mcpl/index.ts +29 -0
  188. package/src/mcpl/push-coalescer.ts +821 -0
  189. package/src/mcpl/push-handler.ts +40 -4
  190. package/src/mcpl/server-connection.ts +48 -2
  191. package/src/mcpl/tool-classes.ts +188 -0
  192. package/src/mcpl/tool-glob.ts +36 -0
  193. package/src/mcpl/tool-lifecycle.ts +730 -0
  194. package/src/mcpl/transport.ts +35 -1
  195. package/src/mcpl/types.ts +69 -1
  196. package/src/modules/discord/index.ts +1 -1
  197. package/src/modules/history/index.ts +1089 -24
  198. package/src/modules/history/semantic.ts +473 -0
  199. package/src/modules/index.ts +2 -1
  200. package/src/tool-result-guard.ts +318 -0
  201. package/src/types/agent.ts +15 -0
  202. package/src/types/framework.ts +23 -0
  203. package/src/types/trace.ts +17 -0
package/src/index.ts CHANGED
@@ -93,6 +93,19 @@ export type { McplServerConfig } from './mcpl/index.js';
93
93
  export { McplServerConnection } from './mcpl/index.js';
94
94
  export type { McplHostCapabilities } from './mcpl/index.js';
95
95
 
96
+ // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host
97
+ // needs to write McplServerConfig.toolLifecycle policy and class tables, and
98
+ // the portable pattern matcher both use.
99
+ export { TOOL_CLASSES, DEFAULT_INPUT_CLASSES, globMatch } from './mcpl/index.js';
100
+ export type {
101
+ ToolClass,
102
+ ToolClassSource,
103
+ ToolLifecycleConfig,
104
+ ToolLifecycleNarrowing,
105
+ ToolLifecycleParams,
106
+ ToolObserveRule,
107
+ } from './mcpl/index.js';
108
+
96
109
  // Per-channel conversation routing
97
110
  export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
98
111
  export type {
@@ -48,6 +48,9 @@ const VOCABULARY: CapNode = {
48
48
  modelInfo: null,
49
49
  inferenceRequest: { streaming: null },
50
50
  inferenceLifecycle: null,
51
+ // RFC-007: metadata observation and argument observation are separate
52
+ // leaves, separately granted (observation never bundles content).
53
+ toolLifecycle: { observe: null, inputs: null },
51
54
  contextHooks: {
52
55
  beforeInference: {
53
56
  observe: null,
@@ -217,8 +220,36 @@ export function capabilityPatternMatches(pattern: string, path: string): boolean
217
220
  // The grant
218
221
  // ============================================================================
219
222
 
220
- /** §13.4: denied unless explicitly enabled by host config. */
221
- const DENY_BY_DEFAULT: readonly string[] = ['contextHooks.beforeInference.inject.system'];
223
+ /** §13.4: denied unless explicitly enabled by host config. RFC-007 adds both
224
+ * tool-lifecycle leaves: observing other servers' tool use (and, above all,
225
+ * their arguments) is an operator decision, never a default. */
226
+ const DENY_BY_DEFAULT: readonly string[] = [
227
+ 'contextHooks.beforeInference.inject.system',
228
+ 'toolLifecycle.observe',
229
+ 'toolLifecycle.inputs',
230
+ ];
231
+
232
+ /**
233
+ * RFC-007: stating a `toolLifecycle.observe` / `.inputs` policy block in the
234
+ * server's config is itself the explicit grant of that path — the operator
235
+ * who wrote a narrowing meant to grant what it narrows.
236
+ */
237
+ function configGrants(config: Pick<McplServerConfig, 'toolLifecycle'>, path: string): boolean {
238
+ const key = path === 'toolLifecycle.observe' ? 'observe' : path === 'toolLifecycle.inputs' ? 'inputs' : null;
239
+ if (!key) return false;
240
+ const block = (config.toolLifecycle as Record<string, unknown> | undefined)?.[key];
241
+ if (block === undefined) return false;
242
+ // Only a policy OBJECT is a grant. `observe: false` (or null, or a string)
243
+ // must never read as "granted with no narrowing".
244
+ if (!isPlainObject(block)) {
245
+ console.error(
246
+ `[mcpl] toolLifecycle.${key} is ${JSON.stringify(block)}, not a policy object — ` +
247
+ `not granted by this block (RFC-007 §4.1)`,
248
+ );
249
+ return false;
250
+ }
251
+ return true;
252
+ }
222
253
 
223
254
  export class CapabilityGrant {
224
255
  /** Exact granted paths (already expanded — no patterns stored). */
@@ -286,7 +317,7 @@ export class CapabilityGrant {
286
317
  */
287
318
  export function computeGrant(
288
319
  capabilities: McplCapabilities | null,
289
- config: Pick<McplServerConfig, 'enabledCapabilities'>,
320
+ config: Pick<McplServerConfig, 'enabledCapabilities' | 'toolLifecycle'>,
290
321
  opts?: {
291
322
  /** §5.1: `tools` is sourced from the OUTER standard MCP
292
323
  * `capabilities.tools` — pass its presence here. The experimental
@@ -304,16 +335,33 @@ export function computeGrant(
304
335
  !!config.enabledCapabilities?.some((pat) => capabilityPatternMatches(pat, path));
305
336
 
306
337
  for (const path of advertised) {
307
- if (DENY_BY_DEFAULT.includes(path) && !explicitlyEnabled(path)) {
338
+ if (DENY_BY_DEFAULT.includes(path) && !explicitlyEnabled(path) && !configGrants(config, path)) {
308
339
  denied.push(path);
309
340
  console.error(
310
- `[mcpl] "${path}" advertised but DENIED BY DEFAULT (SPEC §13.4) — ` +
311
- `grant it explicitly via enabledCapabilities if intended`,
341
+ `[mcpl] "${path}" advertised but DENIED BY DEFAULT (${path.startsWith('toolLifecycle.') ? 'RFC-007' : 'SPEC §13.4'}) — ` +
342
+ `grant it explicitly via enabledCapabilities${path.startsWith('toolLifecycle.') ? ' or a toolLifecycle policy block' : ''} if intended`,
312
343
  );
313
344
  continue;
314
345
  }
315
346
  granted.add(path);
316
347
  }
317
348
 
349
+ // RFC-007 §4.1/§4.3 diagnostics — loud, because each is a grant that
350
+ // silently delivers less than the operator probably meant.
351
+ if (granted.has('toolLifecycle.inputs')) {
352
+ if (!granted.has('toolLifecycle.observe')) {
353
+ console.error('[mcpl] toolLifecycle.inputs granted without toolLifecycle.observe — delivers nothing (RFC-007 §4.1)');
354
+ }
355
+ const n = config.toolLifecycle?.inputs;
356
+ const hasTerm = isPlainObject(n) && ((Array.isArray(n.tools) && n.tools.length > 0)
357
+ || n.classes === 'default' || (Array.isArray(n.classes) && n.classes.length > 0));
358
+ if (!hasTerm) {
359
+ console.error(
360
+ '[mcpl] toolLifecycle.inputs granted with no tools/classes narrowing — treated as narrowed to NO tools; ' +
361
+ 'inputs is never unconditional (RFC-007 §4.3)',
362
+ );
363
+ }
364
+ }
365
+
318
366
  return new CapabilityGrant(granted, denied.sort());
319
367
  }
@@ -26,6 +26,7 @@ import type {
26
26
  ChannelsIncomingParams,
27
27
  ChannelsIncomingResult,
28
28
  ChannelIncomingMessageResult,
29
+ ChannelIncomingMessage,
29
30
  ChannelsPublishParams,
30
31
  ChannelsOpenResult,
31
32
  ChannelHistoryRequest,
@@ -36,6 +37,7 @@ import type { McplServerRegistry } from './server-registry.js';
36
37
  import type { FeatureSetManager } from './feature-set-manager.js';
37
38
  import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
38
39
  import { expandCoreTags } from './tags.js';
40
+ import { validateCoalescedContent } from './push-coalescer.js';
39
41
  import { CapabilityGrant } from './capability-grant.js';
40
42
 
41
43
  // ============================================================================
@@ -401,6 +403,22 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
401
403
  required: [],
402
404
  },
403
405
  },
406
+ {
407
+ name: 'journal',
408
+ description:
409
+ 'Write an entry in your private journal. The entry stays in your own context and ' +
410
+ 'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
411
+ 'than a line that you want to keep for yourself — reflections, what you decided and ' +
412
+ 'why, notes for later. It does not end your turn and does not affect where ordinary ' +
413
+ 'text is routed; to end the turn without replying, call skip_reply afterwards.',
414
+ inputSchema: {
415
+ type: 'object' as const,
416
+ properties: {
417
+ content: { type: 'string', description: 'The journal entry (private; not sent anywhere).' },
418
+ },
419
+ required: ['content'],
420
+ },
421
+ },
404
422
  {
405
423
  name: 'skip_reply',
406
424
  description:
@@ -414,7 +432,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
414
432
  properties: {
415
433
  reason: {
416
434
  type: 'string',
417
- description: 'Optional private note on why you are not replying (not sent anywhere).',
435
+ description:
436
+ 'Optional ONE short line on why you are not replying (private; not sent anywhere). ' +
437
+ 'Keep it under ~100 characters — put anything longer in journal() first.',
418
438
  },
419
439
  wake_in_seconds: {
420
440
  type: 'number',
@@ -434,6 +454,17 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
434
454
  // ============================================================================
435
455
 
436
456
  interface ChannelRegistryOptions {
457
+ /**
458
+ * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with
459
+ * the event the ordinary path would have queued. The handler decides
460
+ * replace / append / withdraw and returns the per-message result. Throws a
461
+ * `CoalesceError` for a malformed or unauthorized occurrence.
462
+ */
463
+ handleCoalescedIncoming?: (
464
+ serverId: string,
465
+ message: ChannelIncomingMessage,
466
+ event: McplChannelIncomingEvent,
467
+ ) => Promise<ChannelIncomingMessageResult>;
437
468
  /** Chronicle store used for durable desired channel lifecycle state. */
438
469
  store?: JsStore;
439
470
  /** Callback to determine whether an incoming message should trigger inference. */
@@ -625,6 +656,7 @@ export class ChannelRegistry {
625
656
  /** One-time migration inputs from the retired recipe auto-open policy. */
626
657
  private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
627
658
  private migratedLegacyPolicies = new Set<string>();
659
+ private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming'];
628
660
 
629
661
  constructor(
630
662
  serverRegistry: McplServerRegistry,
@@ -640,6 +672,7 @@ export class ChannelRegistry {
640
672
  ) => void;
641
673
  },
642
674
  ) {
675
+ this.handleCoalescedIncoming = options?.handleCoalescedIncoming;
643
676
  this.serverRegistry = serverRegistry;
644
677
  this.featureSetManager = featureSetManager;
645
678
  this.pushEventFn = pushEventFn;
@@ -773,7 +806,8 @@ export class ChannelRegistry {
773
806
  }
774
807
  }
775
808
 
776
- // Process added channels (validate per-descriptor, register, reconcile)
809
+ // Process added channels (validate per-descriptor, register; reconcile
810
+ // after responding, below)
777
811
  const accepted: typeof params.added = [];
778
812
  if (params.added) {
779
813
  for (const channel of params.added) {
@@ -792,10 +826,18 @@ export class ChannelRegistry {
792
826
  addedResults.push({ id: channel.id, accepted: true });
793
827
  accepted.push(channel);
794
828
  }
795
- if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
796
829
  }
830
+
831
+ // Respond before reconciliation, as handleRegister does. A server that
832
+ // announces from inside a request it is serving (a tool that refreshes
833
+ // or subscribes) cannot read the channels/open or channels/close that
834
+ // reconciling sends until this response arrives; reconciling first
835
+ // deadlocks both sides until one times out (#160). The verdicts are
836
+ // settled above, so nothing in the response depends on reconciling.
797
837
  responder?.respond({ results: addedResults });
798
838
 
839
+ if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
840
+
799
841
  this.emitTraceFn({
800
842
  type: 'mcpl:channels-changed',
801
843
  serverId,
@@ -811,14 +853,23 @@ export class ChannelRegistry {
811
853
  * Converts each message's content, pushes McplChannelIncomingEvent to the
812
854
  * queue, and responds with per-message results.
813
855
  */
814
- handleIncoming(
856
+ async handleIncoming(
815
857
  serverId: string,
816
858
  params: ChannelsIncomingParams,
817
859
  responder?: Responder,
818
- ): void {
860
+ ): Promise<void> {
819
861
  const results: ChannelIncomingMessageResult[] = [];
820
862
 
821
863
  for (const message of params.messages) {
864
+ if (!message || typeof message.channelId !== 'string' || !message.channelId
865
+ || typeof message.messageId !== 'string' || !message.messageId) {
866
+ results.push({
867
+ messageId: typeof message?.messageId === 'string' ? message.messageId : '',
868
+ accepted: false,
869
+ reason: message?.coalesce !== undefined ? 'coalesce_invalid' : 'invalid channel/message identity',
870
+ });
871
+ continue;
872
+ }
822
873
  // §14.5 FIRST, before ANY semantic processing: admission against the
823
874
  // actually-registered channel precedes tag expansion and content
824
875
  // conversion — decoding an unregistered sender's payload (including
@@ -863,6 +914,17 @@ export class ChannelRegistry {
863
914
 
864
915
  // ACCEPTED from here down: semantic processing only for admitted
865
916
  // messages. §16.3 core-tag closure, then content conversion.
917
+ const coalesced = message.coalesce !== undefined && !!this.handleCoalescedIncoming;
918
+ if (coalesced) {
919
+ // RFC-006 §13: malformed content on a coalesced item is that item's
920
+ // failure, not the batch's — check the shape before converting.
921
+ try {
922
+ validateCoalescedContent(message.content);
923
+ } catch (error) {
924
+ results.push({ messageId: message.messageId, accepted: false, reason: 'coalesce_invalid' });
925
+ continue;
926
+ }
927
+ }
866
928
  if (message.tags) message.tags = expandCoreTags(message.tags);
867
929
  const convertedContent: ContentBlock[] = message.content.map(convertBlock);
868
930
 
@@ -870,16 +932,19 @@ export class ChannelRegistry {
870
932
  // deliberately after §14.5 validation: a rejected message from an
871
933
  // unregistered channel must not retarget outbound speech (the locus is
872
934
  // exactly the authority a self-attested channel would be stealing).
873
- this.defaultPublishChannel = message.channelId;
874
- this.defaultPublishMessageId = message.messageId;
875
- this.defaultPublishThreadId = message.threadId;
876
- {
935
+ // A coalesced item is "accepted" only once the coalescer admits it, so
936
+ // for those this runs after the hook (below).
937
+ const markAccepted = () => {
938
+ this.defaultPublishChannel = message.channelId;
939
+ this.defaultPublishMessageId = message.messageId;
940
+ this.defaultPublishThreadId = message.threadId;
877
941
  // A server sending channels/incoming is authoritative evidence that
878
942
  // the transport is actually open. This repairs transient status only;
879
943
  // durable desired state still changes exclusively through lifecycle
880
944
  // operations.
881
945
  this.channels.get(incomingKey)!.open = true;
882
- }
946
+ };
947
+ if (!coalesced) markAccepted();
883
948
 
884
949
  // Determine whether to trigger inference
885
950
  let triggerInference = true;
@@ -918,6 +983,26 @@ export class ChannelRegistry {
918
983
  triggerInference,
919
984
  };
920
985
 
986
+ if (coalesced) {
987
+ // RFC-006 §14.3: a coalesced message is admitted like any other and
988
+ // then handed, with the event the ordinary path would have queued, to
989
+ // the coalescer, which replaces, appends or withdraws. Malformed
990
+ // `coalesce` is a per-message failure; siblings are unaffected.
991
+ try {
992
+ const result = await this.handleCoalescedIncoming!(serverId, message, event);
993
+ if (result.accepted) markAccepted();
994
+ results.push(result);
995
+ } catch (error) {
996
+ const err = error as Error & { code?: number };
997
+ results.push({
998
+ messageId: message.messageId,
999
+ accepted: false,
1000
+ reason: err.code === -32602 ? 'coalesce_invalid' : err.message,
1001
+ });
1002
+ }
1003
+ continue;
1004
+ }
1005
+
921
1006
  // Push to the processing queue
922
1007
  // Cast through unknown because McplChannelIncomingEvent matches the
923
1008
  // CustomEvent `${string}:${string}` type pattern but lacks an index signature.
@@ -1128,6 +1213,18 @@ export class ChannelRegistry {
1128
1213
  * Get the descriptor for a channel by its channelId (first match across
1129
1214
  * servers). Used by the conversation router for DM classification.
1130
1215
  */
1216
+ /**
1217
+ * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
1218
+ * declaration (channels/register, channels/changed) or an authorized open?
1219
+ * A placeholder minted from a push's `origin` (ensureChannelRegistered)
1220
+ * does not count: a channel id appearing in an untrusted field is not a
1221
+ * registration.
1222
+ */
1223
+ isDeclaredChannel(serverId: string, channelId: string): boolean {
1224
+ const entry = this.channels.get(`${serverId}:${channelId}`);
1225
+ return !!entry && !(entry.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered;
1226
+ }
1227
+
1131
1228
  getDescriptor(channelId: string): ChannelDescriptor | undefined {
1132
1229
  return this.findChannelEntry(channelId)?.descriptor;
1133
1230
  }
@@ -1218,6 +1315,9 @@ export class ChannelRegistry {
1218
1315
  case 'think':
1219
1316
  return this.handleToolThink(input as { content?: string });
1220
1317
 
1318
+ case 'journal':
1319
+ return this.handleToolJournal(input as { content?: string });
1320
+
1221
1321
  case 'skip_reply':
1222
1322
  return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
1223
1323
 
@@ -2345,6 +2445,44 @@ export class ChannelRegistry {
2345
2445
  return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
2346
2446
  }
2347
2447
 
2448
+ /**
2449
+ * The `>>` target to show an agent for a channel: one whitespace-free token
2450
+ * that `resolveProseTarget()` maps back to this same channel. The prefix
2451
+ * grammar takes the target as the first non-whitespace run, so a label with
2452
+ * a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:`
2453
+ * plus body `alice …`, and `>>#fable (antra's server)` delivers
2454
+ * `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`,
2455
+ * `#name` (label without its server suffix), the descriptor id.
2456
+ *
2457
+ * Undefined when there is no safe token: the channel isn't registered (on
2458
+ * `serverId`, when given), no candidate is whitespace-free and resolves back,
2459
+ * or the id is registered by more than one server. A resolved target names a
2460
+ * channel by id alone, and ids are unique only within a connection, so a
2461
+ * shared id could route the reply through the wrong server.
2462
+ */
2463
+ proseTargetFor(channelId: string, serverId?: string): string | undefined {
2464
+ const sameId = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
2465
+ const entry = serverId ? sameId.find((e) => e.serverId === serverId) : sameId[0];
2466
+ if (!entry || sameId.length > 1) return undefined;
2467
+ const d = entry.descriptor;
2468
+ const label = d.label ?? '';
2469
+ const meta = d.metadata as { channelType?: string; recipientName?: string } | undefined;
2470
+ const isDm = meta?.channelType === 'dm' || label.toLowerCase().startsWith('dm: ') || d.id.includes(':dm:');
2471
+ const dmName = meta?.recipientName ?? (label.toLowerCase().startsWith('dm: ') ? label.slice(4) : undefined);
2472
+ const bare = label.replace(/^#/, '');
2473
+ const candidates = [
2474
+ ...(isDm && dmName ? [`@${dmName}`] : []),
2475
+ ...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []),
2476
+ d.id,
2477
+ ];
2478
+ for (const c of candidates) {
2479
+ if (/\s/.test(c) || c === '#') continue;
2480
+ const r = this.resolveProseTarget(c);
2481
+ if ('channelId' in r && r.channelId === d.id) return c;
2482
+ }
2483
+ return undefined;
2484
+ }
2485
+
2348
2486
  /**
2349
2487
  * Open a channel because something was DELIVERED into it (explicit send
2350
2488
  * tool or routed speech). Sending into a closed channel is not a thing:
@@ -2670,6 +2808,28 @@ export class ChannelRegistry {
2670
2808
  };
2671
2809
  }
2672
2810
 
2811
+ /**
2812
+ * Handle the synthesized `journal` tool — a private place for long-form
2813
+ * notes. Sends nothing, does not end the turn, does not touch prose routing.
2814
+ *
2815
+ * Why it exists (sill, 2026-09-19): residents were keeping 2–3KB diaries in
2816
+ * `skip_reply.reason`. Long prose in a private-REASONING tool argument
2817
+ * (`skip_reply.reason`, `think.content`) makes replayed history read as a
2818
+ * reasoning trace, and every memory-compression request over it is refused
2819
+ * `reasoning_extraction` regardless of content; the same prose in a
2820
+ * note-taking tool passes (canary record: context-manager
2821
+ * `tool-prose-hoist.ts`, whose fallback rung rewrites old history into calls
2822
+ * to THIS tool — so the result wording below is mirrored there as
2823
+ * DEFAULT_TOOL_PROSE_RESULT; keep the two in step).
2824
+ */
2825
+ private handleToolJournal(_input: { content?: string }): ToolResult {
2826
+ return {
2827
+ success: true,
2828
+ // No echo: the entry is already in the tool_use block.
2829
+ data: { recorded: true, note: 'Journal entry recorded (private — not sent anywhere).' },
2830
+ };
2831
+ }
2832
+
2673
2833
  /**
2674
2834
  * Handle the synthesized `skip_reply` tool — the deliberate "stay silent"
2675
2835
  * signal. A no-op as far as any surface is concerned (sends nothing); its
package/src/mcpl/index.ts CHANGED
@@ -118,6 +118,35 @@ export { ScopeManager, type ElevationHandler } from './scope-manager.js';
118
118
  // Hook orchestration (beforeInference/afterInference fan-out)
119
119
  export { HookOrchestrator } from './hook-orchestrator.js';
120
120
 
121
+ export {
122
+ ToolLifecycleEmitter,
123
+ parseToolObserveParams,
124
+ selectFields,
125
+ boundInput,
126
+ openingFor,
127
+ DEFAULT_MAX_INPUT_BYTES,
128
+ TOOL_OBSERVE_LIMITS,
129
+ type ToolPhase,
130
+ type ToolLifecycleParams,
131
+ type ToolObserveRule,
132
+ type ToolObserveMatch,
133
+ type ToolLifecycleConfig,
134
+ type ToolLifecycleNarrowing,
135
+ } from './tool-lifecycle.js';
136
+ export {
137
+ TOOL_CLASSES,
138
+ TOOL_CLASS_META_KEY,
139
+ DEFAULT_INPUT_CLASSES,
140
+ BUILTIN_TOOL_CLASSES,
141
+ parseDeclaredClasses,
142
+ resolveToolClass,
143
+ isToolClass,
144
+ type ToolClass,
145
+ type ToolClassSource,
146
+ type EffectiveToolClass,
147
+ } from './tool-classes.js';
148
+ export { globMatch } from './tool-glob.js';
149
+
121
150
  // Push events (Section 9)
122
151
  export { PushHandler } from './push-handler.js';
123
152
  export type { McplPushEvent } from './push-handler.js';