@xmanrui/dsh-im 0.7.1 → 0.7.2

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.
@@ -0,0 +1,472 @@
1
+ const APPROVAL_REPLIES = new Map([
2
+ ['批准', 'allowed-once'],
3
+ ['同意', 'allowed-once'],
4
+ ['yes', 'allowed-once'],
5
+ ['拒绝', 'rejected'],
6
+ ['不同意', 'rejected'],
7
+ ['no', 'rejected'],
8
+ ]);
9
+
10
+ const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
11
+ const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
12
+ const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
13
+ const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
14
+ const MAX_RESOLVED_ROUTES = 2_048;
15
+
16
+ function cleanText(value) {
17
+ return typeof value === 'string' ? value.trim() : '';
18
+ }
19
+
20
+ function printableText(value) {
21
+ return cleanText(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
22
+ }
23
+
24
+ export function harnessApprovalDecision(text) {
25
+ return APPROVAL_REPLIES.get(cleanText(text).toLowerCase()) ?? null;
26
+ }
27
+
28
+ export function validHarnessApproval(payload) {
29
+ return payload?.type === 'approval/requested'
30
+ && Boolean(cleanText(payload.sessionId))
31
+ && Boolean(cleanText(payload.approvalId))
32
+ && Boolean(cleanText(payload.toolName))
33
+ && (payload.callId === undefined || Boolean(cleanText(payload.callId)))
34
+ && (payload.reason === undefined || typeof payload.reason === 'string');
35
+ }
36
+
37
+ function toolArguments(toolCall) {
38
+ const source = toolCall?.arguments;
39
+ if (source !== null && typeof source === 'object') {
40
+ try {
41
+ return JSON.stringify(source, null, 2);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+ if (typeof source !== 'string') return null;
47
+ const raw = printableText(source);
48
+ // Harness treats an empty tool argument string as an empty object.
49
+ if (!raw) return source === '' ? '{}' : null;
50
+ try {
51
+ return JSON.stringify(JSON.parse(raw), null, 2);
52
+ } catch {
53
+ return raw;
54
+ }
55
+ }
56
+
57
+ export function harnessApprovalText(payload, {
58
+ toolCall,
59
+ requiresMention = false,
60
+ maxArgumentsLength = 6_000,
61
+ } = {}) {
62
+ if (!validHarnessApproval(payload)) return null;
63
+ const callId = cleanText(payload.callId);
64
+ if (!callId
65
+ || cleanText(toolCall?.callId) !== callId
66
+ || cleanText(toolCall?.name) !== cleanText(payload.toolName)) return null;
67
+ const operation = toolArguments(toolCall);
68
+ if (!operation || operation.length > maxArgumentsLength) return null;
69
+
70
+ const lines = [
71
+ 'DeepSeek Harness 需要你的审批:',
72
+ '',
73
+ `工具:${printableText(payload.toolName)}`,
74
+ '操作参数:',
75
+ operation,
76
+ ];
77
+ const reason = printableText(payload.reason);
78
+ if (reason) lines.push(`原因:${reason}`);
79
+ lines.push('', APPROVAL_PROMPT);
80
+ if (requiresMention) lines.push('', '群聊中请 @机器人 后发送审批决定。');
81
+ return lines.join('\n');
82
+ }
83
+
84
+ function approvalResult(pending, outcome) {
85
+ return {
86
+ ok: true,
87
+ value: {
88
+ sessionId: pending.sessionId,
89
+ approvalId: pending.approvalId,
90
+ outcome,
91
+ },
92
+ };
93
+ }
94
+
95
+ function approvalOutcomeText(outcome) {
96
+ if (outcome === 'allowed-once') return '已批准,仅对本次操作有效。';
97
+ if (outcome === 'rejected') return '已拒绝此次操作。';
98
+ return APPROVAL_RESOLVED_TEXT;
99
+ }
100
+
101
+ export class HarnessApprovalQueue {
102
+ #label;
103
+ #logger;
104
+ #byId = new Map();
105
+ #routes = new Map();
106
+ #resolvedRoutes = new Map();
107
+
108
+ constructor({ label = 'IM', logger = console } = {}) {
109
+ this.#label = label;
110
+ this.#logger = logger;
111
+ }
112
+
113
+ claimReply({
114
+ key,
115
+ actor,
116
+ text,
117
+ addressed = true,
118
+ hasPendingQuestion = false,
119
+ questionCompletion,
120
+ isQuestionPending,
121
+ send,
122
+ }) {
123
+ const route = this.#routes.get(key);
124
+ const pending = route?.items[0];
125
+ const decision = harnessApprovalDecision(text);
126
+ const notice = (value, resolved = false) => ({
127
+ ...(resolved ? { resolved: true } : {}),
128
+ process: async (before) => {
129
+ if (typeof before === 'function' && await before() === false) return;
130
+ await send(value);
131
+ },
132
+ });
133
+ // Match Harness' own interaction precedence: a live ask_user_question
134
+ // outranks sibling approvals. Otherwise a question answer such as "yes"
135
+ // could accidentally authorize a tool call.
136
+ const deferredByQuestion = hasPendingQuestion
137
+ && pending
138
+ && questionCompletion
139
+ && typeof questionCompletion.then === 'function';
140
+ if (hasPendingQuestion && !deferredByQuestion) return null;
141
+ if (!pending || pending.inactive) {
142
+ const resolvedUntil = this.#resolvedRoutes.get(key) ?? 0;
143
+ if (resolvedUntil <= Date.now()) {
144
+ this.#resolvedRoutes.delete(key);
145
+ return null;
146
+ }
147
+ if (!decision) return null;
148
+ return notice(APPROVAL_RESOLVED_TEXT, true);
149
+ }
150
+ if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) {
151
+ if (!decision) return null;
152
+ return notice('只有发起当前任务的用户可以处理这条审批。');
153
+ }
154
+
155
+ return {
156
+ process: async (before) => {
157
+ const presentedWhenClaimed = pending.presented;
158
+ const previous = pending.replyTail ?? Promise.resolve();
159
+ const task = previous
160
+ .catch(() => undefined)
161
+ .then(async () => {
162
+ if (typeof before === 'function' && await before() === false) return;
163
+ if (deferredByQuestion) {
164
+ await questionCompletion.catch(() => undefined);
165
+ if (pending.inactive || pending.resolving) return;
166
+ if (typeof isQuestionPending === 'function' && isQuestionPending()) {
167
+ await send(APPROVAL_AFTER_QUESTION_PROMPT);
168
+ return;
169
+ }
170
+ }
171
+ await pending.activationTask?.catch(() => undefined);
172
+ await pending.presentationTask?.catch(() => undefined);
173
+ if (pending.inactive || pending.resolving) {
174
+ await send(APPROVAL_RESOLVED_TEXT);
175
+ return;
176
+ }
177
+ pending.send = send;
178
+ // Never turn a decision sent before the operation was visibly
179
+ // presented into an approval. This also covers a failed presentation
180
+ // and the small FIFO promotion window before the next item is shown.
181
+ if (!presentedWhenClaimed || !pending.presented) {
182
+ if (!pending.presented) await this.#present(pending);
183
+ if (pending.inactive || pending.resolving) return;
184
+ await send(APPROVAL_PROMPT);
185
+ return;
186
+ }
187
+ if (pending.submitting) {
188
+ await send('审批决定正在提交,请稍候。');
189
+ return;
190
+ }
191
+ if (!decision) {
192
+ await send(APPROVAL_PROMPT);
193
+ return;
194
+ }
195
+ await this.#submit(pending, decision);
196
+ });
197
+ pending.replyTail = task;
198
+ try {
199
+ await task;
200
+ } finally {
201
+ if (pending.replyTail === task) pending.replyTail = null;
202
+ }
203
+ },
204
+ };
205
+ }
206
+
207
+ async handleRequested(interaction, context) {
208
+ if (interaction?.kind !== 'approval') return false;
209
+ const payload = interaction.payload;
210
+ const approvalId = cleanText(payload?.approvalId);
211
+ if (!cleanText(interaction.rpcId)
212
+ || !cleanText(interaction.sessionId)
213
+ || !approvalId
214
+ || !validHarnessApproval(payload)
215
+ || payload.sessionId !== interaction.sessionId
216
+ || typeof interaction.respond !== 'function') {
217
+ this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an invalid Harness approval`);
218
+ return true;
219
+ }
220
+
221
+ if (interaction.recovered === true) {
222
+ await this.#rejectInteraction(interaction, payload);
223
+ return true;
224
+ }
225
+
226
+ const existing = this.#byId.get(approvalId);
227
+ if (existing) {
228
+ existing.interaction = interaction;
229
+ existing.toolCall = interaction.toolCall;
230
+ if (!existing.presented) await this.#present(existing);
231
+ return true;
232
+ }
233
+
234
+ const send = context?.send;
235
+ const key = cleanText(context?.key);
236
+ const actor = cleanText(context?.actor);
237
+ if (!key || !actor || typeof send !== 'function') {
238
+ this.#logger.warn?.(`[dsh-im:${this.#label}] ignored an approval without a reply route`);
239
+ await this.#rejectInteraction(interaction, payload);
240
+ return true;
241
+ }
242
+
243
+ const text = harnessApprovalText(payload, {
244
+ toolCall: interaction.toolCall,
245
+ requiresMention: context.requiresMention === true,
246
+ });
247
+ if (!text) {
248
+ const rejected = await this.#rejectInteraction(interaction, payload);
249
+ await send(rejected
250
+ ? '无法完整展示这次操作,已安全拒绝此次审批。'
251
+ : APPROVAL_RESOLVED_TEXT);
252
+ return true;
253
+ }
254
+
255
+ const pending = {
256
+ approvalId,
257
+ sessionId: interaction.sessionId,
258
+ interaction,
259
+ toolCall: interaction.toolCall,
260
+ key,
261
+ actor,
262
+ requiresMention: context.requiresMention === true,
263
+ send,
264
+ text,
265
+ presented: false,
266
+ presentationTask: null,
267
+ deliveryCompleted: false,
268
+ replyTail: null,
269
+ submitting: false,
270
+ inactive: false,
271
+ resolving: false,
272
+ closedOutcome: null,
273
+ resolutionNotified: false,
274
+ activationTask: null,
275
+ };
276
+ this.#byId.set(approvalId, pending);
277
+ const route = this.#routes.get(key) ?? { items: [] };
278
+ route.items.push(pending);
279
+ this.#routes.set(key, route);
280
+ if (route.items[0] === pending) await this.#present(pending);
281
+ return true;
282
+ }
283
+
284
+ async handleResolved(resolution) {
285
+ if (resolution?.kind !== 'approval') return false;
286
+ const pending = this.#byId.get(cleanText(resolution.interactionId));
287
+ if (!pending) return true;
288
+ // A queued item may already be the next route head while the previous
289
+ // item's confirmation is still in flight. Preserve that route barrier so
290
+ // resolving this item cannot expose a later approval out of order.
291
+ pending.resolving = true;
292
+ if (pending.activationTask) {
293
+ await pending.activationTask.catch(() => undefined);
294
+ }
295
+ if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return true;
296
+ const presentationTask = pending.presentationTask;
297
+ const shouldNotify = pending.presented || presentationTask;
298
+ const send = pending.send;
299
+ const next = this.#remove(pending);
300
+ await this.#transition(next, async () => {
301
+ let delivered = pending.presented;
302
+ if (presentationTask) {
303
+ delivered = await presentationTask.then(() => true, () => false);
304
+ }
305
+ if (shouldNotify && delivered) {
306
+ pending.resolutionNotified = true;
307
+ await send(approvalOutcomeText(resolution.outcome)).catch(() => undefined);
308
+ }
309
+ });
310
+ return true;
311
+ }
312
+
313
+ async closeRoute(key) {
314
+ const route = this.#routes.get(key);
315
+ if (!route) return;
316
+ const pendingItems = [...route.items];
317
+ for (const pending of pendingItems) this.#remove(pending);
318
+ await Promise.all(pendingItems.map(async (pending) => {
319
+ try {
320
+ await pending.interaction.respond(
321
+ approvalResult(pending, 'rejected'),
322
+ { signal: AbortSignal.timeout(5_000) },
323
+ );
324
+ pending.closedOutcome = 'rejected';
325
+ if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
326
+ pending.resolutionNotified = true;
327
+ await pending.send(approvalOutcomeText('rejected')).catch(() => undefined);
328
+ }
329
+ } catch (error) {
330
+ if (error?.code === 'interaction-not-pending') {
331
+ pending.closedOutcome = 'resolved';
332
+ if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
333
+ pending.resolutionNotified = true;
334
+ await pending.send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
335
+ }
336
+ } else {
337
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
338
+ }
339
+ }
340
+ }));
341
+ }
342
+
343
+ async #present(pending) {
344
+ if (this.#routes.get(pending.key)?.items[0] !== pending
345
+ || pending.inactive || pending.resolving || pending.presented) return;
346
+ await pending.activationTask?.catch(() => undefined);
347
+ if (this.#routes.get(pending.key)?.items[0] !== pending
348
+ || pending.inactive || pending.resolving || pending.presented) return;
349
+ if (pending.presentationTask) return pending.presentationTask;
350
+ const task = Promise.resolve().then(() => pending.send(pending.text));
351
+ pending.presentationTask = task;
352
+ try {
353
+ await task;
354
+ pending.deliveryCompleted = true;
355
+ if (!pending.inactive) {
356
+ pending.presented = true;
357
+ } else if (pending.closedOutcome && !pending.resolutionNotified) {
358
+ pending.resolutionNotified = true;
359
+ await pending.send(approvalOutcomeText(pending.closedOutcome)).catch(() => undefined);
360
+ }
361
+ } finally {
362
+ if (pending.presentationTask === task) pending.presentationTask = null;
363
+ }
364
+ }
365
+
366
+ async #submit(pending, outcome) {
367
+ pending.submitting = true;
368
+ try {
369
+ await pending.interaction.respond(approvalResult(pending, outcome));
370
+ } catch (error) {
371
+ if (error?.code === 'interaction-not-pending') {
372
+ const send = pending.send;
373
+ const next = this.#remove(pending);
374
+ await this.#transition(next, async () => {
375
+ if (!pending.resolutionNotified) {
376
+ await send(APPROVAL_RESOLVED_TEXT).catch(() => undefined);
377
+ }
378
+ });
379
+ return;
380
+ }
381
+ if (pending.inactive) return;
382
+ pending.submitting = false;
383
+ this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
384
+ await pending.send('审批提交失败,请重新回复「批准」或「拒绝」。').catch(() => undefined);
385
+ return;
386
+ }
387
+
388
+ const send = pending.send;
389
+ const next = this.#remove(pending);
390
+ await this.#transition(next, async () => {
391
+ if (!pending.resolutionNotified) {
392
+ await send(approvalOutcomeText(outcome)).catch(() => undefined);
393
+ }
394
+ });
395
+ }
396
+
397
+ async #transition(next, work) {
398
+ let release;
399
+ const barrier = new Promise((resolve) => { release = resolve; });
400
+ if (next) next.activationTask = barrier;
401
+ try {
402
+ await work();
403
+ } finally {
404
+ release();
405
+ if (next?.activationTask === barrier) next.activationTask = null;
406
+ }
407
+ await this.#promote(next);
408
+ }
409
+
410
+ async #promote(pending) {
411
+ if (!pending) return;
412
+ try {
413
+ await this.#present(pending);
414
+ } catch (error) {
415
+ this.#logger.error?.(
416
+ `[dsh-im:${this.#label}] failed to present the next approval:`,
417
+ error,
418
+ );
419
+ try {
420
+ pending.interaction.reconnect?.();
421
+ } catch {
422
+ // A replay will retry presentation when the transport can reconnect.
423
+ }
424
+ }
425
+ }
426
+
427
+ #remove(pending) {
428
+ if (pending.inactive) return null;
429
+ pending.inactive = true;
430
+ this.#rememberResolvedRoute(pending.key);
431
+ this.#byId.delete(pending.approvalId);
432
+ const route = this.#routes.get(pending.key);
433
+ if (!route) return null;
434
+ const wasCurrent = route.items[0] === pending;
435
+ const index = route.items.indexOf(pending);
436
+ if (index !== -1) route.items.splice(index, 1);
437
+ if (route.items.length === 0) {
438
+ this.#routes.delete(pending.key);
439
+ return null;
440
+ }
441
+ return wasCurrent ? route.items[0] : null;
442
+ }
443
+
444
+ #rememberResolvedRoute(key) {
445
+ const now = Date.now();
446
+ for (const [routeKey, expiresAt] of this.#resolvedRoutes) {
447
+ if (expiresAt <= now) this.#resolvedRoutes.delete(routeKey);
448
+ }
449
+ this.#resolvedRoutes.delete(key);
450
+ this.#resolvedRoutes.set(key, now + RESOLVED_ROUTE_TTL_MS);
451
+ while (this.#resolvedRoutes.size > MAX_RESOLVED_ROUTES) {
452
+ this.#resolvedRoutes.delete(this.#resolvedRoutes.keys().next().value);
453
+ }
454
+ }
455
+
456
+ async #rejectInteraction(interaction, payload) {
457
+ try {
458
+ await interaction.respond({
459
+ ok: true,
460
+ value: {
461
+ sessionId: interaction.sessionId,
462
+ approvalId: payload.approvalId,
463
+ outcome: 'rejected',
464
+ },
465
+ }, { signal: AbortSignal.timeout(5_000) });
466
+ return true;
467
+ } catch (error) {
468
+ if (error?.code === 'interaction-not-pending') return false;
469
+ throw error;
470
+ }
471
+ }
472
+ }
@@ -120,7 +120,36 @@ function consumeInteractionOwnership(ownership, entries) {
120
120
  if (event.type === 'turn/end' && event.data?.turn === ownership.turn) {
121
121
  ownership.active = false;
122
122
  ownership.completed = true;
123
+ continue;
124
+ }
125
+ let toolCall = null;
126
+ if (event.type === 'tool/call'
127
+ && ownership.active
128
+ && event.data?.turn === ownership.turn
129
+ && typeof event.data?.callId === 'string'
130
+ && event.data.callId) {
131
+ toolCall = {
132
+ callId: event.data.callId,
133
+ name: event.data?.name,
134
+ arguments: event.data?.arguments,
135
+ };
136
+ } else if (event.type === 'tool/code-dispatch-start'
137
+ && ownership.active
138
+ && typeof event.data?.subCallId === 'string'
139
+ && event.data.subCallId) {
140
+ let argumentsText;
141
+ try {
142
+ argumentsText = JSON.stringify(event.data?.arguments);
143
+ } catch {
144
+ argumentsText = undefined;
145
+ }
146
+ toolCall = {
147
+ callId: event.data.subCallId,
148
+ name: event.data?.name,
149
+ arguments: argumentsText,
150
+ };
123
151
  }
152
+ if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
124
153
  }
125
154
  }
126
155
 
@@ -524,13 +553,12 @@ export class HarnessClient {
524
553
  .sort((left, right) => left.order - right.order);
525
554
  if (active.length > 0) return { ownership: active[0], recovered: false };
526
555
 
527
- // Approval recovery must remain fail-closed until #5 can prove the actor
528
- // and conversation that originally requested the decision.
529
- if (kind !== 'question') return null;
530
-
531
556
  // A newly attached IM conversation may encounter a question left by
532
557
  // an earlier runtime before its queued prompt starts. Let the oldest such
533
558
  // ask adopt that replay so the Session can recover instead of deadlocking.
559
+ // Approval adopters receive recovered=true and must reject it without ever
560
+ // presenting it as approvable; the original actor/route cannot be proven
561
+ // after a runtime restart.
534
562
  const ownership = owners
535
563
  .filter((ownership) => !ownership.started && !ownership.completed)
536
564
  .sort((left, right) => left.order - right.order)[0] ?? null;
@@ -580,6 +608,7 @@ export class HarnessClient {
580
608
  lastSeq: baselineSeq,
581
609
  reconnect: null,
582
610
  order: -1,
611
+ toolCalls: new Map(),
583
612
  }
584
613
  : null;
585
614
  let interactionTask = null;
@@ -740,6 +769,9 @@ export class HarnessClient {
740
769
  if (claim?.ownership !== ownership) return;
741
770
  this.#interactionClaims.set(claimKey, claim);
742
771
  }
772
+ const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string'
773
+ ? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId)
774
+ : undefined;
743
775
  dispatch(onInteraction, Object.freeze({
744
776
  kind,
745
777
  interactionId,
@@ -749,6 +781,7 @@ export class HarnessClient {
749
781
  recovered: ownership
750
782
  ? this.#interactionClaims.get(claimKey)?.recovered === true
751
783
  : false,
784
+ ...(toolCall ? { toolCall } : {}),
752
785
  reconnect: close,
753
786
  respond: (result, options = {}) => this.respondInteraction(
754
787
  envelope.rpcId,
@@ -1,5 +1,6 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
2
  import { askInWorkspaceSession } from './workspace-session.mjs';
3
+ import { HarnessApprovalQueue } from './harness-approval.mjs';
3
4
  import {
4
5
  harnessAnswerForQuestion,
5
6
  harnessQuestionText,
@@ -43,6 +44,8 @@ export class TextHarnessBridge {
43
44
  #pendingInteractions = new Map();
44
45
  #interactionKeys = new Map();
45
46
  #acceptedMessageIds = new Set();
47
+ #approvalTasks = new Set();
48
+ #approvals;
46
49
 
47
50
  constructor({
48
51
  descriptor,
@@ -65,6 +68,10 @@ export class TextHarnessBridge {
65
68
  this.#logger = logger;
66
69
  this.#replyTimeoutMs = replyTimeoutMs;
67
70
  this.#signal = signal;
71
+ this.#approvals = new HarnessApprovalQueue({
72
+ label: descriptor.key,
73
+ logger,
74
+ });
68
75
  }
69
76
 
70
77
  get status() {
@@ -86,6 +93,35 @@ export class TextHarnessBridge {
86
93
 
87
94
  const key = `${kind}:${conversationId}`;
88
95
  const pending = this.#pendingInteractions.get(key);
96
+ const approval = this.#approvals.claimReply({
97
+ key,
98
+ actor: senderId,
99
+ messageId,
100
+ text: normalized.content,
101
+ addressed: normalized.kind !== 'group' || normalized.addressed === true,
102
+ hasPendingQuestion: Boolean(pending),
103
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
104
+ ? pending.queue
105
+ : null,
106
+ isQuestionPending: () => this.#pendingInteractions.has(key),
107
+ send: (text) => this.#bot.sendText(normalized.replyTarget, text),
108
+ });
109
+ if (approval) {
110
+ let task;
111
+ task = approval.process(async () => {
112
+ if (this.#state.hasSeen(messageId)) return false;
113
+ await this.#state.markSeen(messageId);
114
+ this.#status.messagesReceived += 1;
115
+ this.#status.lastMessageAt = new Date().toISOString();
116
+ return true;
117
+ })
118
+ .finally(() => {
119
+ this.#acceptedMessageIds.delete(messageId);
120
+ this.#approvalTasks.delete(task);
121
+ });
122
+ this.#approvalTasks.add(task);
123
+ return task;
124
+ }
89
125
  if (pending && pending.actor !== senderId) {
90
126
  return this.#enqueueMessage(normalized, messageId, senderId, key);
91
127
  }
@@ -147,6 +183,7 @@ export class TextHarnessBridge {
147
183
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
148
184
  pending.queue ? [pending.queue] : []
149
185
  )),
186
+ ...this.#approvalTasks,
150
187
  ]);
151
188
  }
152
189
 
@@ -276,7 +313,10 @@ export class TextHarnessBridge {
276
313
  );
277
314
  }
278
315
  } finally {
279
- await this.#cancelPendingInteraction(conversationKey);
316
+ await Promise.allSettled([
317
+ this.#cancelPendingInteraction(conversationKey),
318
+ this.#approvals.closeRoute(conversationKey),
319
+ ]);
280
320
  }
281
321
  }
282
322
 
@@ -434,8 +474,14 @@ export class TextHarnessBridge {
434
474
  target,
435
475
  requiresMention,
436
476
  }) {
437
- // Approval remains deliberately unanswered until #5 supplies a policy that
438
- // can prove both the actor and the conversation allowed to decide it.
477
+ if (interaction?.kind === 'approval') {
478
+ return this.#approvals.handleRequested(interaction, {
479
+ key,
480
+ actor,
481
+ requiresMention,
482
+ send: (text) => this.#bot.sendText(target, text),
483
+ });
484
+ }
439
485
  if (interaction?.kind !== 'question') return;
440
486
  const questions = interaction?.payload?.questions;
441
487
  const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
@@ -510,7 +556,11 @@ export class TextHarnessBridge {
510
556
  await this.#presentInteraction(pending);
511
557
  }
512
558
 
513
- #handleInteractionResolved(resolution) {
559
+ async #handleInteractionResolved(resolution) {
560
+ if (resolution?.kind === 'approval') {
561
+ await this.#approvals.handleResolved(resolution);
562
+ return;
563
+ }
514
564
  const interactionId = cleanText(resolution?.interactionId);
515
565
  if (resolution?.kind !== 'question' || !interactionId) return;
516
566
  const key = this.#interactionKeys.get(interactionId);