@animalabs/agent-framework 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/src/agent.d.ts +6 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +11 -1
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +129 -15
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +967 -113
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/mcpl/channel-registry.d.ts +87 -7
  10. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  11. package/dist/src/mcpl/channel-registry.js +329 -49
  12. package/dist/src/mcpl/channel-registry.js.map +1 -1
  13. package/dist/src/modules/workspace/index.d.ts +10 -0
  14. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  15. package/dist/src/modules/workspace/index.js +61 -6
  16. package/dist/src/modules/workspace/index.js.map +1 -1
  17. package/dist/src/modules/workspace/types.d.ts +2 -0
  18. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  19. package/dist/src/modules/workspace/types.js.map +1 -1
  20. package/dist/src/modules/workspace/watcher.d.ts +17 -0
  21. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  22. package/dist/src/modules/workspace/watcher.js +143 -7
  23. package/dist/src/modules/workspace/watcher.js.map +1 -1
  24. package/dist/src/types/agent.d.ts +32 -0
  25. package/dist/src/types/agent.d.ts.map +1 -1
  26. package/dist/src/types/trace.d.ts +32 -0
  27. package/dist/src/types/trace.d.ts.map +1 -1
  28. package/dist/test/channel-registry-routing.test.js +186 -10
  29. package/dist/test/channel-registry-routing.test.js.map +1 -1
  30. package/dist/test/explicit-prose-routing.test.d.ts +11 -0
  31. package/dist/test/explicit-prose-routing.test.d.ts.map +1 -0
  32. package/dist/test/explicit-prose-routing.test.js +247 -0
  33. package/dist/test/explicit-prose-routing.test.js.map +1 -0
  34. package/dist/test/present-while-acting.test.js +85 -8
  35. package/dist/test/present-while-acting.test.js.map +1 -1
  36. package/dist/test/trunk-channel-routing.test.js +7 -3
  37. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  38. package/dist/test/workspace-watcher.test.js +161 -9
  39. package/dist/test/workspace-watcher.test.js.map +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +3 -3
  42. package/src/agent.ts +11 -1
  43. package/src/framework.ts +1037 -116
  44. package/src/mcpl/channel-registry.ts +401 -54
  45. package/src/modules/workspace/index.ts +60 -6
  46. package/src/modules/workspace/types.ts +2 -0
  47. package/src/modules/workspace/watcher.ts +160 -7
  48. package/src/types/agent.ts +35 -0
  49. 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: {
@@ -1462,13 +1772,15 @@ export class ChannelRegistry {
1462
1772
  async routeSpeech(
1463
1773
  conversationId: string,
1464
1774
  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> {
1775
+ /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
1776
+ * at turn start) and passed to every segment of the turn. Mandatory:
1777
+ * there is deliberately no live-resolution fallback here — a late
1778
+ * dispatch resolving live can read the NEXT turn's trigger state or a
1779
+ * stale global default and land the reply in the wrong channel (item-3,
1780
+ * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
1781
+ * "this turn is pinned to no locus": fail loudly rather than guess. */
1782
+ locusChannelId: string | null,
1783
+ ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
1472
1784
  // Surface a routing failure: emit a trace AND notify the host (which drops
1473
1785
  // a `[discord-send-failed]` marker into chronicle) so the agent learns her
1474
1786
  // reply never reached the human, instead of it vanishing silently.
@@ -1476,6 +1788,7 @@ export class ChannelRegistry {
1476
1788
  console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
1477
1789
  this.emitTraceFn({
1478
1790
  type: 'mcpl:speech-route-failed',
1791
+ conversationId,
1479
1792
  channelId: channelId ?? '',
1480
1793
  reason,
1481
1794
  textLen: text.length,
@@ -1484,24 +1797,18 @@ export class ChannelRegistry {
1484
1797
  return null;
1485
1798
  };
1486
1799
 
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);
1800
+ // Runtime backstop for JS callers the compiler can't see: an omitted
1801
+ // locus is a routing bug at the call site, never something to paper over
1802
+ // with a live re-resolution.
1803
+ if (locusChannelId === undefined) {
1804
+ return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
1805
+ }
1806
+ const channelId = locusChannelId;
1501
1807
  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)');
1808
+ // The turn froze with no locus (no home, no triggering channel, no
1809
+ // global inbound ever seen) — the agent was told its prose stays in
1810
+ // the archive; honor that.
1811
+ return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
1505
1812
  }
1506
1813
 
1507
1814
  const entry = this.findChannelEntry(channelId);
@@ -1509,6 +1816,38 @@ export class ChannelRegistry {
1509
1816
  return fail(channelId, `no registered channel for locus "${channelId}"`);
1510
1817
  }
1511
1818
 
1819
+ // INVARIANT: it is not possible to send into a closed channel. Speech
1820
+ // routed into a closed-but-registered locus (the canonical case: a DM
1821
+ // that woke the agent — DMs register closed) OPENS the channel first,
1822
+ // so typing indicators, reaction machinery, and inbound forwarding come
1823
+ // alive with the reply. If the open fails, the speech does NOT go out:
1824
+ // a half-alive delivery is worse than a loud marker.
1825
+ if (!entry.open) {
1826
+ try {
1827
+ await this.openChannelNow(entry, 'opened-by-delivery');
1828
+ console.error(
1829
+ `[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`,
1830
+ );
1831
+ this.emitTraceFn({
1832
+ type: 'mcpl:channel-opened-by-send',
1833
+ serverId: entry.serverId,
1834
+ channelId,
1835
+ });
1836
+ try {
1837
+ this.onChannelAutoOpened?.({
1838
+ conversationId,
1839
+ serverId: entry.serverId,
1840
+ source: 'opened-by-delivery',
1841
+ channels: [{ channelId, label: entry.descriptor.label }],
1842
+ });
1843
+ } catch (err) {
1844
+ console.error('onChannelAutoOpened (delivery) failed:', err);
1845
+ }
1846
+ } catch (err) {
1847
+ return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`);
1848
+ }
1849
+ }
1850
+
1512
1851
  const server = this.serverRegistry.getServer(entry.serverId);
1513
1852
  if (!server) {
1514
1853
  return fail(channelId, `server "${entry.serverId}" not found`);
@@ -1521,6 +1860,11 @@ export class ChannelRegistry {
1521
1860
  };
1522
1861
  const result = await server.sendChannelsPublish(publishParams);
1523
1862
  const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true;
1863
+ // Surface the posted message's id (ChannelsPublishResult.messageId) so
1864
+ // trace consumers can act on the just-posted message — e.g. a TTS-relay
1865
+ // tap editing it down to the words actually voiced on interruption.
1866
+ // Previously this was silently dropped here.
1867
+ const messageId = (result as { messageId?: string } | undefined)?.messageId;
1524
1868
 
1525
1869
  // The server accepted the publish RPC but reported the message was not
1526
1870
  // actually delivered (e.g. missing Send Messages permission). Previously
@@ -1532,13 +1876,16 @@ export class ChannelRegistry {
1532
1876
  console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
1533
1877
  this.emitTraceFn({
1534
1878
  type: 'mcpl:speech-routed',
1879
+ conversationId,
1535
1880
  serverId: entry.serverId,
1536
1881
  channelId,
1537
1882
  delivered,
1538
1883
  textLen: text.length,
1884
+ text,
1885
+ ...(messageId !== undefined ? { messageId } : {}),
1539
1886
  });
1540
1887
 
1541
- return { delivered, channelId };
1888
+ return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
1542
1889
  }
1543
1890
 
1544
1891
  private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise<ToolResult> {