@xmanrui/dsh-im 4.10.0 → 4.11.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 (56) hide show
  1. package/PROACTIVE_DELIVERY.en.md +15 -1
  2. package/PROACTIVE_DELIVERY.md +15 -1
  3. package/README.en.md +5 -1
  4. package/README.md +6 -2
  5. package/lib/client.js +1619 -769
  6. package/lib/index.js +248 -244
  7. package/package.json +4 -3
  8. package/plugin-src/client/channel-card-meta.js +9 -1
  9. package/plugin-src/client/channels/dingtalk/api.js +4 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -2
  11. package/plugin-src/client/channels/feishu/api.js +4 -0
  12. package/plugin-src/client/channels/feishu/group-settings.js +581 -0
  13. package/plugin-src/client/channels/feishu/index.js +26 -204
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +25 -2
  16. package/plugin-src/client/channels/shared/token-api.js +4 -0
  17. package/plugin-src/client/channels/shared/token-channel.js +25 -3
  18. package/plugin-src/client/channels/wecom/api.js +4 -0
  19. package/plugin-src/client/channels/wecom/index.js +25 -2
  20. package/plugin-src/client/channels/weixin/api.js +4 -0
  21. package/plugin-src/client/channels/weixin/index.js +25 -2
  22. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  23. package/plugin-src/client/channels/whatsapp/index.js +24 -2
  24. package/plugin-src/client/delivery-settings.js +73 -5
  25. package/plugin-src/client/i18n.js +20 -0
  26. package/plugin-src/client/model-setting.js +104 -0
  27. package/plugin-src/client/styles.js +50 -0
  28. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  29. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  31. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  32. package/plugin-src/host/channels/qq/production.mjs +3 -0
  33. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  35. package/plugin-src/host/channels/shared/production.mjs +3 -0
  36. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  37. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  38. package/plugin-src/host/channels/slack/production.mjs +3 -0
  39. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  40. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  41. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  42. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  43. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  44. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  45. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  46. package/plugin-src/host/delivery-adapter.mjs +141 -3
  47. package/plugin-src/host/delivery-rpc.mjs +12 -0
  48. package/plugin-src/host/delivery-service.mjs +110 -3
  49. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  50. package/plugin-src/host/harness-session-coordinator.mjs +6 -5
  51. package/plugin-src/host/index.mjs +18 -1
  52. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  53. package/scripts/verify-package.mjs +12 -6
  54. package/src/channels/shared/bot-workspace-store.mjs +285 -38
  55. package/src/channels/shared/harness-client.mjs +124 -43
  56. package/src/channels/shared/model-setting.mjs +110 -0
@@ -1,4 +1,8 @@
1
- import { deliverySuggestionsFromSessions } from './delivery-suggestions.mjs';
1
+ import {
2
+ deliverySuggestionsFromSessions,
3
+ privateConversationKeyMatchesTarget,
4
+ resolvePrivateConversationKey,
5
+ } from './delivery-suggestions.mjs';
2
6
 
3
7
  const CHANNELS = new Set([
4
8
  'weixin',
@@ -22,6 +26,12 @@ function invalidTarget(message) {
22
26
  return error;
23
27
  }
24
28
 
29
+ function sessionSyncUnavailable(message = 'Session sync is unavailable for this target') {
30
+ const error = new Error(message);
31
+ error.code = 'session-sync-unavailable';
32
+ return error;
33
+ }
34
+
25
35
  function isRecord(value) {
26
36
  return value !== null && typeof value === 'object' && !Array.isArray(value);
27
37
  }
@@ -129,6 +139,49 @@ export function normalizeDeliveryTarget(channel, value, { targetIdRequired = tru
129
139
  return normalized;
130
140
  }
131
141
 
142
+ function targetWithoutSessionSync(target) {
143
+ return {
144
+ targetId: target.targetId,
145
+ ...(target.name === undefined ? {} : { name: target.name }),
146
+ kind: target.kind,
147
+ route: target.route,
148
+ };
149
+ }
150
+
151
+ function sessionSyncConfigs(workspaces, botId) {
152
+ if (typeof workspaces.listSessionSyncTargets !== 'function') return [];
153
+ const targets = workspaces.listSessionSyncTargets();
154
+ return botId === undefined ? targets : targets.filter((target) => target.botId === botId);
155
+ }
156
+
157
+ function stateSessions(state) {
158
+ if (!state || typeof state.snapshot !== 'function') return null;
159
+ const sessions = state.snapshot()?.sessions;
160
+ return sessions && typeof sessions === 'object' && !Array.isArray(sessions) ? sessions : null;
161
+ }
162
+
163
+ function sessionSyncView(channel, target, sessions, configured) {
164
+ if (configured) {
165
+ const valid = privateConversationKeyMatchesTarget(
166
+ channel,
167
+ configured.conversationKey,
168
+ target,
169
+ );
170
+ if (!valid || !sessions) return { enabled: true, state: 'unavailable' };
171
+ return {
172
+ enabled: true,
173
+ state: typeof sessions[configured.conversationKey] === 'string'
174
+ && Boolean(sessions[configured.conversationKey])
175
+ ? 'active'
176
+ : 'waiting',
177
+ };
178
+ }
179
+ const conversationKey = sessions
180
+ ? resolvePrivateConversationKey(channel, sessions, target)
181
+ : null;
182
+ return { enabled: false, state: conversationKey ? 'off' : 'unavailable' };
183
+ }
184
+
132
185
  /** Bind one channel's existing workspace store and unwrapped controller to DeliveryService. */
133
186
  export function createDeliveryAdapter({ channel, workspaces, coreController, stateFor }) {
134
187
  if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
@@ -145,7 +198,22 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
145
198
  channel,
146
199
  ownsBot: (botId) => workspaces.has(botId),
147
200
  listBots: () => workspaces.listBotIds(),
148
- listTargets: (botId) => workspaces.listDeliveryTargets(botId),
201
+ async listTargets(botId) {
202
+ const targets = workspaces.listDeliveryTargets(botId);
203
+ const configured = new Map(
204
+ sessionSyncConfigs(workspaces, botId).map((target) => [target.targetId, target]),
205
+ );
206
+ let sessions = null;
207
+ try {
208
+ sessions = stateSessions(await stateFor(botId));
209
+ } catch {
210
+ // Target CRUD remains usable when its persisted conversation state cannot be read.
211
+ }
212
+ return targets.map((target) => ({
213
+ ...target,
214
+ sessionSync: sessionSyncView(channel, target, sessions, configured.get(target.targetId)),
215
+ }));
216
+ },
149
217
  async listSuggestions(botId) {
150
218
  const state = await stateFor(botId);
151
219
  if (!state || typeof state.snapshot !== 'function') {
@@ -169,12 +237,82 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
169
237
  ),
170
238
  deleteTarget: (botId, targetId) => workspaces.deleteDeliveryTarget(botId, targetId),
171
239
  async sendText(botId, target, text, options = {}) {
172
- const normalized = normalizeDeliveryTarget(channel, target);
240
+ const normalized = normalizeDeliveryTarget(channel, targetWithoutSessionSync(target));
173
241
  if (typeof coreController.sendProactiveText !== 'function') {
174
242
  throw new TypeError('delivery controller cannot send proactive text');
175
243
  }
176
244
  await coreController.sendProactiveText(botId, normalized, text, options);
177
245
  return { sent: true };
178
246
  },
247
+ async setSessionSync(botId, targetId, enabled) {
248
+ if (typeof workspaces.setDeliveryTargetSessionSync !== 'function') {
249
+ throw sessionSyncUnavailable();
250
+ }
251
+ const target = workspaces.deliveryTargetFor(botId, targetId);
252
+ if (!target) {
253
+ const error = new Error('Unknown target');
254
+ error.code = 'unknown-target';
255
+ throw error;
256
+ }
257
+ if (!enabled) {
258
+ await workspaces.setDeliveryTargetSessionSync(botId, targetId, null);
259
+ let sessions = null;
260
+ try {
261
+ sessions = stateSessions(await stateFor(botId));
262
+ } catch {
263
+ // The setting is already safely disabled; only its availability is unknown.
264
+ }
265
+ return sessionSyncView(channel, target, sessions, null);
266
+ }
267
+ const sessions = stateSessions(await stateFor(botId));
268
+ const conversationKey = resolvePrivateConversationKey(channel, sessions, target);
269
+ if (!conversationKey) throw sessionSyncUnavailable();
270
+ await workspaces.setDeliveryTargetSessionSync(botId, targetId, conversationKey);
271
+ return { enabled: true, state: 'active' };
272
+ },
273
+ async listSessionSyncTargets(sessionId) {
274
+ if (typeof sessionId !== 'string' || !sessionId) {
275
+ throw new TypeError('sessionId is required');
276
+ }
277
+ const matches = [];
278
+ for (const configured of sessionSyncConfigs(workspaces)) {
279
+ const target = workspaces.deliveryTargetFor(configured.botId, configured.targetId);
280
+ if (!target || !privateConversationKeyMatchesTarget(
281
+ channel,
282
+ configured.conversationKey,
283
+ target,
284
+ )) continue;
285
+ let sessions;
286
+ try {
287
+ sessions = stateSessions(await stateFor(configured.botId));
288
+ } catch {
289
+ continue;
290
+ }
291
+ if (sessions?.[configured.conversationKey] === sessionId) {
292
+ matches.push({ botId: configured.botId, targetId: configured.targetId });
293
+ }
294
+ }
295
+ return matches;
296
+ },
297
+ async sendSessionSyncText(botId, targetId, sessionId, text, options = {}) {
298
+ if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
299
+ const configured = sessionSyncConfigs(workspaces, botId)
300
+ .find((target) => target.targetId === targetId);
301
+ const target = workspaces.deliveryTargetFor(botId, targetId);
302
+ if (!configured || !target || !privateConversationKeyMatchesTarget(
303
+ channel,
304
+ configured.conversationKey,
305
+ target,
306
+ )) throw sessionSyncUnavailable('Session sync target is no longer enabled');
307
+ const sessions = stateSessions(await stateFor(botId));
308
+ if (sessions?.[configured.conversationKey] !== sessionId) {
309
+ throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
310
+ }
311
+ if (typeof coreController.sendProactiveText !== 'function') {
312
+ throw new TypeError('delivery controller cannot send proactive text');
313
+ }
314
+ await coreController.sendProactiveText(botId, target, text, options);
315
+ return { sent: true };
316
+ },
179
317
  });
180
318
  }
@@ -9,6 +9,7 @@ export const DELIVERY_ENDPOINTS = Object.freeze({
9
9
  createTarget: 'target.create',
10
10
  updateTarget: 'target.update',
11
11
  deleteTarget: 'target.delete',
12
+ setSessionSync: 'target.session-sync.set',
12
13
  testTarget: 'target.test',
13
14
  });
14
15
 
@@ -22,6 +23,7 @@ const PUBLIC_ERRORS = new Set([
22
23
  'bot-not-connected',
23
24
  'target-rejected',
24
25
  'delivery-failed',
26
+ 'session-sync-unavailable',
25
27
  'cancelled',
26
28
  ]);
27
29
 
@@ -85,6 +87,11 @@ function validPayload(endpoint, payload) {
85
87
  || (exactKeys(payload, ['botId', 'target'])
86
88
  && validBotId(payload.botId) && validDraftTarget(payload.target));
87
89
  }
90
+ if (endpoint === DELIVERY_ENDPOINTS.setSessionSync) {
91
+ return exactKeys(payload, ['botId', 'targetId', 'enabled'])
92
+ && validBotId(payload.botId) && validTargetId(payload.targetId)
93
+ && typeof payload.enabled === 'boolean';
94
+ }
88
95
  return exactKeys(payload, ['botId', 'targetId'])
89
96
  && validBotId(payload.botId) && validTargetId(payload.targetId);
90
97
  }
@@ -131,6 +138,11 @@ export function createDeliveryRpcHandler(service) {
131
138
  value = await service.updateTarget(payload.botId, payload.targetId, payload.target);
132
139
  } else if (endpoint === DELIVERY_ENDPOINTS.deleteTarget) {
133
140
  value = await service.deleteTarget(payload.botId, payload.targetId);
141
+ } else if (endpoint === DELIVERY_ENDPOINTS.setSessionSync) {
142
+ if (typeof service.setSessionSync !== 'function') {
143
+ throw new TypeError('Session sync is unavailable');
144
+ }
145
+ value = await service.setSessionSync(payload.botId, payload.targetId, payload.enabled);
134
146
  } else {
135
147
  value = await service.send(
136
148
  payload.botId,
@@ -25,9 +25,16 @@ const DELIVERY_ERROR_CODES = new Set([
25
25
  'bot-not-connected',
26
26
  'target-rejected',
27
27
  'delivery-failed',
28
+ 'session-sync-unavailable',
28
29
  'cancelled',
29
30
  ]);
30
31
 
32
+ const SESSION_SYNC_METHODS = Object.freeze([
33
+ 'setSessionSync',
34
+ 'listSessionSyncTargets',
35
+ 'sendSessionSyncText',
36
+ ]);
37
+
31
38
  function deliveryError(code, message = code, options) {
32
39
  const error = new Error(message, options);
33
40
  error.code = code;
@@ -93,8 +100,29 @@ function validateAdapter(adapter) {
93
100
  return adapter;
94
101
  }
95
102
 
103
+ function sessionSyncState(value, available) {
104
+ const enabled = value?.enabled === true;
105
+ if (!available) return { enabled, state: 'unavailable' };
106
+ const state = value?.state;
107
+ if (['off', 'active', 'waiting', 'unavailable'].includes(state)) {
108
+ return { enabled, state };
109
+ }
110
+ return { enabled: false, state: 'unavailable' };
111
+ }
112
+
96
113
  export class DeliveryService {
97
114
  #adapters = new Map();
115
+ #unavailableSessionSyncChannels;
116
+
117
+ constructor({ unavailableSessionSyncChannels = [] } = {}) {
118
+ if (!Array.isArray(unavailableSessionSyncChannels)
119
+ || unavailableSessionSyncChannels.some((channel) => (
120
+ typeof channel !== 'string' || !CHANNEL_PATTERN.test(channel)
121
+ ))) {
122
+ throw new TypeError('unavailableSessionSyncChannels must contain valid channel ids');
123
+ }
124
+ this.#unavailableSessionSyncChannels = new Set(unavailableSessionSyncChannels);
125
+ }
98
126
 
99
127
  registerAdapter(value) {
100
128
  const adapter = validateAdapter(value);
@@ -113,7 +141,15 @@ export class DeliveryService {
113
141
  try {
114
142
  const targets = await adapter.listTargets(id);
115
143
  if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
116
- return { botId: id, channel: adapter.channel, targets };
144
+ const available = this.#supportsSessionSync(adapter);
145
+ return {
146
+ botId: id,
147
+ channel: adapter.channel,
148
+ targets: targets.map((target) => ({
149
+ ...target,
150
+ sessionSync: sessionSyncState(target?.sessionSync, available),
151
+ })),
152
+ };
117
153
  } catch (error) {
118
154
  throw publicOperationError(error);
119
155
  }
@@ -188,6 +224,72 @@ export class DeliveryService {
188
224
  }
189
225
  }
190
226
 
227
+ async setSessionSync(botId, targetId, enabled) {
228
+ const id = botIdOf(botId);
229
+ const targetKey = targetIdOf(targetId);
230
+ if (typeof enabled !== 'boolean') throw deliveryError('bad-request', 'Invalid enabled state');
231
+ const adapter = await this.#adapterFor(id);
232
+ const hasMethods = SESSION_SYNC_METHODS.every((method) => typeof adapter[method] === 'function');
233
+ if (!hasMethods || (enabled && !this.#supportsSessionSync(adapter))) {
234
+ throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
235
+ }
236
+ try {
237
+ return await adapter.setSessionSync(id, targetKey, enabled);
238
+ } catch (error) {
239
+ throw publicOperationError(error);
240
+ }
241
+ }
242
+
243
+ async listSessionSyncTargets(sessionId) {
244
+ if (typeof sessionId !== 'string' || !sessionId) {
245
+ throw deliveryError('bad-request', 'Invalid Session id');
246
+ }
247
+ const targets = [];
248
+ for (const { adapter } of this.#adapters.values()) {
249
+ if (!this.#supportsSessionSync(adapter)) continue;
250
+ try {
251
+ const listed = await adapter.listSessionSyncTargets(sessionId);
252
+ if (!Array.isArray(listed)) throw new TypeError('Adapter returned invalid sync targets');
253
+ for (const target of listed) {
254
+ targets.push({
255
+ channel: adapter.channel,
256
+ botId: botIdOf(target?.botId),
257
+ targetId: targetIdOf(target?.targetId),
258
+ });
259
+ }
260
+ } catch (error) {
261
+ console.warn(
262
+ `[dsh-im] ignored ${adapter.channel} Session sync target lookup failure`
263
+ + ` (${error?.code ?? error?.name ?? 'unknown-error'})`,
264
+ );
265
+ }
266
+ }
267
+ return targets;
268
+ }
269
+
270
+ async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
271
+ const id = botIdOf(botId);
272
+ const targetKey = targetIdOf(targetId);
273
+ if (typeof sessionId !== 'string' || !sessionId
274
+ || typeof text !== 'string' || !text.trim()) {
275
+ throw deliveryError('bad-request', 'Invalid session sync delivery');
276
+ }
277
+ cancellation(signal);
278
+ const adapter = await this.#adapterFor(id);
279
+ if (!this.#supportsSessionSync(adapter)) {
280
+ throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
281
+ }
282
+ try {
283
+ await adapter.sendSessionSyncText(id, targetKey, sessionId, text, { signal });
284
+ return { sent: true };
285
+ } catch (error) {
286
+ if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
287
+ throw deliveryError('cancelled', 'Request cancelled', { cause: error });
288
+ }
289
+ throw publicOperationError(error);
290
+ }
291
+ }
292
+
191
293
  async send(botId, targetIdOrDraft, text, { signal } = {}) {
192
294
  const id = botIdOf(botId);
193
295
  const targetKey = typeof targetIdOrDraft === 'string'
@@ -232,8 +334,13 @@ export class DeliveryService {
232
334
  }
233
335
  throw deliveryError('unknown-bot', 'Unknown bot');
234
336
  }
337
+
338
+ #supportsSessionSync(adapter) {
339
+ return !this.#unavailableSessionSyncChannels.has(adapter.channel)
340
+ && SESSION_SYNC_METHODS.every((method) => typeof adapter[method] === 'function');
341
+ }
235
342
  }
236
343
 
237
- export function createDeliveryService() {
238
- return new DeliveryService();
344
+ export function createDeliveryService(options) {
345
+ return new DeliveryService(options);
239
346
  }
@@ -115,6 +115,52 @@ export function deliverySuggestionFromConversationKey(channel, key) {
115
115
  }
116
116
  }
117
117
 
118
+ /** Convert only a persisted private-chat key into its stable delivery route. */
119
+ export function privateDeliverySuggestionFromConversationKey(channel, key) {
120
+ const separator = typeof key === 'string' ? key.indexOf(':') : -1;
121
+ const prefix = separator > 0 ? key.slice(0, separator) : '';
122
+ const privatePrefix = {
123
+ weixin: 'p2p',
124
+ feishu: 'p2p',
125
+ dingtalk: 'p2p',
126
+ wecom: 'direct',
127
+ qq: 'c2c',
128
+ slack: 'direct',
129
+ telegram: 'direct',
130
+ discord: 'direct',
131
+ whatsapp: 'direct',
132
+ }[channel];
133
+ if (!privatePrefix || prefix !== privatePrefix) return null;
134
+ return deliverySuggestionFromConversationKey(channel, key);
135
+ }
136
+
137
+ function routeIdentity(value) {
138
+ if (!isRecord(value?.route) || typeof value.kind !== 'string') return null;
139
+ return JSON.stringify([
140
+ value.kind,
141
+ Object.keys(value.route).sort().map((key) => [key, value.route[key]]),
142
+ ]);
143
+ }
144
+
145
+ /** Check that one saved private conversation key still identifies this target route. */
146
+ export function privateConversationKeyMatchesTarget(channel, key, target) {
147
+ const suggestion = privateDeliverySuggestionFromConversationKey(channel, key);
148
+ const suggestionIdentity = routeIdentity(suggestion);
149
+ return suggestionIdentity !== null && suggestionIdentity === routeIdentity(target);
150
+ }
151
+
152
+ /** Resolve exactly one current private conversation for a normalized delivery target. */
153
+ export function resolvePrivateConversationKey(channel, sessions, target) {
154
+ if (!CHANNELS.has(channel) || !isRecord(sessions)) return null;
155
+ const matches = [];
156
+ for (const [key, sessionId] of Object.entries(sessions)) {
157
+ if (typeof sessionId !== 'string' || !sessionId) continue;
158
+ if (privateConversationKeyMatchesTarget(channel, key, target)) matches.push(key);
159
+ if (matches.length > 1) return null;
160
+ }
161
+ return matches[0] ?? null;
162
+ }
163
+
118
164
  /**
119
165
  * Extract and de-duplicate stable delivery routes from a persisted sessions map.
120
166
  * Session ids and every other state field are intentionally ignored.
@@ -55,12 +55,12 @@ function deepFreeze(value) {
55
55
  return Object.freeze(value);
56
56
  }
57
57
 
58
- function steeringMessage(text) {
58
+ function steeringMessage(text, rpcId) {
59
59
  return deepFreeze({
60
60
  id: randomUUID(),
61
61
  role: 'user',
62
62
  content: [{ type: 'text', text }],
63
- source: { kind: 'user' },
63
+ source: { kind: 'user', rpcId },
64
64
  });
65
65
  }
66
66
 
@@ -71,7 +71,7 @@ function agentBusyError(cause) {
71
71
  }
72
72
 
73
73
  function createControlExecutor(agents) {
74
- return ({ sessionId, expectedTurn, promptRpcId, action, text }) => {
74
+ return ({ sessionId, expectedTurn, promptRpcId, inputRpcId, action, text }) => {
75
75
  const agent = agents.get(sessionId);
76
76
  // An unattached Session cannot be coordinated in-process. Let the client
77
77
  // retain its legacy HTTP path for deployments where attachment is lazy.
@@ -83,10 +83,11 @@ function createControlExecutor(agents) {
83
83
  return true;
84
84
  }
85
85
  if (action === 'steer') {
86
- if (typeof text !== 'string' || !text.trim()) return false;
86
+ if (typeof text !== 'string' || !text.trim()
87
+ || typeof inputRpcId !== 'string' || !inputRpcId) return false;
87
88
  // inject() never wakes an idle driver. Because validation and injection
88
89
  // share one JS tick, this context can only target this live turn's next step.
89
- agent.inject(steeringMessage(text));
90
+ agent.inject(steeringMessage(text, inputRpcId));
90
91
  return true;
91
92
  }
92
93
  throw new TypeError(`Unsupported Harness control action: ${String(action)}`);
@@ -14,6 +14,7 @@ import { installDeliveryRpc } from './delivery-rpc.mjs';
14
14
  import { installDeliveryHttp } from './delivery-http.mjs';
15
15
  import { createDeliveryService } from './delivery-service.mjs';
16
16
  import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
17
+ import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
17
18
  import { installUpdateRpc } from './update-rpc.mjs';
18
19
 
19
20
  export const name = 'dsh-im-host';
@@ -36,6 +37,8 @@ export function createImHostPlugin(internals = {}) {
36
37
  const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
37
38
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
38
39
  const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
40
+ const startSessionSync = internals.installSessionSyncCoordinator
41
+ ?? installSessionSyncCoordinator;
39
42
  const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
40
43
  const startFeishu = internals.applyFeishu ?? applyFeishu;
41
44
  const startWeixin = internals.applyWeixin ?? applyWeixin;
@@ -63,7 +66,11 @@ export function createImHostPlugin(internals = {}) {
63
66
  name,
64
67
  inject,
65
68
  async apply(ctx, config = {}) {
66
- const deliveryService = makeDeliveryService();
69
+ const unavailableSessionSyncChannels = channels
70
+ .map(([channel]) => channel)
71
+ .filter((channel) => channel !== 'office'
72
+ && config[channel]?.harnessBaseUrl !== undefined);
73
+ const deliveryService = makeDeliveryService({ unavailableSessionSyncChannels });
67
74
  if (typeof ctx?.provide === 'function') {
68
75
  ctx.provide('dshIm', Object.freeze({
69
76
  send: (botId, targetId, text, options) => (
@@ -135,6 +142,16 @@ export function createImHostPlugin(internals = {}) {
135
142
  if (failures.length === channels.length) {
136
143
  throw new AggregateError(failures, 'dsh-im failed to activate every channel');
137
144
  }
145
+ if (typeof ctx?.on === 'function') {
146
+ try {
147
+ startSessionSync(ctx, deliveryService, {
148
+ logger,
149
+ inputScope: ctx.root ?? ctx,
150
+ });
151
+ } catch (error) {
152
+ logger.error?.('[dsh-im] failed to activate Session sync; continuing with channels', error);
153
+ }
154
+ }
138
155
  }
139
156
  }
140
157