@qwen-code/channel-base 0.21.1-preview.0 → 0.21.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 (59) hide show
  1. package/README.md +3 -1
  2. package/dist/AcpBridge.d.ts +7 -3
  3. package/dist/AcpBridge.d.ts.map +1 -0
  4. package/dist/AcpBridge.js +35 -3
  5. package/dist/AcpBridge.js.map +1 -1
  6. package/dist/BlockStreamer.d.ts +1 -0
  7. package/dist/BlockStreamer.d.ts.map +1 -0
  8. package/dist/ChannelAgentBridge.d.ts +1 -0
  9. package/dist/ChannelAgentBridge.d.ts.map +1 -0
  10. package/dist/ChannelBase.d.ts +30 -4
  11. package/dist/ChannelBase.d.ts.map +1 -0
  12. package/dist/ChannelBase.js +347 -14
  13. package/dist/ChannelBase.js.map +1 -1
  14. package/dist/ChannelLoopScheduler.d.ts +1 -0
  15. package/dist/ChannelLoopScheduler.d.ts.map +1 -0
  16. package/dist/ChannelLoopStore.d.ts +1 -0
  17. package/dist/ChannelLoopStore.d.ts.map +1 -0
  18. package/dist/ChannelLoopTools.d.ts +1 -0
  19. package/dist/ChannelLoopTools.d.ts.map +1 -0
  20. package/dist/ChannelProactiveDeliveryError.d.ts +1 -0
  21. package/dist/ChannelProactiveDeliveryError.d.ts.map +1 -0
  22. package/dist/ChannelWebhookTask.d.ts +1 -0
  23. package/dist/ChannelWebhookTask.d.ts.map +1 -0
  24. package/dist/DaemonChannelBridge.d.ts +16 -1
  25. package/dist/DaemonChannelBridge.d.ts.map +1 -0
  26. package/dist/DaemonChannelBridge.js +75 -2
  27. package/dist/DaemonChannelBridge.js.map +1 -1
  28. package/dist/DmGate.d.ts +1 -0
  29. package/dist/DmGate.d.ts.map +1 -0
  30. package/dist/GroupGate.d.ts +1 -0
  31. package/dist/GroupGate.d.ts.map +1 -0
  32. package/dist/PairingStore.d.ts +4 -2
  33. package/dist/PairingStore.d.ts.map +1 -0
  34. package/dist/PairingStore.js +11 -2
  35. package/dist/PairingStore.js.map +1 -1
  36. package/dist/PollingChannelBase.d.ts +1 -0
  37. package/dist/PollingChannelBase.d.ts.map +1 -0
  38. package/dist/SenderGate.d.ts +1 -0
  39. package/dist/SenderGate.d.ts.map +1 -0
  40. package/dist/SessionRouter.d.ts +1 -0
  41. package/dist/SessionRouter.d.ts.map +1 -0
  42. package/dist/channel-memory-intent.d.ts +1 -0
  43. package/dist/channel-memory-intent.d.ts.map +1 -0
  44. package/dist/channel-memory-recall.d.ts +1 -0
  45. package/dist/channel-memory-recall.d.ts.map +1 -0
  46. package/dist/group-history-store.d.ts +1 -0
  47. package/dist/group-history-store.d.ts.map +1 -0
  48. package/dist/index.d.ts +5 -3
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +2 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/paths.d.ts +1 -0
  53. package/dist/paths.d.ts.map +1 -0
  54. package/dist/sanitize.d.ts +1 -0
  55. package/dist/sanitize.d.ts.map +1 -0
  56. package/dist/types.d.ts +51 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js.map +1 -1
  59. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { basename, join } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { BlockStreamer } from './BlockStreamer.js';
3
4
  import { ChannelProactiveDeliveryError, isChannelProactiveDeliveryError, } from './ChannelProactiveDeliveryError.js';
4
5
  import { GroupGate } from './GroupGate.js';
@@ -62,6 +63,9 @@ const SENSITIVE_PAYLOAD_KEY_PATTERN = new RegExp([
62
63
  'senderNick',
63
64
  'senderName',
64
65
  ].join('|'), 'i');
66
+ function isRecord(value) {
67
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
68
+ }
65
69
  /**
66
70
  * Character class (sans the enclosing `[]`) for a slash-command token: alphanumerics
67
71
  * plus `_`, `:` and `-`, so hyphenated and namespaced agent commands (e.g.
@@ -212,6 +216,7 @@ export class ChannelBase {
212
216
  sessionId: event.sessionId,
213
217
  target,
214
218
  request: event.request,
219
+ settlementListeners: new Set(),
215
220
  };
216
221
  this.pendingPermissions.set(event.requestId, pending);
217
222
  const chatKey = this.permissionChatKey(target);
@@ -219,6 +224,10 @@ export class ChannelBase {
219
224
  requestIds.push(event.requestId);
220
225
  this.pendingPermissionsByChat.set(chatKey, requestIds);
221
226
  try {
227
+ const presentation = this.tryPresentUserInput(pending);
228
+ if (presentation && (await presentation)) {
229
+ return;
230
+ }
222
231
  const text = this.formatPermissionRequest(pending);
223
232
  if (target.threadId !== undefined &&
224
233
  this.supportsProactiveSend() &&
@@ -230,7 +239,7 @@ export class ChannelBase {
230
239
  }
231
240
  }
232
241
  catch (err) {
233
- this.removePendingPermission(event.requestId);
242
+ this.removePendingPermission(event.requestId, 'cancelled');
234
243
  try {
235
244
  await this.bridge.respondToPermission?.(event.requestId, {
236
245
  outcome: { outcome: 'cancelled' },
@@ -242,6 +251,154 @@ export class ChannelBase {
242
251
  throw err;
243
252
  }
244
253
  }
254
+ tryPresentUserInput(pending) {
255
+ const active = this.activePrompts.get(pending.sessionId);
256
+ const questions = this.normalizeUserQuestions(pending);
257
+ const submitOptionId = this.approvalOptionId(pending);
258
+ if (!active ||
259
+ active.loopPrompt ||
260
+ !active.owner ||
261
+ !questions ||
262
+ !submitOptionId) {
263
+ return undefined;
264
+ }
265
+ const precedingSegment = this.closeOutputSegment(pending.sessionId, active, pending.target);
266
+ let respondInvoked = false;
267
+ const context = {
268
+ requestId: pending.requestId,
269
+ sessionId: pending.sessionId,
270
+ runId: active.runId,
271
+ owner: active.owner,
272
+ target: pending.target,
273
+ ...(precedingSegment
274
+ ? { precedingSegmentId: precedingSegment.segmentId }
275
+ : {}),
276
+ questions,
277
+ submitOptionId,
278
+ onSettled: (listener) => {
279
+ if (pending.settled) {
280
+ listener(pending.settled);
281
+ return () => { };
282
+ }
283
+ pending.settlementListeners.add(listener);
284
+ return () => {
285
+ pending.settlementListeners.delete(listener);
286
+ };
287
+ },
288
+ respond: (response) => {
289
+ respondInvoked = true;
290
+ return this.respondToUserInput(pending, response);
291
+ },
292
+ };
293
+ pending.userInputPresented = true;
294
+ return (async () => {
295
+ try {
296
+ if (precedingSegment) {
297
+ await this.notifyOutputSegmentEnd(pending.target.chatId, pending.sessionId, precedingSegment, 'input_requested');
298
+ }
299
+ const result = await this.presentUserInputRequest(context);
300
+ if (this.pendingPermissions.get(pending.requestId) !== pending) {
301
+ return true;
302
+ }
303
+ if (result.kind === 'presented' ||
304
+ (result.kind === 'handled' && respondInvoked)) {
305
+ return true;
306
+ }
307
+ pending.userInputPresented = false;
308
+ return false;
309
+ }
310
+ catch (err) {
311
+ process.stderr.write(`[${this.name}] user input presentation failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
312
+ if (this.pendingPermissions.get(pending.requestId) !== pending) {
313
+ return true;
314
+ }
315
+ pending.userInputPresented = false;
316
+ return false;
317
+ }
318
+ })();
319
+ }
320
+ normalizeUserQuestions(pending) {
321
+ const toolCall = pending.request.toolCall;
322
+ const meta = isRecord(toolCall['_meta']) ? toolCall['_meta'] : undefined;
323
+ const canonical = meta?.['qwenInteractionKind'] === 'user_question';
324
+ const identifiedLegacy = meta?.['toolName'] === 'ask_user_question' ||
325
+ toolCall['kind'] === 'ask_user_question';
326
+ const rawInput = isRecord(toolCall['rawInput'])
327
+ ? toolCall['rawInput']
328
+ : undefined;
329
+ const rawQuestions = canonical
330
+ ? meta?.['qwenQuestions']
331
+ : identifiedLegacy
332
+ ? rawInput?.['questions']
333
+ : undefined;
334
+ if (!Array.isArray(rawQuestions) ||
335
+ rawQuestions.length < 1 ||
336
+ rawQuestions.length > 4) {
337
+ return undefined;
338
+ }
339
+ const questions = [];
340
+ for (const [index, rawQuestion] of rawQuestions.entries()) {
341
+ if (!isRecord(rawQuestion)) {
342
+ return undefined;
343
+ }
344
+ const header = rawQuestion['header'];
345
+ const question = rawQuestion['question'];
346
+ const rawOptions = rawQuestion['options'];
347
+ const multiSelect = rawQuestion['multiSelect'];
348
+ if (typeof header !== 'string' ||
349
+ header.trim().length === 0 ||
350
+ typeof question !== 'string' ||
351
+ question.trim().length === 0 ||
352
+ !Array.isArray(rawOptions) ||
353
+ rawOptions.length < 2 ||
354
+ rawOptions.length > 4 ||
355
+ (multiSelect !== undefined && typeof multiSelect !== 'boolean')) {
356
+ return undefined;
357
+ }
358
+ const options = [];
359
+ for (const rawOption of rawOptions) {
360
+ if (!isRecord(rawOption) ||
361
+ typeof rawOption['label'] !== 'string' ||
362
+ rawOption['label'].trim().length === 0 ||
363
+ typeof rawOption['description'] !== 'string') {
364
+ return undefined;
365
+ }
366
+ options.push({
367
+ label: rawOption['label'],
368
+ description: rawOption['description'],
369
+ });
370
+ }
371
+ questions.push({
372
+ answerKey: String(index),
373
+ header,
374
+ question,
375
+ options,
376
+ multiSelect: multiSelect ?? false,
377
+ });
378
+ }
379
+ return questions;
380
+ }
381
+ async respondToUserInput(pending, response) {
382
+ if (pending.responsePromise) {
383
+ return pending.responsePromise;
384
+ }
385
+ if (this.pendingPermissions.get(pending.requestId) !== pending ||
386
+ !this.bridge.respondToPermission) {
387
+ return false;
388
+ }
389
+ pending.responsePromise = Promise.resolve()
390
+ .then(() => this.bridge.respondToPermission(pending.requestId, response))
391
+ .then((accepted) => {
392
+ this.removePendingPermission(pending.requestId, accepted
393
+ ? this.userInputSettlementReason(pending, response.outcome)
394
+ : 'cancelled');
395
+ return accepted;
396
+ }, (error) => {
397
+ this.removePendingPermission(pending.requestId, 'cancelled');
398
+ throw error;
399
+ });
400
+ return pending.responsePromise;
401
+ }
245
402
  permissionTargetForEvent(event) {
246
403
  const routeTarget = this.router.getTarget(event.sessionId);
247
404
  if (!routeTarget || routeTarget.channelName !== this.name) {
@@ -268,7 +425,11 @@ export class ChannelBase {
268
425
  return target;
269
426
  }
270
427
  dispatchPermissionResolved(event) {
271
- this.removePendingPermission(event.requestId);
428
+ const pending = this.pendingPermissions.get(event.requestId);
429
+ if (!pending) {
430
+ return;
431
+ }
432
+ this.removePendingPermission(event.requestId, this.userInputSettlementReason(pending, event.outcome));
272
433
  }
273
434
  constructor(name, config, bridge, options) {
274
435
  this.name = name;
@@ -322,6 +483,9 @@ export class ChannelBase {
322
483
  * logged, nothing more.
323
484
  */
324
485
  onTaskLifecycle(_event) { }
486
+ async presentUserInputRequest(_context) {
487
+ return { kind: 'unsupported' };
488
+ }
325
489
  emitTaskLifecycle(event) {
326
490
  try {
327
491
  const result = this.onTaskLifecycle(event);
@@ -351,6 +515,8 @@ export class ChannelBase {
351
515
  return;
352
516
  }
353
517
  active.cancellationEmitted = true;
518
+ const segment = this.closeOutputSegment(sessionId, active);
519
+ void this.notifyOutputSegmentEnd(active.chatId, sessionId, segment, 'cancelled');
354
520
  this.emitTaskLifecycle({
355
521
  ...this.lifecycleBase(active.chatId, sessionId, active.messageId),
356
522
  type: 'cancelled',
@@ -427,15 +593,72 @@ export class ChannelBase {
427
593
  return Boolean(this.config.identity || this.config.memoryScope);
428
594
  }
429
595
  lifecycleBase(chatId, sessionId, messageId) {
596
+ const active = this.activePrompts.get(sessionId);
430
597
  return {
431
598
  channelName: this.name,
432
599
  chatId,
433
600
  sessionId,
434
601
  ...(messageId ? { messageId } : {}),
602
+ ...(active?.runId ? { runId: active.runId } : {}),
603
+ ...(active?.owner ? { owner: active.owner } : {}),
435
604
  identity: this.identity,
436
605
  memoryScope: this.memoryScope,
437
606
  };
438
607
  }
608
+ outputSegmentContext(sessionId, active, segmentId, target) {
609
+ const resolvedTarget = target ??
610
+ (active.senderId
611
+ ? {
612
+ channelName: this.name,
613
+ chatId: active.chatId,
614
+ senderId: active.senderId,
615
+ ...(active.threadId ? { threadId: active.threadId } : {}),
616
+ ...(active.isGroup !== undefined
617
+ ? { isGroup: active.isGroup }
618
+ : {}),
619
+ }
620
+ : undefined);
621
+ if (!active.owner ||
622
+ !resolvedTarget ||
623
+ resolvedTarget.channelName !== this.name) {
624
+ return undefined;
625
+ }
626
+ return {
627
+ channelName: this.name,
628
+ sessionId,
629
+ runId: active.runId,
630
+ segmentId,
631
+ owner: active.owner,
632
+ target: resolvedTarget,
633
+ ...(active.messageId ? { messageId: active.messageId } : {}),
634
+ };
635
+ }
636
+ ensureOutputSegment(sessionId, active) {
637
+ if (!active.owner)
638
+ return undefined;
639
+ const segmentId = active.activeSegmentId ?? randomUUID();
640
+ const context = this.outputSegmentContext(sessionId, active, segmentId);
641
+ if (context)
642
+ active.activeSegmentId = segmentId;
643
+ return context;
644
+ }
645
+ closeOutputSegment(sessionId, active, target) {
646
+ const segmentId = active.activeSegmentId;
647
+ if (!segmentId)
648
+ return undefined;
649
+ active.activeSegmentId = undefined;
650
+ return this.outputSegmentContext(sessionId, active, segmentId, target);
651
+ }
652
+ async notifyOutputSegmentEnd(chatId, sessionId, segment, reason) {
653
+ if (!segment)
654
+ return;
655
+ try {
656
+ await this.onOutputSegmentEnd(chatId, sessionId, segment, reason);
657
+ }
658
+ catch (err) {
659
+ process.stderr.write(`[${this.name}] output segment boundary failed for session ${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
660
+ }
661
+ }
439
662
  supportsProactiveSend() {
440
663
  return false;
441
664
  }
@@ -751,6 +974,7 @@ export class ChannelBase {
751
974
  doneResolve = resolve;
752
975
  });
753
976
  const promptState = {
977
+ runId: randomUUID(),
754
978
  cancelled: false,
755
979
  done,
756
980
  resolve: doneResolve,
@@ -989,6 +1213,7 @@ export class ChannelBase {
989
1213
  doneResolve = resolve;
990
1214
  });
991
1215
  const promptState = {
1216
+ runId: randomUUID(),
992
1217
  cancelled: false,
993
1218
  done,
994
1219
  resolve: doneResolve,
@@ -1151,6 +1376,7 @@ export class ChannelBase {
1151
1376
  this.router.removeSessionId(sessionId);
1152
1377
  this.instructedSessions.delete(sessionId);
1153
1378
  this.unattendedMemorySessions.delete(sessionId);
1379
+ this.discardRetiredSession(promptBridge, sessionId, `timed-out loop ${jobId}`);
1154
1380
  process.stderr.write(`[${this.name}] retired timed out loop ${jobId} session ${sessionId} after cancel did not settle\n`);
1155
1381
  }
1156
1382
  }
@@ -1161,6 +1387,18 @@ export class ChannelBase {
1161
1387
  clearTimeout(graceTimer);
1162
1388
  }
1163
1389
  }
1390
+ discardRetiredSession(promptBridge, sessionId, reason) {
1391
+ const safeSessionId = sanitizeLogText(sessionId, 64);
1392
+ const safeReason = sanitizeLogText(reason, 128);
1393
+ try {
1394
+ void promptBridge.discardSession?.(sessionId).catch((err) => {
1395
+ process.stderr.write(`[${this.name}] failed to discard ${safeReason} session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
1396
+ });
1397
+ }
1398
+ catch (err) {
1399
+ process.stderr.write(`[${this.name}] failed to discard ${safeReason} session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
1400
+ }
1401
+ }
1164
1402
  requestActivePromptCancellation(sessionId, reason = 'cancel_command') {
1165
1403
  const active = this.activePrompts.get(sessionId);
1166
1404
  if (!active) {
@@ -1199,10 +1437,18 @@ export class ChannelBase {
1199
1437
  active.cancelled = true;
1200
1438
  this.stopActiveStreaming(active, sessionId, reason);
1201
1439
  this.dropCollectBuffer(sessionId);
1440
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
1202
1441
  this.emitTaskCancellation(active, sessionId, reason);
1203
1442
  return true;
1204
1443
  });
1205
1444
  }
1445
+ requestPromptRunCancellation(sessionId, runId, reason = 'cancel_command') {
1446
+ const active = this.activePrompts.get(sessionId);
1447
+ if (!active || active.runId !== runId) {
1448
+ return Promise.resolve(false);
1449
+ }
1450
+ return this.requestActivePromptCancellation(sessionId, reason);
1451
+ }
1206
1452
  dropCollectBuffer(sessionId) {
1207
1453
  const buffer = this.collectBuffers.get(sessionId);
1208
1454
  if (!buffer)
@@ -1297,7 +1543,12 @@ export class ChannelBase {
1297
1543
  * Override to implement progressive display (e.g., updating an AI card in-place).
1298
1544
  * Default: no-op (chunks are collected internally and delivered via onResponseComplete).
1299
1545
  */
1300
- onResponseChunk(_chatId, _chunk, _sessionId) { }
1546
+ onResponseChunk(_chatId, _chunk, _sessionId, _segment) { }
1547
+ onOutputSegmentEnd(chatId, sessionId, _segment, reason) {
1548
+ if (reason === 'response_boundary') {
1549
+ return this.onResponseBoundary(chatId, sessionId);
1550
+ }
1551
+ }
1301
1552
  /**
1302
1553
  * Called when the agent starts a new response segment for the same prompt.
1303
1554
  * Override to clear adapter-owned streaming buffers.
@@ -1309,12 +1560,33 @@ export class ChannelBase {
1309
1560
  const threadId = active?.threadId ?? target?.threadId;
1310
1561
  await this.sendThreadMessage(chatId, threadId, text);
1311
1562
  }
1563
+ /**
1564
+ * Adapter hook for delivery-only metadata. The response delivery path can read
1565
+ * the active prompt while it exists; adapters must not retain its raw content.
1566
+ */
1567
+ getResponseMessageId(sessionId) {
1568
+ return this.activePrompts.get(sessionId)?.messageId;
1569
+ }
1570
+ getResponseSenderId(sessionId) {
1571
+ return this.activePrompts.get(sessionId)?.senderId;
1572
+ }
1573
+ getResponseMetadata(sessionId) {
1574
+ return this.activePrompts.get(sessionId)?.metadata;
1575
+ }
1576
+ /**
1577
+ * Returns the active prompt's response thread while it remains available for
1578
+ * adapter delivery. Falls back to the session target after prompt cleanup.
1579
+ */
1580
+ getResponseThreadId(sessionId) {
1581
+ return (this.activePrompts.get(sessionId)?.threadId ??
1582
+ this.router.getTarget(sessionId)?.threadId);
1583
+ }
1312
1584
  /**
1313
1585
  * Called when the agent's full response is ready.
1314
1586
  * Override to customize delivery (e.g., finalize an AI card).
1315
1587
  * Default: sends the full response text.
1316
1588
  */
1317
- async onResponseComplete(chatId, fullText, sessionId) {
1589
+ async onResponseComplete(chatId, fullText, sessionId, _segment) {
1318
1590
  await this.sendResponseMessage(chatId, fullText, sessionId);
1319
1591
  }
1320
1592
  /**
@@ -1371,12 +1643,13 @@ export class ChannelBase {
1371
1643
  }
1372
1644
  return live;
1373
1645
  }
1374
- removePendingPermission(requestId) {
1646
+ removePendingPermission(requestId, reason = 'resolved_outside_presenter') {
1375
1647
  const pending = this.pendingPermissions.get(requestId);
1376
1648
  if (!pending) {
1377
1649
  return;
1378
1650
  }
1379
1651
  this.pendingPermissions.delete(requestId);
1652
+ this.settleUserInput(pending, reason);
1380
1653
  const chatKey = this.permissionChatKey(pending.target);
1381
1654
  const requestIds = this.pendingPermissionsByChat.get(chatKey);
1382
1655
  if (!requestIds) {
@@ -1390,17 +1663,48 @@ export class ChannelBase {
1390
1663
  this.pendingPermissionsByChat.set(chatKey, remaining);
1391
1664
  }
1392
1665
  }
1393
- removePendingPermissionsForSession(sessionId) {
1666
+ removePendingPermissionsForSession(sessionId, reason = 'cancelled') {
1394
1667
  const requestIds = Array.from(this.pendingPermissions)
1395
1668
  .filter(([, pending]) => pending.sessionId === sessionId)
1396
1669
  .map(([requestId]) => requestId);
1397
1670
  for (const requestId of requestIds) {
1398
- this.removePendingPermission(requestId);
1671
+ this.removePendingPermission(requestId, reason);
1399
1672
  }
1400
1673
  }
1401
1674
  clearPendingPermissions() {
1402
- this.pendingPermissions.clear();
1403
- this.pendingPermissionsByChat.clear();
1675
+ for (const requestId of Array.from(this.pendingPermissions.keys())) {
1676
+ this.removePendingPermission(requestId, 'cancelled');
1677
+ }
1678
+ }
1679
+ settleUserInput(pending, reason) {
1680
+ if (pending.settled) {
1681
+ return;
1682
+ }
1683
+ pending.settled = reason;
1684
+ const listeners = Array.from(pending.settlementListeners);
1685
+ pending.settlementListeners.clear();
1686
+ for (const listener of listeners) {
1687
+ try {
1688
+ listener(reason);
1689
+ }
1690
+ catch (err) {
1691
+ process.stderr.write(`[${this.name}] user input settlement listener failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
1692
+ }
1693
+ }
1694
+ }
1695
+ userInputSettlementReason(pending, outcome) {
1696
+ if (outcome?.outcome === 'cancelled') {
1697
+ return 'cancelled';
1698
+ }
1699
+ if (outcome?.outcome === 'selected') {
1700
+ const selected = pending.request.options.find((option) => option.optionId === outcome.optionId);
1701
+ if (selected?.kind === 'reject_once' ||
1702
+ (selected?.optionId === 'cancel' &&
1703
+ selected.kind === undefined)) {
1704
+ return 'cancelled';
1705
+ }
1706
+ }
1707
+ return 'resolved_outside_presenter';
1404
1708
  }
1405
1709
  pendingPermissionForEnvelope(envelope, args) {
1406
1710
  const trimmed = args.trim();
@@ -1434,6 +1738,8 @@ export class ChannelBase {
1434
1738
  canEnvelopeAnswerPendingPermission(envelope, pending) {
1435
1739
  return (pending.target.chatId === envelope.chatId &&
1436
1740
  pending.target.threadId === envelope.threadId &&
1741
+ (!pending.userInputPresented ||
1742
+ pending.target.senderId === envelope.senderId) &&
1437
1743
  (this.isSharedSessionTarget(pending.target) ||
1438
1744
  pending.target.senderId === envelope.senderId));
1439
1745
  }
@@ -1528,6 +1834,10 @@ export class ChannelBase {
1528
1834
  return true;
1529
1835
  }
1530
1836
  const { pending } = lookup;
1837
+ if (pending.userInputPresented && decision !== 'deny') {
1838
+ await this.sendMessage(envelope.chatId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.');
1839
+ return true;
1840
+ }
1531
1841
  const response = (() => {
1532
1842
  if (decision === 'deny') {
1533
1843
  return this.denialResponse(pending);
@@ -1547,7 +1857,9 @@ export class ChannelBase {
1547
1857
  }
1548
1858
  let accepted;
1549
1859
  try {
1550
- accepted = await this.bridge.respondToPermission(pending.requestId, response);
1860
+ accepted = pending.userInputPresented
1861
+ ? await this.respondToUserInput(pending, response)
1862
+ : await this.bridge.respondToPermission(pending.requestId, response);
1551
1863
  }
1552
1864
  catch (err) {
1553
1865
  this.removePendingPermission(pending.requestId);
@@ -1584,7 +1896,7 @@ export class ChannelBase {
1584
1896
  // already queued onto this session sees a stale generation and bails
1585
1897
  // instead of running bridge.prompt() against the cleared session.
1586
1898
  this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
1587
- this.removePendingPermissionsForSession(id);
1899
+ this.removePendingPermissionsForSession(id, 'run_cancelled');
1588
1900
  // Cancel an in-flight turn (and drop its buffered follow-ups) before
1589
1901
  // purging, so a running prompt can't deliver a stale response into —
1590
1902
  // or resurrect via collect-drain — the just-cleared session.
@@ -1658,6 +1970,7 @@ export class ChannelBase {
1658
1970
  // bumped value — reclaim it immediately.
1659
1971
  this.sessionGenerations.delete(id);
1660
1972
  }
1973
+ this.discardRetiredSession(this.bridge, id, 'cleared');
1661
1974
  }
1662
1975
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Session cleared. The next message starts a fresh conversation.');
1663
1976
  }
@@ -3314,6 +3627,7 @@ export class ChannelBase {
3314
3627
  // turn at the channel level (cancelled is already set above), so
3315
3628
  // the event reflects that intent, not the bridge RPC outcome.
3316
3629
  this.emitTaskCancellation(active, sessionId, 'steer');
3630
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
3317
3631
  }
3318
3632
  // Diagnostic watchdog: if the predecessor turn is STILL the active prompt
3319
3633
  // after the wind-down bound, this steered turn is wedged behind a hung
@@ -3442,6 +3756,11 @@ export class ChannelBase {
3442
3756
  doneResolve = r;
3443
3757
  });
3444
3758
  const promptState = {
3759
+ runId: randomUUID(),
3760
+ owner: {
3761
+ kind: 'channel_user',
3762
+ id: envelope.senderId,
3763
+ },
3445
3764
  cancelled: false,
3446
3765
  done,
3447
3766
  resolve: doneResolve,
@@ -3451,6 +3770,7 @@ export class ChannelBase {
3451
3770
  messageId: envelope.messageId,
3452
3771
  senderId: envelope.senderId,
3453
3772
  senderName: envelope.senderName,
3773
+ metadata: envelope.metadata,
3454
3774
  };
3455
3775
  // This turn is now the single owner of the session's active-prompt slot.
3456
3776
  // (Steer no longer hands a still-active session to a replacement; only
@@ -3485,12 +3805,13 @@ export class ChannelBase {
3485
3805
  const releaseHeldChunks = () => {
3486
3806
  for (const held of heldChunks.splice(0)) {
3487
3807
  hasStreamedText = true;
3808
+ const segment = this.ensureOutputSegment(sessionId, promptState);
3488
3809
  this.emitTaskLifecycle({
3489
3810
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3490
3811
  type: 'text_chunk',
3491
3812
  chunk: held,
3492
3813
  });
3493
- this.onResponseChunk(envelope.chatId, held, sessionId);
3814
+ this.onResponseChunk(envelope.chatId, held, sessionId, segment);
3494
3815
  streamer?.push(held);
3495
3816
  }
3496
3817
  };
@@ -3511,7 +3832,8 @@ export class ChannelBase {
3511
3832
  }
3512
3833
  heldChunks.length = 0;
3513
3834
  hasStreamedText = false;
3514
- this.onResponseBoundary(envelope.chatId, sessionId);
3835
+ const segment = this.closeOutputSegment(sessionId, promptState);
3836
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
3515
3837
  streamer?.stop();
3516
3838
  };
3517
3839
  const promptBridge = this.bridge;
@@ -3536,7 +3858,11 @@ export class ChannelBase {
3536
3858
  await streamer.flush();
3537
3859
  }
3538
3860
  else {
3539
- await this.onResponseComplete(envelope.chatId, response, sessionId);
3861
+ const segment = this.ensureOutputSegment(sessionId, promptState);
3862
+ await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
3863
+ if (segment && promptState.activeSegmentId === segment.segmentId) {
3864
+ promptState.activeSegmentId = undefined;
3865
+ }
3540
3866
  }
3541
3867
  }
3542
3868
  // Once delivery started the turn's outcome is fixed — don't let a
@@ -3545,6 +3871,8 @@ export class ChannelBase {
3545
3871
  await this.settleCancelRequested(promptState);
3546
3872
  }
3547
3873
  if (!promptState.cancelled && !promptState.cancellationEmitted) {
3874
+ const segment = this.closeOutputSegment(sessionId, promptState);
3875
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
3548
3876
  this.emitTaskLifecycle({
3549
3877
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3550
3878
  type: 'completed',
@@ -3560,6 +3888,8 @@ export class ChannelBase {
3560
3888
  }
3561
3889
  if (!promptState.cancelled) {
3562
3890
  releaseHeldChunks();
3891
+ const segment = this.closeOutputSegment(sessionId, promptState);
3892
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
3563
3893
  this.emitTaskLifecycle({
3564
3894
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3565
3895
  type: 'failed',
@@ -3573,6 +3903,9 @@ export class ChannelBase {
3573
3903
  const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
3574
3904
  process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
3575
3905
  }
3906
+ if (promptState.cancelled) {
3907
+ return;
3908
+ }
3576
3909
  throw err;
3577
3910
  }
3578
3911
  finally {