@animalabs/agent-framework 0.6.5 → 0.6.7

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 (155) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +56 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +372 -57
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/canonical-json.d.ts +2 -0
  11. package/dist/src/canonical-json.d.ts.map +1 -0
  12. package/dist/src/canonical-json.js +23 -0
  13. package/dist/src/canonical-json.js.map +1 -0
  14. package/dist/src/framework.d.ts +160 -2
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +3098 -167
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts.map +1 -1
  19. package/dist/src/gate/event-gate.js +34 -4
  20. package/dist/src/gate/event-gate.js.map +1 -1
  21. package/dist/src/index.d.ts +4 -0
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +3 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/mcpl/channel-registry.d.ts +36 -15
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +322 -76
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/push-handler.d.ts +1 -0
  30. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  31. package/dist/src/mcpl/push-handler.js +1 -0
  32. package/dist/src/mcpl/push-handler.js.map +1 -1
  33. package/dist/src/mcpl/server-connection.d.ts +39 -4
  34. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  35. package/dist/src/mcpl/server-connection.js +146 -28
  36. package/dist/src/mcpl/server-connection.js.map +1 -1
  37. package/dist/src/mcpl/types.d.ts +43 -12
  38. package/dist/src/mcpl/types.d.ts.map +1 -1
  39. package/dist/src/mcpl/types.js +1 -0
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/modules/workspace/index.d.ts +12 -1
  42. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/index.js +661 -2
  44. package/dist/src/modules/workspace/index.js.map +1 -1
  45. package/dist/src/modules/workspace/types.d.ts +4 -0
  46. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  47. package/dist/src/modules/workspace/types.js.map +1 -1
  48. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  49. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  50. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  51. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  52. package/dist/src/recovery/offline-branch.d.ts +45 -0
  53. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  54. package/dist/src/recovery/offline-branch.js +362 -0
  55. package/dist/src/recovery/offline-branch.js.map +1 -0
  56. package/dist/src/recovery/recover-cli.d.ts +3 -0
  57. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  58. package/dist/src/recovery/recover-cli.js +129 -0
  59. package/dist/src/recovery/recover-cli.js.map +1 -0
  60. package/dist/src/types/agent.d.ts +37 -0
  61. package/dist/src/types/agent.d.ts.map +1 -1
  62. package/dist/src/types/events.d.ts +1 -0
  63. package/dist/src/types/events.d.ts.map +1 -1
  64. package/dist/src/types/framework.d.ts +14 -0
  65. package/dist/src/types/framework.d.ts.map +1 -1
  66. package/dist/src/types/module.d.ts +31 -0
  67. package/dist/src/types/module.d.ts.map +1 -1
  68. package/dist/src/types/trace.d.ts +4 -0
  69. package/dist/src/types/trace.d.ts.map +1 -1
  70. package/dist/test/agent-runtime-settings.test.d.ts +2 -0
  71. package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
  72. package/dist/test/agent-runtime-settings.test.js +227 -0
  73. package/dist/test/agent-runtime-settings.test.js.map +1 -0
  74. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  75. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  76. package/dist/test/busy-agent-scheduler.test.js +62 -0
  77. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  78. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  79. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  80. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  81. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  82. package/dist/test/channel-registry-routing.test.js +5 -4
  83. package/dist/test/channel-registry-routing.test.js.map +1 -1
  84. package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
  85. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  86. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  87. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  88. package/dist/test/discord-awareness-outbox.test.js +248 -0
  89. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  90. package/dist/test/event-gate.test.js +21 -1
  91. package/dist/test/event-gate.test.js.map +1 -1
  92. package/dist/test/helpers/mock-membrane.d.ts +13 -1
  93. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  94. package/dist/test/helpers/mock-membrane.js +9 -2
  95. package/dist/test/helpers/mock-membrane.js.map +1 -1
  96. package/dist/test/maintenance-timer.test.js +103 -1
  97. package/dist/test/maintenance-timer.test.js.map +1 -1
  98. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  99. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  100. package/dist/test/mcpl-awareness-barrier.test.js +845 -0
  101. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  102. package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
  103. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  104. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  105. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  106. package/dist/test/mcpl-request-timeout.test.js +15 -0
  107. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  108. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  109. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  110. package/dist/test/offline-recovery-branch.test.js +226 -0
  111. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  112. package/dist/test/present-while-acting.test.d.ts +19 -0
  113. package/dist/test/present-while-acting.test.d.ts.map +1 -0
  114. package/dist/test/present-while-acting.test.js +295 -0
  115. package/dist/test/present-while-acting.test.js.map +1 -0
  116. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
  117. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
  118. package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
  119. package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
  120. package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
  121. package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
  122. package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
  123. package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
  124. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  125. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  126. package/dist/test/same-round-think-policy.test.js +853 -0
  127. package/dist/test/same-round-think-policy.test.js.map +1 -0
  128. package/dist/test/trunk-channel-routing.test.js +38 -0
  129. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  130. package/dist/test/workspace-read-image.test.d.ts +2 -0
  131. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  132. package/dist/test/workspace-read-image.test.js +518 -0
  133. package/dist/test/workspace-read-image.test.js.map +1 -0
  134. package/dist/tsconfig.tsbuildinfo +1 -1
  135. package/package.json +6 -4
  136. package/src/agent.ts +485 -63
  137. package/src/api/server.ts +8 -2
  138. package/src/canonical-json.ts +22 -0
  139. package/src/framework.ts +4096 -304
  140. package/src/gate/event-gate.ts +50 -4
  141. package/src/index.ts +20 -0
  142. package/src/mcpl/channel-registry.ts +396 -85
  143. package/src/mcpl/push-handler.ts +2 -0
  144. package/src/mcpl/server-connection.ts +173 -28
  145. package/src/mcpl/types.ts +49 -12
  146. package/src/modules/workspace/index.ts +759 -2
  147. package/src/modules/workspace/types.ts +5 -0
  148. package/src/recovery/discord-awareness-outbox.ts +459 -0
  149. package/src/recovery/offline-branch.ts +510 -0
  150. package/src/recovery/recover-cli.ts +130 -0
  151. package/src/types/agent.ts +44 -0
  152. package/src/types/events.ts +1 -1
  153. package/src/types/framework.ts +17 -0
  154. package/src/types/module.ts +33 -0
  155. package/src/types/trace.ts +4 -0
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Responsibilities:
8
8
  * - Register/unregister channel descriptors from MCPL servers
9
- * - Auto-open channels on registration
9
+ * - Reconcile actual channel state to Chronicle-backed desired state
10
10
  * - Route incoming messages to the processing queue
11
11
  * - Manage typing indicator timers (7s interval for Discord compatibility)
12
12
  * - Expose synthesized tools: channel_list, channel_open, channel_close, channel_publish
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { ContentBlock } from '@animalabs/membrane';
17
+ import type { JsStore } from '@animalabs/chronicle';
17
18
 
18
19
  import type {
19
20
  ChannelDescriptor,
@@ -25,6 +26,8 @@ import type {
25
26
  ChannelsIncomingResult,
26
27
  ChannelIncomingMessageResult,
27
28
  ChannelsPublishParams,
29
+ ChannelsOpenResult,
30
+ ChannelHistoryRequest,
28
31
  McplContentBlock,
29
32
  } from './types.js';
30
33
 
@@ -37,6 +40,20 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js
37
40
  // ============================================================================
38
41
 
39
42
  const TYPING_INTERVAL_MS = 7_000;
43
+ const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
44
+
45
+ type DesiredChannelState = 'open' | 'closed';
46
+
47
+ interface ChannelLifecycleEvent {
48
+ kind: 'desired-state' | 'legacy-policy-migrated' | 'invitation-declined';
49
+ serverId: string;
50
+ timestamp: string;
51
+ channelId?: string;
52
+ desired?: DesiredChannelState;
53
+ source?: string;
54
+ messageId?: string;
55
+ acknowledgment?: string;
56
+ }
40
57
 
41
58
  function shallowEqualRecord(
42
59
  a: Record<string, unknown> | undefined,
@@ -141,15 +158,47 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
141
158
  },
142
159
  {
143
160
  name: 'channel_open',
144
- description: 'Open a channel to start receiving messages',
161
+ description:
162
+ 'Open a channel to start receiving its ordinary ongoing traffic. The MCPL ' +
163
+ 'integration performs its own subscribe/join/attach operation. Optionally request ' +
164
+ 'history preceding the message that invited you into the channel.',
145
165
  inputSchema: {
146
166
  type: 'object' as const,
147
167
  properties: {
148
168
  channelId: { type: 'string', description: 'ID of the channel to open' },
169
+ serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
170
+ backscroll: {
171
+ type: 'number',
172
+ description: 'Number of earlier messages to return while opening (0-500; default 0).',
173
+ },
174
+ beforeMessageId: {
175
+ type: 'string',
176
+ description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.',
177
+ },
149
178
  },
150
179
  required: ['channelId'],
151
180
  },
152
181
  },
182
+ {
183
+ name: 'channel_decline',
184
+ description:
185
+ 'Deliberately remain closed after being addressed in a closed channel. Optionally ' +
186
+ 'post a public acknowledgment through the MCPL integration. Acknowledgment is ' +
187
+ 'opt-in; omitting it declines silently.',
188
+ inputSchema: {
189
+ type: 'object' as const,
190
+ properties: {
191
+ channelId: { type: 'string', description: 'Channel from the invitation notice.' },
192
+ serverId: { type: 'string', description: 'Owning MCPL server from the invitation notice.' },
193
+ messageId: { type: 'string', description: 'Triggering message to acknowledge.' },
194
+ acknowledge: {
195
+ type: 'string',
196
+ description: 'Optional surface value such as 👀. Omit for a silent decline.',
197
+ },
198
+ },
199
+ required: ['channelId', 'messageId'],
200
+ },
201
+ },
153
202
  {
154
203
  name: 'channel_close',
155
204
  description: 'Close a channel to stop receiving messages',
@@ -157,6 +206,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
157
206
  type: 'object' as const,
158
207
  properties: {
159
208
  channelId: { type: 'string', description: 'ID of the channel to close' },
209
+ serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
160
210
  },
161
211
  required: ['channelId'],
162
212
  },
@@ -178,10 +228,10 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
178
228
  name: 'think',
179
229
  description:
180
230
  'Reason privately. The content stays in your own context and is NOT sent to any ' +
181
- 'channel or surface. This does NOT end or silence your turn: if you also write ' +
182
- 'plain text this turn, that text is still posted as your reply. Use think() purely ' +
183
- 'to work things out before (or instead of) speaking. To deliberately NOT reply this ' +
184
- 'turn, call skip_reply instead.',
231
+ 'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
232
+ 'your current same_round_think_text_policy; inspect or change it with agent_settings. ' +
233
+ 'Use think() purely to work things out before (or instead of) speaking. To deliberately ' +
234
+ 'NOT reply this turn, call skip_reply instead.',
185
235
  inputSchema: {
186
236
  type: 'object' as const,
187
237
  properties: {
@@ -218,6 +268,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
218
268
  // ============================================================================
219
269
 
220
270
  interface ChannelRegistryOptions {
271
+ /** Chronicle store used for durable desired channel lifecycle state. */
272
+ store?: JsStore;
221
273
  /** Callback to determine whether an incoming message should trigger inference. */
222
274
  shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
223
275
  /**
@@ -285,6 +337,7 @@ export class ChannelRegistry {
285
337
  }) => void;
286
338
  private homeChannelResolver?: (agentName: string) => string | undefined;
287
339
  private activeChannelResolver?: (agentName: string) => string | undefined;
340
+ private store?: JsStore;
288
341
 
289
342
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
290
343
  private channels = new Map<string, ChannelEntry>();
@@ -303,12 +356,14 @@ export class ChannelRegistry {
303
356
  * server keeps getting the same routing hints (e.g. Zulip topic). */
304
357
  private typingMetadata = new Map<string, Record<string, unknown>>();
305
358
 
306
- /**
307
- * Per-server channel auto-open policy. Populated by the framework from
308
- * each McplServerConfig at connect time; default ('auto') applies when
309
- * unset.
310
- */
311
- private subscriptionPolicies = new Map<string, 'auto' | 'manual' | string[]>();
359
+ /** Chronicle-projected desired lifecycle state, keyed by server + channel.
360
+ * Provenance is kept so reconcile can tell a pure default (nobody ever
361
+ * decided) from a real decision (agent-tool, invitation-declined, …). */
362
+ private desiredStates = new Map<string, { state: DesiredChannelState; source: string }>();
363
+
364
+ /** One-time migration inputs from the retired recipe auto-open policy. */
365
+ private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
366
+ private migratedLegacyPolicies = new Set<string>();
312
367
 
313
368
  constructor(
314
369
  serverRegistry: McplServerRegistry,
@@ -333,14 +388,17 @@ export class ChannelRegistry {
333
388
  this.onRouteFailure = options?.onRouteFailure;
334
389
  this.homeChannelResolver = options?.homeChannelResolver;
335
390
  this.activeChannelResolver = options?.activeChannelResolver;
391
+ this.store = options?.store;
392
+ this.initializeLifecycleStore();
336
393
  }
337
394
 
338
395
  /**
339
- * Set the channel auto-open policy for a server. Called by the framework
340
- * when each MCPL server is registered.
396
+ * Supply a legacy recipe policy for one-time migration into Chronicle.
341
397
  */
342
398
  setSubscriptionPolicy(serverId: string, policy: 'auto' | 'manual' | string[]): void {
343
- this.subscriptionPolicies.set(serverId, policy);
399
+ // Backward-compatible recipe ingestion only. The policy is consumed once
400
+ // to seed Chronicle, then never applied to newly discovered channels.
401
+ this.legacyPolicies.set(serverId, policy);
344
402
  }
345
403
 
346
404
  // ==========================================================================
@@ -350,7 +408,7 @@ export class ChannelRegistry {
350
408
  /**
351
409
  * Handle `channels/register` from a server.
352
410
  *
353
- * Registers each channel descriptor in the map and auto-opens them.
411
+ * Registers descriptors and reconciles them to durable desired state.
354
412
  */
355
413
  async handleRegister(
356
414
  serverId: string,
@@ -369,13 +427,15 @@ export class ChannelRegistry {
369
427
  registeredIds.push(channel.id);
370
428
  }
371
429
 
372
- // Respond before auto-opening — the server blocks on this response and
430
+ // Respond before reconciliation — the server blocks on this response and
373
431
  // can't process channels/open until it arrives.
374
432
  const result: ChannelsRegisterResult = { registered: registeredIds };
375
433
  responder?.respond(result);
376
434
 
377
- // Auto-open all registered channels
378
- await this.autoOpenChannels(serverId, params.channels);
435
+ // One-time migration from the retired recipe policy, then reconcile the
436
+ // server to Chronicle-backed desired state.
437
+ this.migrateLegacyPolicy(serverId, params.channels);
438
+ await this.reconcileChannels(serverId, params.channels);
379
439
 
380
440
  this.emitTraceFn({
381
441
  type: 'mcpl:channels-register',
@@ -388,7 +448,7 @@ export class ChannelRegistry {
388
448
  /**
389
449
  * Handle `channels/changed` notification from a server.
390
450
  *
391
- * Processes added (register + auto-open), removed (delete + stop typing),
451
+ * Processes added (register + reconcile), removed (delete + stop typing),
392
452
  * and updated (replace descriptor) channels.
393
453
  */
394
454
  async handleChanged(
@@ -415,7 +475,7 @@ export class ChannelRegistry {
415
475
  }
416
476
  }
417
477
 
418
- // Process added channels (register + auto-open)
478
+ // Process added channels (register + reconcile durable desired state)
419
479
  if (params.added) {
420
480
  for (const channel of params.added) {
421
481
  const key = `${serverId}:${channel.id}`;
@@ -425,7 +485,7 @@ export class ChannelRegistry {
425
485
  open: false,
426
486
  });
427
487
  }
428
- await this.autoOpenChannels(serverId, params.added);
488
+ await this.reconcileChannels(serverId, params.added);
429
489
  }
430
490
 
431
491
  this.emitTraceFn({
@@ -494,6 +554,12 @@ export class ChannelRegistry {
494
554
  channelId: message.channelId,
495
555
  label: channelLabel,
496
556
  });
557
+ } else {
558
+ // A server sending channels/incoming is authoritative evidence that
559
+ // the transport is actually open. This repairs transient status only;
560
+ // durable desired state still changes exclusively through lifecycle
561
+ // operations.
562
+ this.channels.get(incomingKey)!.open = true;
497
563
  }
498
564
 
499
565
  // Determine whether to trigger inference
@@ -556,8 +622,9 @@ export class ChannelRegistry {
556
622
  }
557
623
 
558
624
  /**
559
- * Ensure a channel is registered AND open so it can serve as an outbound
560
- * routing locus, lazy-registering it if unseen and opening it if closed.
625
+ * Ensure a channel is registered so it can serve as an outbound routing
626
+ * locus. A push event from a closed channel must never mutate lifecycle
627
+ * state: direct-address events are intentionally usable without subscribing.
561
628
  *
562
629
  * Mirrors the lazy-registration inside handleIncoming(), but for channels
563
630
  * that only ever arrive as push/events rather than channels/incoming — the
@@ -567,15 +634,11 @@ export class ChannelRegistry {
567
634
  * silently dropped even though the inbound message proves the channel is
568
635
  * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
569
636
  * channels/register or channels/changed overwrites this descriptor with the
570
- * richer one; re-opening an already-open channel is a no-op.
637
+ * richer one.
571
638
  */
572
639
  ensureChannelRegistered(serverId: string, channelId: string, label?: string): void {
573
640
  const existing = this.findChannelEntry(channelId);
574
641
  if (existing) {
575
- if (!existing.open) {
576
- existing.open = true;
577
- this.emitTraceFn({ type: 'mcpl:channel-opened', serverId: existing.serverId, channelId });
578
- }
579
642
  return;
580
643
  }
581
644
 
@@ -589,7 +652,7 @@ export class ChannelRegistry {
589
652
  direction: 'bidirectional',
590
653
  metadata: { lazyRegistered: true },
591
654
  },
592
- open: true,
655
+ open: false,
593
656
  });
594
657
  this.emitTraceFn({
595
658
  type: 'mcpl:channel-lazy-registered',
@@ -713,6 +776,14 @@ export class ChannelRegistry {
713
776
  return this.findChannelEntry(channelId)?.descriptor;
714
777
  }
715
778
 
779
+ isChannelOpen(channelId: string): boolean {
780
+ return this.findChannelEntry(channelId)?.open === true;
781
+ }
782
+
783
+ getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
784
+ return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state;
785
+ }
786
+
716
787
  /**
717
788
  * Get all open channels.
718
789
  */
@@ -746,10 +817,23 @@ export class ChannelRegistry {
746
817
  return this.handleToolList();
747
818
 
748
819
  case 'channel_open':
749
- return this.handleToolOpen(input as { channelId: string });
820
+ return this.handleToolOpen(input as {
821
+ channelId: string;
822
+ serverId?: string;
823
+ backscroll?: number;
824
+ beforeMessageId?: string;
825
+ });
750
826
 
751
827
  case 'channel_close':
752
- return this.handleToolClose(input as { channelId: string });
828
+ return this.handleToolClose(input as { channelId: string; serverId?: string });
829
+
830
+ case 'channel_decline':
831
+ return this.handleToolDecline(input as {
832
+ channelId: string;
833
+ serverId?: string;
834
+ messageId: string;
835
+ acknowledge?: string;
836
+ });
753
837
 
754
838
  case 'channel_publish':
755
839
  return this.handleToolPublish(input as { channelId: string; content: string });
@@ -839,7 +923,7 @@ export class ChannelRegistry {
839
923
  this.defaultPublishThreadId = undefined;
840
924
  }
841
925
  }
842
- this.subscriptionPolicies.delete(serverId);
926
+ // Desired state and migration markers deliberately survive disconnects.
843
927
  }
844
928
 
845
929
  /**
@@ -862,65 +946,162 @@ export class ChannelRegistry {
862
946
  }
863
947
 
864
948
  // ==========================================================================
865
- // Private: Auto-open channels
949
+ // Private: Durable desired state and reconciliation
866
950
  // ==========================================================================
867
951
 
868
- private async autoOpenChannels(
952
+ private lifecycleKey(serverId: string, channelId: string): string {
953
+ return `${serverId}\u0000${channelId}`;
954
+ }
955
+
956
+ private initializeLifecycleStore(): void {
957
+ if (!this.store) return;
958
+
959
+ try {
960
+ this.store.registerState({ id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
961
+ } catch (error) {
962
+ if (!(error instanceof Error) || !error.message.includes('State already exists')) {
963
+ throw error;
964
+ }
965
+ }
966
+
967
+ const raw = this.store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
968
+ if (!Array.isArray(raw)) return;
969
+
970
+ for (const item of raw) {
971
+ if (!item || typeof item !== 'object') continue;
972
+ const event = item as Partial<ChannelLifecycleEvent>;
973
+ if (typeof event.serverId !== 'string') continue;
974
+ if (
975
+ event.kind === 'desired-state' &&
976
+ typeof event.channelId === 'string' &&
977
+ (event.desired === 'open' || event.desired === 'closed')
978
+ ) {
979
+ this.desiredStates.set(
980
+ this.lifecycleKey(event.serverId, event.channelId),
981
+ { state: event.desired, source: typeof event.source === "string" ? event.source : "unknown" },
982
+ );
983
+ } else if (event.kind === 'legacy-policy-migrated') {
984
+ this.migratedLegacyPolicies.add(event.serverId);
985
+ }
986
+ }
987
+ }
988
+
989
+ private appendLifecycleEvent(event: ChannelLifecycleEvent): void {
990
+ this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
991
+ }
992
+
993
+ private setDesiredState(
869
994
  serverId: string,
870
- channels: ChannelDescriptor[],
871
- ): Promise<void> {
872
- const server = this.serverRegistry.getServer(serverId);
873
- if (!server) return;
995
+ channelId: string,
996
+ desired: DesiredChannelState,
997
+ source: string,
998
+ ): void {
999
+ const key = this.lifecycleKey(serverId, channelId);
1000
+ if (this.desiredStates.get(key)?.state === desired) return;
1001
+ this.desiredStates.set(key, { state: desired, source });
1002
+ this.appendLifecycleEvent({
1003
+ kind: 'desired-state',
1004
+ serverId,
1005
+ channelId,
1006
+ desired,
1007
+ source,
1008
+ timestamp: new Date().toISOString(),
1009
+ });
1010
+ }
874
1011
 
875
- const policy = this.subscriptionPolicies.get(serverId) ?? 'auto';
876
- if (policy === 'manual') {
877
- this.emitTraceFn({
878
- type: 'mcpl:channels-auto-open-skipped',
879
- serverId,
880
- reason: 'manual',
881
- count: channels.length,
882
- });
1012
+ /**
1013
+ * Consume the old recipe policy exactly once. It seeds Chronicle for
1014
+ * existing deployments, but is not an ongoing admission policy: channels
1015
+ * discovered later use their server bootstrap preference, otherwise closed.
1016
+ */
1017
+ private migrateLegacyPolicy(serverId: string, channels: ChannelDescriptor[]): void {
1018
+ if (this.migratedLegacyPolicies.has(serverId)) return;
1019
+
1020
+ const policy = this.legacyPolicies.get(serverId) ?? 'manual';
1021
+ const allowList = Array.isArray(policy) ? new Set(policy) : undefined;
1022
+ for (const channel of channels) {
1023
+ if (this.getDesiredState(serverId, channel.id)) continue;
1024
+ const desired: DesiredChannelState = channel.initiallyOpen === true ||
1025
+ policy === 'auto' || allowList?.has(channel.id)
1026
+ ? 'open'
1027
+ : 'closed';
1028
+ this.setDesiredState(serverId, channel.id, desired, 'legacy-recipe-migration');
1029
+ }
1030
+
1031
+ this.migratedLegacyPolicies.add(serverId);
1032
+ this.appendLifecycleEvent({
1033
+ kind: 'legacy-policy-migrated',
1034
+ serverId,
1035
+ source: Array.isArray(policy) ? 'allow-list' : policy,
1036
+ timestamp: new Date().toISOString(),
1037
+ });
1038
+ }
1039
+
1040
+ private ensureInitialDesiredState(serverId: string, channel: ChannelDescriptor): void {
1041
+ const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
1042
+ if (existing) {
1043
+ // A server upgrading its descriptor to initiallyOpen may lift a pure
1044
+ // default — 'default-closed' means nobody ever decided. Real decisions
1045
+ // (agent-tool, invitation-declined, legacy migration, …) always stick.
1046
+ if (channel.initiallyOpen === true && existing.state === 'closed' && existing.source === 'default-closed') {
1047
+ this.setDesiredState(serverId, channel.id, 'open', 'server-bootstrap');
1048
+ }
883
1049
  return;
884
1050
  }
1051
+ this.setDesiredState(
1052
+ serverId,
1053
+ channel.id,
1054
+ channel.initiallyOpen === true ? 'open' : 'closed',
1055
+ channel.initiallyOpen === true ? 'server-bootstrap' : 'default-closed',
1056
+ );
1057
+ }
885
1058
 
886
- const allowList = Array.isArray(policy) ? new Set(policy) : null;
887
- let opened = 0;
1059
+ private async reconcileChannels(
1060
+ serverId: string,
1061
+ channels: ChannelDescriptor[],
1062
+ ): Promise<void> {
1063
+ const server = this.serverRegistry.getServer(serverId);
1064
+ if (!server) return;
888
1065
 
889
1066
  for (const channel of channels) {
890
- if (allowList && !allowList.has(channel.id)) continue;
891
-
1067
+ this.ensureInitialDesiredState(serverId, channel);
892
1068
  const key = `${serverId}:${channel.id}`;
1069
+ const desired = this.getDesiredState(serverId, channel.id);
1070
+ const entry = this.channels.get(key);
1071
+ if (desired !== 'open') {
1072
+ try {
1073
+ await server.sendChannelsClose({ channelId: channel.id });
1074
+ if (entry) entry.open = false;
1075
+ } catch (err) {
1076
+ this.emitTraceFn({
1077
+ type: 'mcpl:channel-reconcile-failed',
1078
+ serverId,
1079
+ channelId: channel.id,
1080
+ desired,
1081
+ error: (err as Error).message,
1082
+ });
1083
+ }
1084
+ continue;
1085
+ }
1086
+
893
1087
  try {
894
1088
  await server.sendChannelsOpen({
1089
+ channelId: channel.id,
895
1090
  type: channel.type,
896
1091
  address: channel.address,
897
1092
  });
898
- const entry = this.channels.get(key);
899
- if (entry) {
900
- entry.open = true;
901
- }
902
- opened++;
1093
+ if (entry) entry.open = true;
903
1094
  } catch (err) {
1095
+ if (entry) entry.open = false;
904
1096
  this.emitTraceFn({
905
- type: 'mcpl:channel-open-failed',
1097
+ type: 'mcpl:channel-reconcile-failed',
906
1098
  serverId,
907
1099
  channelId: channel.id,
1100
+ desired,
908
1101
  error: (err as Error).message,
909
1102
  });
910
1103
  }
911
1104
  }
912
-
913
- // Surface the common typo failure mode: allow-list set but nothing
914
- // matched. Without this trace a one-char typo in a channel id looks
915
- // identical to "MCPL didn't register the channel".
916
- if (allowList && opened === 0 && channels.length > 0) {
917
- this.emitTraceFn({
918
- type: 'mcpl:channels-allow-list-empty',
919
- serverId,
920
- allowList: [...allowList],
921
- registered: channels.map(c => c.id),
922
- });
923
- }
924
1105
  }
925
1106
 
926
1107
  // ==========================================================================
@@ -963,6 +1144,28 @@ export class ChannelRegistry {
963
1144
  return undefined;
964
1145
  }
965
1146
 
1147
+ private resolveToolChannelEntry(
1148
+ channelId: string,
1149
+ serverId?: string,
1150
+ ): { entry?: ChannelEntry; error?: string } {
1151
+ const matches = [...this.channels.values()].filter(
1152
+ (entry) => entry.descriptor.id === channelId && (!serverId || entry.serverId === serverId),
1153
+ );
1154
+ if (matches.length === 0) {
1155
+ return {
1156
+ error: serverId
1157
+ ? `Channel not found: ${channelId} on server ${serverId}`
1158
+ : `Channel not found: ${channelId}`,
1159
+ };
1160
+ }
1161
+ if (matches.length > 1) {
1162
+ return {
1163
+ error: `Channel id is ambiguous across MCPL servers: ${channelId}. Include serverId.`,
1164
+ };
1165
+ }
1166
+ return { entry: matches[0] };
1167
+ }
1168
+
966
1169
  /**
967
1170
  * Find the composite key for a channel by its channelId.
968
1171
  */
@@ -986,6 +1189,7 @@ export class ChannelRegistry {
986
1189
  label: string;
987
1190
  direction: string;
988
1191
  open: boolean;
1192
+ desired: DesiredChannelState | 'unknown';
989
1193
  serverId: string;
990
1194
  }> = [];
991
1195
 
@@ -996,6 +1200,7 @@ export class ChannelRegistry {
996
1200
  label: entry.descriptor.label,
997
1201
  direction: entry.descriptor.direction,
998
1202
  open: entry.open,
1203
+ desired: this.getDesiredState(entry.serverId, entry.descriptor.id) ?? 'unknown',
999
1204
  serverId: entry.serverId,
1000
1205
  });
1001
1206
  }
@@ -1006,23 +1211,32 @@ export class ChannelRegistry {
1006
1211
  };
1007
1212
  }
1008
1213
 
1009
- private async handleToolOpen(input: { channelId: string }): Promise<ToolResult> {
1010
- const entry = this.findChannelEntry(input.channelId);
1214
+ private async handleToolOpen(input: {
1215
+ channelId: string;
1216
+ serverId?: string;
1217
+ backscroll?: number;
1218
+ beforeMessageId?: string;
1219
+ }): Promise<ToolResult> {
1220
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1221
+ const entry = resolved.entry;
1011
1222
  if (!entry) {
1012
1223
  return {
1013
1224
  success: false,
1014
- error: `Channel not found: ${input.channelId}`,
1225
+ error: resolved.error,
1015
1226
  isError: true,
1016
1227
  };
1017
1228
  }
1018
1229
 
1019
- if (entry.open) {
1230
+ const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
1231
+ if (entry.open && alreadyDesiredOpen && !input.backscroll) {
1020
1232
  return {
1021
1233
  success: true,
1022
1234
  data: { channelId: input.channelId, status: 'already open' },
1023
1235
  };
1024
1236
  }
1025
1237
 
1238
+ this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
1239
+
1026
1240
  const server = this.serverRegistry.getServer(entry.serverId);
1027
1241
  if (!server) {
1028
1242
  return {
@@ -1033,14 +1247,27 @@ export class ChannelRegistry {
1033
1247
  }
1034
1248
 
1035
1249
  try {
1036
- await server.sendChannelsOpen({
1250
+ const history: ChannelHistoryRequest | undefined = input.backscroll
1251
+ ? {
1252
+ limit: Math.max(0, Math.min(500, Math.floor(input.backscroll))),
1253
+ ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
1254
+ }
1255
+ : undefined;
1256
+ const result: ChannelsOpenResult = await server.sendChannelsOpen({
1257
+ channelId: entry.descriptor.id,
1037
1258
  type: entry.descriptor.type,
1038
1259
  address: entry.descriptor.address,
1260
+ ...(history ? { history } : {}),
1039
1261
  });
1040
1262
  entry.open = true;
1041
1263
  return {
1042
1264
  success: true,
1043
- data: { channelId: input.channelId, status: 'opened' },
1265
+ data: {
1266
+ channelId: input.channelId,
1267
+ status: alreadyDesiredOpen ? 'reconciled' : 'opened',
1268
+ ...(result.history ? { history: result.history } : {}),
1269
+ ...(result.historyTruncated ? { historyTruncated: true } : {}),
1270
+ },
1044
1271
  };
1045
1272
  } catch (err) {
1046
1273
  return {
@@ -1051,23 +1278,28 @@ export class ChannelRegistry {
1051
1278
  }
1052
1279
  }
1053
1280
 
1054
- private async handleToolClose(input: { channelId: string }): Promise<ToolResult> {
1055
- const entry = this.findChannelEntry(input.channelId);
1281
+ private async handleToolClose(input: { channelId: string; serverId?: string }): Promise<ToolResult> {
1282
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1283
+ const entry = resolved.entry;
1056
1284
  if (!entry) {
1057
1285
  return {
1058
1286
  success: false,
1059
- error: `Channel not found: ${input.channelId}`,
1287
+ error: resolved.error,
1060
1288
  isError: true,
1061
1289
  };
1062
1290
  }
1063
1291
 
1064
- if (!entry.open) {
1292
+ const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
1293
+ if (!entry.open && alreadyDesiredClosed) {
1065
1294
  return {
1066
1295
  success: true,
1067
1296
  data: { channelId: input.channelId, status: 'already closed' },
1068
1297
  };
1069
1298
  }
1070
1299
 
1300
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
1301
+ this.stopTyping(input.channelId);
1302
+
1071
1303
  const server = this.serverRegistry.getServer(entry.serverId);
1072
1304
  if (!server) {
1073
1305
  return {
@@ -1080,7 +1312,6 @@ export class ChannelRegistry {
1080
1312
  try {
1081
1313
  await server.sendChannelsClose({ channelId: input.channelId });
1082
1314
  entry.open = false;
1083
- this.stopTyping(input.channelId);
1084
1315
  return {
1085
1316
  success: true,
1086
1317
  data: { channelId: input.channelId, status: 'closed' },
@@ -1094,6 +1325,76 @@ export class ChannelRegistry {
1094
1325
  }
1095
1326
  }
1096
1327
 
1328
+ private async handleToolDecline(input: {
1329
+ channelId: string;
1330
+ serverId?: string;
1331
+ messageId: string;
1332
+ acknowledge?: string;
1333
+ }): Promise<ToolResult> {
1334
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1335
+ const entry = resolved.entry;
1336
+ if (!entry) {
1337
+ return { success: false, error: resolved.error, isError: true };
1338
+ }
1339
+ if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
1340
+ return {
1341
+ success: false,
1342
+ error: `Channel is already open: ${input.channelId}. Use channel_close to leave it.`,
1343
+ isError: true,
1344
+ };
1345
+ }
1346
+
1347
+ // The lifecycle decision is authoritative even if the optional public
1348
+ // acknowledgment cannot be rendered by the surface.
1349
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
1350
+
1351
+ let acknowledged = false;
1352
+ let representation: string | undefined;
1353
+ let acknowledgmentError: string | undefined;
1354
+ if (input.acknowledge) {
1355
+ const server = this.serverRegistry.getServer(entry.serverId);
1356
+ if (!server) {
1357
+ acknowledgmentError = `Server not found: ${entry.serverId}`;
1358
+ } else {
1359
+ try {
1360
+ const result = await server.sendChannelsAcknowledge({
1361
+ channelId: input.channelId,
1362
+ messageId: input.messageId,
1363
+ intent: 'seen-not-opening',
1364
+ value: input.acknowledge,
1365
+ });
1366
+ acknowledged = result.acknowledged;
1367
+ representation = result.representation;
1368
+ if (!acknowledged) {
1369
+ acknowledgmentError = result.reason ??
1370
+ 'The channel integration could not post the acknowledgment.';
1371
+ }
1372
+ } catch (error) {
1373
+ acknowledgmentError = (error as Error).message;
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ this.appendLifecycleEvent({
1379
+ kind: 'invitation-declined',
1380
+ serverId: entry.serverId,
1381
+ channelId: input.channelId,
1382
+ messageId: input.messageId,
1383
+ acknowledgment: representation ?? input.acknowledge,
1384
+ timestamp: new Date().toISOString(),
1385
+ });
1386
+ return {
1387
+ success: true,
1388
+ data: {
1389
+ channelId: input.channelId,
1390
+ status: 'remained closed',
1391
+ acknowledged,
1392
+ ...(representation ? { representation } : {}),
1393
+ ...(acknowledgmentError ? { acknowledgmentError } : {}),
1394
+ },
1395
+ };
1396
+ }
1397
+
1097
1398
  /**
1098
1399
  * Handle the synthesized `think` tool — a private reasoning scratchpad. It
1099
1400
  * sends nothing, and (unlike before) does NOT silence the turn: trailing
@@ -1103,12 +1404,14 @@ export class ChannelRegistry {
1103
1404
  private handleToolThink(input: { content?: string }): ToolResult {
1104
1405
  return {
1105
1406
  success: true,
1407
+ // Same echo-avoidance as skip_reply: the thought text is already in the
1408
+ // tool_use block; don't duplicate it in the result.
1106
1409
  data: {
1107
1410
  noted: true,
1108
1411
  note:
1109
- 'Thought recorded (private — not sent anywhere). This did NOT silence your turn: ' +
1110
- 'write plain text to reply, or call skip_reply to end the turn without replying.',
1111
- content: typeof input?.content === 'string' ? input.content : undefined,
1412
+ 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
1413
+ 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' +
1414
+ 'call skip_reply to end the turn without replying.',
1112
1415
  },
1113
1416
  };
1114
1417
  }
@@ -1123,10 +1426,18 @@ export class ChannelRegistry {
1123
1426
  private handleToolSkipReply(input: { reason?: string }): ToolResult {
1124
1427
  return {
1125
1428
  success: true,
1429
+ // The note says "ended the turn" — make it TRUE. Without endTurn the
1430
+ // framework resumes the stream after the tool result, and a model with
1431
+ // nothing to say (told its turn already ended) just calls skip_reply
1432
+ // again: observed as a 40+ round skip_reply loop on Fable 5, burning a
1433
+ // round-trip + ~70 tokens per iteration until something kills the turn.
1434
+ endTurn: true,
1435
+ // Deliberately terse: the agent's `reason` is already in the tool_use
1436
+ // block — echoing it back doubled every skip turn's footprint (observed
1437
+ // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
1126
1438
  data: {
1127
1439
  skipped: true,
1128
- note: 'Ended the turn without replying — nothing was sent to any channel.',
1129
- reason: typeof input?.reason === 'string' ? input.reason : undefined,
1440
+ note: 'Turn ended; nothing sent.',
1130
1441
  },
1131
1442
  };
1132
1443
  }