@xmanrui/dsh-im 4.10.0 → 4.12.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 (62) hide show
  1. package/PROACTIVE_DELIVERY.en.md +15 -1
  2. package/PROACTIVE_DELIVERY.md +15 -1
  3. package/README.en.md +7 -2
  4. package/README.md +8 -3
  5. package/lib/client.js +2341 -1315
  6. package/lib/index.js +250 -245
  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 +41 -7
  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 +64 -233
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +41 -7
  16. package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
  17. package/plugin-src/client/channels/shared/token-api.js +4 -0
  18. package/plugin-src/client/channels/shared/token-channel.js +62 -29
  19. package/plugin-src/client/channels/wecom/api.js +4 -0
  20. package/plugin-src/client/channels/wecom/index.js +41 -7
  21. package/plugin-src/client/channels/weixin/api.js +4 -0
  22. package/plugin-src/client/channels/weixin/index.js +41 -7
  23. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  24. package/plugin-src/client/channels/whatsapp/index.js +40 -7
  25. package/plugin-src/client/delivery-settings.js +73 -5
  26. package/plugin-src/client/i18n.js +22 -0
  27. package/plugin-src/client/model-setting.js +104 -0
  28. package/plugin-src/client/styles.js +62 -0
  29. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  30. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  31. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  32. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  33. package/plugin-src/host/channels/qq/production.mjs +3 -0
  34. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  35. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  36. package/plugin-src/host/channels/shared/production.mjs +3 -0
  37. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  38. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  39. package/plugin-src/host/channels/slack/production.mjs +3 -0
  40. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  41. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  42. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  43. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  44. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  45. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  46. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  47. package/plugin-src/host/delivery-adapter.mjs +141 -3
  48. package/plugin-src/host/delivery-rpc.mjs +12 -0
  49. package/plugin-src/host/delivery-service.mjs +110 -3
  50. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  51. package/plugin-src/host/harness-session-coordinator.mjs +6 -5
  52. package/plugin-src/host/index.mjs +18 -1
  53. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  54. package/scripts/verify-package.mjs +12 -6
  55. package/src/channels/shared/bot-workspace-store.mjs +285 -38
  56. package/src/channels/shared/harness-client.mjs +124 -43
  57. package/src/channels/shared/i18n-en/wecom.mjs +24 -0
  58. package/src/channels/shared/model-setting.mjs +110 -0
  59. package/src/channels/wecom/wecom-bridge.mjs +275 -2
  60. package/src/channels/wecom/wecom-cards.mjs +101 -0
  61. package/src/channels/wecom/wecom-runtime.mjs +2 -0
  62. package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
@@ -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
 
@@ -0,0 +1,217 @@
1
+ import {
2
+ AssistantTextAccumulator,
3
+ consumeDshImInputOrigin,
4
+ textFromHarnessContent,
5
+ } from '../../src/channels/shared/harness-client.mjs';
6
+
7
+ const DSH_USER_PREFIX = '[来自 DSH]\n';
8
+ const DSH_ASSISTANT_PREFIX = '[DSH 助手]\n';
9
+
10
+ function completedTurn(reason) {
11
+ return (typeof reason === 'string' ? reason : reason?.kind) === 'completed';
12
+ }
13
+
14
+ function recipientKey(target) {
15
+ return JSON.stringify([target.channel, target.botId, target.targetId]);
16
+ }
17
+
18
+ function sessionIdOf(session) {
19
+ const value = session?.id ?? session?.sessionId;
20
+ return typeof value === 'string' && value ? value : null;
21
+ }
22
+
23
+ function userInputOrigin(scope, event) {
24
+ const source = event?.data?.source;
25
+ if (source?.kind !== 'user' || typeof source.rpcId !== 'string' || !source.rpcId) {
26
+ return 'other';
27
+ }
28
+ return consumeDshImInputOrigin(scope, source.rpcId) ? 'im' : 'dsh';
29
+ }
30
+
31
+ function validRecipient(target) {
32
+ return target && typeof target === 'object'
33
+ && typeof target.channel === 'string' && target.channel
34
+ && typeof target.botId === 'string' && target.botId
35
+ && typeof target.targetId === 'string' && target.targetId;
36
+ }
37
+
38
+ export function createSessionSyncCoordinator({ deliveryService, logger = console }) {
39
+ if (typeof deliveryService?.listSessionSyncTargets !== 'function'
40
+ || typeof deliveryService?.sendSessionSyncText !== 'function') {
41
+ throw new TypeError('Session sync requires a complete delivery service');
42
+ }
43
+
44
+ const turns = new Map();
45
+ const tails = new Map();
46
+ let closed = false;
47
+
48
+ const logFailure = (phase, target, error) => {
49
+ logger.warn?.(
50
+ `[dsh-im] ignored Session sync ${phase} failure`
51
+ + (target ? ` (${target.channel}/${target.botId}/${target.targetId})` : '')
52
+ + ` [${error?.code ?? error?.name ?? 'unknown-error'}]`,
53
+ );
54
+ };
55
+
56
+ const deliver = async (sessionId, targets, text, phase) => {
57
+ const unique = new Map();
58
+ for (const target of targets) {
59
+ if (validRecipient(target)) unique.set(recipientKey(target), target);
60
+ }
61
+ const entries = [...unique.entries()];
62
+ const results = await Promise.allSettled(entries.map(([, target]) => (
63
+ deliveryService.sendSessionSyncText(
64
+ target.botId,
65
+ target.targetId,
66
+ sessionId,
67
+ text,
68
+ )
69
+ )));
70
+ const successful = new Map();
71
+ results.forEach((result, index) => {
72
+ const [key, target] = entries[index];
73
+ if (result.status === 'fulfilled') successful.set(key, target);
74
+ else logFailure(phase, target, result.reason);
75
+ });
76
+ return successful;
77
+ };
78
+
79
+ const processEvent = async (sessionId, event, origin) => {
80
+ if (closed || !event || typeof event !== 'object') return;
81
+
82
+ if (event.type === 'turn/start') {
83
+ const turn = event.data?.turn;
84
+ if (!Number.isSafeInteger(turn)) return;
85
+ turns.set(sessionId, {
86
+ turn,
87
+ step: null,
88
+ origin: 'unknown',
89
+ recipients: null,
90
+ assistant: new AssistantTextAccumulator(),
91
+ });
92
+ return;
93
+ }
94
+
95
+ const state = turns.get(sessionId);
96
+ if (!state) return;
97
+
98
+ if (event.type === 'step/start') {
99
+ if (event.data?.turn !== undefined && event.data.turn !== state.turn) return;
100
+ state.step = Number.isSafeInteger(event.data?.step) ? event.data.step : null;
101
+ return;
102
+ }
103
+
104
+ if (event.type === 'step/end') {
105
+ if (event.data?.turn !== undefined && event.data.turn !== state.turn) return;
106
+ if (event.data?.step === undefined || event.data.step === state.step) state.step = null;
107
+ return;
108
+ }
109
+
110
+ if (event.type === 'user/message') {
111
+ if (event.surfaceOp !== 'append') return;
112
+ if (state.origin === 'unknown') state.origin = origin;
113
+ if (state.origin !== 'dsh' || origin !== 'dsh') return;
114
+ const text = textFromHarnessContent(event.data?.content);
115
+ if (!text) return;
116
+
117
+ let targets;
118
+ try {
119
+ targets = await deliveryService.listSessionSyncTargets(sessionId);
120
+ } catch (error) {
121
+ logFailure('lookup', null, error);
122
+ targets = [];
123
+ }
124
+ const successful = await deliver(
125
+ sessionId,
126
+ targets,
127
+ `${DSH_USER_PREFIX}${text}`,
128
+ 'user delivery',
129
+ );
130
+ if (state.recipients === null) {
131
+ state.recipients = successful;
132
+ } else {
133
+ for (const key of [...state.recipients.keys()]) {
134
+ if (!successful.has(key)) state.recipients.delete(key);
135
+ }
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (event.type === 'assistant/message') {
141
+ if (state.origin !== 'dsh' || event.surfaceOp !== 'append'
142
+ || event.data?.interrupted === true
143
+ || (event.data?.turn !== undefined && event.data.turn !== state.turn)) return;
144
+ const text = textFromHarnessContent(event.data?.message?.content);
145
+ const step = Number.isSafeInteger(event.data?.step) ? event.data.step : state.step;
146
+ state.assistant.setCanonical(step, text);
147
+ return;
148
+ }
149
+
150
+ if (event.type !== 'turn/end' || event.data?.turn !== state.turn) return;
151
+ turns.delete(sessionId);
152
+ if (state.origin !== 'dsh' || !completedTurn(event.data?.reason)
153
+ || !state.recipients?.size || !state.assistant.text) return;
154
+ await deliver(
155
+ sessionId,
156
+ state.recipients.values(),
157
+ `${DSH_ASSISTANT_PREFIX}${state.assistant.text}`,
158
+ 'assistant delivery',
159
+ );
160
+ };
161
+
162
+ const enqueue = (sessionId, event, origin = 'other') => {
163
+ if (closed || typeof sessionId !== 'string' || !sessionId) return Promise.resolve();
164
+ const previous = tails.get(sessionId) ?? Promise.resolve();
165
+ const task = previous.then(
166
+ () => processEvent(sessionId, event, origin),
167
+ () => processEvent(sessionId, event, origin),
168
+ );
169
+ const tail = task.catch((error) => logFailure('event handling', null, error)).finally(() => {
170
+ if (tails.get(sessionId) === tail) tails.delete(sessionId);
171
+ });
172
+ tails.set(sessionId, tail);
173
+ return tail;
174
+ };
175
+
176
+ return Object.freeze({
177
+ enqueue,
178
+ async whenIdle() {
179
+ while (tails.size > 0) await Promise.allSettled([...tails.values()]);
180
+ },
181
+ close() {
182
+ closed = true;
183
+ turns.clear();
184
+ },
185
+ });
186
+ }
187
+
188
+ export function installSessionSyncCoordinator(ctx, deliveryService, {
189
+ logger = console,
190
+ inputScope = ctx?.root ?? ctx,
191
+ } = {}) {
192
+ if (typeof ctx?.on !== 'function') {
193
+ throw new TypeError('Session sync requires Host session events');
194
+ }
195
+ const coordinator = createSessionSyncCoordinator({ deliveryService, logger });
196
+ const disposeEvent = ctx.on('session/event', (session, event) => {
197
+ const sessionId = sessionIdOf(session);
198
+ if (!sessionId) return;
199
+ // Origin lookup must be synchronous: the originating ask may clean its id
200
+ // immediately after this event callback returns.
201
+ const origin = event?.type === 'user/message'
202
+ ? userInputOrigin(inputScope, event)
203
+ : 'other';
204
+ void coordinator.enqueue(sessionId, event, origin);
205
+ }, { global: true });
206
+ let disposed = false;
207
+ const close = () => {
208
+ if (disposed) return;
209
+ disposed = true;
210
+ disposeEvent?.();
211
+ coordinator.close();
212
+ };
213
+ if (typeof ctx.effect === 'function') {
214
+ ctx.effect(() => close, 'dsh-im: Session sync coordinator');
215
+ }
216
+ return Object.freeze({ ...coordinator, close });
217
+ }
@@ -141,15 +141,21 @@ if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).leng
141
141
  if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
142
142
  throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
143
143
  }
144
- // Connections still have no channel-enable toggle. Only the shared context
145
- // editor owns checkable inputs. Its reusable scope component contains one
146
- // switch template and one mapped field-input template; it renders both twice.
144
+ // Connections still have no channel-enable toggle. Checkable inputs are owned
145
+ // only by the shared context editor and the saved-target Session sync row.
146
+ // The context editor contains one switch template and one mapped field-input
147
+ // template; the delivery target adds one ordinary checkbox template.
147
148
  const contextEditorSource = await readFile(resolve(root, 'plugin-src/client/context-enhancement.js'), 'utf8');
148
- const otherClientSources = clientSources.replace(contextEditorSource, '');
149
+ const deliverySettingsSource = await readFile(resolve(root, 'plugin-src/client/delivery-settings.js'), 'utf8');
150
+ const otherClientSources = clientSources
151
+ .replace(contextEditorSource, '')
152
+ .replace(deliverySettingsSource, '');
149
153
  if (/role:\s*["']switch|type:\s*["']checkbox/.test(otherClientSources)
154
+ || (deliverySettingsSource.match(/type:\s*["']checkbox["']/g) ?? []).length !== 1
155
+ || /role:\s*["']switch["']/u.test(deliverySettingsSource)
150
156
  || (client.match(/role:\s*["']switch["']/g) ?? []).length !== 1
151
- || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 2) {
152
- throw new Error('checkable inputs must be limited to the context-enhancement editor');
157
+ || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 3) {
158
+ throw new Error('checkable inputs must be limited to context enhancement and Session sync');
153
159
  }
154
160
  for (const marker of ['bot.context-enhancement.set', '<dsh_im_source>', '<dsh_im_source_guidance>']) {
155
161
  if (!host.includes(marker) || !client.includes(marker)) {