@animalabs/agent-framework 0.6.10 → 0.7.2

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 (83) hide show
  1. package/dist/src/agent.d.ts +2 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +3 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/code-execution/index.d.ts +14 -0
  6. package/dist/src/code-execution/index.d.ts.map +1 -0
  7. package/dist/src/code-execution/index.js +13 -0
  8. package/dist/src/code-execution/index.js.map +1 -0
  9. package/dist/src/code-execution/py-runner.d.ts +91 -0
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -0
  11. package/dist/src/code-execution/py-runner.js +364 -0
  12. package/dist/src/code-execution/py-runner.js.map +1 -0
  13. package/dist/src/code-execution/runtime-py.d.ts +32 -0
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
  15. package/dist/src/code-execution/runtime-py.js +265 -0
  16. package/dist/src/code-execution/runtime-py.js.map +1 -0
  17. package/dist/src/code-execution/tool-definition.d.ts +16 -0
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
  19. package/dist/src/code-execution/tool-definition.js +45 -0
  20. package/dist/src/code-execution/tool-definition.js.map +1 -0
  21. package/dist/src/framework.d.ts +157 -15
  22. package/dist/src/framework.d.ts.map +1 -1
  23. package/dist/src/framework.js +1068 -104
  24. package/dist/src/framework.js.map +1 -1
  25. package/dist/src/index.d.ts +2 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/mcpl/channel-registry.d.ts +99 -7
  30. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  31. package/dist/src/mcpl/channel-registry.js +374 -49
  32. package/dist/src/mcpl/channel-registry.js.map +1 -1
  33. package/dist/src/mcpl/prose-grammar.d.ts +24 -0
  34. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
  35. package/dist/src/mcpl/prose-grammar.js +33 -0
  36. package/dist/src/mcpl/prose-grammar.js.map +1 -0
  37. package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
  38. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
  39. package/dist/src/mcpl/prose-stream-router.js +168 -0
  40. package/dist/src/mcpl/prose-stream-router.js.map +1 -0
  41. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  42. package/dist/src/modules/workspace/index.js +13 -4
  43. package/dist/src/modules/workspace/index.js.map +1 -1
  44. package/dist/src/types/agent.d.ts +17 -0
  45. package/dist/src/types/agent.d.ts.map +1 -1
  46. package/dist/src/types/framework.d.ts +33 -0
  47. package/dist/src/types/framework.d.ts.map +1 -1
  48. package/dist/src/types/trace.d.ts +32 -0
  49. package/dist/src/types/trace.d.ts.map +1 -1
  50. package/dist/test/channel-registry-routing.test.js +186 -10
  51. package/dist/test/channel-registry-routing.test.js.map +1 -1
  52. package/dist/test/code-execution.test.d.ts +11 -0
  53. package/dist/test/code-execution.test.d.ts.map +1 -0
  54. package/dist/test/code-execution.test.js +412 -0
  55. package/dist/test/code-execution.test.js.map +1 -0
  56. package/dist/test/explicit-prose-routing.test.d.ts +11 -0
  57. package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
  58. package/dist/test/explicit-prose-routing.test.js +247 -0
  59. package/dist/test/explicit-prose-routing.test.js.map +1 -0
  60. package/dist/test/present-while-acting.test.js +85 -8
  61. package/dist/test/present-while-acting.test.js.map +1 -1
  62. package/dist/test/prose-stream-router.test.d.ts +2 -0
  63. package/dist/test/prose-stream-router.test.d.ts.map +1 -0
  64. package/dist/test/prose-stream-router.test.js +87 -0
  65. package/dist/test/prose-stream-router.test.js.map +1 -0
  66. package/dist/test/trunk-channel-routing.test.js +2 -2
  67. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +4 -4
  70. package/src/agent.ts +3 -0
  71. package/src/code-execution/index.ts +19 -0
  72. package/src/code-execution/py-runner.ts +433 -0
  73. package/src/code-execution/runtime-py.ts +265 -0
  74. package/src/code-execution/tool-definition.ts +52 -0
  75. package/src/framework.ts +1160 -107
  76. package/src/index.ts +17 -0
  77. package/src/mcpl/channel-registry.ts +454 -54
  78. package/src/mcpl/prose-grammar.ts +40 -0
  79. package/src/mcpl/prose-stream-router.ts +161 -0
  80. package/src/modules/workspace/index.ts +13 -4
  81. package/src/types/agent.ts +18 -0
  82. package/src/types/framework.ts +35 -0
  83. package/src/types/trace.ts +47 -1
@@ -101,6 +101,10 @@ interface McplChannelIncomingEvent {
101
101
  content: ContentBlock[];
102
102
  timestamp: string;
103
103
  metadata?: Record<string, unknown>;
104
+ /** MCPL RFC-001 event tags (`chat:addressed`, `chat:ambient`, …) — carried
105
+ * through so the host can rank addressed messages over ambient chatter
106
+ * when picking a turn's frozen speech locus. */
107
+ tags?: string[];
104
108
  triggerInference?: boolean;
105
109
  targetAgents?: string[];
106
110
  }
@@ -286,6 +290,22 @@ interface ChannelRegistryOptions {
286
290
  reason: string;
287
291
  textLen: number;
288
292
  }) => void;
293
+ /**
294
+ * Called when channels were opened WITHOUT the agent asking (subscription
295
+ * policy admitting a newly discovered channel, or delivery into a closed
296
+ * locus). The host wires this to drop a durable notice into the agent's
297
+ * window — the agent must always learn that new traffic will start
298
+ * flowing, and that `channel_close` opts out (their decision outranks
299
+ * policy). Fires ONCE per channel ever: the desired-state decision is
300
+ * durable, so reboots do not re-announce. Must not trigger inference.
301
+ */
302
+ onChannelAutoOpened?: (info: {
303
+ /** Agent whose action caused the open (delivery); absent for policy opens. */
304
+ conversationId?: string;
305
+ serverId: string;
306
+ source: 'subscription-policy' | 'opened-by-delivery';
307
+ channels: Array<{ channelId: string; label?: string }>;
308
+ }) => void;
289
309
  /**
290
310
  * Resolve a conversation fork's HOME channel from its agent name. Conversation
291
311
  * forks are spawned bound to a single channel (the framework tracks this in
@@ -335,6 +355,12 @@ export class ChannelRegistry {
335
355
  reason: string;
336
356
  textLen: number;
337
357
  }) => void;
358
+ private onChannelAutoOpened?: (info: {
359
+ conversationId?: string;
360
+ serverId: string;
361
+ source: 'subscription-policy' | 'opened-by-delivery';
362
+ channels: Array<{ channelId: string; label?: string }>;
363
+ }) => void;
338
364
  private homeChannelResolver?: (agentName: string) => string | undefined;
339
365
  private activeChannelResolver?: (agentName: string) => string | undefined;
340
366
  private store?: JsStore;
@@ -386,6 +412,7 @@ export class ChannelRegistry {
386
412
  this.sendTypingFn = options?.sendTypingFn;
387
413
  this.shouldTriggerInference = options?.shouldTriggerInference;
388
414
  this.onRouteFailure = options?.onRouteFailure;
415
+ this.onChannelAutoOpened = options?.onChannelAutoOpened;
389
416
  this.homeChannelResolver = options?.homeChannelResolver;
390
417
  this.activeChannelResolver = options?.activeChannelResolver;
391
418
  this.store = options?.store;
@@ -595,6 +622,7 @@ export class ChannelRegistry {
595
622
  content: convertedContent,
596
623
  timestamp: message.timestamp,
597
624
  metadata: message.metadata,
625
+ ...(message.tags ? { tags: message.tags } : {}),
598
626
  triggerInference,
599
627
  };
600
628
 
@@ -636,9 +664,26 @@ export class ChannelRegistry {
636
664
  * channels/register or channels/changed overwrites this descriptor with the
637
665
  * richer one.
638
666
  */
639
- ensureChannelRegistered(serverId: string, channelId: string, label?: string): void {
667
+ ensureChannelRegistered(
668
+ serverId: string,
669
+ channelId: string,
670
+ label?: string,
671
+ extraMetadata?: Record<string, unknown>,
672
+ ): void {
640
673
  const existing = this.findChannelEntry(channelId);
641
674
  if (existing) {
675
+ // Backfill identity onto a bare lazy registration: a DM that first
676
+ // arrived with no label/recipient info gets a usable name the next
677
+ // time a message reveals it (labels must show people, not ids).
678
+ if (
679
+ (existing.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered &&
680
+ label && existing.descriptor.label === channelId
681
+ ) {
682
+ existing.descriptor.label = label;
683
+ if (extraMetadata) {
684
+ existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
685
+ }
686
+ }
642
687
  return;
643
688
  }
644
689
 
@@ -650,7 +695,7 @@ export class ChannelRegistry {
650
695
  type: serverId,
651
696
  label: label ?? channelId,
652
697
  direction: 'bidirectional',
653
- metadata: { lazyRegistered: true },
698
+ metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
654
699
  },
655
700
  open: false,
656
701
  });
@@ -1037,23 +1082,60 @@ export class ChannelRegistry {
1037
1082
  });
1038
1083
  }
1039
1084
 
1040
- private ensureInitialDesiredState(serverId: string, channel: ChannelDescriptor): void {
1085
+ /**
1086
+ * True when the server's recipe subscription policy wants this channel
1087
+ * open. This is an ONGOING admission policy (subscribed ⇒ open, no matter
1088
+ * when the channel is discovered) — not just a bootstrap seed. Allow-list
1089
+ * entries may be composite MCPL ids or raw server-internal ids.
1090
+ */
1091
+ private policyWantsOpen(serverId: string, channel: ChannelDescriptor): boolean {
1092
+ const policy = this.legacyPolicies.get(serverId) ?? 'manual';
1093
+ if (policy === 'auto') return true;
1094
+ if (Array.isArray(policy)) {
1095
+ if (policy.includes(channel.id)) return true;
1096
+ const raw = (channel.address as { channelId?: string } | undefined)?.channelId;
1097
+ return typeof raw === 'string' && raw.length > 0 && policy.includes(raw);
1098
+ }
1099
+ return false;
1100
+ }
1101
+
1102
+ /**
1103
+ * Returns true when this call made a FRESH subscription-policy decision to
1104
+ * open the channel — the caller announces those to the agent (once per
1105
+ * channel ever: the decision persists in chronicle, so reboots see an
1106
+ * existing non-default source and never re-fire).
1107
+ */
1108
+ private ensureInitialDesiredState(serverId: string, channel: ChannelDescriptor): boolean {
1109
+ const byPolicy = this.policyWantsOpen(serverId, channel);
1110
+ const wantsOpen = channel.initiallyOpen === true || byPolicy;
1041
1111
  const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
1042
1112
  if (existing) {
1043
- // A server upgrading its descriptor to initiallyOpen may lift a pure
1044
- // default — 'default-closed' means nobody ever decided. Real decisions
1113
+ // A server upgrading its descriptor to initiallyOpen — or a channel
1114
+ // matching the subscription policy — may lift a pure default:
1115
+ // 'default-closed' means nobody ever decided. Real decisions
1045
1116
  // (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');
1117
+ if (wantsOpen && existing.state === 'closed' && existing.source === 'default-closed') {
1118
+ this.setDesiredState(
1119
+ serverId,
1120
+ channel.id,
1121
+ 'open',
1122
+ channel.initiallyOpen === true ? 'server-bootstrap' : 'subscription-policy',
1123
+ );
1124
+ return channel.initiallyOpen !== true && byPolicy;
1048
1125
  }
1049
- return;
1126
+ return false;
1050
1127
  }
1051
1128
  this.setDesiredState(
1052
1129
  serverId,
1053
1130
  channel.id,
1054
- channel.initiallyOpen === true ? 'open' : 'closed',
1055
- channel.initiallyOpen === true ? 'server-bootstrap' : 'default-closed',
1131
+ wantsOpen ? 'open' : 'closed',
1132
+ channel.initiallyOpen === true
1133
+ ? 'server-bootstrap'
1134
+ : wantsOpen
1135
+ ? 'subscription-policy'
1136
+ : 'default-closed',
1056
1137
  );
1138
+ return channel.initiallyOpen !== true && byPolicy;
1057
1139
  }
1058
1140
 
1059
1141
  private async reconcileChannels(
@@ -1063,8 +1145,15 @@ export class ChannelRegistry {
1063
1145
  const server = this.serverRegistry.getServer(serverId);
1064
1146
  if (!server) return;
1065
1147
 
1148
+ // Channels the subscription policy freshly admitted in THIS pass —
1149
+ // announced to the agent as one batched notice after the loop, so a
1150
+ // first boot on a new policy doesn't produce one notice per channel.
1151
+ const policyOpened: Array<{ channelId: string; label?: string }> = [];
1152
+
1066
1153
  for (const channel of channels) {
1067
- this.ensureInitialDesiredState(serverId, channel);
1154
+ if (this.ensureInitialDesiredState(serverId, channel)) {
1155
+ policyOpened.push({ channelId: channel.id, label: channel.label });
1156
+ }
1068
1157
  const key = `${serverId}:${channel.id}`;
1069
1158
  const desired = this.getDesiredState(serverId, channel.id);
1070
1159
  const entry = this.channels.get(key);
@@ -1102,6 +1191,21 @@ export class ChannelRegistry {
1102
1191
  });
1103
1192
  }
1104
1193
  }
1194
+
1195
+ // Announce policy admissions to the agent — nothing may start flowing
1196
+ // traffic into their window without them being told, and told how to
1197
+ // opt out (channel_close; their decision outranks policy).
1198
+ if (policyOpened.length > 0) {
1199
+ try {
1200
+ this.onChannelAutoOpened?.({
1201
+ serverId,
1202
+ source: 'subscription-policy',
1203
+ channels: policyOpened,
1204
+ });
1205
+ } catch (err) {
1206
+ console.error('onChannelAutoOpened (policy) failed:', err);
1207
+ }
1208
+ }
1105
1209
  }
1106
1210
 
1107
1211
  // ==========================================================================
@@ -1211,6 +1315,229 @@ export class ChannelRegistry {
1211
1315
  };
1212
1316
  }
1213
1317
 
1318
+ /**
1319
+ * The single open executor: record durable desired-open intent, tell the
1320
+ * server to subscribe, mark the live entry open. Every path that opens a
1321
+ * channel — the agent's channel_open tool, delivery into a closed locus,
1322
+ * an explicit send into a closed channel — funnels here, so lifecycle
1323
+ * events, desired state, and live state can never diverge by path.
1324
+ * Desired state is recorded BEFORE the server round-trip: intent sticks
1325
+ * even if the subscribe fails, and reconciliation retries later.
1326
+ */
1327
+ private async openChannelNow(
1328
+ entry: ChannelEntry,
1329
+ source: string,
1330
+ history?: ChannelHistoryRequest,
1331
+ ): Promise<ChannelsOpenResult> {
1332
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
1333
+ const server = this.serverRegistry.getServer(entry.serverId);
1334
+ if (!server) {
1335
+ throw new Error(`Server not found: ${entry.serverId}`);
1336
+ }
1337
+ const result: ChannelsOpenResult = await server.sendChannelsOpen({
1338
+ channelId: entry.descriptor.id,
1339
+ type: entry.descriptor.type,
1340
+ address: entry.descriptor.address,
1341
+ ...(history ? { history } : {}),
1342
+ });
1343
+ entry.open = true;
1344
+ return result;
1345
+ }
1346
+
1347
+ /**
1348
+ * Resolve a `>>` prose-routing target (explicit prose routing —
1349
+ * docs/explicit-prose-routing.md) to a registered channel.
1350
+ *
1351
+ * Accepted spellings, tried in order:
1352
+ * 1. exact descriptor id (`discord:guild:123`, always unambiguous)
1353
+ * 2. exact raw server-internal id (`address.channelId`)
1354
+ * 3. `#label` / bare label — case-insensitive match on descriptor label
1355
+ * with any leading '#' stripped from both sides
1356
+ * 4. `@name` — DM descriptor labels (`DM: name`), case-insensitive
1357
+ *
1358
+ * Ambiguity is an error carrying candidates — never a guess: this is the
1359
+ * mechanism that makes prose misdelivery structurally impossible.
1360
+ */
1361
+ resolveProseTarget(
1362
+ spec: string,
1363
+ ): { channelId: string; label?: string } | { error: string; candidates?: string[] } {
1364
+ const entries = [...this.channels.values()];
1365
+ const trimmed = spec.trim();
1366
+ if (!trimmed) return { error: 'empty target' };
1367
+
1368
+ const exact = entries.filter((e) => e.descriptor.id === trimmed);
1369
+ if (exact.length === 1) {
1370
+ return { channelId: exact[0]!.descriptor.id, label: exact[0]!.descriptor.label };
1371
+ }
1372
+ if (exact.length > 1) {
1373
+ return {
1374
+ error: `channel id "${trimmed}" is ambiguous across servers`,
1375
+ candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`),
1376
+ };
1377
+ }
1378
+
1379
+ const raw = entries.filter(
1380
+ (e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed,
1381
+ );
1382
+ if (raw.length === 1) {
1383
+ return { channelId: raw[0]!.descriptor.id, label: raw[0]!.descriptor.label };
1384
+ }
1385
+ if (raw.length > 1) {
1386
+ return {
1387
+ error: `raw id "${trimmed}" is ambiguous`,
1388
+ candidates: raw.map((e) => e.descriptor.id),
1389
+ };
1390
+ }
1391
+
1392
+ const norm = (s: string) => s.replace(/^#/, '').toLowerCase();
1393
+
1394
+ // DM addressing is PEOPLE-first: usernames and mention tokens, never
1395
+ // ids-only (2026-07-24, antra + Fable's live-canary bug report). A DM
1396
+ // entry is matched by, in order: recipientId (from a `<@id>` mention
1397
+ // token), exact recipient/label name, then prefix-lenient name (handles
1398
+ // "@antra_tessera" vs a display name "antra" and vice versa).
1399
+ const mention = /^<@!?(\d+)>$/.exec(trimmed);
1400
+ if (trimmed.startsWith('@') || mention) {
1401
+ const dmMeta = (e: ChannelEntry) =>
1402
+ e.descriptor.metadata as { channelType?: string; recipientId?: string; recipientName?: string } | undefined;
1403
+ const dmName = (e: ChannelEntry) => {
1404
+ const meta = dmMeta(e);
1405
+ if (meta?.recipientName) return meta.recipientName.toLowerCase();
1406
+ const label = (e.descriptor.label ?? '').toLowerCase();
1407
+ return label.startsWith('dm: ') ? label.slice(4) : label;
1408
+ };
1409
+ const isDmEntry = (e: ChannelEntry) =>
1410
+ dmMeta(e)?.channelType === 'dm' ||
1411
+ (e.descriptor.label ?? '').toLowerCase().startsWith('dm: ') ||
1412
+ e.descriptor.id.includes(':dm:');
1413
+ const dms = entries.filter(isDmEntry);
1414
+
1415
+ if (mention) {
1416
+ const id = mention[1]!;
1417
+ const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
1418
+ if (byId.length === 1) {
1419
+ return { channelId: byId[0]!.descriptor.id, label: byId[0]!.descriptor.label };
1420
+ }
1421
+ return {
1422
+ error: `no registered DM matches the mention <@${id}>`,
1423
+ candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6),
1424
+ };
1425
+ }
1426
+
1427
+ const name = trimmed.slice(1).toLowerCase();
1428
+ const exact = dms.filter((e) => dmName(e) === name);
1429
+ const pool = exact.length > 0
1430
+ ? exact
1431
+ : dms.filter((e) => {
1432
+ const n = dmName(e);
1433
+ return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
1434
+ });
1435
+ if (pool.length === 1) {
1436
+ return { channelId: pool[0]!.descriptor.id, label: pool[0]!.descriptor.label };
1437
+ }
1438
+ if (pool.length > 1) {
1439
+ return {
1440
+ error: `"${trimmed}" matches several DMs`,
1441
+ candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id),
1442
+ };
1443
+ }
1444
+ return {
1445
+ error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
1446
+ ...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}),
1447
+ };
1448
+ }
1449
+
1450
+ const byLabel = entries.filter(
1451
+ (e) => norm(e.descriptor.label ?? '') === norm(trimmed),
1452
+ );
1453
+ if (byLabel.length === 1) {
1454
+ return { channelId: byLabel[0]!.descriptor.id, label: byLabel[0]!.descriptor.label };
1455
+ }
1456
+ if (byLabel.length > 1) {
1457
+ return {
1458
+ error: `label "${trimmed}" matches several channels`,
1459
+ candidates: byLabel.map((e) => e.descriptor.id),
1460
+ };
1461
+ }
1462
+
1463
+ // Name-segment match: server labels carry a disambiguating suffix
1464
+ // (`#fable (antra's server)`) that agents naturally omit — `>>#fable`
1465
+ // must resolve. Strip a trailing parenthetical from the stored label and
1466
+ // compare the bare channel name. Same name in several guilds is a real
1467
+ // ambiguity: error with full labels so the agent can use the exact id.
1468
+ const nameOf = (label: string) => norm(label.replace(/\s*\([^)]*\)\s*$/, ''));
1469
+ const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
1470
+ if (byName.length === 1) {
1471
+ return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label };
1472
+ }
1473
+ if (byName.length > 1) {
1474
+ return {
1475
+ error: `"${trimmed}" matches several channels`,
1476
+ candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`),
1477
+ };
1478
+ }
1479
+
1480
+ // No match: offer near-candidates (labels containing the name) so the
1481
+ // bounce notice is self-healing rather than a dead end.
1482
+ const near = entries
1483
+ .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
1484
+ .slice(0, 5)
1485
+ .map((e) => `${e.descriptor.label} = ${e.descriptor.id}`);
1486
+ return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
1487
+ }
1488
+
1489
+ /**
1490
+ * Open a channel because something was DELIVERED into it (explicit send
1491
+ * tool or routed speech). Sending into a closed channel is not a thing:
1492
+ * engaging a channel opens it, so typing indicators, reaction machinery,
1493
+ * and inbound forwarding all come alive with the first outbound message.
1494
+ * Accepts composite MCPL ids or raw server-internal ids (explicit send
1495
+ * tools receive whatever the agent typed).
1496
+ */
1497
+ async openIfClosedForSend(
1498
+ rawChannelId: string,
1499
+ serverId?: string,
1500
+ ): Promise<{
1501
+ status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed';
1502
+ channelId?: string;
1503
+ label?: string;
1504
+ }> {
1505
+ let matches = [...this.channels.values()].filter(
1506
+ (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
1507
+ );
1508
+ if (matches.length === 0) {
1509
+ matches = [...this.channels.values()].filter(
1510
+ (e) =>
1511
+ (e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId &&
1512
+ (!serverId || e.serverId === serverId),
1513
+ );
1514
+ }
1515
+ if (matches.length === 0) return { status: 'unknown-channel' };
1516
+ if (matches.length > 1) return { status: 'ambiguous' };
1517
+ const entry = matches[0]!;
1518
+ const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
1519
+ if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
1520
+ return { status: 'already-open', ...resolved };
1521
+ }
1522
+ try {
1523
+ await this.openChannelNow(entry, 'opened-by-reply');
1524
+ this.emitTraceFn({
1525
+ type: 'mcpl:channel-opened-by-send',
1526
+ serverId: entry.serverId,
1527
+ channelId: entry.descriptor.id,
1528
+ });
1529
+ return { status: 'opened', ...resolved };
1530
+ } catch (err) {
1531
+ this.emitTraceFn({
1532
+ type: 'mcpl:channel-open-failed',
1533
+ serverId: entry.serverId,
1534
+ channelId: entry.descriptor.id,
1535
+ error: (err as Error).message,
1536
+ });
1537
+ return { status: 'open-failed', ...resolved };
1538
+ }
1539
+ }
1540
+
1214
1541
  private async handleToolOpen(input: {
1215
1542
  channelId: string;
1216
1543
  serverId?: string;
@@ -1235,17 +1562,6 @@ export class ChannelRegistry {
1235
1562
  };
1236
1563
  }
1237
1564
 
1238
- this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
1239
-
1240
- const server = this.serverRegistry.getServer(entry.serverId);
1241
- if (!server) {
1242
- return {
1243
- success: false,
1244
- error: `Server not found: ${entry.serverId}`,
1245
- isError: true,
1246
- };
1247
- }
1248
-
1249
1565
  try {
1250
1566
  const history: ChannelHistoryRequest | undefined = input.backscroll
1251
1567
  ? {
@@ -1253,13 +1569,7 @@ export class ChannelRegistry {
1253
1569
  ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
1254
1570
  }
1255
1571
  : undefined;
1256
- const result: ChannelsOpenResult = await server.sendChannelsOpen({
1257
- channelId: entry.descriptor.id,
1258
- type: entry.descriptor.type,
1259
- address: entry.descriptor.address,
1260
- ...(history ? { history } : {}),
1261
- });
1262
- entry.open = true;
1572
+ const result = await this.openChannelNow(entry, 'agent-tool', history);
1263
1573
  return {
1264
1574
  success: true,
1265
1575
  data: {
@@ -1454,6 +1764,59 @@ export class ChannelRegistry {
1454
1764
  /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
1455
1765
  * process-global default). Public so a multi-segment caller can snapshot it
1456
1766
  * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
1767
+ /**
1768
+ * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the
1769
+ * server owning the channel, AS THE MODEL GENERATES. Emitted only when that
1770
+ * server declared `channels.streaming` in its initialize capabilities —
1771
+ * servers that never opted in (the whole existing fleet) receive nothing.
1772
+ * Fire-and-forget and never throws: streaming is an observer surface; the
1773
+ * authoritative delivery is the eventual channels/publish.
1774
+ */
1775
+ sendOutgoingChunk(
1776
+ channelId: string,
1777
+ conversationId: string,
1778
+ inferenceId: string,
1779
+ index: number,
1780
+ delta: string,
1781
+ ): void {
1782
+ const server = this.streamingServerFor(channelId);
1783
+ if (!server) return;
1784
+ try {
1785
+ server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
1786
+ } catch { /* observer surface — never disturb the turn */ }
1787
+ }
1788
+
1789
+ /** Spec 14.3 companion: final moderated content per channel at stream end. */
1790
+ sendOutgoingComplete(
1791
+ channelId: string,
1792
+ conversationId: string,
1793
+ inferenceId: string,
1794
+ text: string,
1795
+ ): void {
1796
+ const server = this.streamingServerFor(channelId);
1797
+ if (!server) return;
1798
+ try {
1799
+ server.sendChannelsOutgoingComplete({
1800
+ inferenceId,
1801
+ conversationId,
1802
+ channelId,
1803
+ content: [{ type: 'text', text }],
1804
+ });
1805
+ } catch { /* observer surface — never disturb the turn */ }
1806
+ }
1807
+
1808
+ private streamingServerFor(channelId: string) {
1809
+ // The map is keyed `${serverId}:${channelId}`; stream targets arrive as
1810
+ // bare descriptor ids (what resolveProseTarget returns). Scan like the
1811
+ // resolver does, and fail closed on cross-server ambiguity — the same
1812
+ // never-guess rule that governs delivery.
1813
+ const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
1814
+ if (matches.length !== 1) return null;
1815
+ const server = this.serverRegistry.getServer(matches[0]!.serverId);
1816
+ if (!server?.capabilities?.channels?.streaming) return null;
1817
+ return server;
1818
+ }
1819
+
1457
1820
  resolveLocus(conversationId: string): string | null {
1458
1821
  const home = this.homeChannelResolver?.(conversationId);
1459
1822
  return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
@@ -1462,13 +1825,15 @@ export class ChannelRegistry {
1462
1825
  async routeSpeech(
1463
1826
  conversationId: string,
1464
1827
  text: string,
1465
- /** Pin the outbound locus, skipping resolution. Callers delivering MULTIPLE
1466
- * segments of ONE turn snapshot resolveLocus() once and pass it here, so a
1467
- * new turn starting mid-dispatch (segments run after the agent is idle,
1468
- * PR #32) can't overwrite the per-agent triggering channel between segments
1469
- * and split one reply across channels. */
1470
- overrideChannelId?: string | null,
1471
- ): Promise<{ delivered: boolean; channelId: string } | null> {
1828
+ /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
1829
+ * at turn start) and passed to every segment of the turn. Mandatory:
1830
+ * there is deliberately no live-resolution fallback here — a late
1831
+ * dispatch resolving live can read the NEXT turn's trigger state or a
1832
+ * stale global default and land the reply in the wrong channel (item-3,
1833
+ * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
1834
+ * "this turn is pinned to no locus": fail loudly rather than guess. */
1835
+ locusChannelId: string | null,
1836
+ ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
1472
1837
  // Surface a routing failure: emit a trace AND notify the host (which drops
1473
1838
  // a `[discord-send-failed]` marker into chronicle) so the agent learns her
1474
1839
  // reply never reached the human, instead of it vanishing silently.
@@ -1476,6 +1841,7 @@ export class ChannelRegistry {
1476
1841
  console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
1477
1842
  this.emitTraceFn({
1478
1843
  type: 'mcpl:speech-route-failed',
1844
+ conversationId,
1479
1845
  channelId: channelId ?? '',
1480
1846
  reason,
1481
1847
  textLen: text.length,
@@ -1484,24 +1850,18 @@ export class ChannelRegistry {
1484
1850
  return null;
1485
1851
  };
1486
1852
 
1487
- // Resolve the outbound locus, in precedence order:
1488
- // 1. fork HOME channel — a conversation fork always replies in the channel
1489
- // it was spawned to serve (item 3, forks).
1490
- // 2. this turn's TRIGGERING channel — a single trunk agent (connectome-
1491
- // host's only mode) replies in the channel whose message woke THIS
1492
- // inference, so a concurrent inbound elsewhere can't hijack the reply
1493
- // (item-3 redux, trunk agents). Also carries DM channels, which arrive
1494
- // as push/events and never touch `defaultPublishChannel`.
1495
- // 3. the process-global `defaultPublishChannel` — last resort for turns
1496
- // with no triggering channel (heartbeats, timers).
1497
- // Using the global for a fork or a concurrent trunk turn is the item-3 bug:
1498
- // it tracks the most-recent inbound across ALL channels, so a reply lands
1499
- // wherever a message last happened to arrive rather than where it belongs.
1500
- const channelId = overrideChannelId ?? this.resolveLocus(conversationId);
1853
+ // Runtime backstop for JS callers the compiler can't see: an omitted
1854
+ // locus is a routing bug at the call site, never something to paper over
1855
+ // with a live re-resolution.
1856
+ if (locusChannelId === undefined) {
1857
+ return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
1858
+ }
1859
+ const channelId = locusChannelId;
1501
1860
  if (!channelId) {
1502
- // Reached only when the agent has no home, no active triggering channel,
1503
- // AND no global inbound was ever seen.
1504
- return fail(null, 'no locus (no home/active channel, defaultPublishChannel null)');
1861
+ // The turn froze with no locus (no home, no triggering channel, no
1862
+ // global inbound ever seen) — the agent was told its prose stays in
1863
+ // the archive; honor that.
1864
+ return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
1505
1865
  }
1506
1866
 
1507
1867
  const entry = this.findChannelEntry(channelId);
@@ -1509,6 +1869,38 @@ export class ChannelRegistry {
1509
1869
  return fail(channelId, `no registered channel for locus "${channelId}"`);
1510
1870
  }
1511
1871
 
1872
+ // INVARIANT: it is not possible to send into a closed channel. Speech
1873
+ // routed into a closed-but-registered locus (the canonical case: a DM
1874
+ // that woke the agent — DMs register closed) OPENS the channel first,
1875
+ // so typing indicators, reaction machinery, and inbound forwarding come
1876
+ // alive with the reply. If the open fails, the speech does NOT go out:
1877
+ // a half-alive delivery is worse than a loud marker.
1878
+ if (!entry.open) {
1879
+ try {
1880
+ await this.openChannelNow(entry, 'opened-by-delivery');
1881
+ console.error(
1882
+ `[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`,
1883
+ );
1884
+ this.emitTraceFn({
1885
+ type: 'mcpl:channel-opened-by-send',
1886
+ serverId: entry.serverId,
1887
+ channelId,
1888
+ });
1889
+ try {
1890
+ this.onChannelAutoOpened?.({
1891
+ conversationId,
1892
+ serverId: entry.serverId,
1893
+ source: 'opened-by-delivery',
1894
+ channels: [{ channelId, label: entry.descriptor.label }],
1895
+ });
1896
+ } catch (err) {
1897
+ console.error('onChannelAutoOpened (delivery) failed:', err);
1898
+ }
1899
+ } catch (err) {
1900
+ return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`);
1901
+ }
1902
+ }
1903
+
1512
1904
  const server = this.serverRegistry.getServer(entry.serverId);
1513
1905
  if (!server) {
1514
1906
  return fail(channelId, `server "${entry.serverId}" not found`);
@@ -1521,6 +1913,11 @@ export class ChannelRegistry {
1521
1913
  };
1522
1914
  const result = await server.sendChannelsPublish(publishParams);
1523
1915
  const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
1916
+ // Surface the posted message's id (ChannelsPublishResult.messageId) so
1917
+ // trace consumers can act on the just-posted message — e.g. a TTS-relay
1918
+ // tap editing it down to the words actually voiced on interruption.
1919
+ // Previously this was silently dropped here.
1920
+ const messageId = (result as { messageId?: string } | undefined)?.messageId;
1524
1921
 
1525
1922
  // The server accepted the publish RPC but reported the message was not
1526
1923
  // actually delivered (e.g. missing Send Messages permission). Previously
@@ -1532,13 +1929,16 @@ export class ChannelRegistry {
1532
1929
  console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
1533
1930
  this.emitTraceFn({
1534
1931
  type: 'mcpl:speech-routed',
1932
+ conversationId,
1535
1933
  serverId: entry.serverId,
1536
1934
  channelId,
1537
1935
  delivered,
1538
1936
  textLen: text.length,
1937
+ text,
1938
+ ...(messageId !== undefined ? { messageId } : {}),
1539
1939
  });
1540
1940
 
1541
- return { delivered, channelId };
1941
+ return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
1542
1942
  }
1543
1943
 
1544
1944
  private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {