@xmanrui/dsh-im 4.35.0 → 4.36.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 (64) hide show
  1. package/PROACTIVE_DELIVERY.en.md +50 -0
  2. package/PROACTIVE_DELIVERY.md +22 -0
  3. package/README.en.md +3 -1
  4. package/README.md +3 -1
  5. package/lib/client.js +4 -3
  6. package/lib/index.js +311 -310
  7. package/package.json +3 -3
  8. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  9. package/plugin-src/host/channels/feishu/production.mjs +4 -2
  10. package/plugin-src/host/channels/matrix/production.mjs +1 -1
  11. package/plugin-src/host/channels/qq/production.mjs +1 -1
  12. package/plugin-src/host/channels/shared/production.mjs +1 -1
  13. package/plugin-src/host/channels/slack/production.mjs +1 -1
  14. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  15. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  16. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  17. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  18. package/plugin-src/host/delivery-adapter.mjs +60 -12
  19. package/plugin-src/host/delivery-service.mjs +127 -1
  20. package/plugin-src/host/delivery-suggestions.mjs +2 -1
  21. package/plugin-src/host/harness-connection.mjs +3 -7
  22. package/plugin-src/host/index.mjs +5 -2
  23. package/plugin-src/host/modern-harness-api.mjs +139 -4
  24. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -0
  25. package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
  26. package/src/channels/dingtalk/dingtalk-runtime.mjs +10 -0
  27. package/src/channels/discord/discord-runtime.mjs +13 -0
  28. package/src/channels/feishu/bridge.mjs +30 -13
  29. package/src/channels/feishu/external-consumer.mjs +118 -0
  30. package/src/channels/feishu/feishu-runtime.mjs +72 -3
  31. package/src/channels/feishu/history-reader.mjs +92 -0
  32. package/src/channels/feishu/multi-bot-controller.mjs +130 -2
  33. package/src/channels/feishu/nearby-history.mjs +72 -0
  34. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  35. package/src/channels/matrix/matrix-controller.mjs +9 -0
  36. package/src/channels/matrix/matrix-runtime.mjs +14 -0
  37. package/src/channels/qq/qq-bridge.mjs +8 -0
  38. package/src/channels/qq/qq-controller.mjs +14 -0
  39. package/src/channels/qq/qq-runtime.mjs +10 -0
  40. package/src/channels/shared/deferred-delivery-coordinator.mjs +2 -1
  41. package/src/channels/shared/deferred-delivery.mjs +8 -5
  42. package/src/channels/shared/harness-approval.mjs +84 -7
  43. package/src/channels/shared/harness-client.mjs +152 -42
  44. package/src/channels/shared/i18n-en/shared-a.mjs +6 -2
  45. package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
  46. package/src/channels/shared/message-failure.mjs +6 -2
  47. package/src/channels/shared/text-harness-bridge.mjs +6 -0
  48. package/src/channels/shared/token-bot-controller.mjs +16 -0
  49. package/src/channels/slack/manifest.mjs +1 -0
  50. package/src/channels/slack/slack-api.mjs +7 -0
  51. package/src/channels/slack/slack-controller.mjs +14 -0
  52. package/src/channels/slack/slack-runtime.mjs +12 -0
  53. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  54. package/src/channels/wecom/wecom-bridge.mjs +6 -0
  55. package/src/channels/wecom/wecom-controller.mjs +14 -0
  56. package/src/channels/wecom/wecom-runtime.mjs +10 -0
  57. package/src/channels/wecom-app/wecom-app-bridge.mjs +6 -0
  58. package/src/channels/wecom-app/wecom-app-controller.mjs +14 -0
  59. package/src/channels/wecom-app/wecom-app-runtime.mjs +10 -0
  60. package/src/channels/weixin/weixin-bridge.mjs +6 -0
  61. package/src/channels/weixin/weixin-controller.mjs +14 -0
  62. package/src/channels/weixin/weixin-runtime.mjs +10 -0
  63. package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
  64. package/src/channels/whatsapp/whatsapp-runtime.mjs +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.35.0",
3
+ "version": "4.36.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -87,9 +87,9 @@
87
87
  "platform": "web"
88
88
  },
89
89
  "compatibility": {
90
- "dsh": "0.1.7-alpha.1",
90
+ "dsh": "0.2.0-rc.2",
91
91
  "dshReleases": {
92
- "0.1.7-alpha.1": "compatible"
92
+ "0.2.0-rc.2": "compatible"
93
93
  },
94
94
  "profiles": [
95
95
  "web"
@@ -45,7 +45,7 @@ function pluginPaths(config) {
45
45
 
46
46
  export async function createProductionController(ctx, config = {}, internals = {}) {
47
47
  if (!ctx?.credentials) throw new TypeError('dsh-dingtalk requires ctx.credentials');
48
- const connection = harnessConnection(ctx, config);
48
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
49
49
 
50
50
  const ConfigStore = internals.ConfigStore ?? DingtalkConfigStore;
51
51
  const DeviceAuth = internals.DeviceAuth ?? DingtalkDeviceAuth;
@@ -101,7 +101,7 @@ function pluginPaths(config) {
101
101
  */
102
102
  export async function createProductionController(ctx, config = {}, internals = {}) {
103
103
  if (!ctx?.credentials) throw new TypeError('dsh-feishu requires ctx.credentials');
104
- const connection = harnessConnection(ctx, config);
104
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
105
105
 
106
106
  const lark = internals.lark ?? Lark;
107
107
  const Controller = internals.Controller ?? MultiBotDshFeishuController;
@@ -226,7 +226,7 @@ export async function createProductionController(ctx, config = {}, internals = {
226
226
  verifyApp,
227
227
  credentials: ctx.credentials,
228
228
  configStore: observedConfigStore,
229
- createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
229
+ createRuntime: async ({ botId, config: botConfig, appSecret, repair, acceptExternal }) => {
230
230
  const state = await stateFor(botConfig);
231
231
  const id = botId ?? botConfig.id ?? botConfig.appId;
232
232
  await workspaces.ensure(id, {
@@ -248,6 +248,8 @@ export async function createProductionController(ctx, config = {}, internals = {
248
248
  }
249
249
  return new Runtime({
250
250
  lark,
251
+ consumerMode: botConfig.consumerMode,
252
+ acceptExternal,
251
253
  botId: id,
252
254
  repair,
253
255
  appId: botConfig.appId,
@@ -34,7 +34,7 @@ import {
34
34
 
35
35
  export async function createProductionController(ctx, config = {}, internals = {}) {
36
36
  if (!ctx?.credentials) throw new TypeError('dsh-im matrix requires ctx.credentials');
37
- const connection = harnessConnection(ctx, config);
37
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
38
38
 
39
39
  const ResolvedConfigStore = internals.ConfigStore ?? MatrixConfigStore;
40
40
  const ResolvedStateStore = internals.StateStore ?? ConversationStateStore;
@@ -45,7 +45,7 @@ function pluginPaths(config) {
45
45
 
46
46
  export async function createProductionController(ctx, config = {}, internals = {}) {
47
47
  if (!ctx?.credentials) throw new TypeError('dsh-im QQ requires ctx.credentials');
48
- const connection = harnessConnection(ctx, config);
48
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
49
49
 
50
50
  const ConfigStore = internals.ConfigStore ?? QqConfigStore;
51
51
  const StateStore = internals.StateStore ?? QqStateStore;
@@ -44,7 +44,7 @@ export async function createTokenProductionController(ctx, config, internals, de
44
44
  channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
45
45
  } = definitions;
46
46
  if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
47
- const connection = harnessConnection(ctx, config);
47
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: ['telegram', 'discord'].includes(channel) });
48
48
 
49
49
  const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore;
50
50
  const ResolvedStateStore = internals.StateStore ?? StateStore;
@@ -33,7 +33,7 @@ import {
33
33
 
34
34
  export async function createProductionController(ctx, config = {}, internals = {}) {
35
35
  if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
36
- const connection = harnessConnection(ctx, config);
36
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
37
37
 
38
38
  const ResolvedConfigStore = internals.ConfigStore ?? SlackConfigStore;
39
39
  const ResolvedStateStore = internals.StateStore ?? SlackStateStore;
@@ -44,7 +44,7 @@ function pluginPaths(config) {
44
44
 
45
45
  export async function createProductionController(ctx, config = {}, internals = {}) {
46
46
  if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials');
47
- const connection = harnessConnection(ctx, config);
47
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
48
48
 
49
49
  const ConfigStore = internals.ConfigStore ?? WecomConfigStore;
50
50
  const StateStore = internals.StateStore ?? WecomStateStore;
@@ -49,7 +49,7 @@ function normalizeOrigin(value) {
49
49
 
50
50
  export async function createProductionController(ctx, config = {}, internals = {}) {
51
51
  if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat app requires ctx.credentials');
52
- const connection = harnessConnection(ctx, config);
52
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
53
53
 
54
54
  const ConfigStore = internals.ConfigStore ?? WecomAppConfigStore;
55
55
  const StateStore = internals.StateStore ?? WecomAppStateStore;
@@ -77,7 +77,7 @@ function diagnosticWorkspaces(store) {
77
77
 
78
78
  export async function createProductionController(ctx, config = {}, internals = {}) {
79
79
  if (!ctx?.credentials) throw new TypeError('dsh-weixin requires ctx.credentials');
80
- const connection = harnessConnection(ctx, config);
80
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
81
81
 
82
82
  const ConfigStore = internals.ConfigStore ?? WeixinConfigStore;
83
83
  const StateStore = internals.StateStore ?? WeixinStateStore;
@@ -54,7 +54,7 @@ function pluginPaths(config) {
54
54
  }
55
55
 
56
56
  export async function createProductionController(ctx, config = {}, internals = {}) {
57
- const connection = harnessConnection(ctx, config);
57
+ const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
58
58
  const logger = typeof ctx.logger === 'function'
59
59
  ? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
60
60
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
@@ -231,8 +231,35 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
231
231
  if (typeof stateFor !== 'function') {
232
232
  throw new TypeError('delivery adapter requires a bot state getter');
233
233
  }
234
+ const syncTarget = async (botId, targetId, sessionId) => {
235
+ const configured = sessionSyncConfigs(workspaces, botId).find((entry) => entry.targetId === targetId);
236
+ const target = workspaces.deliveryTargetFor(botId, targetId);
237
+ if (!configured || !target || !privateConversationKeyMatchesTarget(channel, configured.conversationKey, target)) {
238
+ throw sessionSyncUnavailable('Session sync target is no longer enabled');
239
+ }
240
+ const sessions = stateSessions(await stateFor(botId));
241
+ if (sessions?.[configured.conversationKey] !== sessionId) {
242
+ throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
243
+ }
244
+ return { target, conversationKey: configured.conversationKey };
245
+ };
234
246
  return Object.freeze({
235
247
  channel,
248
+ consumeInbound: (botId, options) => {
249
+ if (typeof coreController.consumeInbound !== 'function')
250
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
251
+ return coreController.consumeInbound(botId, options);
252
+ },
253
+ historyChecked: (botId, route, query, options) => {
254
+ if (typeof coreController.historyChecked !== 'function')
255
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
256
+ return coreController.historyChecked(botId, route, query, options);
257
+ },
258
+ replyChecked: (botId, route, text, options) => {
259
+ if (typeof coreController.replyChecked !== 'function')
260
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
261
+ return coreController.replyChecked(botId, route, text, options);
262
+ },
236
263
  describeAccount: (botId) => {
237
264
  if (typeof coreController.describeDeliveryAccount !== 'function') {
238
265
  const error = new Error('Verified account capability unavailable');
@@ -339,20 +366,41 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
339
366
  }
340
367
  return matches;
341
368
  },
369
+ async describeSessionSyncApprovalTarget(botId, targetId, sessionId) {
370
+ const { conversationKey } = await syncTarget(botId, targetId, sessionId);
371
+ return { conversationKey };
372
+ },
373
+ async presentSessionSyncApproval(botId, targetId, sessionId, interaction, options = {}) {
374
+ const original = await syncTarget(botId, targetId, sessionId);
375
+ if (typeof coreController.presentSessionSyncApproval !== 'function') {
376
+ throw sessionSyncUnavailable('This channel cannot present synced approvals');
377
+ }
378
+ const validate = async () => {
379
+ const current = await syncTarget(botId, targetId, sessionId);
380
+ if (current.conversationKey !== original.conversationKey || options.signal?.aborted) {
381
+ throw sessionSyncUnavailable('The approval target changed or the approval ended');
382
+ }
383
+ };
384
+ const guarded = {
385
+ ...interaction,
386
+ respond: async (result, responseOptions) => {
387
+ try { await validate(); }
388
+ catch (error) {
389
+ await interaction.withdraw?.();
390
+ throw Object.assign(new Error('Approval route is no longer available', { cause: error }), {
391
+ code: 'interaction-not-pending',
392
+ });
393
+ }
394
+ return interaction.respond(result, responseOptions);
395
+ },
396
+ };
397
+ return coreController.presentSessionSyncApproval(botId, original.target, guarded, {
398
+ ...options, key: original.conversationKey, validate,
399
+ });
400
+ },
342
401
  async sendSessionSyncText(botId, targetId, sessionId, text, options = {}) {
343
402
  if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
344
- const configured = sessionSyncConfigs(workspaces, botId)
345
- .find((target) => target.targetId === targetId);
346
- const target = workspaces.deliveryTargetFor(botId, targetId);
347
- if (!configured || !target || !privateConversationKeyMatchesTarget(
348
- channel,
349
- configured.conversationKey,
350
- target,
351
- )) throw sessionSyncUnavailable('Session sync target is no longer enabled');
352
- const sessions = stateSessions(await stateFor(botId));
353
- if (sessions?.[configured.conversationKey] !== sessionId) {
354
- throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
355
- }
403
+ const { target } = await syncTarget(botId, targetId, sessionId);
356
404
  if (typeof coreController.sendProactiveText !== 'function') {
357
405
  throw new TypeError('delivery controller cannot send proactive text');
358
406
  }
@@ -1,5 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
3
+ import { COMPETITIVE_APPROVAL_CHANNELS } from '../../src/channels/shared/harness-approval.mjs';
4
+ import { t } from '../../src/channels/shared/i18n.mjs';
3
5
 
4
6
  const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
5
7
  const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
@@ -33,6 +35,17 @@ const DELIVERY_ERROR_CODES = new Set([
33
35
  'send-result-unknown',
34
36
  'session-sync-unavailable',
35
37
  'cancelled',
38
+ 'provider-unavailable',
39
+ 'consumer-unavailable',
40
+ 'consumer-conflict',
41
+ 'ingress-not-accepted',
42
+ 'invalid-inbound',
43
+ 'stale-route',
44
+ 'reply-result-unknown',
45
+ 'history-permission-denied',
46
+ 'history-unavailable',
47
+ 'thread-unavailable',
48
+ 'untrusted-source',
36
49
  ]);
37
50
 
38
51
  const SESSION_SYNC_METHODS = Object.freeze([
@@ -132,11 +145,13 @@ export class DeliveryService {
132
145
 
133
146
  registerAdapter(value) {
134
147
  const adapter = validateAdapter(value);
135
- const registration = Object.freeze({ adapter });
148
+ this.#adapters.get(adapter.channel)?.controller.abort(deliveryError('provider-unavailable'));
149
+ const registration = Object.freeze({ adapter, controller: new AbortController() });
136
150
  this.#adapters.set(adapter.channel, registration);
137
151
  return () => {
138
152
  if (this.#adapters.get(adapter.channel) !== registration) return false;
139
153
  this.#adapters.delete(adapter.channel);
154
+ registration.controller.abort(deliveryError('provider-unavailable'));
140
155
  return true;
141
156
  };
142
157
  }
@@ -273,6 +288,59 @@ export class DeliveryService {
273
288
  return targets;
274
289
  }
275
290
 
291
+ /** Offer one existing bound private chat the same approval the Host is awaiting. */
292
+ async presentSessionSyncApproval(sessionId, interaction, { signal, completion } = {}) {
293
+ const targets = (await this.listSessionSyncTargets(sessionId))
294
+ .filter((target) => COMPETITIVE_APPROVAL_CHANNELS.includes(target.channel));
295
+ if (signal?.aborted || targets.length === 0) return false;
296
+ let outcome = null;
297
+ let notified = false;
298
+ const notify = async (finished = false) => {
299
+ if (!finished && (signal?.aborted || outcome !== null)) return;
300
+ const text = finished
301
+ ? t('未找到可处理端,本次操作未获批准。')
302
+ : t('有操作在等待审批:{tool},请到 Web 处理。', { tool: interaction.payload.toolName });
303
+ const results = await Promise.allSettled(targets.map((target) => this.sendSessionSyncText(
304
+ target.botId, target.targetId, sessionId, text, finished ? {} : { signal },
305
+ )));
306
+ notified ||= results.some((result) => result.status === 'fulfilled');
307
+ };
308
+ Promise.resolve(completion).then(async (value) => {
309
+ outcome = value;
310
+ if (value === 'unavailable' && notified) await notify(true);
311
+ }).catch((error) => console.warn('[dsh-im] approval status notification failed:', error?.code ?? error?.name));
312
+ const candidates = new Map();
313
+ let unresolvedTarget = false;
314
+ for (const target of targets) {
315
+ try {
316
+ const adapter = await this.#adapterFor(target.botId);
317
+ if (typeof adapter.describeSessionSyncApprovalTarget !== 'function') {
318
+ unresolvedTarget = true;
319
+ continue;
320
+ }
321
+ const info = await adapter.describeSessionSyncApprovalTarget(target.botId, target.targetId, sessionId);
322
+ candidates.set(JSON.stringify([target.channel, target.botId, info.conversationKey]), { adapter, target });
323
+ } catch (error) {
324
+ unresolvedTarget = true;
325
+ console.warn('[dsh-im] approval target unavailable:', error?.code ?? error?.name);
326
+ }
327
+ }
328
+ if (signal?.aborted) return false;
329
+ if (!unresolvedTarget && candidates.size === 1) {
330
+ const { adapter, target } = candidates.values().next().value;
331
+ try {
332
+ if (await adapter.presentSessionSyncApproval(target.botId, target.targetId, sessionId, interaction, {
333
+ signal, completion,
334
+ })) return true;
335
+ } catch (error) {
336
+ if (!signal?.aborted) console.warn('[dsh-im] synced approval unavailable:', error?.code ?? error?.name);
337
+ }
338
+ }
339
+ if (outcome === 'unavailable') await notify(true);
340
+ else await notify();
341
+ return false;
342
+ }
343
+
276
344
  async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
277
345
  const id = botIdOf(botId);
278
346
  const targetKey = targetIdOf(targetId);
@@ -315,6 +383,64 @@ export class DeliveryService {
315
383
  } catch (error) { throw publicOperationError(error); }
316
384
  }
317
385
 
386
+ async consumeInbound(botId, options = {}) {
387
+ const id = botIdOf(botId);
388
+ cancellation(options.signal);
389
+ if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '') || typeof options.onEvent !== 'function')
390
+ throw deliveryError('bad-request');
391
+ const registration = await this.#checkedRegistrationFor(id);
392
+ if (typeof registration.adapter.consumeInbound !== 'function' || typeof options.onEvent !== 'function')
393
+ throw deliveryError('capability-unavailable');
394
+ this.#assertRegistered(registration);
395
+ const dispose = await registration.adapter.consumeInbound(id, {
396
+ ...options,
397
+ signal: options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal,
398
+ onEvent: async (evidence, context) => {
399
+ this.#assertRegistered(registration);
400
+ const result = await options.onEvent(evidence, context);
401
+ this.#assertRegistered(registration);
402
+ return result;
403
+ },
404
+ });
405
+ try { this.#assertRegistered(registration); } catch (error) { dispose(); throw error; }
406
+ return dispose;
407
+ }
408
+
409
+ async historyChecked(botId, route, query, options = {}) {
410
+ const id = botIdOf(botId);
411
+ cancellation(options.signal);
412
+ if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '')) throw deliveryError('bad-request');
413
+ const registration = await this.#checkedRegistrationFor(id);
414
+ if (typeof registration.adapter.historyChecked !== 'function') throw deliveryError('capability-unavailable');
415
+ this.#assertRegistered(registration);
416
+ const signal = options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal;
417
+ try {
418
+ const result = await registration.adapter.historyChecked(id, structuredClone(route), structuredClone(query), { ...options, signal });
419
+ this.#assertRegistered(registration);
420
+ cancellation(options.signal);
421
+ return result;
422
+ } catch (error) {
423
+ this.#assertRegistered(registration);
424
+ if (signal.aborted || error?.name === 'AbortError') throw deliveryError('cancelled');
425
+ throw publicOperationError(error, 'history-unavailable');
426
+ }
427
+ }
428
+
429
+ async replyChecked(botId, route, text, options = {}) {
430
+ const id = botIdOf(botId);
431
+ cancellation(options.signal);
432
+ if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '')
433
+ || typeof text !== 'string' || !text.trim() || text.length > 4000)
434
+ throw deliveryError('bad-request');
435
+ const registration = await this.#checkedRegistrationFor(id);
436
+ if (typeof registration.adapter.replyChecked !== 'function') throw deliveryError('capability-unavailable');
437
+ this.#assertRegistered(registration);
438
+ try { return await registration.adapter.replyChecked(id, structuredClone(route), text, {
439
+ ...options, signal: options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal,
440
+ }); }
441
+ catch (error) { throw publicOperationError(error); }
442
+ }
443
+
318
444
  async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
319
445
  const id = botIdOf(botId);
320
446
  const key = targetIdOf(targetId);
@@ -124,7 +124,7 @@ export function deliverySuggestionFromConversationKey(channel, key) {
124
124
  case 'whatsapp':
125
125
  return whatsappSuggestion(key);
126
126
  case 'matrix':
127
- return matrixSuggestion(key);
127
+ return matrixSuggestion(key.replace(/^(direct|group):/, ''));
128
128
  default:
129
129
  return null;
130
130
  }
@@ -132,6 +132,7 @@ export function deliverySuggestionFromConversationKey(channel, key) {
132
132
 
133
133
  /** Convert only a persisted private-chat key into its stable delivery route. */
134
134
  export function privateDeliverySuggestionFromConversationKey(channel, key) {
135
+ if (channel === 'matrix' && typeof key === 'string') key = key.replace(/^direct:dm:/, 'dm:');
135
136
  const separator = typeof key === 'string' ? key.indexOf(':') : -1;
136
137
  const prefix = separator > 0 ? key.slice(0, separator) : '';
137
138
  const privatePrefix = {
@@ -1,18 +1,14 @@
1
1
  import { modernHarnessApi } from './modern-harness-api.mjs';
2
2
 
3
- function optionalService(ctx, name) {
4
- if (ctx && Object.hasOwn(ctx, name)) return ctx[name];
5
- return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
6
- }
7
-
8
3
  /** Connect to this Host directly unless an external Harness URL was configured. */
9
- export function harnessConnection(ctx, config = {}) {
4
+ export function harnessConnection(ctx, config = {}, { competitiveApprovals = false } = {}) {
10
5
  if (config.harnessBaseUrl !== undefined) {
11
6
  return { baseUrl: new URL(config.harnessBaseUrl) };
12
7
  }
13
- const apiProxy = optionalService(ctx, 'apiProxy') ?? modernHarnessApi(ctx);
8
+ const apiProxy = modernHarnessApi(ctx, { deliveryService: config.deliveryService });
14
9
  return {
15
10
  apiProxy,
11
+ competitiveApprovals,
16
12
  // Cordis child contexts share one root; different Hosts must not share
17
13
  // ownership of pending questions and approvals.
18
14
  interactionScope: ctx.root ?? ctx,
@@ -93,6 +93,10 @@ export function createImHostPlugin(internals = {}) {
93
93
  ctx.provide('dshIm', Object.freeze({
94
94
  contractVersion: 1,
95
95
  receiptVersion: 1,
96
+ inboundVersion: 1,
97
+ consumeInbound: (botId, options) => deliveryService.consumeInbound(botId, options),
98
+ historyChecked: (botId, route, query, options) => deliveryService.historyChecked(botId, route, query, options),
99
+ replyChecked: (botId, route, text, options) => deliveryService.replyChecked(botId, route, text, options),
96
100
  describeBot: (botId) => deliveryService.describeBot(botId),
97
101
  sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
98
102
  send: (botId, targetId, text, options) => (
@@ -106,9 +110,8 @@ export function createImHostPlugin(internals = {}) {
106
110
  await activateChannels(readyCtx, config, deliveryService);
107
111
  };
108
112
  if (typeof ctx?.inject === 'function') {
109
- const modern = typeof ctx?.typertGateway?.stream === 'function';
110
113
  await ctx.inject(
111
- modern ? ['sessionController', 'workspaceController'] : ['apiProxy'],
114
+ ['sessionController', 'workspaceController'],
112
115
  activate,
113
116
  );
114
117
  ctx.inject(['webServer'], (httpCtx) => {
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
- import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
3
+ import { activeHarnessInteractionOwner, hasActiveHarnessInteractionOwner, harnessToolCallFromEvent, HarnessInteractionError } from '../../src/channels/shared/harness-client.mjs';
4
4
  import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
5
5
 
6
6
  const modernApis = new WeakMap();
@@ -196,6 +196,7 @@ class MuxSubscription {
196
196
 
197
197
  class ModernHarnessApi {
198
198
  #gateway;
199
+ #deliveryService;
199
200
  #scope;
200
201
  #mux = new Set();
201
202
  #pendingQuestions = new Map();
@@ -286,6 +287,10 @@ class ModernHarnessApi {
286
287
  }
287
288
  }
288
289
 
290
+ setDeliveryService(service) {
291
+ if (service) this.#deliveryService = service;
292
+ }
293
+
289
294
  async #invoke(namespace, method, args, signal) {
290
295
  return this.#gateway.invoke(remoteRequest(namespace, method, args, signal));
291
296
  }
@@ -467,7 +472,9 @@ class ModernHarnessApi {
467
472
  subscription = new MuxSubscription(signal, () => this.#mux.delete(subscription));
468
473
  this.#mux.add(subscription);
469
474
  for (const pending of this.#pendingQuestions.values()) subscription.push(this.#questionFrame(pending));
470
- for (const pending of this.#pendingApprovals.values()) subscription.push(this.#approvalFrame(pending));
475
+ for (const pending of this.#pendingApprovals.values()) {
476
+ if (pending.broadcast !== false && pending.imAvailable !== false) subscription.push(this.#approvalFrame(pending));
477
+ }
471
478
  return subscription;
472
479
  }
473
480
 
@@ -630,6 +637,7 @@ class ModernHarnessApi {
630
637
  type: 'approval/requested',
631
638
  sessionId: pending.sessionId,
632
639
  approvalId: pending.approvalId,
640
+ ...(pending.competitive ? { competitive: true } : {}),
633
641
  toolName: pending.toolName,
634
642
  ...(pending.callId === undefined ? {} : { callId: pending.callId }),
635
643
  ...(pending.reason === undefined ? {} : { reason: pending.reason }),
@@ -638,6 +646,123 @@ class ModernHarnessApi {
638
646
  }
639
647
 
640
648
  #requestApproval(request, next) {
649
+ const session = request?.agent?.session;
650
+ const sessionId = session?.id ?? request?.agent?.id;
651
+ const events = sessionEvents(session);
652
+ if (typeof sessionId !== 'string' || !events) return next();
653
+ const owner = activeHarnessInteractionOwner(this.#scope, sessionId, events);
654
+ if (owner && !owner.competitiveApprovals) return this.#requestExclusiveApproval(request, next);
655
+ if (!owner && typeof this.#deliveryService?.presentSessionSyncApproval !== 'function') return next();
656
+ const turnSignal = request.signal;
657
+ if (turnSignal?.aborted) return Promise.resolve('cancelled');
658
+ const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
659
+ const decided = new Set();
660
+ let approvalId;
661
+ for (let index = events.length - 1; index >= 0; index -= 1) {
662
+ const event = events[index];
663
+ if (event.type === 'approval/decided') decided.add(event.data?.id);
664
+ if (event.type !== 'approval/asked') continue;
665
+ const id = event.data?.id;
666
+ if (!id || decided.has(id) || claimed.has(id)
667
+ || (request.callId ?? null) !== (event.data?.callId ?? null)) continue;
668
+ approvalId = id;
669
+ break;
670
+ }
671
+ if (!approvalId) return next();
672
+ let toolCall = owner?.toolCalls.get(request.callId);
673
+ if (!toolCall) {
674
+ for (let index = events.length - 1; index >= 0; index -= 1) {
675
+ const event = events[index];
676
+ if (event.type === 'turn/start') break;
677
+ const call = harnessToolCallFromEvent(event);
678
+ if (call?.callId === request.callId && call.name === request.toolName) {
679
+ toolCall = call;
680
+ break;
681
+ }
682
+ }
683
+ }
684
+ // This controller retires only this approval; the caller keeps its turn signal.
685
+ const lifetime = new AbortController();
686
+ request.signal = lifetime.signal;
687
+ let nativeAvailable = true;
688
+ let resolveResult;
689
+ const result = new Promise((resolve) => { resolveResult = resolve; });
690
+ const pending = {
691
+ rpcId: randomUUID(), sessionId, approvalId,
692
+ toolName: request.toolName, callId: request.callId, reason: request.reason,
693
+ competitive: true, broadcast: Boolean(owner), imAvailable: true,
694
+ settle: (outcome) => {
695
+ if (!this.#pendingApprovals.delete(pending.rpcId)) return false;
696
+ turnSignal?.removeEventListener('abort', onAbort);
697
+ if (pending.broadcast) this.#broadcast({
698
+ type: 'approval/resolved', sessionId, approvalId, outcome,
699
+ });
700
+ // Reserve the result before aborting the losing native presentation.
701
+ resolveResult(outcome);
702
+ lifetime.abort(new Error('approval settled'));
703
+ if (request.signal === lifetime.signal) request.signal = turnSignal;
704
+ return true;
705
+ },
706
+ withdraw: () => {
707
+ if (!this.#pendingApprovals.has(pending.rpcId) || !pending.imAvailable) return false;
708
+ pending.imAvailable = false;
709
+ if (pending.broadcast) this.#broadcast({
710
+ type: 'approval/resolved', sessionId, approvalId, outcome: 'unavailable',
711
+ });
712
+ if (!nativeAvailable) pending.settle('unavailable');
713
+ return true;
714
+ },
715
+ };
716
+ const onAbort = () => pending.settle('cancelled');
717
+ this.#pendingApprovals.set(pending.rpcId, pending);
718
+ turnSignal?.addEventListener('abort', onAbort, { once: true });
719
+ if (turnSignal?.aborted) onAbort();
720
+ // Start both providers without waiting for target lookup or platform delivery.
721
+ Promise.resolve().then(() => {
722
+ if (lifetime.signal.aborted) return 'cancelled';
723
+ return next();
724
+ }).then((outcome) => {
725
+ if (outcome === 'allowed-once' || outcome === 'rejected') pending.settle(outcome);
726
+ else {
727
+ nativeAvailable = false;
728
+ if (!pending.imAvailable) pending.settle('unavailable');
729
+ }
730
+ }, (error) => {
731
+ nativeAvailable = false;
732
+ if (!lifetime.signal.aborted) console.warn('[dsh-im] native approval provider unavailable:', error?.code ?? error?.name);
733
+ if (!pending.imAvailable) pending.settle('unavailable');
734
+ });
735
+ if (!lifetime.signal.aborted && owner) {
736
+ this.#broadcast(this.#approvalFrame(pending).payload, pending.rpcId);
737
+ } else if (!lifetime.signal.aborted) {
738
+ const respond = async (response) => {
739
+ const receipt = this.#respond({ rpcId: pending.rpcId, result: response });
740
+ if (!receipt.accepted) throw new HarnessInteractionError(
741
+ `interaction-${receipt.reason}`, 'This approval is no longer answerable',
742
+ );
743
+ return receipt;
744
+ };
745
+ const interaction = {
746
+ kind: 'approval', interactionId: approvalId, rpcId: pending.rpcId, sessionId,
747
+ payload: this.#approvalFrame(pending).payload, toolCall, respond,
748
+ withdraw: async () => pending.withdraw(),
749
+ };
750
+ Promise.resolve().then(() => {
751
+ if (lifetime.signal.aborted) return false;
752
+ return this.#deliveryService.presentSessionSyncApproval(sessionId, interaction, {
753
+ signal: lifetime.signal, completion: result,
754
+ });
755
+ }).then((available) => {
756
+ if (!available) pending.withdraw();
757
+ }, (error) => {
758
+ if (!lifetime.signal.aborted) console.warn('[dsh-im] synced approval delivery unavailable:', error?.code ?? error?.name);
759
+ pending.withdraw();
760
+ });
761
+ }
762
+ return result;
763
+ }
764
+
765
+ #requestExclusiveApproval(request, next) {
641
766
  const owner = this.#claimableAgent(request?.agent);
642
767
  if (!owner) return next();
643
768
  if (request.signal?.aborted) return Promise.resolve('cancelled');
@@ -687,6 +812,12 @@ class ModernHarnessApi {
687
812
  #respond(message) {
688
813
  const approval = this.#pendingApprovals.get(message?.rpcId);
689
814
  if (approval) {
815
+ if (approval.imAvailable === false) return { accepted: false, reason: 'not-pending' };
816
+ if (approval.competitive && message?.result?.ok === false
817
+ && message?.result?.error?.code === 'unavailable') {
818
+ return approval.withdraw()
819
+ ? { accepted: true } : { accepted: false, reason: 'not-pending' };
820
+ }
690
821
  const value = message?.result?.value;
691
822
  if (message?.result?.ok !== true
692
823
  || !value || typeof value !== 'object'
@@ -738,18 +869,22 @@ class ModernHarnessApi {
738
869
  }
739
870
 
740
871
  /** Create one modern compatibility API per Cordis Host root. */
741
- export function modernHarnessApi(ctx) {
872
+ export function modernHarnessApi(ctx, { deliveryService } = {}) {
742
873
  const scope = ctx?.root ?? ctx;
743
874
  if (!scope || !['object', 'function'].includes(typeof scope)) {
744
875
  throw new TypeError('dsh-im requires a Cordis Host context');
745
876
  }
746
877
  const cached = modernApis.get(scope);
747
- if (cached) return cached;
878
+ if (cached) {
879
+ cached.setDeliveryService(deliveryService);
880
+ return cached;
881
+ }
748
882
  const gateway = ctx?.typertGateway;
749
883
  if (!gateway || typeof gateway.invoke !== 'function' || typeof gateway.stream !== 'function') {
750
884
  throw new TypeError('dsh-im requires the modern Host Typert gateway');
751
885
  }
752
886
  const api = new ModernHarnessApi(ctx, gateway, scope);
887
+ api.setDeliveryService(deliveryService);
753
888
  modernApis.set(scope, api);
754
889
  if (typeof ctx.effect === 'function') {
755
890
  ctx.effect(() => () => {