@xmanrui/dsh-im 3.1.1 → 4.0.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 (75) hide show
  1. package/README.en.md +16 -1
  2. package/README.md +16 -1
  3. package/lib/client.js +1653 -333
  4. package/lib/index.js +235 -226
  5. package/package.json +5 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +3 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +18 -5
  8. package/plugin-src/client/channels/feishu/api.js +3 -0
  9. package/plugin-src/client/channels/feishu/index.js +18 -5
  10. package/plugin-src/client/channels/qq/api.js +3 -0
  11. package/plugin-src/client/channels/qq/index.js +13 -0
  12. package/plugin-src/client/channels/shared/token-api.js +3 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +13 -1
  14. package/plugin-src/client/channels/wecom/api.js +3 -0
  15. package/plugin-src/client/channels/wecom/index.js +13 -0
  16. package/plugin-src/client/channels/weixin/api.js +3 -0
  17. package/plugin-src/client/channels/weixin/index.js +19 -5
  18. package/plugin-src/client/channels/whatsapp/api.js +3 -0
  19. package/plugin-src/client/channels/whatsapp/index.js +13 -0
  20. package/plugin-src/client/context-enhancement.js +275 -0
  21. package/plugin-src/client/i18n.js +54 -3
  22. package/plugin-src/client/styles.js +78 -0
  23. package/plugin-src/client/update-panel.js +108 -7
  24. package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
  25. package/plugin-src/host/channels/dingtalk/production.mjs +1 -0
  26. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  27. package/plugin-src/host/channels/discord/index.mjs +1 -1
  28. package/plugin-src/host/channels/feishu/index.mjs +1 -1
  29. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  30. package/plugin-src/host/channels/feishu/rpc.mjs +13 -0
  31. package/plugin-src/host/channels/qq/index.mjs +1 -1
  32. package/plugin-src/host/channels/qq/production.mjs +1 -0
  33. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/shared/context-enhancement-rpc.mjs +17 -0
  35. package/plugin-src/host/channels/shared/production.mjs +1 -0
  36. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  37. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  38. package/plugin-src/host/channels/slack/index.mjs +1 -1
  39. package/plugin-src/host/channels/slack/production.mjs +1 -0
  40. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  41. package/plugin-src/host/channels/telegram/index.mjs +1 -1
  42. package/plugin-src/host/channels/wecom/index.mjs +1 -1
  43. package/plugin-src/host/channels/wecom/production.mjs +1 -0
  44. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  45. package/plugin-src/host/channels/weixin/index.mjs +1 -1
  46. package/plugin-src/host/channels/weixin/production.mjs +1 -0
  47. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  48. package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
  49. package/plugin-src/host/channels/whatsapp/production.mjs +1 -0
  50. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  51. package/plugin-src/host/harness-connection.mjs +9 -4
  52. package/plugin-src/host/index.mjs +43 -29
  53. package/plugin-src/host/modern-harness-api.mjs +577 -0
  54. package/plugin-src/host/update-runtime.mjs +3 -2
  55. package/plugin-src/host/update-service.mjs +2 -0
  56. package/scripts/verify-package.mjs +15 -2
  57. package/src/channels/dingtalk/dingtalk-api.mjs +39 -16
  58. package/src/channels/dingtalk/dingtalk-bridge.mjs +52 -23
  59. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -1
  60. package/src/channels/discord/discord-runtime.mjs +18 -4
  61. package/src/channels/feishu/bridge.mjs +18 -3
  62. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  63. package/src/channels/qq/qq-bridge.mjs +20 -4
  64. package/src/channels/qq/qq-runtime.mjs +4 -0
  65. package/src/channels/shared/bot-workspace-store.mjs +103 -6
  66. package/src/channels/shared/context-enhancement.mjs +135 -0
  67. package/src/channels/shared/harness-client.mjs +18 -0
  68. package/src/channels/shared/text-harness-bridge.mjs +19 -4
  69. package/src/channels/slack/slack-runtime.mjs +4 -0
  70. package/src/channels/telegram/telegram-runtime.mjs +24 -2
  71. package/src/channels/wecom/wecom-bridge.mjs +18 -3
  72. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  73. package/src/channels/weixin/weixin-bridge.mjs +19 -4
  74. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  75. package/src/channels/whatsapp/whatsapp-runtime.mjs +5 -0
@@ -0,0 +1,577 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
4
+
5
+ const modernApis = new WeakMap();
6
+
7
+ function failureOf(error) {
8
+ const failure = error?.failure;
9
+ if (failure && typeof failure === 'object'
10
+ && typeof failure.code === 'string'
11
+ && typeof failure.message === 'string') {
12
+ return {
13
+ code: failure.code,
14
+ message: failure.message,
15
+ details: failure.details && typeof failure.details === 'object' ? failure.details : {},
16
+ };
17
+ }
18
+ if (error?.name === 'AbortError' || error?.name === 'RemoteInvocationCancelled') {
19
+ return {
20
+ code: 'cancelled',
21
+ message: error instanceof Error ? error.message : 'Harness request was cancelled',
22
+ details: {},
23
+ };
24
+ }
25
+ return {
26
+ code: 'internal',
27
+ message: error instanceof Error ? error.message : String(error),
28
+ details: {},
29
+ };
30
+ }
31
+
32
+ function rpcResult(request, operation) {
33
+ return Promise.resolve().then(operation).then(
34
+ (value) => ({ rpcId: request.rpcId, result: { ok: true, value } }),
35
+ (error) => ({ rpcId: request.rpcId, result: { ok: false, error: failureOf(error) } }),
36
+ );
37
+ }
38
+
39
+ function remoteRequest(namespace, method, args, signal) {
40
+ return {
41
+ namespace,
42
+ method,
43
+ args,
44
+ ...(signal === undefined ? {} : { signal }),
45
+ };
46
+ }
47
+
48
+ function questionError(message, code) {
49
+ const error = new Error(message);
50
+ error.name = 'UserQuestionError';
51
+ error.code = code;
52
+ return error;
53
+ }
54
+
55
+ function matchesQuestions(value, pending) {
56
+ if (!value || typeof value !== 'object'
57
+ || value.sessionId !== pending.sessionId
58
+ || !value.answer || typeof value.answer !== 'object'
59
+ || !Array.isArray(value.answer.answers)
60
+ || value.answer.answers.length !== pending.questions.length) return false;
61
+ return value.answer.answers.every((answer, index) => {
62
+ const question = pending.questions[index];
63
+ if (!answer || typeof answer !== 'object'
64
+ || answer.id !== question.id
65
+ || !Array.isArray(answer.selected)
66
+ || answer.selected.some((label) => typeof label !== 'string')
67
+ || new Set(answer.selected).size !== answer.selected.length
68
+ || (answer.custom !== undefined && typeof answer.custom !== 'string')) return false;
69
+ const custom = answer.custom?.trim();
70
+ if (custom !== undefined && !custom) return false;
71
+ if (question.multiSelect !== true
72
+ && ((custom !== undefined && answer.selected.length > 0) || answer.selected.length > 1)) return false;
73
+ const labels = new Set(question.options?.map((option) => option.label) ?? []);
74
+ return answer.selected.every((label) => labels.has(label));
75
+ });
76
+ }
77
+
78
+ function expandChunkRecord(record) {
79
+ if (record?.type === 'event' && record.event && typeof record.event === 'object') {
80
+ return [{ event: record.event }];
81
+ }
82
+ const packed = record?.type === 'chunks' ? record.event : null;
83
+ if (!packed || typeof packed !== 'object') {
84
+ throw new Error('Harness returned an invalid session history record');
85
+ }
86
+ const { data } = packed;
87
+ const kind = packed.type;
88
+ const members = kind === 'chunkrow/tool-call-chunks' ? data?.args : data?.texts;
89
+ if (!data || !Array.isArray(members) || members.length === 0
90
+ || members.some((member) => typeof member !== 'string')
91
+ || !Array.isArray(data.dt) || data.dt.length !== members.length - 1) {
92
+ throw new Error('Harness returned an invalid packed session chunk');
93
+ }
94
+ let time = packed.time;
95
+ return members.map((member, index) => {
96
+ if (index > 0) time += data.dt[index - 1];
97
+ let chunk;
98
+ if (kind === 'chunkrow/text-chunks') {
99
+ chunk = { type: 'text-delta', index: data.index, text: member };
100
+ } else if (kind === 'chunkrow/reasoning-chunks') {
101
+ chunk = { type: 'reasoning-delta', index: data.index, text: member };
102
+ } else if (kind === 'chunkrow/tool-call-chunks') {
103
+ chunk = {
104
+ type: 'tool-call-delta',
105
+ index: data.index,
106
+ id: data.id,
107
+ ...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
108
+ argumentsDelta: member,
109
+ };
110
+ } else {
111
+ throw new Error(`Harness returned an unsupported packed session chunk: ${String(kind)}`);
112
+ }
113
+ return {
114
+ event: {
115
+ type: 'assistant/chunk',
116
+ seq: packed.seq + index,
117
+ time,
118
+ data: { turn: data.turn, step: data.step, chunk },
119
+ },
120
+ };
121
+ });
122
+ }
123
+
124
+ function historyEntries(records) {
125
+ if (!Array.isArray(records)) throw new Error('Harness returned invalid session history records');
126
+ return records.flatMap(expandChunkRecord);
127
+ }
128
+
129
+ class MuxSubscription {
130
+ #frames = [];
131
+ #waiting = null;
132
+ #closed = false;
133
+ #signal;
134
+ #onAbort;
135
+ #dispose;
136
+
137
+ constructor(signal, dispose) {
138
+ this.#signal = signal;
139
+ this.#dispose = dispose;
140
+ this.#onAbort = () => this.close();
141
+ signal?.addEventListener('abort', this.#onAbort, { once: true });
142
+ if (signal?.aborted) this.close();
143
+ }
144
+
145
+ push(frame) {
146
+ if (this.#closed) return;
147
+ if (this.#waiting) {
148
+ const resolve = this.#waiting;
149
+ this.#waiting = null;
150
+ resolve({ value: frame, done: false });
151
+ } else {
152
+ this.#frames.push(frame);
153
+ }
154
+ }
155
+
156
+ next() {
157
+ if (this.#frames.length > 0) {
158
+ return Promise.resolve({ value: this.#frames.shift(), done: false });
159
+ }
160
+ if (this.#closed) return Promise.resolve({ value: undefined, done: true });
161
+ return new Promise((resolve) => { this.#waiting = resolve; });
162
+ }
163
+
164
+ return() {
165
+ this.close();
166
+ return Promise.resolve({ value: undefined, done: true });
167
+ }
168
+
169
+ close() {
170
+ if (this.#closed) return;
171
+ this.#closed = true;
172
+ this.#signal?.removeEventListener('abort', this.#onAbort);
173
+ this.#dispose?.();
174
+ this.#dispose = null;
175
+ if (this.#waiting) {
176
+ const resolve = this.#waiting;
177
+ this.#waiting = null;
178
+ resolve({ value: undefined, done: true });
179
+ }
180
+ }
181
+
182
+ [Symbol.asyncIterator]() {
183
+ return this;
184
+ }
185
+ }
186
+
187
+ class ModernHarnessApi {
188
+ #gateway;
189
+ #scope;
190
+ #mux = new Set();
191
+ #pendingQuestions = new Map();
192
+ #pendingApprovals = new Map();
193
+ #sessionCursors = new Map();
194
+ #disposers = [];
195
+ #disposed = false;
196
+
197
+ constructor(ctx, gateway, scope) {
198
+ this.#gateway = gateway;
199
+ this.#scope = scope;
200
+
201
+ this.host = Object.freeze({
202
+ describe: (request) => rpcResult(request, () => ({ ready: true, transport: 'typert' })),
203
+ });
204
+ this.workspace = Object.freeze({
205
+ list: (request, signal) => rpcResult(request, () => this.#workspaceList(signal)),
206
+ create: (request, signal) => rpcResult(request, () => this.#invoke(
207
+ 'workspace', 'create', { request: request.payload }, signal,
208
+ )),
209
+ });
210
+ this.sessions = Object.freeze({
211
+ list: (request, signal) => rpcResult(request, () => this.#invoke(
212
+ 'session', 'list', { _request: request.payload }, signal,
213
+ )),
214
+ create: (request, signal) => rpcResult(request, () => this.#invoke(
215
+ 'session', 'create', { request: request.payload }, signal,
216
+ )),
217
+ history: (request, signal) => rpcResult(request, () => this.#history(request.payload, signal)),
218
+ prompt: (request, signal) => rpcResult(request, () => this.#invoke(
219
+ 'session',
220
+ 'prompt',
221
+ { request: { requestId: request.rpcId, ...request.payload } },
222
+ signal,
223
+ )),
224
+ cancel: (request, signal) => rpcResult(request, () => this.#invoke(
225
+ 'session', 'cancel', { request: { sessionId: request.payload.sessionId } }, signal,
226
+ )),
227
+ models: (request, signal) => rpcResult(request, () => this.#sessionModels(
228
+ request.payload.sessionId, signal,
229
+ )),
230
+ selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
231
+ 'session', 'selectModel', { request: request.payload }, signal,
232
+ )),
233
+ });
234
+ this.llm = Object.freeze({
235
+ models: (request, signal) => rpcResult(request, async () => {
236
+ const catalog = await this.#modelCatalog(signal);
237
+ return { groups: catalog.groups, failures: catalog.failures };
238
+ }),
239
+ });
240
+ this.events = Object.freeze({
241
+ mux: (_request, signal) => this.#openMux(signal),
242
+ });
243
+ this.respond = (message) => Promise.resolve(this.#respond(message));
244
+
245
+ if (typeof ctx?.on === 'function') {
246
+ this.#disposers.push(ctx.on('session/event', (session, event) => {
247
+ const sessionId = session?.id;
248
+ if (typeof sessionId !== 'string' || !event || typeof event !== 'object') return;
249
+ if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
250
+ this.#broadcast({ type: 'session/event', sessionId, event });
251
+ }, { global: true }));
252
+ this.#disposers.push(ctx.on(
253
+ 'approval/request',
254
+ (request, next) => this.#requestApproval(request, next),
255
+ { global: true, prepend: true },
256
+ ));
257
+ this.#disposers.push(ctx.on(
258
+ 'user-questions/request',
259
+ (request, next) => this.#requestQuestion(request, next),
260
+ { global: true, prepend: true },
261
+ ));
262
+ }
263
+ }
264
+
265
+ async #invoke(namespace, method, args, signal) {
266
+ return this.#gateway.invoke(remoteRequest(namespace, method, args, signal));
267
+ }
268
+
269
+ async #streamFirst(namespace, method, args, signal) {
270
+ const controller = new AbortController();
271
+ const streamSignal = signal
272
+ ? AbortSignal.any([signal, controller.signal])
273
+ : controller.signal;
274
+ let iterator;
275
+ try {
276
+ const source = await this.#gateway.stream(remoteRequest(
277
+ namespace, method, args, streamSignal,
278
+ ));
279
+ iterator = source[Symbol.asyncIterator]();
280
+ const first = await iterator.next();
281
+ if (first.done) throw new Error(`Harness ${namespace}.${method} stream ended before its baseline`);
282
+ return first.value;
283
+ } finally {
284
+ controller.abort(new DOMException('Baseline received', 'AbortError'));
285
+ await Promise.resolve(iterator?.return?.()).catch(() => undefined);
286
+ }
287
+ }
288
+
289
+ async #workspaceList(signal) {
290
+ const frame = await this.#streamFirst('workspace', 'follow', {}, signal);
291
+ if (frame?.type !== 'baseline' || !frame.value || typeof frame.value !== 'object') {
292
+ throw new Error('Harness workspace.follow returned no baseline');
293
+ }
294
+ return frame.value;
295
+ }
296
+
297
+ async #sessionSnapshot(sessionId, maxMessages, signal) {
298
+ const frame = await this.#streamFirst('session', 'follow', {
299
+ request: {
300
+ address: { kind: 'session', sessionId },
301
+ maxMessages,
302
+ },
303
+ }, signal);
304
+ if (frame?.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
305
+ throw new Error('Harness session.follow returned no snapshot');
306
+ }
307
+ this.#rememberCursor(sessionId, frame.cursor);
308
+ return frame;
309
+ }
310
+
311
+ async #history(payload, signal) {
312
+ const { sessionId, maxMessages = 50, beforeSeq } = payload;
313
+ let cursor = this.#sessionCursors.get(sessionId);
314
+ if (cursor === undefined) {
315
+ const snapshot = await this.#sessionSnapshot(sessionId, maxMessages, signal);
316
+ cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
317
+ if (beforeSeq === undefined && cursor === snapshot.cursor) {
318
+ return {
319
+ events: historyEntries(snapshot.records),
320
+ hasMore: snapshot.hasMore === true,
321
+ ...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
322
+ };
323
+ }
324
+ }
325
+ const page = await this.#invoke('session', 'page', {
326
+ request: {
327
+ address: { kind: 'session', sessionId },
328
+ throughSeq: cursor,
329
+ maxMessages,
330
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
331
+ },
332
+ }, signal);
333
+ return { events: historyEntries(page.records), hasMore: page.hasMore === true };
334
+ }
335
+
336
+ #rememberCursor(sessionId, cursor) {
337
+ const previous = this.#sessionCursors.get(sessionId);
338
+ if (previous === undefined || cursor > previous) this.#sessionCursors.set(sessionId, cursor);
339
+ }
340
+
341
+ #modelCatalog(signal) {
342
+ return this.#invoke('session', 'modelCatalog', {}, signal);
343
+ }
344
+
345
+ async #sessionModels(sessionId, signal) {
346
+ const [catalog, list] = await Promise.all([
347
+ this.#modelCatalog(signal),
348
+ this.#invoke('session', 'list', { _request: {} }, signal),
349
+ ]);
350
+ const summary = list?.items?.find((item) => item?.sessionId === sessionId);
351
+ if (!summary) {
352
+ const error = new Error(`session "${sessionId}" not found`);
353
+ error.failure = {
354
+ code: 'session-not-found',
355
+ message: error.message,
356
+ details: { sessionId },
357
+ };
358
+ throw error;
359
+ }
360
+ const selection = summary.projections?.values?.modelSelection;
361
+ const current = selection?.next ?? selection?.lastUsed ?? catalog.default;
362
+ return {
363
+ current,
364
+ routable: catalog.routableProviders.includes(current.provider),
365
+ groups: catalog.groups,
366
+ failures: catalog.failures,
367
+ };
368
+ }
369
+
370
+ #openMux(signal) {
371
+ let subscription;
372
+ subscription = new MuxSubscription(signal, () => this.#mux.delete(subscription));
373
+ this.#mux.add(subscription);
374
+ for (const pending of this.#pendingQuestions.values()) subscription.push(this.#questionFrame(pending));
375
+ for (const pending of this.#pendingApprovals.values()) subscription.push(this.#approvalFrame(pending));
376
+ return subscription;
377
+ }
378
+
379
+ #broadcast(payload, rpcId = randomUUID()) {
380
+ const frame = { rpcId, payload };
381
+ for (const subscription of this.#mux) subscription.push(frame);
382
+ }
383
+
384
+ #claimableAgent(agent) {
385
+ const sessionId = agent?.session?.id ?? agent?.id;
386
+ if (typeof sessionId !== 'string' || !agent?.session || !Array.isArray(agent.session.events)) {
387
+ return null;
388
+ }
389
+ return hasActiveHarnessInteractionOwner(
390
+ this.#scope,
391
+ sessionId,
392
+ agent.session.events,
393
+ ) ? { sessionId, session: agent.session } : null;
394
+ }
395
+
396
+ #questionFrame(pending) {
397
+ return {
398
+ rpcId: pending.rpcId,
399
+ payload: {
400
+ type: 'question/requested',
401
+ sessionId: pending.sessionId,
402
+ questions: pending.questions,
403
+ },
404
+ };
405
+ }
406
+
407
+ #requestQuestion(request, next) {
408
+ const owner = this.#claimableAgent(request?.agent);
409
+ if (!owner) return next();
410
+ if (request.signal?.aborted) {
411
+ return Promise.reject(questionError(
412
+ 'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
413
+ ));
414
+ }
415
+ return new Promise((resolve, reject) => {
416
+ const pending = {
417
+ rpcId: randomUUID(),
418
+ sessionId: owner.sessionId,
419
+ questions: request.questions,
420
+ signal: request.signal,
421
+ settle: (outcome, value) => {
422
+ if (!this.#pendingQuestions.delete(pending.rpcId)) return;
423
+ request.signal?.removeEventListener('abort', onAbort);
424
+ this.#broadcast({
425
+ type: 'question/resolved',
426
+ sessionId: pending.sessionId,
427
+ questionRpcId: pending.rpcId,
428
+ outcome,
429
+ });
430
+ if (outcome === 'answered') resolve(value);
431
+ else reject(value);
432
+ },
433
+ };
434
+ const onAbort = () => pending.settle('cancelled', questionError(
435
+ 'ask_user_question was aborted before the user answered', 'ASK_ABORTED',
436
+ ));
437
+ this.#pendingQuestions.set(pending.rpcId, pending);
438
+ request.signal?.addEventListener('abort', onAbort, { once: true });
439
+ this.#broadcast(this.#questionFrame(pending).payload, pending.rpcId);
440
+ });
441
+ }
442
+
443
+ #approvalFrame(pending) {
444
+ return {
445
+ rpcId: pending.rpcId,
446
+ payload: {
447
+ type: 'approval/requested',
448
+ sessionId: pending.sessionId,
449
+ approvalId: pending.approvalId,
450
+ toolName: pending.toolName,
451
+ ...(pending.callId === undefined ? {} : { callId: pending.callId }),
452
+ ...(pending.reason === undefined ? {} : { reason: pending.reason }),
453
+ },
454
+ };
455
+ }
456
+
457
+ #requestApproval(request, next) {
458
+ const owner = this.#claimableAgent(request?.agent);
459
+ if (!owner) return next();
460
+ if (request.signal?.aborted) return Promise.resolve('cancelled');
461
+ const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
462
+ const decided = new Set();
463
+ let approvalId;
464
+ for (let index = owner.session.events.length - 1; index >= 0; index -= 1) {
465
+ const event = owner.session.events[index];
466
+ if (event.type === 'approval/decided') {
467
+ decided.add(event.data?.id);
468
+ } else if (event.type === 'approval/asked') {
469
+ const id = event.data?.id;
470
+ if (!id || decided.has(id) || claimed.has(id)) continue;
471
+ if ((request.callId ?? null) !== (event.data?.callId ?? null)) continue;
472
+ approvalId = id;
473
+ break;
474
+ }
475
+ }
476
+ if (approvalId === undefined) return next();
477
+ return new Promise((resolve) => {
478
+ const pending = {
479
+ rpcId: randomUUID(),
480
+ sessionId: owner.sessionId,
481
+ approvalId,
482
+ toolName: request.toolName,
483
+ callId: request.callId,
484
+ reason: request.reason,
485
+ settle: (outcome) => {
486
+ if (!this.#pendingApprovals.delete(pending.rpcId)) return;
487
+ request.signal?.removeEventListener('abort', onAbort);
488
+ this.#broadcast({
489
+ type: 'approval/resolved',
490
+ sessionId: pending.sessionId,
491
+ approvalId: pending.approvalId,
492
+ outcome,
493
+ });
494
+ resolve(outcome);
495
+ },
496
+ };
497
+ const onAbort = () => pending.settle('cancelled');
498
+ this.#pendingApprovals.set(pending.rpcId, pending);
499
+ request.signal?.addEventListener('abort', onAbort, { once: true });
500
+ this.#broadcast(this.#approvalFrame(pending).payload, pending.rpcId);
501
+ });
502
+ }
503
+
504
+ #respond(message) {
505
+ const approval = this.#pendingApprovals.get(message?.rpcId);
506
+ if (approval) {
507
+ const value = message?.result?.value;
508
+ if (message?.result?.ok !== true
509
+ || !value || typeof value !== 'object'
510
+ || value.sessionId !== approval.sessionId
511
+ || value.approvalId !== approval.approvalId
512
+ || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
513
+ return { accepted: false, reason: 'bad-response' };
514
+ }
515
+ approval.settle(value.outcome);
516
+ return { accepted: true };
517
+ }
518
+ const question = this.#pendingQuestions.get(message?.rpcId);
519
+ if (!question) return { accepted: false, reason: 'not-pending' };
520
+ if (message?.result?.ok !== true) {
521
+ if (message?.result?.error?.code !== 'cancelled') {
522
+ return { accepted: false, reason: 'bad-response' };
523
+ }
524
+ question.settle('cancelled', questionError(
525
+ 'the user cancelled ask_user_question', 'ASK_CANCELLED',
526
+ ));
527
+ return { accepted: true };
528
+ }
529
+ if (!matchesQuestions(message.result.value, question)) {
530
+ return { accepted: false, reason: 'bad-response' };
531
+ }
532
+ const answer = {
533
+ answers: message.result.value.answer.answers.map((item) => ({
534
+ id: item.id,
535
+ selected: [...item.selected],
536
+ ...(item.custom === undefined ? {} : { custom: item.custom }),
537
+ })),
538
+ };
539
+ question.settle('answered', answer);
540
+ return { accepted: true };
541
+ }
542
+
543
+ dispose() {
544
+ if (this.#disposed) return;
545
+ this.#disposed = true;
546
+ for (const subscription of [...this.#mux]) subscription.close();
547
+ for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
548
+ for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
549
+ 'cancelled',
550
+ questionError('dsh-im interaction adapter was disposed', 'ASK_ABORTED'),
551
+ );
552
+ for (const dispose of this.#disposers.splice(0).reverse()) dispose?.();
553
+ }
554
+ }
555
+
556
+ /** Create one modern compatibility API per Cordis Host root. */
557
+ export function modernHarnessApi(ctx) {
558
+ const scope = ctx?.root ?? ctx;
559
+ if (!scope || !['object', 'function'].includes(typeof scope)) {
560
+ throw new TypeError('dsh-im requires a Cordis Host context');
561
+ }
562
+ const cached = modernApis.get(scope);
563
+ if (cached) return cached;
564
+ const gateway = ctx?.typertGateway;
565
+ if (!gateway || typeof gateway.invoke !== 'function' || typeof gateway.stream !== 'function') {
566
+ throw new TypeError('dsh-im requires the modern Host Typert gateway');
567
+ }
568
+ const api = new ModernHarnessApi(ctx, gateway, scope);
569
+ modernApis.set(scope, api);
570
+ if (typeof ctx.effect === 'function') {
571
+ ctx.effect(() => () => {
572
+ if (modernApis.get(scope) === api) modernApis.delete(scope);
573
+ api.dispose();
574
+ }, 'dsh-im: modern Harness compatibility API');
575
+ }
576
+ return api;
577
+ }
@@ -280,6 +280,8 @@ export function createUpdateRuntime(options = {}) {
280
280
 
281
281
  const profile = await packageAt(runtime.profileDir);
282
282
  const installed = await packageAt(join(runtime.profileDir, 'node_modules', PACKAGE_NAME));
283
+ result.sourceInstall = !registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
284
+ || !inside(join(runtime.profileDir, 'node_modules'), installed.directory);
283
285
  result.installedVersion = typeof installed.manifest.version === 'string' ? installed.manifest.version : null;
284
286
  result.packageValid = await validPackage(installed);
285
287
  const loaded = await loadedPackage;
@@ -298,8 +300,7 @@ export function createUpdateRuntime(options = {}) {
298
300
  if (boundProfile !== undefined && boundProfile !== identity) result.blockedReason = 'installation-changed';
299
301
  else if (!sameLoadedPackage && boundProfile === undefined) result.blockedReason = 'installation-changed';
300
302
  else if (!result.packageValid) result.blockedReason = 'invalid-installation';
301
- else if (!registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
302
- || !inside(join(runtime.profileDir, 'node_modules'), installed.directory)) result.blockedReason = 'source-install';
303
+ else if (result.sourceInstall) result.blockedReason = 'source-install';
303
304
  else if (runtime.blockedReason) result.blockedReason = runtime.blockedReason;
304
305
  else if (!sameLoadedPackage) result.blockedReason = 'pending-restart';
305
306
  else if (preflight) await checkRegistry(runtime);
@@ -201,6 +201,8 @@ export function createUpdateService({
201
201
  latestVersion: checked?.release.version ?? null,
202
202
  profileName: environment.profileName ?? null,
203
203
  environmentKind: environment.environmentKind ?? 'cli',
204
+ // Preserve source protection even when restart/recovery takes status priority.
205
+ sourceInstall: environment.sourceInstall === true || environment.blockedReason === 'source-install',
204
206
  canInstall,
205
207
  blockedReason,
206
208
  checkedAt: checked?.checkedAt ?? null,
@@ -29,6 +29,7 @@ const required = [
29
29
  'plugin-src/client/channels/slack/index.js',
30
30
  'plugin-src/client/i18n.js',
31
31
  'plugin-src/client/update-panel.js',
32
+ 'plugin-src/client/context-enhancement.js',
32
33
  'plugin-src/host/update-service.mjs',
33
34
  'plugin-src/host/update-runtime.mjs',
34
35
  'plugin-src/host/update-rpc.mjs',
@@ -51,6 +52,7 @@ const required = [
51
52
  'src/channels/discord/discord-runtime.mjs',
52
53
  'src/channels/whatsapp/whatsapp-runtime.mjs',
53
54
  'src/channels/whatsapp/whatsapp-web-session.mjs',
55
+ 'src/channels/shared/context-enhancement.mjs',
54
56
  ];
55
57
  await Promise.all(required.map((path) => access(resolve(root, path))));
56
58
 
@@ -135,8 +137,19 @@ if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).leng
135
137
  if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
136
138
  throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
137
139
  }
138
- if (/role:\s*["']switch|type:\s*["']checkbox/.test(client)) {
139
- throw new Error('client bundle contains a channel enable switch');
140
+ // Connections still have no channel-enable toggle. Only the shared context
141
+ // editor owns checkable inputs: two scope switches and one mapped field input.
142
+ const contextEditorSource = await readFile(resolve(root, 'plugin-src/client/context-enhancement.js'), 'utf8');
143
+ const otherClientSources = clientSources.replace(contextEditorSource, '');
144
+ if (/role:\s*["']switch|type:\s*["']checkbox/.test(otherClientSources)
145
+ || (client.match(/role:\s*["']switch["']/g) ?? []).length !== 2
146
+ || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 3) {
147
+ throw new Error('checkable inputs must be limited to the context-enhancement editor');
148
+ }
149
+ for (const marker of ['bot.context-enhancement.set', '<dsh_im_source>', '<dsh_im_source_guidance>']) {
150
+ if (!host.includes(marker) || !client.includes(marker)) {
151
+ throw new Error(`context-enhancement marker missing from Host or Client bundle: ${marker}`);
152
+ }
140
153
  }
141
154
  if (!client.includes('container-type: inline-size')
142
155
  || !client.includes('@container (max-width: 680px)')) {