@zhin.js/adapter 1.1.11 → 1.2.1

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.
@@ -10,20 +10,31 @@ import {
10
10
  type RuntimeSnapshot,
11
11
  } from '@zhin.js/plugin-runtime';
12
12
  import { createCapabilityContext } from '@zhin.js/feature-kit';
13
- import type {
14
- AdapterCapability,
15
- AdapterDefinition,
16
- AdapterSegmentPolicy,
17
- EndpointInstance,
18
- EndpointSendRequest,
13
+ import {
14
+ endpointCapabilitiesOf,
15
+ resolveAdapterOperations,
16
+ type AdapterCapability,
17
+ type AdapterDefinition,
18
+ type AdapterOperation,
19
+ type AdapterSegmentPolicy,
20
+ type EndpointSendRequest,
19
21
  } from './definition.js';
22
+ import { bindEndpoint, isEndpoint, type Endpoint } from './endpoint.js';
20
23
  import {
21
24
  listEndpointManagementCapabilities,
22
25
  type EndpointManagementCapability,
23
26
  } from './endpoint-management.js';
24
- import { assertDeclaredEndpointOperations } from './endpoint-control.js';
27
+ import {
28
+ assertDeclaredEndpointOperations,
29
+ endpointControlOf,
30
+ type EndpointControl,
31
+ } from './endpoint-control.js';
25
32
  import { endpointContentOf, type EndpointContentResolveContext } from './endpoint-content.js';
26
- import type { ConversationReference, ConversationResolution } from '@zhin.js/im-contract';
33
+ import type {
34
+ ConversationReference,
35
+ ConversationResolution,
36
+ EndpointCapabilities,
37
+ } from '@zhin.js/im-contract';
27
38
 
28
39
  export interface AdapterDescriptor {
29
40
  readonly id: CapabilityId;
@@ -31,6 +42,7 @@ export interface AdapterDescriptor {
31
42
  readonly name: string;
32
43
  readonly source: string;
33
44
  readonly capabilities: readonly AdapterCapability[];
45
+ readonly operations: readonly AdapterOperation[];
34
46
  }
35
47
 
36
48
  /** Console / Host-facing endpoint row (connected = admission open). */
@@ -45,7 +57,7 @@ export type AdapterEndpointPhase =
45
57
  'pending' | 'starting' | 'online';
46
58
 
47
59
  interface AdapterRecord extends AdapterDescriptor {
48
- readonly endpoint: EndpointInstance;
60
+ readonly endpoint: Endpoint;
49
61
  readonly segments?: AdapterSegmentPolicy;
50
62
  started: boolean;
51
63
  open: boolean;
@@ -80,7 +92,7 @@ export class AdapterIndex {
80
92
  for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
81
93
  signal.throwIfAborted();
82
94
  for (const expansion of expandEndpointConfigs(slot, snapshot)) {
83
- const endpoint = await createEndpoint(slot, snapshot, admission, signal, expansion);
95
+ const created = await createEndpoint(slot, snapshot, admission, signal, expansion);
84
96
  signal.throwIfAborted();
85
97
  records.push({
86
98
  id: expansion.id,
@@ -90,7 +102,8 @@ export class AdapterIndex {
90
102
  name: expansion.endpointId,
91
103
  source: slot.source,
92
104
  capabilities: slot.definition.capabilities,
93
- endpoint,
105
+ operations: created.operations,
106
+ endpoint: created.endpoint,
94
107
  ...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
95
108
  started: false,
96
109
  open: false,
@@ -122,6 +135,7 @@ export class AdapterIndex {
122
135
  name: endpointLiveName(record.endpoint) ?? record.name,
123
136
  source: record.source,
124
137
  capabilities: record.capabilities,
138
+ operations: record.operations,
125
139
  connected: record.open && !record.stopped,
126
140
  status: record.open && !record.stopped ? 'online' as const : 'offline' as const,
127
141
  phase: endpointPhase(record),
@@ -143,21 +157,70 @@ export class AdapterIndex {
143
157
  return exact?.id ?? matches[0]?.id;
144
158
  }
145
159
 
146
- /**
147
- * Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
148
- */
149
- instance(adapter: string, endpointKey: string): EndpointInstance | undefined {
160
+ /** Resolve the framework-owned Endpoint for internal Host control ports. */
161
+ connection(adapter: string, endpointKey: string): Endpoint | undefined {
150
162
  const id = this.resolve(adapter, endpointKey);
151
163
  if (!id) return undefined;
152
164
  return this.#records.get(id)?.endpoint;
153
165
  }
154
166
 
167
+ /** Resolve the platform-native client owned by one active Endpoint. */
168
+ client<TClient>(adapter: string, endpointKey: string): TClient {
169
+ const id = this.resolve(adapter, endpointKey);
170
+ const record = id ? this.#records.get(id) : undefined;
171
+ if (!record) throw new Error(`Endpoint ${adapter}/${endpointKey} does not exist`);
172
+ if (!record.started || record.stopped) {
173
+ throw new Error(`Endpoint ${adapter}/${endpointKey} is not active`);
174
+ }
175
+ return record.endpoint.client as TClient;
176
+ }
177
+
178
+ /** Resolve the Client directly from a generation-stable CapabilityId. */
179
+ clientById<TClient>(id: CapabilityId): TClient {
180
+ const record = this.#records.get(id);
181
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
182
+ if (!record.started || record.stopped) {
183
+ throw new Error(`Adapter Endpoint ${id} is not active`);
184
+ }
185
+ return record.endpoint.client as TClient;
186
+ }
187
+
188
+ /** Literal adapter name used by authoring-context type discrimination. */
189
+ clientAdapter(id: CapabilityId): string {
190
+ const record = this.#records.get(id);
191
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
192
+ return record.endpoint.identity.adapter;
193
+ }
194
+
195
+ /** Optional Client lookup for cross-platform middleware and routing. */
196
+ findClient<TClient>(adapter: string, endpointKey: string): TClient | undefined {
197
+ const id = this.resolve(adapter, endpointKey);
198
+ const record = id ? this.#records.get(id) : undefined;
199
+ if (!record || !record.started || record.stopped) return undefined;
200
+ return record.endpoint.client as TClient;
201
+ }
202
+
155
203
  owner(id: CapabilityId): PluginId {
156
204
  const record = this.#records.get(id);
157
205
  if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
158
206
  return record.owner;
159
207
  }
160
208
 
209
+ /** Exact, serializable capabilities for one concrete Endpoint. */
210
+ capabilities(id: CapabilityId): EndpointCapabilities {
211
+ const record = this.#records.get(id);
212
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
213
+ return endpointCapabilitiesOf(record, record.operations);
214
+ }
215
+
216
+ /** Returns the control port only when the concrete Endpoint declared the operation and is active. */
217
+ control(id: CapabilityId, operation: AdapterOperation): EndpointControl | undefined {
218
+ const record = this.#records.get(id);
219
+ if (!record || !record.started || record.stopped) return undefined;
220
+ if (!record.operations.includes(operation)) return undefined;
221
+ return endpointControlOf(record.endpoint);
222
+ }
223
+
161
224
  /**
162
225
  * Endpoint 的消息段能力声明(出站协商降级依据);
163
226
  * 未声明或未知 id 返回 undefined(调用方按历史行为处理)。
@@ -199,6 +262,11 @@ export class AdapterIndex {
199
262
  }
200
263
  signal.throwIfAborted();
201
264
  if (record.stopped) throw new Error(`Adapter Endpoint stopped during start: ${record.id}`);
265
+ if (record.endpoint.client === record.endpoint) {
266
+ throw new TypeError(
267
+ `Adapter Endpoint ${record.id} must expose a distinct platform client`,
268
+ );
269
+ }
202
270
  record.started = true;
203
271
  }
204
272
  } catch (error) {
@@ -306,7 +374,7 @@ function matchesEndpoint(
306
374
  || record.id.endsWith(`/${adapter}`)
307
375
  || record.owner === adapter
308
376
  || record.owner.endsWith(`/${adapter}`);
309
- // Live EndpointInstance.name is the bot runtime id (e.g. ICQQ uin). Host /
377
+ // The live Endpoint identity is the bot runtime id (e.g. ICQQ uin). Host /
310
378
  // activity-feedback resolve with that id; slot.localName alone is not enough
311
379
  // when multiple plugin instances share localName "icqq".
312
380
  const liveName = endpointLiveName(record.endpoint);
@@ -317,7 +385,7 @@ function matchesEndpoint(
317
385
  return adapterOk && endpointOk;
318
386
  }
319
387
 
320
- function endpointLiveName(endpoint: EndpointInstance): string | undefined {
388
+ function endpointLiveName(endpoint: Endpoint): string | undefined {
321
389
  const name = (endpoint as { readonly name?: unknown }).name;
322
390
  return typeof name === 'string' && name.length > 0 ? name : undefined;
323
391
  }
@@ -328,9 +396,9 @@ function endpointPhase(record: AdapterRecord): AdapterEndpointPhase {
328
396
  return 'pending';
329
397
  }
330
398
 
331
- function assertEndpoint(value: unknown, id: CapabilityId): asserts value is EndpointInstance {
332
- if (!value || typeof value !== 'object') {
333
- throw new TypeError(`Adapter ${id} create() must return an Endpoint instance`);
399
+ function assertEndpoint(value: unknown, id: CapabilityId): asserts value is Endpoint {
400
+ if (!isEndpoint(value)) {
401
+ throw new TypeError(`Adapter ${id} create() must return an Endpoint subclass`);
334
402
  }
335
403
  }
336
404
 
@@ -396,16 +464,17 @@ async function createEndpoint(
396
464
  admission: GenerationAdmissionGate,
397
465
  signal: AbortSignal,
398
466
  expansion?: EndpointExpansion,
399
- ): Promise<EndpointInstance> {
400
- const endpoint = await slot.definition.create(
401
- Object.freeze({
402
- ...createCapabilityContext(snapshot, slot.owner, admission, signal),
403
- ...(expansion?.config ? { config: expansion.config } : {}),
404
- id: expansion?.id ?? slot.id,
405
- name: slot.localName,
406
- }),
407
- );
467
+ ): Promise<Readonly<{ endpoint: Endpoint; operations: readonly AdapterOperation[] }>> {
468
+ const context = Object.freeze({
469
+ ...createCapabilityContext(snapshot, slot.owner, admission, signal),
470
+ ...(expansion?.config ? { config: expansion.config } : {}),
471
+ id: expansion?.id ?? slot.id,
472
+ name: slot.localName,
473
+ });
474
+ const operations = resolveAdapterOperations(slot.definition, context);
475
+ const endpoint = await slot.definition.create(context);
408
476
  assertEndpoint(endpoint, expansion?.id ?? slot.id);
477
+ bindEndpoint(endpoint, context, admission);
409
478
  if (slot.definition.capabilities.includes('outbound') && typeof endpoint.send !== 'function') {
410
479
  throw new TypeError(
411
480
  `Adapter Endpoint ${String(expansion?.id ?? slot.id)} declares outbound but send() is missing`,
@@ -413,10 +482,10 @@ async function createEndpoint(
413
482
  }
414
483
  assertDeclaredEndpointOperations(
415
484
  endpoint,
416
- slot.definition.operations,
485
+ operations,
417
486
  String(expansion?.id ?? slot.id),
418
487
  );
419
- return endpoint;
488
+ return Object.freeze({ endpoint, operations });
420
489
  }
421
490
 
422
491
  async function stopRecords(
package/src/definition.ts CHANGED
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Adapter authoring API consumed from `zhin.js/adapter`.
3
+ * @module zhin.js/adapter
4
+ */
1
5
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
6
  import type { CapabilityContext } from '@zhin.js/feature-kit';
3
7
  import type {
@@ -5,9 +9,19 @@ import type {
5
9
  EndpointCapabilities,
6
10
  EndpointOperation,
7
11
  } from '@zhin.js/im-contract';
8
- import type { EndpointManagement } from './endpoint-management.js';
9
- import type { EndpointControl } from './endpoint-control.js';
10
- import type { EndpointContentPort } from './endpoint-content.js';
12
+ import type { Endpoint } from './endpoint.js';
13
+
14
+ export { Endpoint } from './endpoint.js';
15
+ export type {
16
+ EndpointEvent,
17
+ EndpointIdentity,
18
+ PlatformEvent,
19
+ } from './endpoint.js';
20
+ export { defineEndpointClient } from './endpoint-client.js';
21
+ export type {
22
+ EndpointClientContext,
23
+ EndpointClientToken,
24
+ } from './endpoint-client.js';
11
25
 
12
26
  const adapterBrand = 'zhin.adapter/1' as const;
13
27
 
@@ -16,6 +30,11 @@ export type AdapterCapability = 'inbound' | 'outbound';
16
30
  /** Operations beyond sending, declared by an Adapter definition. */
17
31
  export type AdapterOperation = Exclude<EndpointOperation, 'send'>;
18
32
 
33
+ /** Resolve operations for one concrete Endpoint configuration. */
34
+ export type AdapterOperationDeclaration<TConfig = unknown> =
35
+ | readonly AdapterOperation[]
36
+ | ((context: AdapterContext<TConfig>) => readonly AdapterOperation[]);
37
+
19
38
  /** 端点可消费的出站媒体来源形式。 */
20
39
  export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
21
40
 
@@ -30,25 +49,9 @@ export interface EndpointSendRequest {
30
49
  readonly payload: unknown;
31
50
  }
32
51
 
33
- export interface EndpointInstance {
34
- /** Optional platform-neutral Console/Host management surface. */
35
- readonly management?: EndpointManagement;
36
- /** Optional platform-neutral control surface for existing messages. */
37
- readonly control?: EndpointControl;
38
- /** Optional canonical resolver for message, merged-forward and media references. */
39
- readonly content?: EndpointContentPort;
40
- /** Required readiness; must observe abort and settle before rollback returns. */
41
- start?(signal: AbortSignal): void | Promise<void>;
42
- /** Opens Endpoint-local flow behind the candidate generation admission gate. */
43
- open?(): void;
44
- /** Stops new inbound events while preserving in-flight work. */
45
- close?(): void | Promise<void>;
46
- /** Releases transport resources. Calls must be idempotent. */
47
- stop?(): void | Promise<void>;
48
- /** Platform message id. Core wraps it in the canonical MessageRef/DeliveryReceipt. */
49
- send?(request: EndpointSendRequest): string | Promise<string>;
50
- }
51
-
52
+ /**
53
+ * Generation-owned construction context for one runtime Endpoint.
54
+ */
52
55
  export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TConfig> {
53
56
  readonly id: CapabilityId;
54
57
  readonly name: string;
@@ -95,7 +98,8 @@ const OUTBOUND_MEDIA_FORMS: readonly AdapterOutboundMedia[] = [
95
98
 
96
99
  const ADAPTER_OPERATIONS: readonly AdapterOperation[] = ['recall', 'edit', 'reaction', 'typing'];
97
100
 
98
- export interface AdapterDefinition<TConfig = unknown> {
101
+ export interface AdapterDefinition<TConfig = unknown, TClient = unknown> {
102
+ /** @internal Runtime feature brand. */
99
103
  readonly $feature: typeof adapterBrand;
100
104
  readonly capabilities: readonly AdapterCapability[];
101
105
  /**
@@ -103,12 +107,12 @@ export interface AdapterDefinition<TConfig = unknown> {
103
107
  * `capabilities: ['outbound']`; a method existing on an endpoint is not a
104
108
  * capability declaration.
105
109
  */
106
- readonly operations?: readonly AdapterOperation[];
110
+ readonly operations?: AdapterOperationDeclaration<TConfig>;
107
111
  /** 可选:端点消息段能力声明(出站协商降级挂载点)。 */
108
112
  readonly segments?: AdapterSegmentPolicy;
109
113
  create(
110
114
  context: AdapterContext<TConfig>,
111
- ): EndpointInstance | Promise<EndpointInstance>;
115
+ ): Endpoint<TClient> | Promise<Endpoint<TClient>>;
112
116
  }
113
117
 
114
118
  declare module '@zhin.js/plugin-runtime' {
@@ -120,6 +124,23 @@ declare module '@zhin.js/plugin-runtime' {
120
124
  }
121
125
  }
122
126
 
127
+ /**
128
+ * Define an Adapter module for the `adapters/` convention directory.
129
+ *
130
+ * The returned definition is immutable and declares capabilities before an
131
+ * Endpoint is created, so Runtime admission can fail closed.
132
+ *
133
+ * @public
134
+ * @example
135
+ * ```ts
136
+ * import { defineAdapter } from 'zhin.js/adapter';
137
+ *
138
+ * export default defineAdapter({
139
+ * capabilities: ['inbound', 'outbound'],
140
+ * create: () => new MyPlatformEndpoint(),
141
+ * });
142
+ * ```
143
+ */
123
144
  export function defineAdapter<TConfig = unknown>(
124
145
  definition: Omit<AdapterDefinition<TConfig>, '$feature'>,
125
146
  ): Readonly<AdapterDefinition<TConfig>> {
@@ -134,7 +155,9 @@ export function defineAdapter<TConfig = unknown>(
134
155
  throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
135
156
  }
136
157
  const segments = normalizeSegmentPolicy(definition.segments);
137
- const operations = normalizeOperations(definition.operations);
158
+ const operations = typeof definition.operations === 'function'
159
+ ? definition.operations
160
+ : normalizeOperations(definition.operations);
138
161
  return Object.freeze({
139
162
  ...definition,
140
163
  $feature: adapterBrand,
@@ -144,11 +167,20 @@ export function defineAdapter<TConfig = unknown>(
144
167
  });
145
168
  }
146
169
 
147
- /** Converts the definition's compact authoring form into the public contract. */
170
+ /**
171
+ * Converts the definition's compact authoring form into the Runtime contract.
172
+ * @internal Adapter projection helper.
173
+ */
148
174
  export function endpointCapabilitiesOf(
149
175
  definition: Pick<AdapterDefinition, 'capabilities' | 'operations'>,
176
+ resolvedOperations?: readonly AdapterOperation[],
150
177
  ): EndpointCapabilities {
151
- const operations = definition.operations?.reduce<Partial<Record<AdapterOperation, true>>>(
178
+ if (typeof definition.operations === 'function' && resolvedOperations === undefined) {
179
+ throw new TypeError('Dynamic Adapter operations must be resolved for one Endpoint');
180
+ }
181
+ const declared = resolvedOperations
182
+ ?? (Array.isArray(definition.operations) ? definition.operations : undefined);
183
+ const operations = declared?.reduce<Partial<Record<AdapterOperation, true>>>(
152
184
  (result, operation) => ({ ...result, [operation]: true }),
153
185
  {},
154
186
  );
@@ -159,6 +191,21 @@ export function endpointCapabilitiesOf(
159
191
  });
160
192
  }
161
193
 
194
+ /**
195
+ * Resolve and validate the operation declaration for one concrete Endpoint.
196
+ * @internal Adapter projection helper.
197
+ */
198
+ export function resolveAdapterOperations<TConfig>(
199
+ definition: Pick<AdapterDefinition<TConfig>, 'operations'>,
200
+ context: AdapterContext<TConfig>,
201
+ ): readonly AdapterOperation[] {
202
+ const declaration = definition.operations;
203
+ const operations = typeof declaration === 'function'
204
+ ? declaration(context)
205
+ : declaration;
206
+ return normalizeOperations(operations) ?? Object.freeze([]);
207
+ }
208
+
162
209
  function normalizeOperations(
163
210
  operations: readonly AdapterOperation[] | undefined,
164
211
  ): readonly AdapterOperation[] | undefined {
@@ -226,6 +273,7 @@ function normalizeSegmentPolicy(
226
273
  });
227
274
  }
228
275
 
276
+ /** @internal Runtime validation for convention-discovered modules. */
229
277
  export function parseAdapterDefinition(value: unknown): AdapterDefinition {
230
278
  if (!value || typeof value !== 'object') throw invalidAdapter();
231
279
  const definition = value as Partial<AdapterDefinition>;
@@ -240,7 +288,9 @@ export function parseAdapterDefinition(value: unknown): AdapterDefinition {
240
288
  ) throw invalidAdapter();
241
289
  // defineAdapter 已校验过形状;外部手工构造的 definition 也在此兜底
242
290
  normalizeSegmentPolicy(definition.segments);
243
- normalizeOperations(definition.operations);
291
+ if (typeof definition.operations !== 'function') {
292
+ normalizeOperations(definition.operations);
293
+ }
244
294
  return definition as AdapterDefinition;
245
295
  }
246
296
 
@@ -0,0 +1,250 @@
1
+ import type { CapabilityContext } from '@zhin.js/feature-kit';
2
+ import { isAdapterIndex } from './adapter-index.js';
3
+ import { Endpoint } from './endpoint.js';
4
+ import { adapterFeatureId } from './provider.js';
5
+
6
+ const endpointClientBrand = 'zhin.endpoint-client/1' as const;
7
+
8
+ export type {
9
+ AdapterClient,
10
+ AdapterClientRegistry,
11
+ AdapterClientTypes,
12
+ AdapterEvents,
13
+ RegisteredAdapterName,
14
+ } from '@zhin.js/feature-kit';
15
+
16
+ /** Convert an EventEmitter-style tuple map into the payload map used by handlers. */
17
+ export type ClientEventPayloads<TEvents extends object> = {
18
+ readonly [K in keyof TEvents]: TEvents[K] extends readonly [infer TPayload, ...unknown[]]
19
+ ? TPayload
20
+ : never;
21
+ };
22
+
23
+ interface ClientEventSource {
24
+ on(name: string, listener: (payload: unknown) => void): unknown;
25
+ off(name: string, listener: (payload: unknown) => void): unknown;
26
+ }
27
+
28
+ /**
29
+ * Forward one Client's public event surface through the Endpoint boundary.
30
+ * The returned disposer is useful when a Client can outlive its Endpoint.
31
+ */
32
+ export function forwardEndpointClientEvents(
33
+ client: ClientEventSource,
34
+ names: readonly string[],
35
+ receive: (name: string, payload: unknown) => void,
36
+ ): () => void {
37
+ const subscriptions = names.map((name) => {
38
+ const listener = (payload: unknown) => receive(name, payload);
39
+ client.on(name, listener);
40
+ return { name, listener };
41
+ });
42
+ return () => {
43
+ for (const { name, listener } of subscriptions) client.off(name, listener);
44
+ };
45
+ }
46
+
47
+ export type ClientEventSubscription = (
48
+ receive: (name: string, payload: unknown) => void,
49
+ ) => () => void;
50
+
51
+ /**
52
+ * Deep Endpoint base for SDK/protocol Clients.
53
+ *
54
+ * It owns the open admission gate and the single Client-event → Endpoint-event
55
+ * bridge. Concrete Endpoints only own account transport and raw-event
56
+ * normalization; they do not repeat dispatch plumbing.
57
+ */
58
+ export abstract class ClientEndpoint<TClient = unknown> extends Endpoint<TClient> {
59
+ #clientEventsOpen = false;
60
+ #clientEventsRelease?: () => void;
61
+
62
+ open(): void {
63
+ this.#clientEventsOpen = true;
64
+ }
65
+
66
+ close(): void {
67
+ this.#clientEventsOpen = false;
68
+ }
69
+
70
+ protected get clientEventsOpen(): boolean {
71
+ return this.#clientEventsOpen;
72
+ }
73
+
74
+ protected bindClientEvents(
75
+ subscribe: ClientEventSubscription,
76
+ receive?: (name: string, payload: unknown) => void,
77
+ onError?: (name: string, error: unknown) => void,
78
+ ): void {
79
+ this.#clientEventsRelease?.();
80
+ this.#clientEventsRelease = subscribe((name, payload) => {
81
+ if (!this.#clientEventsOpen) return;
82
+ void this.emitPlatform(name, payload).catch((error) => onError?.(name, error));
83
+ try {
84
+ receive?.(name, payload);
85
+ } catch (error) {
86
+ onError?.(name, error);
87
+ }
88
+ });
89
+ }
90
+
91
+ protected releaseClientEvents(): void {
92
+ this.#clientEventsRelease?.();
93
+ this.#clientEventsRelease = undefined;
94
+ }
95
+ }
96
+
97
+ /** Typed identity for one platform's native Client surface. */
98
+ export interface EndpointClientToken<TClient, TEvents extends object = Record<string, unknown>> {
99
+ readonly $client: typeof endpointClientBrand;
100
+ readonly adapter: string;
101
+ /** @internal Type-only covariance anchor. */
102
+ readonly _client?: TClient;
103
+ /** @internal Type-only covariance anchor for native platform events. */
104
+ readonly _events?: TEvents;
105
+ /**
106
+ * Resolve the Client from an operation context. Current inbound operations
107
+ * infer their Endpoint; detached operations must provide `endpointKey`.
108
+ */
109
+ get(context: EndpointClientContext, endpointKey?: string): TClient;
110
+ /** Resolve when this operation belongs to the platform; otherwise return undefined. */
111
+ find(context: EndpointClientContext, endpointKey?: string): TClient | undefined;
112
+ }
113
+
114
+ /** Operation-scoped sources that can resolve an Endpoint Client. */
115
+ export interface EndpointClientContext {
116
+ readonly project?: CapabilityContext['project'];
117
+ readonly message?: unknown;
118
+ readonly input?: unknown;
119
+ readonly endpoint?: string;
120
+ readonly origin?: unknown;
121
+ readonly conversation?: unknown;
122
+ readonly $client?: unknown;
123
+ readonly clientAdapter?: string;
124
+ }
125
+
126
+ /** Declare the native Client type exported by a platform adapter. */
127
+ export function defineEndpointClient<
128
+ TClient,
129
+ TEvents extends object = Record<string, unknown>,
130
+ >(adapter: string): EndpointClientToken<TClient, TEvents> {
131
+ const normalized = adapter.trim();
132
+ if (!normalized) throw new TypeError('Endpoint Client adapter cannot be empty');
133
+ const token: EndpointClientToken<TClient, TEvents> = {
134
+ $client: endpointClientBrand,
135
+ adapter: normalized,
136
+ get(context, endpointKey) {
137
+ return resolveEndpointClient(context, token, endpointKey);
138
+ },
139
+ find(context, endpointKey) {
140
+ return findEndpointClient(context, token, endpointKey);
141
+ },
142
+ };
143
+ return Object.freeze(token);
144
+ }
145
+
146
+ /**
147
+ * Resolve a platform Client from the current generation.
148
+ *
149
+ * The returned object is valid only for the lifetime of `context`; callers
150
+ * must not retain it beyond the current command, handler, tool, task, or
151
+ * schedule operation.
152
+ */
153
+ function resolveEndpointClient<TClient, TEvents extends object>(
154
+ context: EndpointClientContext,
155
+ token: EndpointClientToken<TClient, TEvents>,
156
+ endpointKey?: string,
157
+ ): TClient {
158
+ if (token.$client !== endpointClientBrand) {
159
+ throw new TypeError('Invalid Endpoint Client token');
160
+ }
161
+ const current = currentClientSource(context);
162
+ if (current && '$client' in current) {
163
+ if (endpointKey && !matchesCurrentEndpoint(current, endpointKey)) {
164
+ throw new Error(
165
+ `Endpoint Client ${endpointKey} does not match the current operation Endpoint`,
166
+ );
167
+ }
168
+ if (current.clientAdapter && current.clientAdapter !== token.adapter) {
169
+ throw new Error(
170
+ `Endpoint Client ${token.adapter} cannot access ${current.clientAdapter}`,
171
+ );
172
+ }
173
+ return current.$client as TClient;
174
+ }
175
+ const resolvedEndpointKey = endpointKey ?? endpointKeyFromContext(context);
176
+ if (!resolvedEndpointKey) {
177
+ throw new Error('Detached Endpoint Client access requires an explicit endpoint key');
178
+ }
179
+ if (!context.project) {
180
+ throw new Error('Endpoint Client access requires a generation operation context');
181
+ }
182
+ const projection = context.project<unknown>(adapterFeatureId);
183
+ if (!isAdapterIndex(projection)) {
184
+ throw new Error('Adapter Feature projection is not installed');
185
+ }
186
+ return projection.client<TClient>(token.adapter, resolvedEndpointKey);
187
+ }
188
+
189
+ interface CurrentClientSource {
190
+ readonly endpointId?: string;
191
+ readonly clientAdapter?: string;
192
+ readonly conversation?: unknown;
193
+ readonly $client?: unknown;
194
+ }
195
+
196
+ function findEndpointClient<TClient, TEvents extends object>(
197
+ context: EndpointClientContext,
198
+ token: EndpointClientToken<TClient, TEvents>,
199
+ endpointKey?: string,
200
+ ): TClient | undefined {
201
+ const current = currentClientSource(context);
202
+ if (current && '$client' in current) {
203
+ if (current.clientAdapter && current.clientAdapter !== token.adapter) return undefined;
204
+ if (endpointKey && !matchesCurrentEndpoint(current, endpointKey)) return undefined;
205
+ try {
206
+ return current.$client as TClient;
207
+ } catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ const resolvedEndpointKey = endpointKey ?? endpointKeyFromContext(context);
212
+ if (!resolvedEndpointKey || !context.project) return undefined;
213
+ const projection = context.project<unknown>(adapterFeatureId);
214
+ if (!isAdapterIndex(projection)) return undefined;
215
+ return projection.findClient<TClient>(token.adapter, resolvedEndpointKey);
216
+ }
217
+
218
+ function currentClientSource(context: EndpointClientContext): CurrentClientSource | undefined {
219
+ for (const candidate of [context, context.message, context.input]) {
220
+ if (candidate && typeof candidate === 'object'
221
+ && ('$client' in candidate || 'clientAdapter' in candidate)) {
222
+ return candidate as CurrentClientSource;
223
+ }
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ function matchesCurrentEndpoint(source: CurrentClientSource, endpointKey: string): boolean {
229
+ const candidates = [source.endpointId, conversationEndpointId(source.conversation)]
230
+ .filter((value): value is string => typeof value === 'string' && value.length > 0);
231
+ return candidates.length === 0 || candidates.includes(endpointKey);
232
+ }
233
+
234
+ function endpointKeyFromContext(context: EndpointClientContext): string | undefined {
235
+ if (typeof context.endpoint === 'string' && context.endpoint.length > 0) return context.endpoint;
236
+ const origin = context.origin as { readonly kind?: unknown; readonly endpoint?: unknown } | undefined;
237
+ if (origin?.kind === 'im' && typeof origin.endpoint === 'string' && origin.endpoint.length > 0) {
238
+ return origin.endpoint;
239
+ }
240
+ return conversationEndpointId(context.conversation)
241
+ ?? conversationEndpointId((context.input as { readonly conversation?: unknown } | undefined)?.conversation);
242
+ }
243
+
244
+ function conversationEndpointId(value: unknown): string | undefined {
245
+ if (!value || typeof value !== 'object') return undefined;
246
+ const endpoint = (value as { readonly endpoint?: unknown }).endpoint;
247
+ if (!endpoint || typeof endpoint !== 'object') return undefined;
248
+ const id = (endpoint as { readonly id?: unknown }).id;
249
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
250
+ }