@animalabs/agent-framework 0.23.1 → 0.23.3

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.
@@ -130,6 +130,9 @@ export interface WakeProvenance {
130
130
  * (2026-09-30: a DM reply published into a guild channel). Ambient-only
131
131
  * batches still set nothing and keep the legacy fallback. */
132
132
  routeChannelId?: string;
133
+ /** The server whose event `routeChannelId` came from: the turn's replies
134
+ * to that channel go out through this server only. */
135
+ routeServerId?: string;
133
136
  /** Timestamp (ms) of the chosen event, so a consumer coalescing several
134
137
  * requests can order by event recency, not by flush order. */
135
138
  at?: number;
@@ -235,7 +238,9 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin
235
238
  ...(channelId ? { channelId } : {}),
236
239
  ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}),
237
240
  ...(pick.addressed ? { addressed: true } : {}),
238
- ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}),
241
+ ...(pick.addressed && pick.routeChannelId
242
+ ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) }
243
+ : {}),
239
244
  at: pick.timestamp,
240
245
  };
241
246
  }
@@ -682,7 +682,17 @@ interface ChannelRegistryOptions {
682
682
  */
683
683
  export type ChannelToolOrigin =
684
684
  | { kind: 'module'; moduleName?: string }
685
- | { kind: 'agent'; agentName: string };
685
+ | {
686
+ kind: 'agent';
687
+ agentName: string;
688
+ /** The calling turn's trigger channel with the server whose message it
689
+ * was: a channel_publish into that channel goes out through that server
690
+ * only. */
691
+ trigger?: { channelId: string; serverId: string };
692
+ /** The calling turn's locus with the server of the message that put it
693
+ * there, where that isn't the trigger: the same holds for it. */
694
+ locus?: { channelId: string; serverId: string };
695
+ };
686
696
 
687
697
  /** The machine decision sources a module-origin channel_close may record.
688
698
  * Closed on purpose: honest provenance means a small, auditable vocabulary,
@@ -772,6 +782,8 @@ export class ChannelRegistry {
772
782
 
773
783
  /** Most recent incoming channel ID — used for speech routing / default publish. */
774
784
  private defaultPublishChannel: string | null = null;
785
+ /** The server whose accepted message set defaultPublishChannel. */
786
+ private defaultPublishServer: string | null = null;
775
787
 
776
788
  /** Most recent incoming message metadata, used for buildChannelContext. */
777
789
  private defaultPublishMessageId: string | null = null;
@@ -932,7 +944,7 @@ export class ChannelRegistry {
932
944
  }
933
945
  const key = `${serverId}:${channel.id}`;
934
946
  this.registerEntry(key, serverId, channel);
935
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
947
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
936
948
  registeredIds.push(channel.id);
937
949
  results.push({ id: channel.id, accepted: true });
938
950
  }
@@ -1018,7 +1030,7 @@ export class ChannelRegistry {
1018
1030
  }
1019
1031
  }
1020
1032
  existing.descriptor = channel;
1021
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1033
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1022
1034
  addedResults.push({ id: channel.id, accepted: true });
1023
1035
  }
1024
1036
  }
@@ -1035,7 +1047,7 @@ export class ChannelRegistry {
1035
1047
  }
1036
1048
  const key = `${serverId}:${channel.id}`;
1037
1049
  this.registerEntry(key, serverId, channel);
1038
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
1050
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
1039
1051
  addedResults.push({ id: channel.id, accepted: true });
1040
1052
  accepted.push(channel);
1041
1053
  }
@@ -1237,6 +1249,7 @@ export class ChannelRegistry {
1237
1249
  const incomingEntry = this.channels.get(incomingKey)!;
1238
1250
  const markAccepted = () => {
1239
1251
  this.defaultPublishChannel = message.channelId;
1252
+ this.defaultPublishServer = serverId;
1240
1253
  this.defaultPublishMessageId = message.messageId;
1241
1254
  this.defaultPublishThreadId = message.threadId;
1242
1255
  // Where the host directs this server's lifecycle, only a receipt for
@@ -1376,7 +1389,9 @@ export class ChannelRegistry {
1376
1389
  label?: string,
1377
1390
  extraMetadata?: Record<string, unknown>,
1378
1391
  ): void {
1379
- const existing = this.findChannelEntry(channelId);
1392
+ // The pushing server's own entry: another server's entry for the same id
1393
+ // is that server's, and a push leaves it as it was.
1394
+ const existing = this.channels.get(`${serverId}:${channelId}`);
1380
1395
  if (existing) {
1381
1396
  // Backfill identity onto a bare lazy registration: a DM that first
1382
1397
  // arrived with no label/recipient info gets a usable name the next
@@ -1392,7 +1407,7 @@ export class ChannelRegistry {
1392
1407
  // Recorded before the live descriptor takes it, since this branch's
1393
1408
  // guard reads the descriptor: an append that throws leaves the
1394
1409
  // placeholder, so the next push tries again.
1395
- this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
1410
+ this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
1396
1411
  existing.descriptor.label = label;
1397
1412
  existing.descriptor.metadata = metadata;
1398
1413
  }
@@ -1412,7 +1427,7 @@ export class ChannelRegistry {
1412
1427
  // routing; only the durable write is skipped when there's no real
1413
1428
  // label to record. A real label is recorded before the entry exists:
1414
1429
  // an append that throws registers nothing, so the next push tries again.
1415
- if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
1430
+ if (label) this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
1416
1431
  const key = `${serverId}:${channelId}`;
1417
1432
  this.channels.set(key, {
1418
1433
  serverId,
@@ -1742,8 +1757,11 @@ export class ChannelRegistry {
1742
1757
  if (entry.serverId !== serverId) continue;
1743
1758
  this.channels.delete(key);
1744
1759
  this.stopTyping(entry.descriptor.id);
1745
- if (this.defaultPublishChannel === entry.descriptor.id) {
1760
+ // The fallback goes with the server whose message set it, not with
1761
+ // another server's entry for the same id.
1762
+ if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
1746
1763
  this.defaultPublishChannel = null;
1764
+ this.defaultPublishServer = null;
1747
1765
  this.defaultPublishMessageId = null;
1748
1766
  this.defaultPublishThreadId = undefined;
1749
1767
  }
@@ -1766,6 +1784,7 @@ export class ChannelRegistry {
1766
1784
 
1767
1785
  // Reset default publish tracking
1768
1786
  this.defaultPublishChannel = null;
1787
+ this.defaultPublishServer = null;
1769
1788
  this.defaultPublishMessageId = null;
1770
1789
  this.defaultPublishThreadId = undefined;
1771
1790
  }
@@ -1910,11 +1929,17 @@ export class ChannelRegistry {
1910
1929
  * `resolveProseTargetDurable()`.
1911
1930
  */
1912
1931
  private appendLabelSighting(
1932
+ serverId: string,
1913
1933
  channelId: string,
1914
1934
  label: string | undefined,
1915
1935
  dmMeta: { recipientId?: string; recipientName?: string; isDm?: boolean } = {},
1916
1936
  ): void {
1917
1937
  if (typeof label !== 'string' || !label) return;
1938
+ // The log is kept by the bare id, and a durable lookup by label resolves
1939
+ // to that id with no server. So a server's label for an id another
1940
+ // server registers isn't recorded: it would name the other server's
1941
+ // channel, across restarts.
1942
+ if (this.registeredByAnotherServer(serverId, channelId)) return;
1918
1943
  const { recipientId, recipientName, isDm } = dmMeta;
1919
1944
  // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1920
1945
  // `label ?? channelId` when no real label was ever supplied — e.g. a
@@ -2735,11 +2760,28 @@ export class ChannelRegistry {
2735
2760
  .map((e) => e.descriptor);
2736
2761
  }
2737
2762
 
2763
+ /**
2764
+ * The server whose accepted message set the global fallback, where
2765
+ * `channelId` is the fallback's channel: a turn whose locus is the
2766
+ * fallback resolves it with this server, taken with the locus.
2767
+ */
2768
+ fallbackServerFor(channelId: string): string | undefined {
2769
+ return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
2770
+ }
2771
+
2738
2772
  /** Server id owning a registered channel (by descriptor id), or null. */
2739
2773
  getChannelServerId(channelId: string): string | null {
2740
2774
  return this.findChannelEntry(channelId)?.serverId ?? null;
2741
2775
  }
2742
2776
 
2777
+ /** Whether a server other than `serverId` registers `channelId`. */
2778
+ private registeredByAnotherServer(serverId: string, channelId: string): boolean {
2779
+ for (const entry of this.channels.values()) {
2780
+ if (entry.descriptor.id === channelId && entry.serverId !== serverId) return true;
2781
+ }
2782
+ return false;
2783
+ }
2784
+
2743
2785
  /**
2744
2786
  * Find a channel entry by channelId (searches across all servers).
2745
2787
  * Returns the first match.
@@ -3488,8 +3530,11 @@ export class ChannelRegistry {
3488
3530
  inferenceId: string,
3489
3531
  index: number,
3490
3532
  delta: string,
3533
+ /** The server the channel resolves with, as the speech's locus does
3534
+ * (deliverSpeech's locusServerId). */
3535
+ serverId?: string,
3491
3536
  ): void {
3492
- const server = this.streamingServerFor(channelId);
3537
+ const server = this.streamingServerFor(channelId, serverId);
3493
3538
  if (!server) return;
3494
3539
  try {
3495
3540
  server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
@@ -3502,8 +3547,10 @@ export class ChannelRegistry {
3502
3547
  conversationId: string,
3503
3548
  inferenceId: string,
3504
3549
  text: string,
3550
+ /** As sendOutgoingChunk's. */
3551
+ serverId?: string,
3505
3552
  ): void {
3506
- const server = this.streamingServerFor(channelId);
3553
+ const server = this.streamingServerFor(channelId, serverId);
3507
3554
  if (!server) return;
3508
3555
  try {
3509
3556
  server.sendChannelsOutgoingComplete({
@@ -3515,14 +3562,18 @@ export class ChannelRegistry {
3515
3562
  } catch { /* observer surface — never disturb the turn */ }
3516
3563
  }
3517
3564
 
3518
- private streamingServerFor(channelId: string) {
3565
+ private streamingServerFor(channelId: string, serverId?: string) {
3519
3566
  // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
3520
- // bare descriptor ids (what resolveProseTarget returns). Scan like the
3521
- // resolver does, and fail closed on cross-server ambiguity — the same
3522
- // never-guess rule that governs delivery.
3567
+ // bare descriptor ids (what resolveProseTarget returns). With the server
3568
+ // the speech resolves with, only that server's entry; otherwise scan like
3569
+ // the resolver does, and fail closed on cross-server ambiguity — the
3570
+ // same never-guess rule that governs delivery.
3523
3571
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
3524
- if (matches.length !== 1) return null;
3525
- const server = this.serverRegistry.getServer(matches[0]!.serverId);
3572
+ const entry = serverId !== undefined
3573
+ ? matches.find((e) => e.serverId === serverId)
3574
+ : matches.length === 1 ? matches[0] : undefined;
3575
+ if (!entry) return null;
3576
+ const server = this.serverRegistry.getServer(entry.serverId);
3526
3577
  // §5.4: the GRANT gates streaming, not the raw advertisement. The old
3527
3578
  // `capabilities?.channels?.streaming` check was doubly wrong: undefined
3528
3579
  // for the boolean `channels: true` shape (masking discord-mcpl's latent
@@ -3762,8 +3813,13 @@ export class ChannelRegistry {
3762
3813
  locusChannelId: string | null,
3763
3814
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3764
3815
  * draft with its own notice, so no failure marker is written for it.
3765
- * An `unknown` outcome still gets its marker. */
3766
- opts?: { heldOnFailure?: boolean },
3816
+ * An `unknown` outcome still gets its marker. `locusServerId`: the
3817
+ * server the locus resolves with, snapshotted by the caller with the
3818
+ * locus: the server whose message triggered the turn, where the locus is
3819
+ * that message's channel. The speech goes out through that server only,
3820
+ * and fails where it no longer registers the channel. Without it, the
3821
+ * locus is taken only where exactly one server registers it. */
3822
+ opts?: { heldOnFailure?: boolean; locusServerId?: string },
3767
3823
  ): Promise<PublishOutcome> {
3768
3824
  const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => {
3769
3825
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
@@ -3806,7 +3862,11 @@ export class ChannelRegistry {
3806
3862
  return fail(null, reason, { status: 'failed', reason, at: Date.now() });
3807
3863
  }
3808
3864
 
3809
- const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
3865
+ const outcome = await this.publish(conversationId, text, {
3866
+ ...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
3867
+ channelId: locusChannelId,
3868
+ routed: true,
3869
+ });
3810
3870
  const channelId = outcome.destination?.channelId ?? locusChannelId;
3811
3871
  if (outcome.status !== 'delivered') {
3812
3872
  return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
@@ -3829,13 +3889,16 @@ export class ChannelRegistry {
3829
3889
  return outcome;
3830
3890
  }
3831
3891
 
3832
- /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
3892
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was
3893
+ * none). `opts.locusServerId` is deliverSpeech's. */
3833
3894
  async routeSpeech(
3834
3895
  conversationId: string,
3835
3896
  text: string,
3836
3897
  locusChannelId: string | null,
3898
+ opts?: { locusServerId?: string },
3837
3899
  ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
3838
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
3900
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId,
3901
+ opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
3839
3902
  if (outcome.status !== 'delivered') return null;
3840
3903
  return {
3841
3904
  delivered: true,
@@ -3845,7 +3908,7 @@ export class ChannelRegistry {
3845
3908
  }
3846
3909
 
3847
3910
  private async handleToolPublish(
3848
- input: { channelId?: string; content?: string; text?: string },
3911
+ input: { channelId?: string; serverId?: string; content?: string; text?: string },
3849
3912
  origin?: ChannelToolOrigin,
3850
3913
  ): Promise<ToolResult> {
3851
3914
  // Resolve content: accept both `content` and `text` (backward compat)
@@ -3873,11 +3936,30 @@ export class ChannelRegistry {
3873
3936
  };
3874
3937
  }
3875
3938
 
3876
- const entry = this.findChannelEntry(channelId);
3939
+ // A serverId the call gives names the exact pair, as the other channel
3940
+ // tools read theirs (this tool's schema doesn't declare one); with no
3941
+ // channelId, it's the default channel on that server. A call naming no
3942
+ // server takes the calling turn's server for its trigger's or locus's
3943
+ // channel; else, for the default channel, the server whose accepted
3944
+ // message set it; else a channelId only where exactly one server
3945
+ // registers it.
3946
+ const serverId = input.serverId;
3947
+ const trigger = !serverId && origin?.kind === 'agent'
3948
+ ? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
3949
+ : undefined;
3950
+ const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
3951
+ ? this.fallbackServerFor(channelId)
3952
+ : undefined;
3953
+ const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
3954
+ const entry = resolved.entry;
3877
3955
  if (!entry) {
3878
3956
  return {
3879
3957
  success: false,
3880
- error: `Channel not found: ${channelId}`,
3958
+ error: trigger !== undefined
3959
+ ? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
3960
+ : defaultServer !== undefined
3961
+ ? `Channel ${channelId}, which this call took as its default, is no longer registered by the server whose message set that default, so nothing was published.`
3962
+ : resolved.error!,
3881
3963
  isError: true,
3882
3964
  };
3883
3965
  }
@@ -108,6 +108,10 @@ export interface ConversationRouterConfig {
108
108
 
109
109
  export interface ConversationBinding {
110
110
  channelId: string;
111
+ /** The channel's MCPL server, where the binding was made with one: channel
112
+ * ids are unique only within one server's connection (MCPL §14.2), so each
113
+ * server's channel has its own binding. */
114
+ serverId?: string;
111
115
  agentName: string;
112
116
  /** Engagement counter per channel — fresh forks get a new generation. */
113
117
  generation: number;
@@ -157,11 +161,15 @@ export const DEFAULT_CLOSURE_PROMPT =
157
161
  export class ConversationRouter {
158
162
  private config: ConversationRouterConfig;
159
163
 
160
- /** Active bindings, keyed by channelId. */
164
+ /** Active bindings, keyed by the channel's server and id (bindingKey):
165
+ * two servers' channels with one id are two conversations. */
161
166
  private bindings = new Map<string, ConversationBinding>();
162
167
 
163
- /** Engagement counter per channel — survives unbind, so a re-bound channel
164
- * gets a distinct fork agent name. */
168
+ /** Engagement counter per channel id — survives unbind, so a re-bound
169
+ * channel gets a distinct fork agent name. Kept by bare id, as it's
170
+ * persisted: two servers sharing an id share a counter, which still never
171
+ * repeats a name, and their forks' names differ anyway, since each hashes
172
+ * its server (forkAgentName). */
165
173
  private generations = new Map<string, number>();
166
174
 
167
175
  constructor(config: ConversationRouterConfig) {
@@ -191,7 +199,7 @@ export class ConversationRouter {
191
199
  * unchanged; a failed spawn doesn't leave a dangling route.
192
200
  */
193
201
  route(facts: IncomingMessageFacts): RoutingDecision {
194
- const existing = this.bindings.get(facts.channelId);
202
+ const existing = this.bindings.get(bindingKey(facts));
195
203
 
196
204
  if (existing) {
197
205
  return {
@@ -223,33 +231,57 @@ export class ConversationRouter {
223
231
  * Record a binding after the framework successfully spawned the fork.
224
232
  * Returns the new binding.
225
233
  */
226
- bind(channelId: string, agentName: string, generation: number, now = Date.now()): ConversationBinding {
227
- this.generations.set(channelId, generation);
234
+ bind(channel: string | ConversationChannel, agentName: string, generation: number, now = Date.now()): ConversationBinding {
235
+ const { channelId, serverId } = asChannel(channel);
236
+ // The counter is kept by bare id, and two servers' spawns on one id can
237
+ // run beside each other, so a bind can finish after a later spawn's: the
238
+ // counter keeps the higher, or the next spawn would propose a fork name
239
+ // already used.
240
+ this.generations.set(channelId, Math.max(this.generations.get(channelId) ?? 0, generation));
228
241
  const binding: ConversationBinding = {
229
242
  channelId,
243
+ ...(serverId !== undefined ? { serverId } : {}),
230
244
  agentName,
231
245
  generation,
232
246
  boundAt: now,
233
247
  lastActivity: now,
234
248
  };
235
- this.bindings.set(channelId, binding);
249
+ this.bindings.set(bindingKey({ channelId, serverId }), binding);
236
250
  return binding;
237
251
  }
238
252
 
239
253
  /** Record delivered activity on an existing binding. Does not bind a
240
254
  * channel or advance its generation. Call after successful delivery, not
241
255
  * when previewing a route. The optional clock supports deterministic use. */
242
- touch(channelId: string, now = Date.now()): void {
243
- const binding = this.bindings.get(channelId);
244
- if (binding) binding.lastActivity = now;
256
+ touch(channel: string | ConversationChannel, now = Date.now()): void {
257
+ const found = this.findBinding(channel);
258
+ if (found) found[1].lastActivity = now;
259
+ }
260
+
261
+ unbind(channel: string | ConversationChannel): void {
262
+ const found = this.findBinding(channel);
263
+ if (found) this.bindings.delete(found[0]);
245
264
  }
246
265
 
247
- unbind(channelId: string): void {
248
- this.bindings.delete(channelId);
266
+ getBinding(channel: string | ConversationChannel): ConversationBinding | undefined {
267
+ return this.findBinding(channel)?.[1];
249
268
  }
250
269
 
251
- getBinding(channelId: string): ConversationBinding | undefined {
252
- return this.bindings.get(channelId);
270
+ /**
271
+ * The binding a caller names, with its key. A channel given with its server
272
+ * names that server's binding only. A bare id, as callers gave it before
273
+ * bindings had servers, names the binding made with no server, else the one
274
+ * binding for that id; where several servers' channels with that id are
275
+ * bound, it names none.
276
+ */
277
+ private findBinding(channel: string | ConversationChannel): [string, ConversationBinding] | undefined {
278
+ const named = asChannel(channel);
279
+ const key = bindingKey(named);
280
+ const exact = this.bindings.get(key);
281
+ if (exact) return [key, exact];
282
+ if (named.serverId !== undefined) return undefined;
283
+ const matches = [...this.bindings.entries()].filter(([, b]) => b.channelId === named.channelId);
284
+ return matches.length === 1 ? matches[0] : undefined;
253
285
  }
254
286
 
255
287
  /** Reverse lookup: the channel an agent is bound to, if any. */
@@ -317,6 +349,25 @@ export class ConversationRouter {
317
349
  }
318
350
  }
319
351
 
352
+ /**
353
+ * A channel as the router's bindings take it: its id, with the MCPL server it
354
+ * belongs to where the caller has one. A bare id, as callers gave it before,
355
+ * is the channel with no server.
356
+ */
357
+ export interface ConversationChannel {
358
+ channelId: string;
359
+ serverId?: string;
360
+ }
361
+
362
+ function asChannel(channel: string | ConversationChannel): ConversationChannel {
363
+ return typeof channel === 'string' ? { channelId: channel } : channel;
364
+ }
365
+
366
+ /** A binding's key: its channel's server and id, unambiguous whatever either holds. */
367
+ function bindingKey(channel: ConversationChannel): string {
368
+ return JSON.stringify([channel.serverId ?? null, channel.channelId]);
369
+ }
370
+
320
371
  /** The readable part of a fork's name stops here: uniqueness rides on the hash. */
321
372
  const FORK_NAME_READABLE_MAX = 64;
322
373
 
@@ -22,6 +22,7 @@ import type { InboundSource } from './inbound-source.js';
22
22
  import { isSilentHeartbeatMarker, isVisiblyEmptyConversion } from './visible-content.js';
23
23
  import { logDetail, logValue } from './log-value.js';
24
24
  import { RefusalLines } from './refusal-lines.js';
25
+ import { channelNotPermitted } from './errors.js';
25
26
 
26
27
  // ============================================================================
27
28
  // McplPushEvent (the ProcessEvent shape pushed to the queue)
@@ -109,8 +110,9 @@ export class PushHandler {
109
110
  private emitTraceFn: (event: { type: string; [key: string]: unknown }) => void;
110
111
  private shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
111
112
  private dedup = new DedupSet(1000);
112
- /** Holds back repeats of the line for a channel-scoped push refused because
113
- * its channel isn't declared (-32023) or may not be used (-32017). */
113
+ /** Holds back repeats of the line for a push refused for its channel: a
114
+ * channel-scoped one whose channel isn't declared (-32023) or may not be
115
+ * used (-32017), and one naming a channel another server registers. */
114
116
  private channelRefusalLines = new RefusalLines();
115
117
 
116
118
  constructor(
@@ -123,6 +125,9 @@ export class PushHandler {
123
125
  * or acknowledged: return its source envelope, frozen now
124
126
  * (mcpl/inbound-source.ts). Coalesced work is stamped by its own path. */
125
127
  private readonly acceptInbound?: (event: McplPushEvent) => InboundSource | undefined,
128
+ /** The channel a push names, and the server that registers it, when
129
+ * another server registers that channel id and this server doesn't. */
130
+ private readonly channelOfAnotherServer?: (serverId: string, params: PushEventParams) => { channelId: string; holder: string } | undefined,
126
131
  ) {
127
132
  this.featureSetManager = featureSetManager;
128
133
  this.pushEventFn = pushEventFn;
@@ -135,6 +140,7 @@ export class PushHandler {
135
140
  *
136
141
  * 1. Validate feature set
137
142
  * 2. Convert content blocks; reject visibly-empty content
143
+ * Refuse a push naming a channel another server registers
138
144
  * 3. Deduplicate by eventId
139
145
  * 4. Optionally check shouldTriggerInference callback
140
146
  * 5. Push event to queue
@@ -208,6 +214,18 @@ export class PushHandler {
208
214
  return;
209
215
  }
210
216
 
217
+ // A push is its own server's event, and the agent's reply to it goes to
218
+ // the channel the push names. A channel's id is the agent's locus, so a
219
+ // push naming a channel another server registers, which this server
220
+ // doesn't, would have the reply sent through that other server. It's
221
+ // refused, before dedup, so a retry once this server registers the
222
+ // channel isn't refused as a duplicate.
223
+ const elsewhere = this.channelOfAnotherServer?.(serverId, params);
224
+ if (elsewhere) {
225
+ this.refuseChannelOfAnotherServer(serverId, params, elsewhere, responder);
226
+ return;
227
+ }
228
+
211
229
  // 3. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
212
230
  // coalescer's receipts instead (RFC-006 §3.1: a retry within the window
213
231
  // gets its original result, which this set could not return).
@@ -335,6 +353,38 @@ export class PushHandler {
335
353
  });
336
354
  }
337
355
 
356
+ /** A push naming a channel another server registers: refused with -32017.
357
+ * The operator's line names the server that registers it; the answer to
358
+ * the pushing server doesn't. The line prints at most once a minute per
359
+ * channel, as the other channel refusals' do, and every refusal is traced. */
360
+ private refuseChannelOfAnotherServer(
361
+ serverId: string,
362
+ params: PushEventParams,
363
+ elsewhere: { channelId: string; holder: string },
364
+ responder: Responder | undefined,
365
+ ): void {
366
+ const held = this.channelRefusalLines.admit(serverId, elsewhere.channelId);
367
+ if (held !== null) {
368
+ console.error(
369
+ `[push-event-rejected] server=${serverId} featureSet=${logValue(params.featureSet ?? '')} eventId=${logValue(params.eventId ?? '')} ` +
370
+ `channel=${logValue(elsewhere.channelId)} reason=channel-of-another-server: registered by server ${logValue(elsewhere.holder)}, not this one` +
371
+ (held ? ` (${held} more since the last line)` : ''),
372
+ );
373
+ }
374
+ this.emitTraceFn({
375
+ type: 'mcpl:push-event-rejected',
376
+ serverId,
377
+ eventId: typeof params.eventId === 'string' ? params.eventId : '',
378
+ featureSet: params.featureSet,
379
+ reason: 'channel-of-another-server',
380
+ channelId: elsewhere.channelId,
381
+ detail: `registered by server ${elsewhere.holder}`,
382
+ });
383
+ const error = channelNotPermitted(elsewhere.channelId);
384
+ if (responder?.respondError) responder.respondError(error.code, error.message, error.data);
385
+ else responder?.respond({ accepted: false, reason: error.message });
386
+ }
387
+
338
388
  /** Loud, like every other push rejection: the producer's wake went nowhere.
339
389
  * A missing eventId prints as `""` and traces as `''`, as on every push line. */
340
390
  private traceEmptyRejection(serverId: string, params: PushEventParams): void {
@@ -386,6 +386,12 @@ export interface InferenceRequest {
386
386
  * ephemeral run's (a subagent's), which takes no global fallback.
387
387
  */
388
388
  channelId?: string;
389
+ /**
390
+ * Host-owned: the MCPL server whose message set `channelId`. A turn's
391
+ * replies to that channel go out through this server only. Unset where the
392
+ * wake's channel came from no one server's message.
393
+ */
394
+ serverId?: string;
389
395
  /**
390
396
  * True when the triggering message explicitly addressed the agent (mention,
391
397
  * reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake