@animalabs/agent-framework 0.22.5 → 0.22.6

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.
@@ -610,7 +610,17 @@ interface ChannelRegistryOptions {
610
610
  */
611
611
  export type ChannelToolOrigin =
612
612
  | { kind: 'module'; moduleName?: string }
613
- | { kind: 'agent'; agentName: string };
613
+ | {
614
+ kind: 'agent';
615
+ agentName: string;
616
+ /** The calling turn's trigger channel with the server whose message it
617
+ * was: a channel_publish into that channel goes out through that server
618
+ * only. */
619
+ trigger?: { channelId: string; serverId: string };
620
+ /** The calling turn's locus with the server of the message that put it
621
+ * there, where that isn't the trigger: the same holds for it. */
622
+ locus?: { channelId: string; serverId: string };
623
+ };
614
624
 
615
625
  /** The machine decision sources a module-origin channel_close may record.
616
626
  * Closed on purpose: honest provenance means a small, auditable vocabulary,
@@ -699,6 +709,8 @@ export class ChannelRegistry {
699
709
 
700
710
  /** Most recent incoming channel ID — used for speech routing / default publish. */
701
711
  private defaultPublishChannel: string | null = null;
712
+ /** The server whose accepted message set defaultPublishChannel. */
713
+ private defaultPublishServer: string | null = null;
702
714
 
703
715
  /** Most recent incoming message metadata, used for buildChannelContext. */
704
716
  private defaultPublishMessageId: string | null = null;
@@ -822,7 +834,7 @@ export class ChannelRegistry {
822
834
  descriptor: channel,
823
835
  open: false,
824
836
  });
825
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
837
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
826
838
  registeredIds.push(channel.id);
827
839
  results.push({ id: channel.id, accepted: true });
828
840
  }
@@ -892,7 +904,7 @@ export class ChannelRegistry {
892
904
  continue;
893
905
  }
894
906
  existing.descriptor = channel;
895
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
907
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
896
908
  addedResults.push({ id: channel.id, accepted: true });
897
909
  }
898
910
  }
@@ -913,7 +925,7 @@ export class ChannelRegistry {
913
925
  descriptor: channel,
914
926
  open: false,
915
927
  });
916
- this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
928
+ this.appendLabelSighting(serverId, channel.id, channel.label, extractDmMeta(channel.metadata));
917
929
  addedResults.push({ id: channel.id, accepted: true });
918
930
  accepted.push(channel);
919
931
  }
@@ -1113,6 +1125,7 @@ export class ChannelRegistry {
1113
1125
  // for those this runs after the hook (below).
1114
1126
  const markAccepted = () => {
1115
1127
  this.defaultPublishChannel = message.channelId;
1128
+ this.defaultPublishServer = serverId;
1116
1129
  this.defaultPublishMessageId = message.messageId;
1117
1130
  this.defaultPublishThreadId = message.threadId;
1118
1131
  // A server sending channels/incoming is authoritative evidence that
@@ -1237,7 +1250,9 @@ export class ChannelRegistry {
1237
1250
  label?: string,
1238
1251
  extraMetadata?: Record<string, unknown>,
1239
1252
  ): void {
1240
- const existing = this.findChannelEntry(channelId);
1253
+ // The pushing server's own entry: another server's entry for the same id
1254
+ // is that server's, and a push leaves it as it was.
1255
+ const existing = this.channels.get(`${serverId}:${channelId}`);
1241
1256
  if (existing) {
1242
1257
  // Backfill identity onto a bare lazy registration: a DM that first
1243
1258
  // arrived with no label/recipient info gets a usable name the next
@@ -1253,7 +1268,7 @@ export class ChannelRegistry {
1253
1268
  // Recorded before the live descriptor takes it, since this branch's
1254
1269
  // guard reads the descriptor: an append that throws leaves the
1255
1270
  // placeholder, so the next push tries again.
1256
- this.appendLabelSighting(existing.descriptor.id, label, extractDmMeta(metadata));
1271
+ this.appendLabelSighting(serverId, existing.descriptor.id, label, extractDmMeta(metadata));
1257
1272
  existing.descriptor.label = label;
1258
1273
  existing.descriptor.metadata = metadata;
1259
1274
  }
@@ -1273,7 +1288,7 @@ export class ChannelRegistry {
1273
1288
  // routing; only the durable write is skipped when there's no real
1274
1289
  // label to record. A real label is recorded before the entry exists:
1275
1290
  // an append that throws registers nothing, so the next push tries again.
1276
- if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
1291
+ if (label) this.appendLabelSighting(serverId, channelId, label, extractDmMeta(extraMetadata));
1277
1292
  const key = `${serverId}:${channelId}`;
1278
1293
  this.channels.set(key, {
1279
1294
  serverId,
@@ -1508,7 +1523,7 @@ export class ChannelRegistry {
1508
1523
  });
1509
1524
 
1510
1525
  case 'channel_publish':
1511
- return this.handleToolPublish(input as { channelId: string; content: string });
1526
+ return this.handleToolPublish(input as { channelId: string; content: string }, origin);
1512
1527
 
1513
1528
  case 'think':
1514
1529
  return this.handleToolThink(input as { content?: string });
@@ -1593,8 +1608,11 @@ export class ChannelRegistry {
1593
1608
  if (entry.serverId !== serverId) continue;
1594
1609
  this.channels.delete(key);
1595
1610
  this.stopTyping(entry.descriptor.id);
1596
- if (this.defaultPublishChannel === entry.descriptor.id) {
1611
+ // The fallback goes with the server whose message set it, not with
1612
+ // another server's entry for the same id.
1613
+ if (this.defaultPublishChannel === entry.descriptor.id && this.defaultPublishServer === serverId) {
1597
1614
  this.defaultPublishChannel = null;
1615
+ this.defaultPublishServer = null;
1598
1616
  this.defaultPublishMessageId = null;
1599
1617
  this.defaultPublishThreadId = undefined;
1600
1618
  }
@@ -1617,6 +1635,7 @@ export class ChannelRegistry {
1617
1635
 
1618
1636
  // Reset default publish tracking
1619
1637
  this.defaultPublishChannel = null;
1638
+ this.defaultPublishServer = null;
1620
1639
  this.defaultPublishMessageId = null;
1621
1640
  this.defaultPublishThreadId = undefined;
1622
1641
  }
@@ -1761,11 +1780,17 @@ export class ChannelRegistry {
1761
1780
  * `resolveProseTargetDurable()`.
1762
1781
  */
1763
1782
  private appendLabelSighting(
1783
+ serverId: string,
1764
1784
  channelId: string,
1765
1785
  label: string | undefined,
1766
1786
  dmMeta: { recipientId?: string; recipientName?: string; isDm?: boolean } = {},
1767
1787
  ): void {
1768
1788
  if (typeof label !== 'string' || !label) return;
1789
+ // The log is kept by the bare id, and a durable lookup by label resolves
1790
+ // to that id with no server. So a server's label for an id another
1791
+ // server registers isn't recorded: it would name the other server's
1792
+ // channel, across restarts.
1793
+ if (this.registeredByAnotherServer(serverId, channelId)) return;
1769
1794
  const { recipientId, recipientName, isDm } = dmMeta;
1770
1795
  // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1771
1796
  // `label ?? channelId` when no real label was ever supplied — e.g. a
@@ -2379,11 +2404,28 @@ export class ChannelRegistry {
2379
2404
  .map((e) => e.descriptor);
2380
2405
  }
2381
2406
 
2407
+ /**
2408
+ * The server whose accepted message set the global fallback, where
2409
+ * `channelId` is the fallback's channel: a turn whose locus is the
2410
+ * fallback resolves it with this server, taken with the locus.
2411
+ */
2412
+ fallbackServerFor(channelId: string): string | undefined {
2413
+ return this.defaultPublishChannel === channelId ? this.defaultPublishServer ?? undefined : undefined;
2414
+ }
2415
+
2382
2416
  /** Server id owning a registered channel (by descriptor id), or null. */
2383
2417
  getChannelServerId(channelId: string): string | null {
2384
2418
  return this.findChannelEntry(channelId)?.serverId ?? null;
2385
2419
  }
2386
2420
 
2421
+ /** Whether a server other than `serverId` registers `channelId`. */
2422
+ private registeredByAnotherServer(serverId: string, channelId: string): boolean {
2423
+ for (const entry of this.channels.values()) {
2424
+ if (entry.descriptor.id === channelId && entry.serverId !== serverId) return true;
2425
+ }
2426
+ return false;
2427
+ }
2428
+
2387
2429
  /**
2388
2430
  * Find a channel entry by channelId (searches across all servers).
2389
2431
  * Returns the first match.
@@ -3089,8 +3131,11 @@ export class ChannelRegistry {
3089
3131
  inferenceId: string,
3090
3132
  index: number,
3091
3133
  delta: string,
3134
+ /** The server the channel resolves with, as the speech's locus does
3135
+ * (deliverSpeech's locusServerId). */
3136
+ serverId?: string,
3092
3137
  ): void {
3093
- const server = this.streamingServerFor(channelId);
3138
+ const server = this.streamingServerFor(channelId, serverId);
3094
3139
  if (!server) return;
3095
3140
  try {
3096
3141
  server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
@@ -3103,8 +3148,10 @@ export class ChannelRegistry {
3103
3148
  conversationId: string,
3104
3149
  inferenceId: string,
3105
3150
  text: string,
3151
+ /** As sendOutgoingChunk's. */
3152
+ serverId?: string,
3106
3153
  ): void {
3107
- const server = this.streamingServerFor(channelId);
3154
+ const server = this.streamingServerFor(channelId, serverId);
3108
3155
  if (!server) return;
3109
3156
  try {
3110
3157
  server.sendChannelsOutgoingComplete({
@@ -3116,14 +3163,18 @@ export class ChannelRegistry {
3116
3163
  } catch { /* observer surface — never disturb the turn */ }
3117
3164
  }
3118
3165
 
3119
- private streamingServerFor(channelId: string) {
3166
+ private streamingServerFor(channelId: string, serverId?: string) {
3120
3167
  // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
3121
- // bare descriptor ids (what resolveProseTarget returns). Scan like the
3122
- // resolver does, and fail closed on cross-server ambiguity — the same
3123
- // never-guess rule that governs delivery.
3168
+ // bare descriptor ids (what resolveProseTarget returns). With the server
3169
+ // the speech resolves with, only that server's entry; otherwise scan like
3170
+ // the resolver does, and fail closed on cross-server ambiguity — the
3171
+ // same never-guess rule that governs delivery.
3124
3172
  const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
3125
- if (matches.length !== 1) return null;
3126
- const server = this.serverRegistry.getServer(matches[0]!.serverId);
3173
+ const entry = serverId !== undefined
3174
+ ? matches.find((e) => e.serverId === serverId)
3175
+ : matches.length === 1 ? matches[0] : undefined;
3176
+ if (!entry) return null;
3177
+ const server = this.serverRegistry.getServer(entry.serverId);
3127
3178
  // §5.4: the GRANT gates streaming, not the raw advertisement. The old
3128
3179
  // `capabilities?.channels?.streaming` check was doubly wrong: undefined
3129
3180
  // for the boolean `channels: true` shape (masking discord-mcpl's latent
@@ -3323,8 +3374,13 @@ export class ChannelRegistry {
3323
3374
  locusChannelId: string | null,
3324
3375
  /** `heldOnFailure`: the caller holds a `failed` delivery's words as a
3325
3376
  * draft with its own notice, so no failure marker is written for it.
3326
- * An `unknown` outcome still gets its marker. */
3327
- opts?: { heldOnFailure?: boolean },
3377
+ * An `unknown` outcome still gets its marker. `locusServerId`: the
3378
+ * server the locus resolves with, snapshotted by the caller with the
3379
+ * locus: the server whose message triggered the turn, where the locus is
3380
+ * that message's channel. The speech goes out through that server only,
3381
+ * and fails where it no longer registers the channel. Without it, the
3382
+ * locus is taken only where exactly one server registers it. */
3383
+ opts?: { heldOnFailure?: boolean; locusServerId?: string },
3328
3384
  ): Promise<PublishOutcome> {
3329
3385
  const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => {
3330
3386
  const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
@@ -3361,7 +3417,11 @@ export class ChannelRegistry {
3361
3417
  return fail(null, reason, { status: 'failed', reason, at: Date.now() });
3362
3418
  }
3363
3419
 
3364
- const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
3420
+ const outcome = await this.publish(conversationId, text, {
3421
+ ...(opts?.locusServerId !== undefined ? { serverId: opts.locusServerId } : {}),
3422
+ channelId: locusChannelId,
3423
+ routed: true,
3424
+ });
3365
3425
  const channelId = outcome.destination?.channelId ?? locusChannelId;
3366
3426
  if (outcome.status !== 'delivered') {
3367
3427
  return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome);
@@ -3384,13 +3444,16 @@ export class ChannelRegistry {
3384
3444
  return outcome;
3385
3445
  }
3386
3446
 
3387
- /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
3447
+ /** deliverSpeech, reduced to the confirmed delivery (null when there was
3448
+ * none). `opts.locusServerId` is deliverSpeech's. */
3388
3449
  async routeSpeech(
3389
3450
  conversationId: string,
3390
3451
  text: string,
3391
3452
  locusChannelId: string | null,
3453
+ opts?: { locusServerId?: string },
3392
3454
  ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
3393
- const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
3455
+ const outcome = await this.deliverSpeech(conversationId, text, locusChannelId,
3456
+ opts?.locusServerId !== undefined ? { locusServerId: opts.locusServerId } : undefined);
3394
3457
  if (outcome.status !== 'delivered') return null;
3395
3458
  return {
3396
3459
  delivered: true,
@@ -3399,7 +3462,10 @@ export class ChannelRegistry {
3399
3462
  };
3400
3463
  }
3401
3464
 
3402
- private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {
3465
+ private async handleToolPublish(
3466
+ input: { channelId?: string; serverId?: string; content?: string; text?: string },
3467
+ origin?: ChannelToolOrigin,
3468
+ ): Promise<ToolResult> {
3403
3469
  // Resolve content: accept both `content` and `text` (backward compat)
3404
3470
  const messageText = input.content ?? input.text;
3405
3471
  if (!messageText) {
@@ -3420,11 +3486,30 @@ export class ChannelRegistry {
3420
3486
  };
3421
3487
  }
3422
3488
 
3423
- const entry = this.findChannelEntry(channelId);
3489
+ // A serverId the call gives names the exact pair, as the other channel
3490
+ // tools read theirs (this tool's schema doesn't declare one); with no
3491
+ // channelId, it's the default channel on that server. A call naming no
3492
+ // server takes the calling turn's server for its trigger's or locus's
3493
+ // channel; else, for the default channel, the server whose accepted
3494
+ // message set it; else a channelId only where exactly one server
3495
+ // registers it.
3496
+ const serverId = input.serverId;
3497
+ const trigger = !serverId && origin?.kind === 'agent'
3498
+ ? [origin.trigger, origin.locus].find((o) => o?.channelId === channelId)
3499
+ : undefined;
3500
+ const defaultServer = !serverId && trigger === undefined && input.channelId === undefined
3501
+ ? this.fallbackServerFor(channelId)
3502
+ : undefined;
3503
+ const resolved = this.resolveToolChannelEntry(channelId, serverId || (trigger?.serverId ?? defaultServer));
3504
+ const entry = resolved.entry;
3424
3505
  if (!entry) {
3425
3506
  return {
3426
3507
  success: false,
3427
- error: `Channel not found: ${channelId}`,
3508
+ error: trigger !== undefined
3509
+ ? `Channel ${channelId} is no longer registered by the server whose message brought you there, so nothing was published.`
3510
+ : defaultServer !== undefined
3511
+ ? `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.`
3512
+ : resolved.error!,
3428
3513
  isError: true,
3429
3514
  };
3430
3515
  }
@@ -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 {
@@ -375,6 +375,12 @@ export interface InferenceRequest {
375
375
  * which correctly fall back to the global default channel.
376
376
  */
377
377
  channelId?: string;
378
+ /**
379
+ * Host-owned: the MCPL server whose message set `channelId`. A turn's
380
+ * replies to that channel go out through this server only. Unset where the
381
+ * wake's channel came from no one server's message.
382
+ */
383
+ serverId?: string;
378
384
  /**
379
385
  * True when the triggering message explicitly addressed the agent (mention,
380
386
  * reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake