@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
@@ -190,6 +190,7 @@ export class ChannelRegistry {
190
190
  sendTypingFn;
191
191
  shouldTriggerInference;
192
192
  onRouteFailure;
193
+ onChannelAutoOpened;
193
194
  homeChannelResolver;
194
195
  activeChannelResolver;
195
196
  store;
@@ -220,6 +221,7 @@ export class ChannelRegistry {
220
221
  this.sendTypingFn = options?.sendTypingFn;
221
222
  this.shouldTriggerInference = options?.shouldTriggerInference;
222
223
  this.onRouteFailure = options?.onRouteFailure;
224
+ this.onChannelAutoOpened = options?.onChannelAutoOpened;
223
225
  this.homeChannelResolver = options?.homeChannelResolver;
224
226
  this.activeChannelResolver = options?.activeChannelResolver;
225
227
  this.store = options?.store;
@@ -398,6 +400,7 @@ export class ChannelRegistry {
398
400
  content: convertedContent,
399
401
  timestamp: message.timestamp,
400
402
  metadata: message.metadata,
403
+ ...(message.tags ? { tags: message.tags } : {}),
401
404
  triggerInference,
402
405
  };
403
406
  // Push to the processing queue
@@ -434,9 +437,19 @@ export class ChannelRegistry {
434
437
  * channels/register or channels/changed overwrites this descriptor with the
435
438
  * richer one.
436
439
  */
437
- ensureChannelRegistered(serverId, channelId, label) {
440
+ ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
438
441
  const existing = this.findChannelEntry(channelId);
439
442
  if (existing) {
443
+ // Backfill identity onto a bare lazy registration: a DM that first
444
+ // arrived with no label/recipient info gets a usable name the next
445
+ // time a message reveals it (labels must show people, not ids).
446
+ if (existing.descriptor.metadata?.lazyRegistered &&
447
+ label && existing.descriptor.label === channelId) {
448
+ existing.descriptor.label = label;
449
+ if (extraMetadata) {
450
+ existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
451
+ }
452
+ }
440
453
  return;
441
454
  }
442
455
  const key = `${serverId}:${channelId}`;
@@ -447,7 +460,7 @@ export class ChannelRegistry {
447
460
  type: serverId,
448
461
  label: label ?? channelId,
449
462
  direction: 'bidirectional',
450
- metadata: { lazyRegistered: true },
463
+ metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
451
464
  },
452
465
  open: false,
453
466
  });
@@ -775,25 +788,64 @@ export class ChannelRegistry {
775
788
  timestamp: new Date().toISOString(),
776
789
  });
777
790
  }
791
+ /**
792
+ * True when the server's recipe subscription policy wants this channel
793
+ * open. This is an ONGOING admission policy (subscribed ⇒ open, no matter
794
+ * when the channel is discovered) — not just a bootstrap seed. Allow-list
795
+ * entries may be composite MCPL ids or raw server-internal ids.
796
+ */
797
+ policyWantsOpen(serverId, channel) {
798
+ const policy = this.legacyPolicies.get(serverId) ?? 'manual';
799
+ if (policy === 'auto')
800
+ return true;
801
+ if (Array.isArray(policy)) {
802
+ if (policy.includes(channel.id))
803
+ return true;
804
+ const raw = channel.address?.channelId;
805
+ return typeof raw === 'string' && raw.length > 0 && policy.includes(raw);
806
+ }
807
+ return false;
808
+ }
809
+ /**
810
+ * Returns true when this call made a FRESH subscription-policy decision to
811
+ * open the channel — the caller announces those to the agent (once per
812
+ * channel ever: the decision persists in chronicle, so reboots see an
813
+ * existing non-default source and never re-fire).
814
+ */
778
815
  ensureInitialDesiredState(serverId, channel) {
816
+ const byPolicy = this.policyWantsOpen(serverId, channel);
817
+ const wantsOpen = channel.initiallyOpen === true || byPolicy;
779
818
  const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
780
819
  if (existing) {
781
- // A server upgrading its descriptor to initiallyOpen may lift a pure
782
- // default — 'default-closed' means nobody ever decided. Real decisions
820
+ // A server upgrading its descriptor to initiallyOpen — or a channel
821
+ // matching the subscription policy — may lift a pure default:
822
+ // 'default-closed' means nobody ever decided. Real decisions
783
823
  // (agent-tool, invitation-declined, legacy migration, …) always stick.
784
- if (channel.initiallyOpen === true && existing.state === 'closed' && existing.source === 'default-closed') {
785
- this.setDesiredState(serverId, channel.id, 'open', 'server-bootstrap');
824
+ if (wantsOpen && existing.state === 'closed' && existing.source === 'default-closed') {
825
+ this.setDesiredState(serverId, channel.id, 'open', channel.initiallyOpen === true ? 'server-bootstrap' : 'subscription-policy');
826
+ return channel.initiallyOpen !== true && byPolicy;
786
827
  }
787
- return;
828
+ return false;
788
829
  }
789
- this.setDesiredState(serverId, channel.id, channel.initiallyOpen === true ? 'open' : 'closed', channel.initiallyOpen === true ? 'server-bootstrap' : 'default-closed');
830
+ this.setDesiredState(serverId, channel.id, wantsOpen ? 'open' : 'closed', channel.initiallyOpen === true
831
+ ? 'server-bootstrap'
832
+ : wantsOpen
833
+ ? 'subscription-policy'
834
+ : 'default-closed');
835
+ return channel.initiallyOpen !== true && byPolicy;
790
836
  }
791
837
  async reconcileChannels(serverId, channels) {
792
838
  const server = this.serverRegistry.getServer(serverId);
793
839
  if (!server)
794
840
  return;
841
+ // Channels the subscription policy freshly admitted in THIS pass —
842
+ // announced to the agent as one batched notice after the loop, so a
843
+ // first boot on a new policy doesn't produce one notice per channel.
844
+ const policyOpened = [];
795
845
  for (const channel of channels) {
796
- this.ensureInitialDesiredState(serverId, channel);
846
+ if (this.ensureInitialDesiredState(serverId, channel)) {
847
+ policyOpened.push({ channelId: channel.id, label: channel.label });
848
+ }
797
849
  const key = `${serverId}:${channel.id}`;
798
850
  const desired = this.getDesiredState(serverId, channel.id);
799
851
  const entry = this.channels.get(key);
@@ -835,6 +887,21 @@ export class ChannelRegistry {
835
887
  });
836
888
  }
837
889
  }
890
+ // Announce policy admissions to the agent — nothing may start flowing
891
+ // traffic into their window without them being told, and told how to
892
+ // opt out (channel_close; their decision outranks policy).
893
+ if (policyOpened.length > 0) {
894
+ try {
895
+ this.onChannelAutoOpened?.({
896
+ serverId,
897
+ source: 'subscription-policy',
898
+ channels: policyOpened,
899
+ });
900
+ }
901
+ catch (err) {
902
+ console.error('onChannelAutoOpened (policy) failed:', err);
903
+ }
904
+ }
838
905
  }
839
906
  // ==========================================================================
840
907
  // Private: Typing notification
@@ -919,6 +986,198 @@ export class ChannelRegistry {
919
986
  data: allChannels,
920
987
  };
921
988
  }
989
+ /**
990
+ * The single open executor: record durable desired-open intent, tell the
991
+ * server to subscribe, mark the live entry open. Every path that opens a
992
+ * channel — the agent's channel_open tool, delivery into a closed locus,
993
+ * an explicit send into a closed channel — funnels here, so lifecycle
994
+ * events, desired state, and live state can never diverge by path.
995
+ * Desired state is recorded BEFORE the server round-trip: intent sticks
996
+ * even if the subscribe fails, and reconciliation retries later.
997
+ */
998
+ async openChannelNow(entry, source, history) {
999
+ this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
1000
+ const server = this.serverRegistry.getServer(entry.serverId);
1001
+ if (!server) {
1002
+ throw new Error(`Server not found: ${entry.serverId}`);
1003
+ }
1004
+ const result = await server.sendChannelsOpen({
1005
+ channelId: entry.descriptor.id,
1006
+ type: entry.descriptor.type,
1007
+ address: entry.descriptor.address,
1008
+ ...(history ? { history } : {}),
1009
+ });
1010
+ entry.open = true;
1011
+ return result;
1012
+ }
1013
+ /**
1014
+ * Resolve a `>>` prose-routing target (explicit prose routing —
1015
+ * docs/explicit-prose-routing.md) to a registered channel.
1016
+ *
1017
+ * Accepted spellings, tried in order:
1018
+ * 1. exact descriptor id (`discord:guild:123`, always unambiguous)
1019
+ * 2. exact raw server-internal id (`address.channelId`)
1020
+ * 3. `#label` / bare label — case-insensitive match on descriptor label
1021
+ * with any leading '#' stripped from both sides
1022
+ * 4. `@name` — DM descriptor labels (`DM: name`), case-insensitive
1023
+ *
1024
+ * Ambiguity is an error carrying candidates — never a guess: this is the
1025
+ * mechanism that makes prose misdelivery structurally impossible.
1026
+ */
1027
+ resolveProseTarget(spec) {
1028
+ const entries = [...this.channels.values()];
1029
+ const trimmed = spec.trim();
1030
+ if (!trimmed)
1031
+ return { error: 'empty target' };
1032
+ const exact = entries.filter((e) => e.descriptor.id === trimmed);
1033
+ if (exact.length === 1) {
1034
+ return { channelId: exact[0].descriptor.id, label: exact[0].descriptor.label };
1035
+ }
1036
+ if (exact.length > 1) {
1037
+ return {
1038
+ error: `channel id "${trimmed}" is ambiguous across servers`,
1039
+ candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`),
1040
+ };
1041
+ }
1042
+ const raw = entries.filter((e) => e.descriptor.address?.channelId === trimmed);
1043
+ if (raw.length === 1) {
1044
+ return { channelId: raw[0].descriptor.id, label: raw[0].descriptor.label };
1045
+ }
1046
+ if (raw.length > 1) {
1047
+ return {
1048
+ error: `raw id "${trimmed}" is ambiguous`,
1049
+ candidates: raw.map((e) => e.descriptor.id),
1050
+ };
1051
+ }
1052
+ const norm = (s) => s.replace(/^#/, '').toLowerCase();
1053
+ // DM addressing is PEOPLE-first: usernames and mention tokens, never
1054
+ // ids-only (2026-07-24, antra + Fable's live-canary bug report). A DM
1055
+ // entry is matched by, in order: recipientId (from a `<@id>` mention
1056
+ // token), exact recipient/label name, then prefix-lenient name (handles
1057
+ // "@antra_tessera" vs a display name "antra" and vice versa).
1058
+ const mention = /^<@!?(\d+)>$/.exec(trimmed);
1059
+ if (trimmed.startsWith('@') || mention) {
1060
+ const dmMeta = (e) => e.descriptor.metadata;
1061
+ const dmName = (e) => {
1062
+ const meta = dmMeta(e);
1063
+ if (meta?.recipientName)
1064
+ return meta.recipientName.toLowerCase();
1065
+ const label = (e.descriptor.label ?? '').toLowerCase();
1066
+ return label.startsWith('dm: ') ? label.slice(4) : label;
1067
+ };
1068
+ const isDmEntry = (e) => dmMeta(e)?.channelType === 'dm' ||
1069
+ (e.descriptor.label ?? '').toLowerCase().startsWith('dm: ') ||
1070
+ e.descriptor.id.includes(':dm:');
1071
+ const dms = entries.filter(isDmEntry);
1072
+ if (mention) {
1073
+ const id = mention[1];
1074
+ const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
1075
+ if (byId.length === 1) {
1076
+ return { channelId: byId[0].descriptor.id, label: byId[0].descriptor.label };
1077
+ }
1078
+ return {
1079
+ error: `no registered DM matches the mention <@${id}>`,
1080
+ candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6),
1081
+ };
1082
+ }
1083
+ const name = trimmed.slice(1).toLowerCase();
1084
+ const exact = dms.filter((e) => dmName(e) === name);
1085
+ const pool = exact.length > 0
1086
+ ? exact
1087
+ : dms.filter((e) => {
1088
+ const n = dmName(e);
1089
+ return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
1090
+ });
1091
+ if (pool.length === 1) {
1092
+ return { channelId: pool[0].descriptor.id, label: pool[0].descriptor.label };
1093
+ }
1094
+ if (pool.length > 1) {
1095
+ return {
1096
+ error: `"${trimmed}" matches several DMs`,
1097
+ candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id),
1098
+ };
1099
+ }
1100
+ return {
1101
+ error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
1102
+ ...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}),
1103
+ };
1104
+ }
1105
+ const byLabel = entries.filter((e) => norm(e.descriptor.label ?? '') === norm(trimmed));
1106
+ if (byLabel.length === 1) {
1107
+ return { channelId: byLabel[0].descriptor.id, label: byLabel[0].descriptor.label };
1108
+ }
1109
+ if (byLabel.length > 1) {
1110
+ return {
1111
+ error: `label "${trimmed}" matches several channels`,
1112
+ candidates: byLabel.map((e) => e.descriptor.id),
1113
+ };
1114
+ }
1115
+ // Name-segment match: server labels carry a disambiguating suffix
1116
+ // (`#fable (antra's server)`) that agents naturally omit — `>>#fable`
1117
+ // must resolve. Strip a trailing parenthetical from the stored label and
1118
+ // compare the bare channel name. Same name in several guilds is a real
1119
+ // ambiguity: error with full labels so the agent can use the exact id.
1120
+ const nameOf = (label) => norm(label.replace(/\s*\([^)]*\)\s*$/, ''));
1121
+ const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
1122
+ if (byName.length === 1) {
1123
+ return { channelId: byName[0].descriptor.id, label: byName[0].descriptor.label };
1124
+ }
1125
+ if (byName.length > 1) {
1126
+ return {
1127
+ error: `"${trimmed}" matches several channels`,
1128
+ candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`),
1129
+ };
1130
+ }
1131
+ // No match: offer near-candidates (labels containing the name) so the
1132
+ // bounce notice is self-healing rather than a dead end.
1133
+ const near = entries
1134
+ .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
1135
+ .slice(0, 5)
1136
+ .map((e) => `${e.descriptor.label} = ${e.descriptor.id}`);
1137
+ return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
1138
+ }
1139
+ /**
1140
+ * Open a channel because something was DELIVERED into it (explicit send
1141
+ * tool or routed speech). Sending into a closed channel is not a thing:
1142
+ * engaging a channel opens it, so typing indicators, reaction machinery,
1143
+ * and inbound forwarding all come alive with the first outbound message.
1144
+ * Accepts composite MCPL ids or raw server-internal ids (explicit send
1145
+ * tools receive whatever the agent typed).
1146
+ */
1147
+ async openIfClosedForSend(rawChannelId, serverId) {
1148
+ let matches = [...this.channels.values()].filter((e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId));
1149
+ if (matches.length === 0) {
1150
+ matches = [...this.channels.values()].filter((e) => e.descriptor.address?.channelId === rawChannelId &&
1151
+ (!serverId || e.serverId === serverId));
1152
+ }
1153
+ if (matches.length === 0)
1154
+ return { status: 'unknown-channel' };
1155
+ if (matches.length > 1)
1156
+ return { status: 'ambiguous' };
1157
+ const entry = matches[0];
1158
+ const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
1159
+ if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
1160
+ return { status: 'already-open', ...resolved };
1161
+ }
1162
+ try {
1163
+ await this.openChannelNow(entry, 'opened-by-reply');
1164
+ this.emitTraceFn({
1165
+ type: 'mcpl:channel-opened-by-send',
1166
+ serverId: entry.serverId,
1167
+ channelId: entry.descriptor.id,
1168
+ });
1169
+ return { status: 'opened', ...resolved };
1170
+ }
1171
+ catch (err) {
1172
+ this.emitTraceFn({
1173
+ type: 'mcpl:channel-open-failed',
1174
+ serverId: entry.serverId,
1175
+ channelId: entry.descriptor.id,
1176
+ error: err.message,
1177
+ });
1178
+ return { status: 'open-failed', ...resolved };
1179
+ }
1180
+ }
922
1181
  async handleToolOpen(input) {
923
1182
  const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
924
1183
  const entry = resolved.entry;
@@ -936,15 +1195,6 @@ export class ChannelRegistry {
936
1195
  data: { channelId: input.channelId, status: 'already open' },
937
1196
  };
938
1197
  }
939
- this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
940
- const server = this.serverRegistry.getServer(entry.serverId);
941
- if (!server) {
942
- return {
943
- success: false,
944
- error: `Server not found: ${entry.serverId}`,
945
- isError: true,
946
- };
947
- }
948
1198
  try {
949
1199
  const history = input.backscroll
950
1200
  ? {
@@ -952,13 +1202,7 @@ export class ChannelRegistry {
952
1202
  ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
953
1203
  }
954
1204
  : undefined;
955
- const result = await server.sendChannelsOpen({
956
- channelId: entry.descriptor.id,
957
- type: entry.descriptor.type,
958
- address: entry.descriptor.address,
959
- ...(history ? { history } : {}),
960
- });
961
- entry.open = true;
1205
+ const result = await this.openChannelNow(entry, 'agent-tool', history);
962
1206
  return {
963
1207
  success: true,
964
1208
  data: {
@@ -1144,12 +1388,14 @@ export class ChannelRegistry {
1144
1388
  return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
1145
1389
  }
1146
1390
  async routeSpeech(conversationId, text,
1147
- /** Pin the outbound locus, skipping resolution. Callers delivering MULTIPLE
1148
- * segments of ONE turn snapshot resolveLocus() once and pass it here, so a
1149
- * new turn starting mid-dispatch (segments run after the agent is idle,
1150
- * PR #32) can't overwrite the per-agent triggering channel between segments
1151
- * and split one reply across channels. */
1152
- overrideChannelId) {
1391
+ /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
1392
+ * at turn start) and passed to every segment of the turn. Mandatory:
1393
+ * there is deliberately no live-resolution fallback here — a late
1394
+ * dispatch resolving live can read the NEXT turn's trigger state or a
1395
+ * stale global default and land the reply in the wrong channel (item-3,
1396
+ * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
1397
+ * "this turn is pinned to no locus": fail loudly rather than guess. */
1398
+ locusChannelId) {
1153
1399
  // Surface a routing failure: emit a trace AND notify the host (which drops
1154
1400
  // a `[discord-send-failed]` marker into chronicle) so the agent learns her
1155
1401
  // reply never reached the human, instead of it vanishing silently.
@@ -1157,6 +1403,7 @@ export class ChannelRegistry {
1157
1403
  console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`);
1158
1404
  this.emitTraceFn({
1159
1405
  type: 'mcpl:speech-route-failed',
1406
+ conversationId,
1160
1407
  channelId: channelId ?? '',
1161
1408
  reason,
1162
1409
  textLen: text.length,
@@ -1164,29 +1411,54 @@ export class ChannelRegistry {
1164
1411
  this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length });
1165
1412
  return null;
1166
1413
  };
1167
- // Resolve the outbound locus, in precedence order:
1168
- // 1. fork HOME channel — a conversation fork always replies in the channel
1169
- // it was spawned to serve (item 3, forks).
1170
- // 2. this turn's TRIGGERING channel — a single trunk agent (connectome-
1171
- // host's only mode) replies in the channel whose message woke THIS
1172
- // inference, so a concurrent inbound elsewhere can't hijack the reply
1173
- // (item-3 redux, trunk agents). Also carries DM channels, which arrive
1174
- // as push/events and never touch `defaultPublishChannel`.
1175
- // 3. the process-global `defaultPublishChannel` — last resort for turns
1176
- // with no triggering channel (heartbeats, timers).
1177
- // Using the global for a fork or a concurrent trunk turn is the item-3 bug:
1178
- // it tracks the most-recent inbound across ALL channels, so a reply lands
1179
- // wherever a message last happened to arrive rather than where it belongs.
1180
- const channelId = overrideChannelId ?? this.resolveLocus(conversationId);
1414
+ // Runtime backstop for JS callers the compiler can't see: an omitted
1415
+ // locus is a routing bug at the call site, never something to paper over
1416
+ // with a live re-resolution.
1417
+ if (locusChannelId === undefined) {
1418
+ return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
1419
+ }
1420
+ const channelId = locusChannelId;
1181
1421
  if (!channelId) {
1182
- // Reached only when the agent has no home, no active triggering channel,
1183
- // AND no global inbound was ever seen.
1184
- return fail(null, 'no locus (no home/active channel, defaultPublishChannel null)');
1422
+ // The turn froze with no locus (no home, no triggering channel, no
1423
+ // global inbound ever seen) — the agent was told its prose stays in
1424
+ // the archive; honor that.
1425
+ return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
1185
1426
  }
1186
1427
  const entry = this.findChannelEntry(channelId);
1187
1428
  if (!entry) {
1188
1429
  return fail(channelId, `no registered channel for locus "${channelId}"`);
1189
1430
  }
1431
+ // INVARIANT: it is not possible to send into a closed channel. Speech
1432
+ // routed into a closed-but-registered locus (the canonical case: a DM
1433
+ // that woke the agent — DMs register closed) OPENS the channel first,
1434
+ // so typing indicators, reaction machinery, and inbound forwarding come
1435
+ // alive with the reply. If the open fails, the speech does NOT go out:
1436
+ // a half-alive delivery is worse than a loud marker.
1437
+ if (!entry.open) {
1438
+ try {
1439
+ await this.openChannelNow(entry, 'opened-by-delivery');
1440
+ console.error(`[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`);
1441
+ this.emitTraceFn({
1442
+ type: 'mcpl:channel-opened-by-send',
1443
+ serverId: entry.serverId,
1444
+ channelId,
1445
+ });
1446
+ try {
1447
+ this.onChannelAutoOpened?.({
1448
+ conversationId,
1449
+ serverId: entry.serverId,
1450
+ source: 'opened-by-delivery',
1451
+ channels: [{ channelId, label: entry.descriptor.label }],
1452
+ });
1453
+ }
1454
+ catch (err) {
1455
+ console.error('onChannelAutoOpened (delivery) failed:', err);
1456
+ }
1457
+ }
1458
+ catch (err) {
1459
+ return fail(channelId, `locus channel is closed and open failed: ${err.message}`);
1460
+ }
1461
+ }
1190
1462
  const server = this.serverRegistry.getServer(entry.serverId);
1191
1463
  if (!server) {
1192
1464
  return fail(channelId, `server "${entry.serverId}" not found`);
@@ -1198,6 +1470,11 @@ export class ChannelRegistry {
1198
1470
  };
1199
1471
  const result = await server.sendChannelsPublish(publishParams);
1200
1472
  const delivered = result?.delivered ?? true;
1473
+ // Surface the posted message's id (ChannelsPublishResult.messageId) so
1474
+ // trace consumers can act on the just-posted message — e.g. a TTS-relay
1475
+ // tap editing it down to the words actually voiced on interruption.
1476
+ // Previously this was silently dropped here.
1477
+ const messageId = result?.messageId;
1201
1478
  // The server accepted the publish RPC but reported the message was not
1202
1479
  // actually delivered (e.g. missing Send Messages permission). Previously
1203
1480
  // this returned `{ delivered: true }`, masking the failure. Surface it.
@@ -1207,12 +1484,15 @@ export class ChannelRegistry {
1207
1484
  console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`);
1208
1485
  this.emitTraceFn({
1209
1486
  type: 'mcpl:speech-routed',
1487
+ conversationId,
1210
1488
  serverId: entry.serverId,
1211
1489
  channelId,
1212
1490
  delivered,
1213
1491
  textLen: text.length,
1492
+ text,
1493
+ ...(messageId !== undefined ? { messageId } : {}),
1214
1494
  });
1215
- return { delivered, channelId };
1495
+ return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) };
1216
1496
  }
1217
1497
  async handleToolPublish(input) {
1218
1498
  // Resolve content: accept both `content` and `text` (backward compat)