@animalabs/agent-framework 0.7.4 → 0.9.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 (237) hide show
  1. package/dist/src/agent.d.ts +10 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +33 -3
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/code-execution/index.d.ts +1 -1
  6. package/dist/src/code-execution/index.d.ts.map +1 -1
  7. package/dist/src/code-execution/index.js.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +23 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +40 -4
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/runtime-py.d.ts +15 -2
  13. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  14. package/dist/src/code-execution/runtime-py.js +156 -7
  15. package/dist/src/code-execution/runtime-py.js.map +1 -1
  16. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  17. package/dist/src/code-execution/tool-definition.js +27 -2
  18. package/dist/src/code-execution/tool-definition.js.map +1 -1
  19. package/dist/src/framework.d.ts +234 -5
  20. package/dist/src/framework.d.ts.map +1 -1
  21. package/dist/src/framework.js +1818 -188
  22. package/dist/src/framework.js.map +1 -1
  23. package/dist/src/gate/event-gate.d.ts +11 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -1
  25. package/dist/src/gate/event-gate.js +66 -0
  26. package/dist/src/gate/event-gate.js.map +1 -1
  27. package/dist/src/index.d.ts +1 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +1 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/mcpl/capability-grant.d.ts +115 -0
  32. package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
  33. package/dist/src/mcpl/capability-grant.js +291 -0
  34. package/dist/src/mcpl/capability-grant.js.map +1 -0
  35. package/dist/src/mcpl/capability-mask.d.ts +51 -0
  36. package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
  37. package/dist/src/mcpl/capability-mask.js +190 -0
  38. package/dist/src/mcpl/capability-mask.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +30 -2
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +216 -43
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  45. package/dist/src/mcpl/checkpoint-manager.js +28 -8
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  47. package/dist/src/mcpl/errors.d.ts +6 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -1
  49. package/dist/src/mcpl/errors.js +6 -0
  50. package/dist/src/mcpl/errors.js.map +1 -1
  51. package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  53. package/dist/src/mcpl/feature-set-manager.js +45 -4
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  57. package/dist/src/mcpl/hook-orchestrator.js +58 -67
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  59. package/dist/src/mcpl/index.d.ts +1 -1
  60. package/dist/src/mcpl/index.d.ts.map +1 -1
  61. package/dist/src/mcpl/index.js.map +1 -1
  62. package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
  63. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-stream-router.js +16 -0
  65. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  66. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  67. package/dist/src/mcpl/push-handler.js +20 -2
  68. package/dist/src/mcpl/push-handler.js.map +1 -1
  69. package/dist/src/mcpl/server-connection.d.ts +93 -9
  70. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  71. package/dist/src/mcpl/server-connection.js +231 -32
  72. package/dist/src/mcpl/server-connection.js.map +1 -1
  73. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  74. package/dist/src/mcpl/server-registry.js +4 -3
  75. package/dist/src/mcpl/server-registry.js.map +1 -1
  76. package/dist/src/mcpl/tags.d.ts +24 -0
  77. package/dist/src/mcpl/tags.d.ts.map +1 -0
  78. package/dist/src/mcpl/tags.js +52 -0
  79. package/dist/src/mcpl/tags.js.map +1 -0
  80. package/dist/src/mcpl/transport.d.ts +1 -1
  81. package/dist/src/mcpl/transport.d.ts.map +1 -1
  82. package/dist/src/mcpl/transport.js +35 -7
  83. package/dist/src/mcpl/transport.js.map +1 -1
  84. package/dist/src/mcpl/types.d.ts +169 -64
  85. package/dist/src/mcpl/types.d.ts.map +1 -1
  86. package/dist/src/mcpl/types.js +16 -2
  87. package/dist/src/mcpl/types.js.map +1 -1
  88. package/dist/src/module-registry.d.ts +12 -0
  89. package/dist/src/module-registry.d.ts.map +1 -1
  90. package/dist/src/module-registry.js +26 -0
  91. package/dist/src/module-registry.js.map +1 -1
  92. package/dist/src/modules/workspace/index.d.ts +20 -0
  93. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/index.js +48 -15
  95. package/dist/src/modules/workspace/index.js.map +1 -1
  96. package/dist/src/modules/workspace/sync.d.ts +4 -0
  97. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/sync.js +1 -1
  99. package/dist/src/modules/workspace/sync.js.map +1 -1
  100. package/dist/src/refusal-reactions.d.ts +27 -0
  101. package/dist/src/refusal-reactions.d.ts.map +1 -0
  102. package/dist/src/refusal-reactions.js +35 -0
  103. package/dist/src/refusal-reactions.js.map +1 -0
  104. package/dist/src/tool-result-history.d.ts +10 -0
  105. package/dist/src/tool-result-history.d.ts.map +1 -1
  106. package/dist/src/tool-result-history.js +16 -5
  107. package/dist/src/tool-result-history.js.map +1 -1
  108. package/dist/src/types/agent.d.ts +35 -0
  109. package/dist/src/types/agent.d.ts.map +1 -1
  110. package/dist/src/types/framework.d.ts +26 -0
  111. package/dist/src/types/framework.d.ts.map +1 -1
  112. package/dist/src/types/module.d.ts +22 -0
  113. package/dist/src/types/module.d.ts.map +1 -1
  114. package/dist/src/types/trace.d.ts +24 -0
  115. package/dist/src/types/trace.d.ts.map +1 -1
  116. package/dist/test/busy-agent-scheduler.test.js +1 -0
  117. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  118. package/dist/test/channel-registry-routing.test.js +38 -10
  119. package/dist/test/channel-registry-routing.test.js.map +1 -1
  120. package/dist/test/channel-registry-typing.test.js +10 -1
  121. package/dist/test/channel-registry-typing.test.js.map +1 -1
  122. package/dist/test/checkpoint-attribution.test.js +168 -8
  123. package/dist/test/checkpoint-attribution.test.js.map +1 -1
  124. package/dist/test/code-execution.test.js +327 -1
  125. package/dist/test/code-execution.test.js.map +1 -1
  126. package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
  127. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  128. package/dist/test/deferred-wake-integrity.test.d.ts +29 -0
  129. package/dist/test/deferred-wake-integrity.test.d.ts.map +1 -0
  130. package/dist/test/deferred-wake-integrity.test.js +320 -0
  131. package/dist/test/deferred-wake-integrity.test.js.map +1 -0
  132. package/dist/test/event-gate-selfwake.test.d.ts +15 -0
  133. package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
  134. package/dist/test/event-gate-selfwake.test.js +125 -0
  135. package/dist/test/event-gate-selfwake.test.js.map +1 -0
  136. package/dist/test/machine-close-provenance.test.d.ts +2 -0
  137. package/dist/test/machine-close-provenance.test.d.ts.map +1 -0
  138. package/dist/test/machine-close-provenance.test.js +116 -0
  139. package/dist/test/machine-close-provenance.test.js.map +1 -0
  140. package/dist/test/mcpl-access-provider.test.d.ts +2 -0
  141. package/dist/test/mcpl-access-provider.test.d.ts.map +1 -0
  142. package/dist/test/mcpl-access-provider.test.js +20 -0
  143. package/dist/test/mcpl-access-provider.test.js.map +1 -0
  144. package/dist/test/mcpl-awareness-barrier.test.js +10 -0
  145. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  146. package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
  147. package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
  148. package/dist/test/mcpl-capability-grant.test.js +109 -0
  149. package/dist/test/mcpl-capability-grant.test.js.map +1 -0
  150. package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
  151. package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
  152. package/dist/test/mcpl-capability-scoping.test.js +266 -0
  153. package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
  154. package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
  155. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  156. package/dist/test/mcpl-list-status.test.d.ts +2 -0
  157. package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
  158. package/dist/test/mcpl-list-status.test.js +78 -0
  159. package/dist/test/mcpl-list-status.test.js.map +1 -0
  160. package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
  161. package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
  162. package/dist/test/mcpl-manifest-changed.test.js +177 -0
  163. package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
  164. package/dist/test/mcpl-reconnect-events.test.js +27 -2
  165. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  166. package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
  167. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  168. package/dist/test/mcpl-request-timeout.test.js +1 -1
  169. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  170. package/dist/test/module-context-integration.test.d.ts +18 -0
  171. package/dist/test/module-context-integration.test.d.ts.map +1 -0
  172. package/dist/test/module-context-integration.test.js +181 -0
  173. package/dist/test/module-context-integration.test.js.map +1 -0
  174. package/dist/test/physical-window-restart.test.d.ts +14 -0
  175. package/dist/test/physical-window-restart.test.d.ts.map +1 -0
  176. package/dist/test/physical-window-restart.test.js +222 -0
  177. package/dist/test/physical-window-restart.test.js.map +1 -0
  178. package/dist/test/present-while-acting.test.js +270 -0
  179. package/dist/test/present-while-acting.test.js.map +1 -1
  180. package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
  181. package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
  182. package/dist/test/refusal-reaction-baseline.test.js +74 -0
  183. package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
  184. package/dist/test/state-scaling-e2e.test.js +7 -1
  185. package/dist/test/state-scaling-e2e.test.js.map +1 -1
  186. package/dist/test/state-scaling.test.js +9 -1
  187. package/dist/test/state-scaling.test.js.map +1 -1
  188. package/dist/test/tool-result-spill.test.d.ts +13 -0
  189. package/dist/test/tool-result-spill.test.d.ts.map +1 -0
  190. package/dist/test/tool-result-spill.test.js +463 -0
  191. package/dist/test/tool-result-spill.test.js.map +1 -0
  192. package/dist/test/trunk-channel-routing.test.js +45 -0
  193. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  194. package/dist/test/utils-surface.test.d.ts +2 -0
  195. package/dist/test/utils-surface.test.d.ts.map +1 -0
  196. package/dist/test/utils-surface.test.js +145 -0
  197. package/dist/test/utils-surface.test.js.map +1 -0
  198. package/dist/test/workspace-read-image.test.js +71 -0
  199. package/dist/test/workspace-read-image.test.js.map +1 -1
  200. package/dist/test/workspace-restore-order.test.d.ts +16 -0
  201. package/dist/test/workspace-restore-order.test.d.ts.map +1 -0
  202. package/dist/test/workspace-restore-order.test.js +140 -0
  203. package/dist/test/workspace-restore-order.test.js.map +1 -0
  204. package/dist/tsconfig.tsbuildinfo +1 -1
  205. package/package.json +4 -4
  206. package/src/agent.ts +37 -3
  207. package/src/code-execution/index.ts +1 -0
  208. package/src/code-execution/py-runner.ts +56 -4
  209. package/src/code-execution/runtime-py.ts +156 -7
  210. package/src/code-execution/tool-definition.ts +27 -2
  211. package/src/framework.ts +2080 -203
  212. package/src/gate/event-gate.ts +72 -0
  213. package/src/index.ts +1 -0
  214. package/src/mcpl/capability-grant.ts +319 -0
  215. package/src/mcpl/capability-mask.ts +228 -0
  216. package/src/mcpl/channel-registry.ts +265 -43
  217. package/src/mcpl/checkpoint-manager.ts +28 -8
  218. package/src/mcpl/errors.ts +7 -0
  219. package/src/mcpl/feature-set-manager.ts +50 -5
  220. package/src/mcpl/hook-orchestrator.ts +64 -86
  221. package/src/mcpl/index.ts +0 -2
  222. package/src/mcpl/prose-stream-router.ts +17 -0
  223. package/src/mcpl/push-handler.ts +18 -2
  224. package/src/mcpl/server-connection.ts +262 -34
  225. package/src/mcpl/server-registry.ts +4 -3
  226. package/src/mcpl/tags.ts +50 -0
  227. package/src/mcpl/transport.ts +37 -7
  228. package/src/mcpl/types.ts +187 -78
  229. package/src/module-registry.ts +34 -0
  230. package/src/modules/workspace/index.ts +49 -15
  231. package/src/modules/workspace/sync.ts +1 -1
  232. package/src/refusal-reactions.ts +37 -0
  233. package/src/tool-result-history.ts +17 -5
  234. package/src/types/agent.ts +36 -0
  235. package/src/types/framework.ts +27 -0
  236. package/src/types/module.ts +24 -0
  237. package/src/types/trace.ts +22 -0
@@ -12,6 +12,8 @@
12
12
  * - Expose synthesized tools: channel_list, channel_open, channel_close, channel_publish
13
13
  * - Build channel context for beforeInference params
14
14
  */
15
+ import { expandCoreTags } from './tags.js';
16
+ import { CapabilityGrant } from './capability-grant.js';
15
17
  // ============================================================================
16
18
  // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
17
19
  // ============================================================================
@@ -166,7 +168,8 @@ const CHANNEL_TOOL_DEFINITIONS = [
166
168
  description: 'End your turn WITHOUT sending anything to any channel or surface. Use when you have ' +
167
169
  'read the messages but deliberately choose not to reply right now — ambient chatter, ' +
168
170
  'nothing to add, or you are waiting. Any plain text you wrote this turn stays private ' +
169
- 'and is NOT posted. To reply instead, just write plain text (no tool call).',
171
+ 'and is NOT posted. To reply instead, just write plain text (no tool call). ' +
172
+ 'To end this turn but come back on your own shortly, set wake_in_seconds.',
170
173
  inputSchema: {
171
174
  type: 'object',
172
175
  properties: {
@@ -174,14 +177,21 @@ const CHANNEL_TOOL_DEFINITIONS = [
174
177
  type: 'string',
175
178
  description: 'Optional private note on why you are not replying (not sent anywhere).',
176
179
  },
180
+ wake_in_seconds: {
181
+ type: 'number',
182
+ description: 'Optional self-wake: if nothing else wakes you first, you wake again after this ' +
183
+ 'many seconds (1 = go again almost immediately; clamped to 1–3600). Any other ' +
184
+ 'wake before then cancels it. Omit to stay idle until the next external wake.',
185
+ },
177
186
  },
178
187
  required: [],
179
188
  },
180
189
  },
181
190
  ];
182
- // ============================================================================
183
- // ChannelRegistry
184
- // ============================================================================
191
+ /** The machine decision sources a module-origin channel_close may record.
192
+ * Closed on purpose: honest provenance means a small, auditable vocabulary,
193
+ * not arbitrary self-description. */
194
+ export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
185
195
  export class ChannelRegistry {
186
196
  serverRegistry;
187
197
  featureSetManager;
@@ -245,7 +255,18 @@ export class ChannelRegistry {
245
255
  */
246
256
  async handleRegister(serverId, params, responder) {
247
257
  const registeredIds = [];
258
+ // §14.5: per-descriptor authorization with ITEMIZED results — one entry
259
+ // per submitted descriptor. Strict 0.5 servers treat a result without
260
+ // `results` (or a descriptor missing from it) as rejected, so the shape
261
+ // is interop-critical, not decorative. Rejection here is per-descriptor
262
+ // validation; the method-level channels.register gate already ran at the
263
+ // connection (§14.1).
264
+ const results = [];
248
265
  for (const channel of params.channels) {
266
+ if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
267
+ results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
268
+ continue;
269
+ }
249
270
  const key = `${serverId}:${channel.id}`;
250
271
  this.channels.set(key, {
251
272
  serverId,
@@ -253,10 +274,11 @@ export class ChannelRegistry {
253
274
  open: false,
254
275
  });
255
276
  registeredIds.push(channel.id);
277
+ results.push({ id: channel.id, accepted: true });
256
278
  }
257
279
  // Respond before reconciliation — the server blocks on this response and
258
280
  // can't process channels/open until it arrives.
259
- const result = { registered: registeredIds };
281
+ const result = { registered: registeredIds, results };
260
282
  responder?.respond(result);
261
283
  // One-time migration from the retired recipe policy, then reconcile the
262
284
  // server to Chronicle-backed desired state.
@@ -275,37 +297,67 @@ export class ChannelRegistry {
275
297
  * Processes added (register + reconcile), removed (delete + stop typing),
276
298
  * and updated (replace descriptor) channels.
277
299
  */
278
- async handleChanged(serverId, params) {
279
- // Process removed channels
300
+ async handleChanged(serverId, params, responder) {
301
+ // §14.5: dual-mode. Request form answers ITEMIZED per-descriptor
302
+ // results for added channels; Notification form filters itemwise with a
303
+ // diagnostic. Either way, added descriptors are validated exactly like
304
+ // channels/register — a changed-set cannot smuggle in what register
305
+ // would have rejected (PR #79 review blocker 5).
306
+ const addedResults = [];
307
+ // Process removed channels — itemized truthfully: removal of a channel
308
+ // we never had is reported, not silently accepted.
280
309
  if (params.removed) {
281
310
  for (const channelId of params.removed) {
282
311
  const key = `${serverId}:${channelId}`;
283
- this.channels.delete(key);
312
+ const existed = this.channels.delete(key);
284
313
  this.stopTyping(channelId);
314
+ addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
285
315
  }
286
316
  }
287
- // Process updated channels (replace descriptor, preserve open state)
317
+ // Process updated channels — validated itemwise exactly like added
318
+ // (§14.5: a changed-set cannot smuggle in what register would reject),
319
+ // and every submitted descriptor gets a verdict: a strict server reads
320
+ // absence from `results` as rejection.
288
321
  if (params.updated) {
289
322
  for (const channel of params.updated) {
323
+ if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
324
+ addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
325
+ this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
326
+ continue;
327
+ }
290
328
  const key = `${serverId}:${channel.id}`;
291
329
  const existing = this.channels.get(key);
292
- if (existing) {
293
- existing.descriptor = channel;
330
+ if (!existing) {
331
+ addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
332
+ this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
333
+ continue;
294
334
  }
335
+ existing.descriptor = channel;
336
+ addedResults.push({ id: channel.id, accepted: true });
295
337
  }
296
338
  }
297
- // Process added channels (register + reconcile durable desired state)
339
+ // Process added channels (validate per-descriptor, register, reconcile)
340
+ const accepted = [];
298
341
  if (params.added) {
299
342
  for (const channel of params.added) {
343
+ if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
344
+ addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
345
+ this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
346
+ continue;
347
+ }
300
348
  const key = `${serverId}:${channel.id}`;
301
349
  this.channels.set(key, {
302
350
  serverId,
303
351
  descriptor: channel,
304
352
  open: false,
305
353
  });
354
+ addedResults.push({ id: channel.id, accepted: true });
355
+ accepted.push(channel);
306
356
  }
307
- await this.reconcileChannels(serverId, params.added);
357
+ if (accepted.length > 0)
358
+ await this.reconcileChannels(serverId, accepted);
308
359
  }
360
+ responder?.respond({ results: addedResults });
309
361
  this.emitTraceFn({
310
362
  type: 'mcpl:channels-changed',
311
363
  serverId,
@@ -323,12 +375,11 @@ export class ChannelRegistry {
323
375
  handleIncoming(serverId, params, responder) {
324
376
  const results = [];
325
377
  for (const message of params.messages) {
326
- // Convert MCPL content blocks to membrane ContentBlocks
327
- const convertedContent = message.content.map(convertBlock);
328
- // Track default publish channel (most recent incoming)
329
- this.defaultPublishChannel = message.channelId;
330
- this.defaultPublishMessageId = message.messageId;
331
- this.defaultPublishThreadId = message.threadId;
378
+ // §14.5 FIRST, before ANY semantic processing: admission against the
379
+ // actually-registered channel precedes tag expansion and content
380
+ // conversion — decoding an unregistered sender's payload (including
381
+ // inline base64) is allocation and parsing work done for a message
382
+ // that must be rejected (Sol, PR #79 re-review blocker 2).
332
383
  // Lazy-register the channel if we've never seen it. A channel can deliver
333
384
  // an incoming message before its channels/register (boot enumeration) or
334
385
  // channels/changed (post-boot create / View-permission grant) round-trip
@@ -340,31 +391,43 @@ export class ChannelRegistry {
340
391
  // is reachable. The inbound message carries enough to make it publishable,
341
392
  // so register it here; a later authoritative channels/register or
342
393
  // channels/changed will overwrite this descriptor with the richer one.
394
+ // §14.5: channels/incoming is validated against the ACTUALLY
395
+ // REGISTERED channel. An unknown claimed channelId is rejected
396
+ // per-message with an itemized result — never minted by its first
397
+ // message (PR #79 review blocker 5: lazy-registration let a server
398
+ // self-attest a channel identity without ever passing
399
+ // channels/register authorization). A server whose channel genuinely
400
+ // exists registers it first; discord's DM case goes through
401
+ // ensureChannelRegistered on push/event, which creates a CLOSED
402
+ // routable entry rather than an open self-attested one.
343
403
  const incomingKey = `${serverId}:${message.channelId}`;
344
404
  if (!this.channels.has(incomingKey)) {
345
- const channelLabel = typeof message.metadata?.channelName === 'string' && message.metadata.channelName
346
- ? message.metadata.channelName
347
- : message.channelId;
348
- this.channels.set(incomingKey, {
349
- serverId,
350
- descriptor: {
351
- id: message.channelId,
352
- type: serverId,
353
- label: channelLabel,
354
- direction: 'bidirectional',
355
- address: message.threadId ? { threadId: message.threadId } : undefined,
356
- metadata: { lazyRegistered: true },
357
- },
358
- open: true,
359
- });
360
405
  this.emitTraceFn({
361
- type: 'mcpl:channel-lazy-registered',
406
+ type: 'mcpl:channel-incoming-rejected',
362
407
  serverId,
363
408
  channelId: message.channelId,
364
- label: channelLabel,
409
+ reason: 'unknown channel — not registered (§14.5)',
365
410
  });
411
+ results.push({
412
+ messageId: message.messageId,
413
+ accepted: false,
414
+ reason: `unknown channel "${message.channelId}" — register it first (§14.5)`,
415
+ });
416
+ continue;
366
417
  }
367
- else {
418
+ // ACCEPTED from here down: semantic processing only for admitted
419
+ // messages. §16.3 core-tag closure, then content conversion.
420
+ if (message.tags)
421
+ message.tags = expandCoreTags(message.tags);
422
+ const convertedContent = message.content.map(convertBlock);
423
+ // Track default publish channel (most recent ACCEPTED incoming) —
424
+ // deliberately after §14.5 validation: a rejected message from an
425
+ // unregistered channel must not retarget outbound speech (the locus is
426
+ // exactly the authority a self-attested channel would be stealing).
427
+ this.defaultPublishChannel = message.channelId;
428
+ this.defaultPublishMessageId = message.messageId;
429
+ this.defaultPublishThreadId = message.threadId;
430
+ {
368
431
  // A server sending channels/incoming is authoritative evidence that
369
432
  // the transport is actually open. This repairs transient status only;
370
433
  // durable desired state still changes exclusively through lifecycle
@@ -604,15 +667,24 @@ export class ChannelRegistry {
604
667
  }
605
668
  /**
606
669
  * Handle a call to one of the synthesized channel tools.
670
+ *
671
+ * `origin` is TRUSTED DISPATCH CONTEXT, supplied by the framework's own
672
+ * routing (dispatchChannelToolCall and the public executeToolCall for
673
+ * model-origin calls, the framework's private ModuleRegistry closure for
674
+ * module ctx.callTool) — never derived from tool input. Machine
675
+ * provenance on channel_close is honored only for module origin; a
676
+ * model-origin call carrying the same fields is recorded as the agent
677
+ * decision it actually is. Absent origin is treated as agent-origin (the
678
+ * untrusted-safe default).
607
679
  */
608
- async handleChannelToolCall(toolName, input) {
680
+ async handleChannelToolCall(toolName, input, origin) {
609
681
  switch (toolName) {
610
682
  case 'channel_list':
611
683
  return this.handleToolList();
612
684
  case 'channel_open':
613
685
  return this.handleToolOpen(input);
614
686
  case 'channel_close':
615
- return this.handleToolClose(input);
687
+ return this.handleToolClose(input, origin);
616
688
  case 'channel_decline':
617
689
  return this.handleToolDecline(input);
618
690
  case 'channel_publish':
@@ -838,6 +910,18 @@ export class ChannelRegistry {
838
910
  const server = this.serverRegistry.getServer(serverId);
839
911
  if (!server)
840
912
  return;
913
+ // §14.1: channels/open + channels/close require channels.lifecycle.
914
+ // Skipping reconciliation for an ungranted server is the enforcement —
915
+ // its channels simply stay in whatever state the server chose, and the
916
+ // host never directs lifecycle it was not granted authority over.
917
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
918
+ this.emitTraceFn({
919
+ type: 'mcpl:channel-reconcile-skipped',
920
+ serverId,
921
+ reason: 'channels.lifecycle not in effective grant (§14.1)',
922
+ });
923
+ return;
924
+ }
841
925
  // Channels the subscription policy freshly admitted in THIS pass —
842
926
  // announced to the agent as one batched notice after the loop, so a
843
927
  // first boot on a new policy doesn't produce one notice per channel.
@@ -913,6 +997,11 @@ export class ChannelRegistry {
913
997
  * (typing timer lifecycle is still managed for when the callback is wired).
914
998
  */
915
999
  sendTypingNotification(serverId, channelId) {
1000
+ // §14.1: channels/typing requires channels.typing in the grant. Silent
1001
+ // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
1002
+ // tick would be noise.
1003
+ if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing'))
1004
+ return;
916
1005
  if (this.sendTypingFn) {
917
1006
  this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
918
1007
  }
@@ -922,6 +1011,12 @@ export class ChannelRegistry {
922
1011
  // ==========================================================================
923
1012
  // Private: Channel Lookup
924
1013
  // ==========================================================================
1014
+ /** Descriptors registered by one server — the §14.3 channels/list answer. */
1015
+ descriptorsForServer(serverId) {
1016
+ return [...this.channels.values()]
1017
+ .filter((e) => e.serverId === serverId)
1018
+ .map((e) => e.descriptor);
1019
+ }
925
1020
  /** Server id owning a registered channel (by descriptor id), or null. */
926
1021
  getChannelServerId(channelId) {
927
1022
  return this.findChannelEntry(channelId)?.serverId ?? null;
@@ -1001,6 +1096,11 @@ export class ChannelRegistry {
1001
1096
  if (!server) {
1002
1097
  throw new Error(`Server not found: ${entry.serverId}`);
1003
1098
  }
1099
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1100
+ // Desired state is already recorded — intent sticks; reconciliation
1101
+ // will retry if the grant later widens (§6.7 expansion-on-receipt).
1102
+ throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
1103
+ }
1004
1104
  const result = await server.sendChannelsOpen({
1005
1105
  channelId: entry.descriptor.id,
1006
1106
  type: entry.descriptor.type,
@@ -1221,7 +1321,7 @@ export class ChannelRegistry {
1221
1321
  };
1222
1322
  }
1223
1323
  }
1224
- async handleToolClose(input) {
1324
+ async handleToolClose(input, origin) {
1225
1325
  const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1226
1326
  const entry = resolved.entry;
1227
1327
  if (!entry) {
@@ -1238,7 +1338,43 @@ export class ChannelRegistry {
1238
1338
  data: { channelId: input.channelId, status: 'already closed' },
1239
1339
  };
1240
1340
  }
1241
- this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
1341
+ // Provenance comes from trusted dispatch, not from self-description:
1342
+ // only module-origin calls may record a machine source, and only from
1343
+ // the closed vocabulary. Agent-origin (or origin-less — the safe
1344
+ // default for any legacy caller) records 'agent-tool' and has its
1345
+ // machine fields ignored entirely.
1346
+ const isModuleOrigin = origin?.kind === 'module';
1347
+ let closeSource = 'agent-tool';
1348
+ if (isModuleOrigin && input.source !== undefined) {
1349
+ if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
1350
+ return {
1351
+ success: false,
1352
+ isError: true,
1353
+ error: `Unknown machine close source '${input.source}'. Machine closes must use one of: ` +
1354
+ `${[...MACHINE_CLOSE_SOURCES].join(', ')}.`,
1355
+ };
1356
+ }
1357
+ closeSource = input.source;
1358
+ }
1359
+ // Housekeeping must not override stated intent (issue #5: GC closes wore
1360
+ // the agent's badge and reset explicitly-opened doors). A machine-sourced
1361
+ // close of a channel whose current desired state is an agent/operator
1362
+ // 'open' is refused — structurally, so the caller can stand down rather
1363
+ // than retry — unless it certifies an explicit idle lease.
1364
+ if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
1365
+ const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
1366
+ if (current?.state === 'open' && current.source === 'agent-tool') {
1367
+ return {
1368
+ success: false,
1369
+ isError: false,
1370
+ error: `Channel ${input.channelId} was explicitly opened (source: agent-tool); ` +
1371
+ `a '${closeSource}' close does not override stated intent. Machine closes ` +
1372
+ `of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
1373
+ data: { refusal: 'explicit-open', channelId: input.channelId },
1374
+ };
1375
+ }
1376
+ }
1377
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
1242
1378
  this.stopTyping(input.channelId);
1243
1379
  const server = this.serverRegistry.getServer(entry.serverId);
1244
1380
  if (!server) {
@@ -1249,6 +1385,13 @@ export class ChannelRegistry {
1249
1385
  };
1250
1386
  }
1251
1387
  try {
1388
+ if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
1389
+ return {
1390
+ success: false,
1391
+ error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
1392
+ isError: true,
1393
+ };
1394
+ }
1252
1395
  await server.sendChannelsClose({ channelId: input.channelId });
1253
1396
  entry.open = false;
1254
1397
  return {
@@ -1288,6 +1431,9 @@ export class ChannelRegistry {
1288
1431
  if (!server) {
1289
1432
  acknowledgmentError = `Server not found: ${entry.serverId}`;
1290
1433
  }
1434
+ else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
1435
+ acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
1436
+ }
1291
1437
  else {
1292
1438
  try {
1293
1439
  const result = await server.sendChannelsAcknowledge({
@@ -1354,6 +1500,13 @@ export class ChannelRegistry {
1354
1500
  * overloaded use of `think` for staying silent.
1355
1501
  */
1356
1502
  handleToolSkipReply(input) {
1503
+ // The self-wake itself is armed by the framework (which owns the
1504
+ // EventGate) BEFORE this dispatch; when the gate is absent the framework
1505
+ // strips the field so this confirmation stays truthful.
1506
+ const wakeSecs = Number(input.wake_in_seconds);
1507
+ const selfWake = Number.isFinite(wakeSecs) && wakeSecs > 0
1508
+ ? Math.max(1, Math.min(3600, Math.floor(wakeSecs)))
1509
+ : undefined;
1357
1510
  return {
1358
1511
  success: true,
1359
1512
  // The note says "ended the turn" — make it TRUE. Without endTurn the
@@ -1367,7 +1520,9 @@ export class ChannelRegistry {
1367
1520
  // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
1368
1521
  data: {
1369
1522
  skipped: true,
1370
- note: 'Turn ended; nothing sent.',
1523
+ note: selfWake !== undefined
1524
+ ? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
1525
+ : 'Turn ended; nothing sent.',
1371
1526
  },
1372
1527
  };
1373
1528
  }
@@ -1424,7 +1579,12 @@ export class ChannelRegistry {
1424
1579
  if (matches.length !== 1)
1425
1580
  return null;
1426
1581
  const server = this.serverRegistry.getServer(matches[0].serverId);
1427
- if (!server?.capabilities?.channels?.streaming)
1582
+ // §5.4: the GRANT gates streaming, not the raw advertisement. The old
1583
+ // `capabilities?.channels?.streaming` check was doubly wrong: undefined
1584
+ // for the boolean `channels: true` shape (masking discord-mcpl's latent
1585
+ // double-post, AUDIT-001), and pre-policy it would have sent before the
1586
+ // server was told anything was granted.
1587
+ if (!CapabilityGrant.of(server).has('channels.streaming'))
1428
1588
  return null;
1429
1589
  return server;
1430
1590
  }
@@ -1508,6 +1668,12 @@ export class ChannelRegistry {
1508
1668
  if (!server) {
1509
1669
  return fail(channelId, `server "${entry.serverId}" not found`);
1510
1670
  }
1671
+ // §14.1: channels/publish requires channels.publish in the grant. The
1672
+ // host not sending is the enforcement — a send to an ungranted server
1673
+ // would have it act on authority it was never told it has.
1674
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
1675
+ return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`);
1676
+ }
1511
1677
  const publishParams = {
1512
1678
  conversationId,
1513
1679
  channelId,
@@ -1575,6 +1741,13 @@ export class ChannelRegistry {
1575
1741
  };
1576
1742
  }
1577
1743
  try {
1744
+ if (!CapabilityGrant.of(server).has('channels.publish')) {
1745
+ return {
1746
+ success: false,
1747
+ error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
1748
+ isError: true,
1749
+ };
1750
+ }
1578
1751
  const publishParams = {
1579
1752
  conversationId: '', // Framework will fill this when wired
1580
1753
  channelId,