dsh-connect 0.9.0 → 0.9.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.
Files changed (54) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +119 -18
  3. package/README.zh.md +87 -13
  4. package/client/client.js +18 -15
  5. package/client/client.js.map +2 -2
  6. package/client/settings-client.mjs +22 -13
  7. package/lib/channels/dingtalk/index.d.ts +49 -49
  8. package/lib/channels/dingtalk/index.d.ts.map +1 -1
  9. package/lib/channels/feishu/adapter.d.ts +8 -1
  10. package/lib/channels/feishu/adapter.d.ts.map +1 -1
  11. package/lib/channels/feishu/adapter.js +120 -39
  12. package/lib/channels/feishu/adapter.js.map +1 -1
  13. package/lib/channels/feishu/index.d.ts +27 -27
  14. package/lib/channels/feishu/index.d.ts.map +1 -1
  15. package/lib/channels/telegram/index.d.ts +13 -13
  16. package/lib/channels/telegram/index.d.ts.map +1 -1
  17. package/lib/channels/web/adapter.d.ts +3 -1
  18. package/lib/channels/web/adapter.d.ts.map +1 -1
  19. package/lib/channels/web/adapter.js +3 -1
  20. package/lib/channels/web/adapter.js.map +1 -1
  21. package/lib/channels/web/index.d.ts +5 -5
  22. package/lib/channels/web/index.d.ts.map +1 -1
  23. package/lib/index.d.ts +42 -58
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +162 -57
  26. package/lib/index.js.map +1 -1
  27. package/lib/interaction.d.ts +90 -50
  28. package/lib/interaction.d.ts.map +1 -1
  29. package/lib/interaction.js +233 -272
  30. package/lib/interaction.js.map +1 -1
  31. package/lib/retry.d.ts.map +1 -1
  32. package/lib/retry.js +7 -1
  33. package/lib/retry.js.map +1 -1
  34. package/lib/service.d.ts.map +1 -1
  35. package/lib/service.js +0 -2
  36. package/lib/service.js.map +1 -1
  37. package/lib/settings/legacy-import.d.ts +190 -0
  38. package/lib/settings/legacy-import.d.ts.map +1 -0
  39. package/lib/settings/legacy-import.js +373 -0
  40. package/lib/settings/legacy-import.js.map +1 -0
  41. package/lib/settings/namespace.d.ts +204 -116
  42. package/lib/settings/namespace.d.ts.map +1 -1
  43. package/lib/settings/namespace.js +235 -148
  44. package/lib/settings/namespace.js.map +1 -1
  45. package/lib/settings/settings-model.d.ts.map +1 -1
  46. package/lib/settings/settings-model.js +10 -1
  47. package/lib/settings/settings-model.js.map +1 -1
  48. package/lib/settings/settings-service.d.ts +13 -0
  49. package/lib/settings/settings-service.d.ts.map +1 -1
  50. package/lib/settings/settings-service.js +73 -4
  51. package/lib/settings/settings-service.js.map +1 -1
  52. package/lib/types.d.ts +14 -1
  53. package/lib/types.d.ts.map +1 -1
  54. package/package.json +12 -11
@@ -2,6 +2,15 @@ import { messages } from "./i18n.js";
2
2
  function delay(ms) {
3
3
  return new Promise((resolve) => setTimeout(resolve, ms));
4
4
  }
5
+ /**
6
+ * Whether the host has already given up on a request (its signal fired).
7
+ * A free function on purpose: reading `req.signal?.aborted` directly and
8
+ * re-checking it later would be narrowed away by the first check's early
9
+ * return, even though the signal can fire across an `await`.
10
+ */
11
+ function hostCancelled(signal) {
12
+ return signal?.aborted === true;
13
+ }
5
14
  /**
6
15
  * Decode a free-text chat reply into the answer for one question. Exact label
7
16
  * match wins; otherwise space/comma separated numbers are mapped onto the
@@ -40,9 +49,8 @@ function decodeChoice(q, choice) {
40
49
  return { selected: [opt.label] };
41
50
  }
42
51
  /**
43
- * Bridge user choices and permission approvals from DSH into the chat channel.
44
- * Subscribes once to the host mux stream; frames for connect-bound sessions are
45
- * rendered as interactive cards and answered via `apiProxy.respond`.
52
+ * Answer the host's user-question and approval waterfalls from the chat
53
+ * channel. Registered once, eagerly, at construction time.
46
54
  */
47
55
  export class InteractionBridge {
48
56
  ctx;
@@ -50,27 +58,29 @@ export class InteractionBridge {
50
58
  bindings;
51
59
  config;
52
60
  pending = new Map();
53
- started = false;
54
- apiProxy;
55
- controller;
56
61
  constructor(ctx, adapters, bindings, config) {
57
62
  this.ctx = ctx;
58
63
  this.adapters = adapters;
59
64
  this.bindings = bindings;
60
65
  this.config = config;
61
- this.ctx.effect(() => () => this.controller?.abort(), "connect: interaction bridge");
66
+ const seam = ctx;
67
+ ctx.effect(() => this.register(seam, "user-questions/request", this.answerQuestion.bind(this)), "connect: user-questions answerer");
68
+ ctx.effect(() => this.register(seam, "approval/request", this.answerApproval.bind(this)), "connect: approval answerer");
69
+ ctx.effect(() => () => {
70
+ // Teardown: unblock every in-flight card so its loop unwinds.
71
+ for (const p of [...this.pending.values()])
72
+ this.settle(p);
73
+ }, "connect: interaction bridge");
62
74
  }
63
- /** Open the mux subscription (idempotent). No-op when the host api-proxy is absent. */
64
- start() {
65
- if (this.started)
66
- return;
67
- const apiProxy = this.ctx.get("apiProxy");
68
- if (apiProxy?.events?.mux === undefined || apiProxy.respond === undefined)
69
- return;
70
- this.started = true;
71
- this.apiProxy = apiProxy;
72
- this.controller = new AbortController();
73
- void this.run(this.controller.signal);
75
+ /** Register one waterfall listener, tolerating a host without the event API. */
76
+ register(seam, name, listener) {
77
+ try {
78
+ return seam.on(name, listener, { prepend: true });
79
+ }
80
+ catch (error) {
81
+ this.log(`connect: could not register the "${name}" answerer: ${String(error)}`);
82
+ return () => undefined;
83
+ }
74
84
  }
75
85
  /** Whether a question/approval is waiting on an answer in this chat. */
76
86
  pendingFor(chatKey) {
@@ -90,254 +100,153 @@ export class InteractionBridge {
90
100
  this.recordAnswer(p, q.id, answer);
91
101
  return true;
92
102
  }
93
- async run(signal) {
94
- const stream = this.apiProxy.events.mux({ rpcId: "connect-interaction", payload: {} }, signal);
95
- try {
96
- for await (const { rpcId, payload } of stream) {
97
- try {
98
- this.onFrame(rpcId, payload);
99
- }
100
- catch (error) {
101
- this.log(`connect: interaction frame handler error: ${String(error)}`);
102
- }
103
- }
104
- }
105
- catch (error) {
106
- this.log(`connect: interaction mux stream closed: ${String(error)}`);
107
- }
108
- }
109
- onFrame(rpcId, payload) {
110
- switch (payload.type) {
111
- case "question/requested": {
112
- const sessionId = String(payload.sessionId ?? "");
113
- const questions = Array.isArray(payload.questions) ? payload.questions : [];
114
- if (sessionId === "" || questions.length === 0)
115
- return;
116
- for (const target of this.targetsFor(sessionId)) {
117
- this.presentQuestion(target, rpcId, sessionId, questions);
118
- }
119
- break;
120
- }
121
- case "approval/requested": {
122
- const sessionId = String(payload.sessionId ?? "");
123
- const approvalId = String(payload.approvalId ?? "");
124
- if (sessionId === "" || approvalId === "")
125
- return;
126
- const toolName = String(payload.toolName ?? "?");
127
- const reason = typeof payload.reason === "string" ? payload.reason : undefined;
128
- for (const target of this.targetsFor(sessionId)) {
129
- this.presentApproval(target, rpcId, sessionId, approvalId, toolName, reason);
130
- }
131
- break;
132
- }
133
- case "question/resolved": {
134
- // Someone (Web GUI or this bridge) answered/cancelled the ask.
135
- const questionRpcId = String(payload.questionRpcId ?? "");
136
- for (const p of [...this.pending.values()]) {
137
- if (p.kind === "question" && p.rpcId === questionRpcId)
138
- this.settle(p);
139
- }
140
- break;
141
- }
142
- case "session/event": {
143
- const event = payload.event;
144
- if (event?.type !== "turn/end")
145
- return;
146
- const sessionId = String(payload.sessionId ?? "");
147
- for (const p of [...this.pending.values()]) {
148
- if (p.sessionId === sessionId)
149
- this.settle(p);
150
- }
151
- break;
152
- }
153
- default:
154
- break;
155
- }
156
- }
157
- /** Every connect-bound chat that currently uses this session (active or mirrored). */
158
- targetsFor(sessionId) {
159
- const out = [];
160
- for (const binding of this.bindings.list()) {
161
- if (binding.sessionId !== sessionId && binding.webMirrorSessionId !== sessionId)
162
- continue;
163
- const adapter = this.adapters.get(binding.channel);
164
- if (adapter === undefined)
165
- continue;
166
- out.push({ binding, adapter });
167
- }
168
- return out;
169
- }
170
- presentQuestion(target, rpcId, sessionId, questions) {
171
- // One pending ask per session — a replayed frame after the mux reopens
172
- // must not stack duplicate cards.
173
- for (const p of this.pending.values()) {
174
- if (p.sessionId === sessionId)
175
- return;
176
- }
103
+ // ── user-questions/request ─────────────────────────────────────────────────
104
+ async answerQuestion(req, next) {
105
+ const questions = Array.isArray(req.questions) ? req.questions : [];
106
+ const sessionId = req.agent?.id;
107
+ if (sessionId === undefined || sessionId === "" || questions.length === 0)
108
+ return next();
109
+ if (hostCancelled(req.signal))
110
+ return next();
111
+ const target = this.claimTarget(sessionId);
112
+ if (target === undefined)
113
+ return next();
177
114
  const interaction = {
178
115
  kind: "question",
179
116
  chatKey: target.binding.chatKey,
180
117
  chatType: target.binding.chatType,
181
118
  adapter: target.adapter,
182
119
  sessionId,
183
- rpcId,
184
120
  language: target.binding.language ?? this.config.language,
185
121
  questions,
186
122
  answers: new Map(),
187
123
  current: 0,
124
+ waiters: new Set(),
188
125
  controller: new AbortController(),
189
126
  settled: false,
190
127
  };
191
128
  this.pending.set(interaction.chatKey, interaction);
192
- this.stepQuestion(interaction);
193
- }
194
- presentApproval(target, rpcId, sessionId, approvalId, toolName, reason) {
195
- for (const p of this.pending.values()) {
196
- if (p.sessionId === sessionId)
197
- return;
198
- }
199
- const interaction = {
200
- kind: "approval",
201
- chatKey: target.binding.chatKey,
202
- chatType: target.binding.chatType,
203
- adapter: target.adapter,
204
- sessionId,
205
- rpcId,
206
- approvalId,
207
- toolName,
208
- ...(reason === undefined ? {} : { reason }),
209
- language: target.binding.language ?? this.config.language,
210
- controller: new AbortController(),
211
- settled: false,
212
- };
213
- this.pending.set(interaction.chatKey, interaction);
214
- void this.presentApprovalLoop(interaction);
215
- }
216
- /** Advance to the next unanswered question, or settle once all are answered. */
217
- stepQuestion(interaction) {
218
- while (interaction.current < interaction.questions.length &&
219
- interaction.answers.has(interaction.questions[interaction.current].id)) {
220
- interaction.current += 1;
129
+ const onAbort = () => this.settle(interaction);
130
+ req.signal?.addEventListener("abort", onAbort, { once: true });
131
+ try {
132
+ const answers = await this.runQuestions(interaction);
133
+ if (answers === undefined)
134
+ return next();
135
+ const t = messages(interaction.language);
136
+ await interaction.adapter.sendText(this.outbound(interaction), t.answerReceived).catch(() => undefined);
137
+ this.log(`connect: answered ${answers.length} question(s) from ${interaction.chatKey}`);
138
+ return { answers };
221
139
  }
222
- if (interaction.current >= interaction.questions.length) {
223
- void this.settleQuestion(interaction);
224
- return;
140
+ catch (error) {
141
+ // The card could not be delivered (or the channel errored): hand the
142
+ // request back so the Web GUI — or the host's fail-closed default — gets it.
143
+ this.log(`connect: question presentation failed (${interaction.chatKey}): ${String(error)}`);
144
+ return next();
225
145
  }
226
- const q = interaction.questions[interaction.current];
227
- const hasOptions = Array.isArray(q.options) && q.options.length > 0;
228
- if (hasOptions) {
229
- void this.presentQuestionButtons(interaction, q);
146
+ finally {
147
+ req.signal?.removeEventListener("abort", onAbort);
148
+ this.settle(interaction);
230
149
  }
231
- else {
232
- void this.presentQuestionText(interaction, q);
150
+ }
151
+ /** Ask every question in turn. `undefined` = the flow was interrupted. */
152
+ async runQuestions(interaction) {
153
+ for (let i = 0; i < interaction.questions.length; i += 1) {
154
+ interaction.current = i;
155
+ const q = interaction.questions[i];
156
+ if (interaction.answers.has(q.id))
157
+ continue;
158
+ const answered = await this.askOne(interaction, q);
159
+ if (!answered)
160
+ return undefined;
233
161
  }
162
+ return interaction.questions.map((q) => {
163
+ const a = interaction.answers.get(q.id);
164
+ const custom = a?.custom?.trim();
165
+ return {
166
+ id: q.id,
167
+ selected: a?.selected ?? [],
168
+ ...(custom === undefined || custom === "" ? {} : { custom }),
169
+ };
170
+ });
234
171
  }
235
- async presentQuestionButtons(interaction, q) {
172
+ async askOne(interaction, q) {
173
+ const options = Array.isArray(q.options) ? q.options : [];
174
+ if (options.length === 0)
175
+ return this.askByText(interaction, q);
236
176
  const t = messages(interaction.language);
237
- const options = (q.options ?? []).map((o, i) => ({
238
- id: `q:${q.id}:${i}`,
239
- label: o.label,
240
- }));
241
177
  const prompt = {
242
178
  title: t.questionCardTitle,
243
179
  description: this.questionPromptText(interaction, q),
244
- options,
180
+ options: options.map((o, i) => ({ id: `q:${q.id}:${i}`, label: o.label })),
245
181
  footer: q.multiSelect === true ? `${t.questionCardHint}\n${t.questionMultiHint}` : t.questionCardHint,
246
182
  };
247
- // One answered question steps the interaction to the next one; settling is
248
- // the flow's job (stepQuestion → settleQuestion), not this loop's.
249
- await this.presentLoop(interaction, prompt, (choice) => {
250
- if (choice === undefined)
251
- return undefined;
252
- const answer = decodeChoice(q, choice);
253
- if (answer === undefined)
254
- return undefined;
255
- this.recordAnswer(interaction, q.id, answer);
256
- return "answered";
257
- });
183
+ while (!interaction.controller.signal.aborted && !interaction.settled) {
184
+ const { choice, messageId } = await interaction.adapter.promptChoice(this.outbound(interaction), prompt, interaction.messageId, interaction.controller.signal);
185
+ // An adapter aborted before presenting has no card to point at; keep the
186
+ // last real id so the next round still targets the right message.
187
+ if (messageId !== "")
188
+ interaction.messageId = messageId;
189
+ if (choice !== undefined) {
190
+ const answer = decodeChoice(q, choice);
191
+ if (answer !== undefined) {
192
+ this.recordAnswer(interaction, q.id, answer);
193
+ return true;
194
+ }
195
+ continue;
196
+ }
197
+ // The card expired without a tap. Re-present the same card in place so
198
+ // the choice stays clickable for as long as the agent is waiting.
199
+ if (interaction.controller.signal.aborted || interaction.settled)
200
+ break;
201
+ await delay(1000);
202
+ }
203
+ return false;
258
204
  }
259
- async presentQuestionText(interaction, q) {
205
+ /** Options-free question: the user answers by sending an ordinary message. */
206
+ async askByText(interaction, q) {
260
207
  const t = messages(interaction.language);
261
208
  const target = this.outbound(interaction);
262
209
  await interaction.adapter.sendText(target, t.questionTextHint(q.question)).catch(() => undefined);
263
210
  while (!interaction.controller.signal.aborted && !interaction.settled) {
264
211
  if (interaction.answers.has(q.id))
265
- return;
266
- await delay(60_000);
267
- if (interaction.controller.signal.aborted || interaction.settled || interaction.answers.has(q.id))
268
- return;
212
+ return true;
213
+ const waited = await this.waitForAnswer(interaction, 60_000);
214
+ if (waited === "answered")
215
+ return true;
216
+ if (waited === "stopped")
217
+ return false;
269
218
  await interaction.adapter.sendText(target, t.questionWaiting(q.question)).catch(() => undefined);
270
219
  }
271
- }
272
- async presentApprovalLoop(interaction) {
273
- const t = messages(interaction.language);
274
- const prompt = {
275
- title: t.approvalCardTitle,
276
- description: t.approvalAsk(interaction.toolName, interaction.reason),
277
- options: [
278
- { id: "approval:allow", label: t.approveLabel },
279
- { id: "approval:reject", label: t.rejectLabel },
280
- ],
281
- };
282
- const result = await this.presentLoop(interaction, prompt, async (choice, messageId) => {
283
- if (choice !== "approval:allow" && choice !== "approval:reject")
284
- return undefined;
285
- const outcome = choice === "approval:allow" ? "allowed-once" : "rejected";
286
- // Submit the decision through the host respond path and wait for the
287
- // verdict — this is what tells the user whether their tap actually took
288
- // effect (the Web GUI may have answered first, or the request expired).
289
- const accepted = await this.respond(interaction.rpcId, {
290
- sessionId: interaction.sessionId,
291
- approvalId: interaction.approvalId,
292
- outcome,
293
- });
294
- if (accepted) {
295
- // Replace the buttons with a clear "done" state so the user sees the
296
- // decision landed even if the chat keeps streaming afterwards.
297
- await interaction.adapter
298
- .closeMenu(messageId, t.approvalDone(outcome, interaction.toolName))
299
- .catch(() => undefined);
300
- }
301
- else {
302
- // Not accepted: the approval was already handled elsewhere, or is
303
- // stale. Say so explicitly instead of silently ignoring the tap.
304
- await interaction.adapter
305
- .closeMenu(messageId, t.approvalStale)
306
- .catch(() => undefined);
307
- await interaction.adapter
308
- .sendText(this.outbound(interaction), t.approvalStale)
309
- .catch(() => undefined);
310
- }
311
- return "answered";
312
- });
313
- if (result === "answered")
314
- this.settle(interaction);
220
+ return false;
315
221
  }
316
222
  /**
317
- * Keep one interactive card alive until the user answers (or the ask is
318
- * cancelled). The channel's choice prompt expires after its own idle
319
- * timeout; on expiry the same card is re-presented in place so the choice
320
- * stays clickable for as long as the agent waits. Resolves `"answered"`
321
- * when `onChoice` accepted a choice, `"stopped"` otherwise.
322
- *
323
- * `onChoice` may be async and receives the card's message id (for feedback
324
- * such as updating the card once the decision is delivered). A thrown
325
- * `onChoice` error resolves the loop as `"stopped"` (best-effort channel).
223
+ * Wait until an answer is recorded, the flow stops, or `timeoutMs` elapses.
224
+ * Resolves (never rejects) and always removes its own listener and timer.
326
225
  */
327
- async presentLoop(interaction, prompt, onChoice) {
328
- while (!interaction.controller.signal.aborted && !interaction.settled) {
329
- try {
330
- const { choice, messageId } = await interaction.adapter.promptChoice(this.outbound(interaction), prompt, interaction.messageId);
331
- interaction.messageId = messageId;
332
- if ((await onChoice(choice, messageId)) === "answered")
333
- return "answered";
334
- }
335
- catch {
336
- return "stopped";
337
- }
338
- await delay(1200);
339
- }
340
- return "stopped";
226
+ waitForAnswer(interaction, timeoutMs) {
227
+ return new Promise((resolve) => {
228
+ let done = false;
229
+ let timer;
230
+ const finish = (result) => {
231
+ if (done)
232
+ return;
233
+ done = true;
234
+ if (timer !== undefined)
235
+ clearTimeout(timer);
236
+ interaction.waiters.delete(onWake);
237
+ interaction.controller.signal.removeEventListener("abort", onStop);
238
+ resolve(result);
239
+ };
240
+ const onWake = () => finish("answered");
241
+ const onStop = () => finish("stopped");
242
+ interaction.waiters.add(onWake);
243
+ interaction.controller.signal.addEventListener("abort", onStop, { once: true });
244
+ timer = setTimeout(() => finish("timeout"), timeoutMs);
245
+ timer.unref?.();
246
+ // The signal may have fired between the loop check and this registration.
247
+ if (interaction.controller.signal.aborted)
248
+ finish("stopped");
249
+ });
341
250
  }
342
251
  questionPromptText(interaction, q) {
343
252
  const t = messages(interaction.language);
@@ -352,57 +261,109 @@ export class InteractionBridge {
352
261
  if (interaction.answers.has(qid))
353
262
  return;
354
263
  interaction.answers.set(qid, answer);
355
- this.stepQuestion(interaction);
356
- }
357
- /** All questions answered — deliver the answers through the host respond path. */
358
- async settleQuestion(interaction) {
359
- if (interaction.settled)
360
- return;
361
- interaction.settled = true;
362
- const answers = interaction.questions.map((q) => {
363
- const a = interaction.answers.get(q.id);
364
- return {
365
- id: q.id,
366
- selected: a?.selected ?? [],
367
- ...(a?.custom !== undefined && a.custom.trim() !== "" ? { custom: a.custom.trim() } : {}),
368
- };
369
- });
370
- const accepted = await this.respond(interaction.rpcId, {
371
- sessionId: interaction.sessionId,
372
- answer: { answers },
373
- });
374
- const t = messages(interaction.language);
375
- if (accepted) {
376
- await interaction.adapter.sendText(this.outbound(interaction), t.answerReceived).catch(() => undefined);
377
- }
378
- else {
379
- // The host did not accept the answers (already answered elsewhere, or
380
- // the question expired) — tell the user their reply had no effect.
381
- await interaction.adapter.sendText(this.outbound(interaction), t.questionStale).catch(() => undefined);
382
- }
383
- this.settle(interaction);
264
+ for (const wake of [...interaction.waiters])
265
+ wake();
384
266
  }
385
- async respond(rpcId, value) {
386
- if (this.apiProxy?.respond === undefined)
387
- return false;
267
+ // ── approval/request ───────────────────────────────────────────────────────
268
+ async answerApproval(req, next) {
269
+ const sessionId = req.agent?.id;
270
+ if (sessionId === undefined || sessionId === "" || hostCancelled(req.signal))
271
+ return next();
272
+ const target = this.claimTarget(sessionId);
273
+ if (target === undefined)
274
+ return next();
275
+ const t = messages(target.binding.language ?? this.config.language);
276
+ const interaction = {
277
+ kind: "approval",
278
+ chatKey: target.binding.chatKey,
279
+ chatType: target.binding.chatType,
280
+ adapter: target.adapter,
281
+ sessionId,
282
+ language: target.binding.language ?? this.config.language,
283
+ toolName: req.toolName,
284
+ ...(req.reason === undefined ? {} : { reason: req.reason }),
285
+ controller: new AbortController(),
286
+ settled: false,
287
+ };
288
+ this.pending.set(interaction.chatKey, interaction);
289
+ const onAbort = () => this.settle(interaction);
290
+ req.signal?.addEventListener("abort", onAbort, { once: true });
291
+ const prompt = {
292
+ title: t.approvalCardTitle,
293
+ description: t.approvalAsk(req.toolName, req.reason),
294
+ options: [
295
+ { id: "approval:allow", label: t.approveLabel },
296
+ { id: "approval:reject", label: t.rejectLabel },
297
+ ],
298
+ };
388
299
  try {
389
- const res = await this.apiProxy.respond({
390
- type: "client-response",
391
- rpcId,
392
- result: { ok: true, value },
393
- });
394
- return res?.accepted === true;
300
+ while (!interaction.controller.signal.aborted && !interaction.settled) {
301
+ const { choice, messageId } = await interaction.adapter.promptChoice(this.outbound(interaction), prompt, interaction.messageId, interaction.controller.signal);
302
+ if (messageId !== "")
303
+ interaction.messageId = messageId;
304
+ if (choice !== "approval:allow" && choice !== "approval:reject") {
305
+ if (interaction.controller.signal.aborted || interaction.settled)
306
+ break;
307
+ // Card expired without a tap — re-present it and keep waiting. The
308
+ // host cancels the request itself if it stops caring (its signal
309
+ // fires, which aborts us and lands on `next()` below).
310
+ await delay(1000);
311
+ continue;
312
+ }
313
+ if (hostCancelled(req.signal)) {
314
+ // Decided, but too late: the host already gave up on this request.
315
+ await interaction.adapter.closeMenu(messageId, t.approvalStale).catch(() => undefined);
316
+ return next();
317
+ }
318
+ const outcome = choice === "approval:allow" ? "allowed-once" : "rejected";
319
+ // Replace the buttons with a clear "done" state so the user sees the
320
+ // decision landed even if the chat keeps streaming afterwards.
321
+ await interaction.adapter.closeMenu(messageId, t.approvalDone(outcome, req.toolName)).catch(() => undefined);
322
+ this.log(`connect: approval ${outcome} for ${req.toolName} from ${interaction.chatKey}`);
323
+ return outcome;
324
+ }
325
+ return next();
395
326
  }
396
327
  catch (error) {
397
- this.log(`connect: interaction respond failed: ${String(error)}`);
398
- return false;
328
+ this.log(`connect: approval presentation failed (${interaction.chatKey}): ${String(error)}`);
329
+ return next();
330
+ }
331
+ finally {
332
+ req.signal?.removeEventListener("abort", onAbort);
333
+ this.settle(interaction);
334
+ }
335
+ }
336
+ // ── shared ─────────────────────────────────────────────────────────────────
337
+ /**
338
+ * Pick the one chat that owns `sessionId` and can actually present a prompt.
339
+ *
340
+ * Only the first match is claimed: two cards for one question would leave the
341
+ * second one unanswerable (the answer is delivered to the agent, not to a
342
+ * specific card), and the Web GUI would render a duplicate.
343
+ *
344
+ * The `web` channel is a GUI mirror whose outbound face is a no-op by design
345
+ * — it can neither show a card nor report a tap, so claiming it would spin
346
+ * the re-present loop forever. Declining it leaves the request to the host's
347
+ * own forwarder, which is precisely the Web GUI.
348
+ */
349
+ claimTarget(sessionId) {
350
+ for (const binding of this.bindings.list()) {
351
+ if (binding.sessionId !== sessionId && binding.webMirrorSessionId !== sessionId)
352
+ continue;
353
+ const adapter = this.adapters.get(binding.channel);
354
+ if (adapter === undefined || adapter.supportsChoices === false)
355
+ continue;
356
+ if (this.pending.has(binding.chatKey))
357
+ continue;
358
+ return { binding, adapter };
399
359
  }
360
+ return undefined;
400
361
  }
401
- /** Stop presenting and drop the interaction (answered, cancelled, or the turn ended). */
362
+ /** Stop presenting and drop the interaction (answered, cancelled, or torn down). */
402
363
  settle(interaction) {
364
+ interaction.settled = true;
403
365
  if (this.pending.get(interaction.chatKey) === interaction)
404
366
  this.pending.delete(interaction.chatKey);
405
- interaction.settled = true;
406
367
  interaction.controller.abort();
407
368
  }
408
369
  outbound(p) {