@nebutra/agent-runtime 0.2.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 (179) hide show
  1. package/.turbo/turbo-build.log +115 -0
  2. package/.turbo/turbo-test.log +44 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/CHANGELOG.md +253 -0
  5. package/LICENSE +676 -0
  6. package/README.md +50 -0
  7. package/dist/adapters/dispatcher-sse.d.ts +68 -0
  8. package/dist/adapters/dispatcher-sse.js +11 -0
  9. package/dist/adapters/dispatcher-sse.js.map +1 -0
  10. package/dist/adapters/index.d.ts +12 -0
  11. package/dist/adapters/index.js +21 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/mcp-catalog.d.ts +58 -0
  14. package/dist/adapters/mcp-catalog.js +9 -0
  15. package/dist/adapters/mcp-catalog.js.map +1 -0
  16. package/dist/adapters/prisma-rollout.d.ts +60 -0
  17. package/dist/adapters/prisma-rollout.js +7 -0
  18. package/dist/adapters/prisma-rollout.js.map +1 -0
  19. package/dist/chunk-24ZXP7FI.js +93 -0
  20. package/dist/chunk-24ZXP7FI.js.map +1 -0
  21. package/dist/chunk-2DA6Q6TN.js +126 -0
  22. package/dist/chunk-2DA6Q6TN.js.map +1 -0
  23. package/dist/chunk-37BBB2P2.js +73 -0
  24. package/dist/chunk-37BBB2P2.js.map +1 -0
  25. package/dist/chunk-57W3AR43.js +52 -0
  26. package/dist/chunk-57W3AR43.js.map +1 -0
  27. package/dist/chunk-5N4644PB.js +67 -0
  28. package/dist/chunk-5N4644PB.js.map +1 -0
  29. package/dist/chunk-5YS7WAPS.js +177 -0
  30. package/dist/chunk-5YS7WAPS.js.map +1 -0
  31. package/dist/chunk-6EGG2OZC.js +13 -0
  32. package/dist/chunk-6EGG2OZC.js.map +1 -0
  33. package/dist/chunk-7BUOF367.js +126 -0
  34. package/dist/chunk-7BUOF367.js.map +1 -0
  35. package/dist/chunk-BJBBR3QA.js +121 -0
  36. package/dist/chunk-BJBBR3QA.js.map +1 -0
  37. package/dist/chunk-CGRCUKGT.js +73 -0
  38. package/dist/chunk-CGRCUKGT.js.map +1 -0
  39. package/dist/chunk-FUG5DT2C.js +75 -0
  40. package/dist/chunk-FUG5DT2C.js.map +1 -0
  41. package/dist/chunk-LO24VOA3.js +199 -0
  42. package/dist/chunk-LO24VOA3.js.map +1 -0
  43. package/dist/chunk-MUF7ZZTO.js +57 -0
  44. package/dist/chunk-MUF7ZZTO.js.map +1 -0
  45. package/dist/chunk-NN7DATXA.js +46 -0
  46. package/dist/chunk-NN7DATXA.js.map +1 -0
  47. package/dist/chunk-PGGWSUTM.js +33 -0
  48. package/dist/chunk-PGGWSUTM.js.map +1 -0
  49. package/dist/chunk-RDKYDMXT.js +135 -0
  50. package/dist/chunk-RDKYDMXT.js.map +1 -0
  51. package/dist/chunk-YYFPDBJG.js +63 -0
  52. package/dist/chunk-YYFPDBJG.js.map +1 -0
  53. package/dist/chunk-ZMYX5VBU.js +135 -0
  54. package/dist/chunk-ZMYX5VBU.js.map +1 -0
  55. package/dist/chunk-ZTSKS42I.js +131 -0
  56. package/dist/chunk-ZTSKS42I.js.map +1 -0
  57. package/dist/commands.d.ts +74 -0
  58. package/dist/commands.js +10 -0
  59. package/dist/commands.js.map +1 -0
  60. package/dist/definitions.d.ts +94 -0
  61. package/dist/definitions.js +15 -0
  62. package/dist/definitions.js.map +1 -0
  63. package/dist/dispatcher.d.ts +50 -0
  64. package/dist/dispatcher.js +8 -0
  65. package/dist/dispatcher.js.map +1 -0
  66. package/dist/durable-turn.d.ts +58 -0
  67. package/dist/durable-turn.js +9 -0
  68. package/dist/durable-turn.js.map +1 -0
  69. package/dist/hook-pipeline.d.ts +114 -0
  70. package/dist/hook-pipeline.js +13 -0
  71. package/dist/hook-pipeline.js.map +1 -0
  72. package/dist/index.d.ts +1874 -0
  73. package/dist/index.js +3117 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/loop.d.ts +78 -0
  76. package/dist/loop.js +9 -0
  77. package/dist/loop.js.map +1 -0
  78. package/dist/mcp-bridge.d.ts +48 -0
  79. package/dist/mcp-bridge.js +8 -0
  80. package/dist/mcp-bridge.js.map +1 -0
  81. package/dist/model.d.ts +154 -0
  82. package/dist/model.js +9 -0
  83. package/dist/model.js.map +1 -0
  84. package/dist/policy.d.ts +130 -0
  85. package/dist/policy.js +23 -0
  86. package/dist/policy.js.map +1 -0
  87. package/dist/protocol.d.ts +170 -0
  88. package/dist/protocol.js +15 -0
  89. package/dist/protocol.js.map +1 -0
  90. package/dist/rollout-store-persistent.d.ts +48 -0
  91. package/dist/rollout-store-persistent.js +9 -0
  92. package/dist/rollout-store-persistent.js.map +1 -0
  93. package/dist/rollout.d.ts +82 -0
  94. package/dist/rollout.js +15 -0
  95. package/dist/rollout.js.map +1 -0
  96. package/dist/sandbox.d.ts +65 -0
  97. package/dist/sandbox.js +15 -0
  98. package/dist/sandbox.js.map +1 -0
  99. package/dist/skills.d.ts +93 -0
  100. package/dist/skills.js +10 -0
  101. package/dist/skills.js.map +1 -0
  102. package/dist/subagents.d.ts +129 -0
  103. package/dist/subagents.js +21 -0
  104. package/dist/subagents.js.map +1 -0
  105. package/dist/tools.d.ts +77 -0
  106. package/dist/tools.js +9 -0
  107. package/dist/tools.js.map +1 -0
  108. package/package.json +74 -0
  109. package/src/adapters/dispatcher-sse.test.ts +218 -0
  110. package/src/adapters/dispatcher-sse.ts +222 -0
  111. package/src/adapters/index.ts +18 -0
  112. package/src/adapters/mcp-catalog.test.ts +213 -0
  113. package/src/adapters/mcp-catalog.ts +188 -0
  114. package/src/adapters/prisma-rollout.test.ts +153 -0
  115. package/src/adapters/prisma-rollout.ts +104 -0
  116. package/src/agent-runtime.test.ts +176 -0
  117. package/src/artifact-stream.test.ts +330 -0
  118. package/src/artifact-stream.ts +453 -0
  119. package/src/channel-gateway.test.ts +432 -0
  120. package/src/channel-gateway.ts +357 -0
  121. package/src/code-review.test.ts +501 -0
  122. package/src/code-review.ts +495 -0
  123. package/src/command-suggestions.test.ts +251 -0
  124. package/src/command-suggestions.ts +338 -0
  125. package/src/commands.test.ts +184 -0
  126. package/src/commands.ts +140 -0
  127. package/src/commit-message.test.ts +249 -0
  128. package/src/commit-message.ts +180 -0
  129. package/src/context-compaction.test.ts +522 -0
  130. package/src/context-compaction.ts +434 -0
  131. package/src/definitions.test.ts +78 -0
  132. package/src/definitions.ts +190 -0
  133. package/src/deployment-status.test.ts +215 -0
  134. package/src/deployment-status.ts +227 -0
  135. package/src/design-context.test.ts +195 -0
  136. package/src/design-context.ts +198 -0
  137. package/src/dispatcher.test.ts +234 -0
  138. package/src/dispatcher.ts +189 -0
  139. package/src/durable-turn.test.ts +209 -0
  140. package/src/durable-turn.ts +135 -0
  141. package/src/edit-planner.test.ts +204 -0
  142. package/src/edit-planner.ts +325 -0
  143. package/src/fuzzy-match.test.ts +311 -0
  144. package/src/fuzzy-match.ts +444 -0
  145. package/src/hook-pipeline.test.ts +279 -0
  146. package/src/hook-pipeline.ts +373 -0
  147. package/src/inbound-admission.test.ts +394 -0
  148. package/src/inbound-admission.ts +246 -0
  149. package/src/index.ts +47 -0
  150. package/src/loop.test.ts +161 -0
  151. package/src/loop.ts +211 -0
  152. package/src/mcp-bridge.test.ts +165 -0
  153. package/src/mcp-bridge.ts +76 -0
  154. package/src/memory-provider.test.ts +232 -0
  155. package/src/memory-provider.ts +257 -0
  156. package/src/model.ts +168 -0
  157. package/src/permission-ruleset.test.ts +301 -0
  158. package/src/permission-ruleset.ts +200 -0
  159. package/src/policy.ts +151 -0
  160. package/src/project-repo.test.ts +232 -0
  161. package/src/project-repo.ts +311 -0
  162. package/src/protocol.ts +159 -0
  163. package/src/rollout-store-persistent.test.ts +217 -0
  164. package/src/rollout-store-persistent.ts +166 -0
  165. package/src/rollout.ts +150 -0
  166. package/src/sandbox.ts +113 -0
  167. package/src/session-share.test.ts +360 -0
  168. package/src/session-share.ts +310 -0
  169. package/src/skill-distillation.test.ts +177 -0
  170. package/src/skill-distillation.ts +369 -0
  171. package/src/skills.test.ts +277 -0
  172. package/src/skills.ts +255 -0
  173. package/src/subagents.test.ts +290 -0
  174. package/src/subagents.ts +332 -0
  175. package/src/tools.ts +126 -0
  176. package/src/workbench.test.ts +0 -0
  177. package/src/workbench.ts +0 -0
  178. package/tsconfig.json +12 -0
  179. package/tsup.config.ts +33 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Multi-channel gateway (WRAP — channel-adapter layer).
3
+ *
4
+ * A faithful, brand-neutral re-expression of a multi-channel AI assistant's
5
+ * channel-adapter seam: every transport (chat platform, email, SMS, …) is
6
+ * reduced to a single `ChannelAdapter` port that produces/consumes
7
+ * channel-agnostic normalized contracts. Platform SDKs are NOT ported —
8
+ * transport is injected through the port so this package stays dependency-free.
9
+ *
10
+ * Multi-tenant by construction: `tenantId` is mandatory on every operation and
11
+ * fails closed (empty/blank/foreign tenant → typed error, never a silent pass).
12
+ * Boundaries are validated with zod; inputs are never mutated.
13
+ */
14
+
15
+ import { z } from "zod";
16
+
17
+ /* -------------------------------------------------------------------------- */
18
+ /* Branded ChannelId */
19
+ /* -------------------------------------------------------------------------- */
20
+
21
+ declare const channelIdBrand: unique symbol;
22
+
23
+ /** A validated, non-empty channel identifier. Construct via {@link asChannelId}. */
24
+ export type ChannelId = string & { readonly [channelIdBrand]: "ChannelId" };
25
+
26
+ const channelIdSchema = z.string().trim().min(1, "channelId is required (fail-closed)");
27
+
28
+ /** Brand a string as a {@link ChannelId}; throws on empty/blank input. */
29
+ export function asChannelId(value: string): ChannelId {
30
+ return channelIdSchema.parse(value) as ChannelId;
31
+ }
32
+
33
+ /* -------------------------------------------------------------------------- */
34
+ /* Errors */
35
+ /* -------------------------------------------------------------------------- */
36
+
37
+ /** Lookup/route against a channel that is not registered for the tenant. */
38
+ export class UnknownChannelError extends Error {
39
+ override readonly name = "UnknownChannelError";
40
+ constructor(channelId: string) {
41
+ super(`Unknown channel "${channelId}" for the current tenant`);
42
+ }
43
+ }
44
+
45
+ /** A channelId is registered twice within the same tenant scope. */
46
+ export class DuplicateChannelError extends Error {
47
+ override readonly name = "DuplicateChannelError";
48
+ constructor(channelId: string) {
49
+ super(`Channel "${channelId}" is already registered for this tenant`);
50
+ }
51
+ }
52
+
53
+ /** A tenantId on a contract does not match the resolved adapter scope. */
54
+ export class TenantMismatchError extends Error {
55
+ override readonly name = "TenantMismatchError";
56
+ constructor(detail: string) {
57
+ super(`Tenant mismatch: ${detail}`);
58
+ }
59
+ }
60
+
61
+ /* -------------------------------------------------------------------------- */
62
+ /* Tenant guard (fail-closed) */
63
+ /* -------------------------------------------------------------------------- */
64
+
65
+ const tenantIdSchema = z.string().trim().min(1, "tenantId is required (fail-closed)");
66
+
67
+ /** Returns a trimmed, non-empty tenantId or throws (fail-closed). */
68
+ function requireTenant(tenantId: string): string {
69
+ return tenantIdSchema.parse(tenantId);
70
+ }
71
+
72
+ /* -------------------------------------------------------------------------- */
73
+ /* Normalized contracts */
74
+ /* -------------------------------------------------------------------------- */
75
+
76
+ /** A channel-agnostic attachment reference. */
77
+ export interface InboundAttachment {
78
+ readonly kind: "image" | "file" | "audio";
79
+ readonly ref: string;
80
+ }
81
+
82
+ /** A normalized, channel-agnostic inbound user message. */
83
+ export interface InboundMessage {
84
+ readonly tenantId: string;
85
+ readonly channelId: ChannelId;
86
+ readonly chatId: string;
87
+ readonly threadId?: string | undefined;
88
+ readonly senderId: string;
89
+ readonly senderLabel?: string | undefined;
90
+ readonly text: string;
91
+ readonly attachments?: readonly InboundAttachment[] | undefined;
92
+ readonly receivedAt: string;
93
+ readonly raw: unknown;
94
+ }
95
+
96
+ /** A normalized, channel-agnostic outbound message. */
97
+ export interface OutboundMessage {
98
+ readonly tenantId: string;
99
+ readonly channelId: ChannelId;
100
+ readonly chatId: string;
101
+ readonly threadId?: string | undefined;
102
+ readonly text: string;
103
+ readonly replyToMessageId?: string | undefined;
104
+ }
105
+
106
+ /** Capability surface a channel exposes. */
107
+ export interface ChannelCapabilities {
108
+ readonly threads: boolean;
109
+ readonly reactions: boolean;
110
+ readonly streaming: boolean;
111
+ }
112
+
113
+ /** Static description of a channel. */
114
+ export interface ChannelMeta {
115
+ readonly id: ChannelId;
116
+ readonly displayName: string;
117
+ readonly capabilities: ChannelCapabilities;
118
+ }
119
+
120
+ /* -------------------------------------------------------------------------- */
121
+ /* Boundary schemas */
122
+ /* -------------------------------------------------------------------------- */
123
+
124
+ const attachmentSchema = z.object({
125
+ kind: z.enum(["image", "file", "audio"]),
126
+ ref: z.string().min(1),
127
+ });
128
+
129
+ const inboundSchema = z.object({
130
+ tenantId: tenantIdSchema,
131
+ channelId: channelIdSchema,
132
+ chatId: z.string().min(1, "chatId is required"),
133
+ threadId: z.string().min(1).optional(),
134
+ senderId: z.string().min(1, "senderId is required"),
135
+ senderLabel: z.string().min(1).optional(),
136
+ text: z.string(),
137
+ attachments: z.array(attachmentSchema).optional(),
138
+ receivedAt: z.string().min(1, "receivedAt is required"),
139
+ raw: z.unknown(),
140
+ });
141
+
142
+ const outboundSchema = z.object({
143
+ tenantId: tenantIdSchema,
144
+ channelId: channelIdSchema,
145
+ chatId: z.string().min(1, "chatId is required"),
146
+ threadId: z.string().min(1).optional(),
147
+ text: z.string().min(1, "text is required"),
148
+ replyToMessageId: z.string().min(1).optional(),
149
+ });
150
+
151
+ /**
152
+ * Validate an adapter-produced inbound message. Throws a clearly-labelled
153
+ * error (never a raw TypeError) when an adapter returns a malformed shape.
154
+ */
155
+ function validateInbound(candidate: unknown): InboundMessage {
156
+ const parsed = inboundSchema.safeParse(candidate);
157
+ if (!parsed.success) {
158
+ throw new Error(
159
+ `Adapter produced an invalid InboundMessage: ${parsed.error.issues
160
+ .map((i) => `${i.path.join(".") || "(root)"} ${i.message}`)
161
+ .join("; ")}`,
162
+ );
163
+ }
164
+ const d = parsed.data;
165
+ return {
166
+ tenantId: d.tenantId,
167
+ channelId: d.channelId as ChannelId,
168
+ chatId: d.chatId,
169
+ senderId: d.senderId,
170
+ text: d.text,
171
+ receivedAt: d.receivedAt,
172
+ raw: d.raw,
173
+ ...(d.threadId !== undefined ? { threadId: d.threadId } : {}),
174
+ ...(d.senderLabel !== undefined ? { senderLabel: d.senderLabel } : {}),
175
+ ...(d.attachments !== undefined ? { attachments: d.attachments } : {}),
176
+ };
177
+ }
178
+
179
+ /** Validate an outbound message at the route boundary. */
180
+ function validateOutbound(candidate: unknown): OutboundMessage {
181
+ const parsed = outboundSchema.safeParse(candidate);
182
+ if (!parsed.success) {
183
+ throw new Error(
184
+ `Invalid OutboundMessage: ${parsed.error.issues
185
+ .map((i) => `${i.path.join(".") || "(root)"} ${i.message}`)
186
+ .join("; ")}`,
187
+ );
188
+ }
189
+ const d = parsed.data;
190
+ return {
191
+ tenantId: d.tenantId,
192
+ channelId: d.channelId as ChannelId,
193
+ chatId: d.chatId,
194
+ text: d.text,
195
+ ...(d.threadId !== undefined ? { threadId: d.threadId } : {}),
196
+ ...(d.replyToMessageId !== undefined ? { replyToMessageId: d.replyToMessageId } : {}),
197
+ };
198
+ }
199
+
200
+ /* -------------------------------------------------------------------------- */
201
+ /* ChannelAdapter port */
202
+ /* -------------------------------------------------------------------------- */
203
+
204
+ /**
205
+ * A transport adapter. Implementations wrap a concrete platform SDK (not
206
+ * shipped here) and translate to/from the normalized contracts.
207
+ *
208
+ * - `parseInbound` MUST return `null` for events that are not user messages
209
+ * (joins, typing, presence, …) and MUST NOT throw on junk payloads. The
210
+ * resolved `tenantId` is stamped onto the result by the adapter.
211
+ * - `sendOutbound` performs the actual delivery and returns the platform's
212
+ * message id.
213
+ */
214
+ export interface ChannelAdapter {
215
+ readonly meta: ChannelMeta;
216
+ parseInbound(tenantId: string, raw: unknown): InboundMessage | null;
217
+ sendOutbound(msg: OutboundMessage): Promise<{ messageId: string }>;
218
+ }
219
+
220
+ /* -------------------------------------------------------------------------- */
221
+ /* ChannelRegistry (per-op tenantId, fail-closed) */
222
+ /* -------------------------------------------------------------------------- */
223
+
224
+ /**
225
+ * Tenant-scoped registry of {@link ChannelAdapter}s. Every operation takes an
226
+ * explicit `tenantId`; a tenant only ever sees adapters it registered. Empty,
227
+ * blank, or foreign tenants fail closed.
228
+ */
229
+ export class ChannelRegistry {
230
+ /** tenantId → (channelId → adapter). */
231
+ readonly #byTenant = new Map<string, Map<string, ChannelAdapter>>();
232
+
233
+ /** Register an adapter for a tenant. Duplicate channelId → throws. */
234
+ register(tenantId: string, adapter: ChannelAdapter): void {
235
+ const tid = requireTenant(tenantId);
236
+ const channelId = asChannelId(adapter.meta.id);
237
+ const tenantMap = this.#byTenant.get(tid) ?? new Map<string, ChannelAdapter>();
238
+ if (tenantMap.has(channelId)) {
239
+ throw new DuplicateChannelError(channelId);
240
+ }
241
+ const next = new Map(tenantMap);
242
+ next.set(channelId, adapter);
243
+ this.#byTenant.set(tid, next);
244
+ }
245
+
246
+ /** Resolve an adapter for a tenant. Unknown/foreign → UnknownChannelError. */
247
+ lookup(tenantId: string, channelId: ChannelId): ChannelAdapter {
248
+ const tid = requireTenant(tenantId);
249
+ const adapter = this.#byTenant.get(tid)?.get(channelId);
250
+ if (adapter === undefined) {
251
+ throw new UnknownChannelError(channelId);
252
+ }
253
+ return adapter;
254
+ }
255
+
256
+ /** List the channel ids registered for a tenant (empty array if none). */
257
+ list(tenantId: string): readonly string[] {
258
+ const tid = requireTenant(tenantId);
259
+ return [...(this.#byTenant.get(tid)?.keys() ?? [])];
260
+ }
261
+ }
262
+
263
+ /* -------------------------------------------------------------------------- */
264
+ /* replyTo (pure helper) */
265
+ /* -------------------------------------------------------------------------- */
266
+
267
+ export interface ReplyOptions {
268
+ readonly replyToMessageId?: string | undefined;
269
+ }
270
+
271
+ /**
272
+ * Derive a correctly-addressed {@link OutboundMessage} from an inbound message:
273
+ * same channel, chat, and thread. Pure — never mutates `inbound`. Fails closed
274
+ * on an empty inbound tenantId.
275
+ */
276
+ export function replyTo(
277
+ inbound: InboundMessage,
278
+ text: string,
279
+ opts?: ReplyOptions,
280
+ ): OutboundMessage {
281
+ const tenantId = requireTenant(inbound.tenantId);
282
+ return {
283
+ tenantId,
284
+ channelId: inbound.channelId,
285
+ chatId: inbound.chatId,
286
+ text,
287
+ ...(inbound.threadId !== undefined ? { threadId: inbound.threadId } : {}),
288
+ ...(opts?.replyToMessageId !== undefined ? { replyToMessageId: opts.replyToMessageId } : {}),
289
+ };
290
+ }
291
+
292
+ /* -------------------------------------------------------------------------- */
293
+ /* ChannelGateway */
294
+ /* -------------------------------------------------------------------------- */
295
+
296
+ /**
297
+ * Facade over a {@link ChannelRegistry}: ingest inbound payloads into
298
+ * normalized messages and route outbound replies back through the originating
299
+ * channel. Junk is swallowed (null); malformed adapter output and invalid
300
+ * replies surface as typed validation errors rather than crashes.
301
+ */
302
+ export class ChannelGateway {
303
+ readonly #registry: ChannelRegistry;
304
+
305
+ constructor(registry: ChannelRegistry) {
306
+ this.#registry = registry;
307
+ }
308
+
309
+ /**
310
+ * Resolve the adapter, parse the raw payload, and return the normalized
311
+ * message (or `null` for non-message events). Never throws on junk; a
312
+ * structurally-invalid adapter result is rejected via zod (typed error).
313
+ */
314
+ ingest(tenantId: string, channelId: ChannelId, raw: unknown): InboundMessage | null {
315
+ const tid = requireTenant(tenantId);
316
+ const adapter = this.#registry.lookup(tid, channelId);
317
+ const parsed = adapter.parseInbound(tid, raw);
318
+ if (parsed === null) return null;
319
+ const validated = validateInbound(parsed);
320
+ if (validated.tenantId !== tid) {
321
+ throw new TenantMismatchError(
322
+ `adapter stamped "${validated.tenantId}" but request scope is "${tid}"`,
323
+ );
324
+ }
325
+ if (validated.channelId !== channelId) {
326
+ throw new TenantMismatchError(
327
+ `adapter stamped channel "${validated.channelId}" but request channel is "${channelId}"`,
328
+ );
329
+ }
330
+ return validated;
331
+ }
332
+
333
+ /**
334
+ * Send a reply back through the channel the conversation belongs to
335
+ * (`reply.channelId`). Tenant must be non-empty and resolve within scope.
336
+ */
337
+ async route(reply: OutboundMessage): Promise<{ messageId: string }> {
338
+ const validated = validateOutbound(reply);
339
+ const adapter = this.#registry.lookup(validated.tenantId, validated.channelId);
340
+ if (adapter.meta.id !== validated.channelId) {
341
+ throw new TenantMismatchError(
342
+ `resolved adapter "${adapter.meta.id}" does not match reply channel "${validated.channelId}"`,
343
+ );
344
+ }
345
+ return adapter.sendOutbound(validated);
346
+ }
347
+
348
+ /** Full {@link ChannelMeta} for a registered channel. */
349
+ describe(tenantId: string, channelId: ChannelId): ChannelMeta {
350
+ return this.#registry.lookup(tenantId, channelId).meta;
351
+ }
352
+
353
+ /** {@link ChannelCapabilities} surfaced from the adapter meta. */
354
+ capabilities(tenantId: string, channelId: ChannelId): ChannelCapabilities {
355
+ return this.describe(tenantId, channelId).capabilities;
356
+ }
357
+ }