@qwen-code/channel-base 0.21.1 → 0.21.2-nightly.20260801.bc382c3ff

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.
@@ -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.
@@ -89,6 +93,10 @@ function isUnattendedWebhookApprovalMode(mode) {
89
93
  }
90
94
  export class ChannelBase {
91
95
  config;
96
+ /**
97
+ * Recovery invariant: session-resolution and prompt-capture paths must await
98
+ * waitForBridgeRecovery() immediately before that operation.
99
+ */
92
100
  bridge;
93
101
  groupGate;
94
102
  dmGate;
@@ -100,6 +108,8 @@ export class ChannelBase {
100
108
  memoryScope;
101
109
  /** Resolved proxy URL, available to subclasses for adapter-specific clients. */
102
110
  proxy;
111
+ /** Adapter-owned persistent state directory, when supplied by the runtime. */
112
+ stateDir;
103
113
  channelMemory;
104
114
  memoryIntentClassifier;
105
115
  channelMemoryRecallObserver;
@@ -115,6 +125,7 @@ export class ChannelBase {
115
125
  /** Per-session promise chain to serialize prompt + send (followup mode). */
116
126
  sessionQueues = new Map();
117
127
  registerBridgeEvents;
128
+ bridgeRecovery;
118
129
  /**
119
130
  * Per-session generation, bumped by /clear. A queued followup turn captures the
120
131
  * generation when it enqueues and bails if /clear bumped it before the turn ran,
@@ -212,6 +223,7 @@ export class ChannelBase {
212
223
  sessionId: event.sessionId,
213
224
  target,
214
225
  request: event.request,
226
+ settlementListeners: new Set(),
215
227
  };
216
228
  this.pendingPermissions.set(event.requestId, pending);
217
229
  const chatKey = this.permissionChatKey(target);
@@ -219,6 +231,10 @@ export class ChannelBase {
219
231
  requestIds.push(event.requestId);
220
232
  this.pendingPermissionsByChat.set(chatKey, requestIds);
221
233
  try {
234
+ const presentation = this.tryPresentUserInput(pending);
235
+ if (presentation && (await presentation)) {
236
+ return;
237
+ }
222
238
  const text = this.formatPermissionRequest(pending);
223
239
  if (target.threadId !== undefined &&
224
240
  this.supportsProactiveSend() &&
@@ -230,7 +246,7 @@ export class ChannelBase {
230
246
  }
231
247
  }
232
248
  catch (err) {
233
- this.removePendingPermission(event.requestId);
249
+ this.removePendingPermission(event.requestId, 'cancelled');
234
250
  try {
235
251
  await this.bridge.respondToPermission?.(event.requestId, {
236
252
  outcome: { outcome: 'cancelled' },
@@ -242,6 +258,154 @@ export class ChannelBase {
242
258
  throw err;
243
259
  }
244
260
  }
261
+ tryPresentUserInput(pending) {
262
+ const active = this.activePrompts.get(pending.sessionId);
263
+ const questions = this.normalizeUserQuestions(pending);
264
+ const submitOptionId = this.approvalOptionId(pending);
265
+ if (!active ||
266
+ active.loopPrompt ||
267
+ !active.owner ||
268
+ !questions ||
269
+ !submitOptionId) {
270
+ return undefined;
271
+ }
272
+ const precedingSegment = this.closeOutputSegment(pending.sessionId, active, pending.target);
273
+ let respondInvoked = false;
274
+ const context = {
275
+ requestId: pending.requestId,
276
+ sessionId: pending.sessionId,
277
+ runId: active.runId,
278
+ owner: active.owner,
279
+ target: pending.target,
280
+ ...(precedingSegment
281
+ ? { precedingSegmentId: precedingSegment.segmentId }
282
+ : {}),
283
+ questions,
284
+ submitOptionId,
285
+ onSettled: (listener) => {
286
+ if (pending.settled) {
287
+ listener(pending.settled);
288
+ return () => { };
289
+ }
290
+ pending.settlementListeners.add(listener);
291
+ return () => {
292
+ pending.settlementListeners.delete(listener);
293
+ };
294
+ },
295
+ respond: (response) => {
296
+ respondInvoked = true;
297
+ return this.respondToUserInput(pending, response);
298
+ },
299
+ };
300
+ pending.userInputPresented = true;
301
+ return (async () => {
302
+ try {
303
+ if (precedingSegment) {
304
+ await this.notifyOutputSegmentEnd(pending.target.chatId, pending.sessionId, precedingSegment, 'input_requested');
305
+ }
306
+ const result = await this.presentUserInputRequest(context);
307
+ if (this.pendingPermissions.get(pending.requestId) !== pending) {
308
+ return true;
309
+ }
310
+ if (result.kind === 'presented' ||
311
+ (result.kind === 'handled' && respondInvoked)) {
312
+ return true;
313
+ }
314
+ pending.userInputPresented = false;
315
+ return false;
316
+ }
317
+ catch (err) {
318
+ process.stderr.write(`[${this.name}] user input presentation failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
319
+ if (this.pendingPermissions.get(pending.requestId) !== pending) {
320
+ return true;
321
+ }
322
+ pending.userInputPresented = false;
323
+ return false;
324
+ }
325
+ })();
326
+ }
327
+ normalizeUserQuestions(pending) {
328
+ const toolCall = pending.request.toolCall;
329
+ const meta = isRecord(toolCall['_meta']) ? toolCall['_meta'] : undefined;
330
+ const canonical = meta?.['qwenInteractionKind'] === 'user_question';
331
+ const identifiedLegacy = meta?.['toolName'] === 'ask_user_question' ||
332
+ toolCall['kind'] === 'ask_user_question';
333
+ const rawInput = isRecord(toolCall['rawInput'])
334
+ ? toolCall['rawInput']
335
+ : undefined;
336
+ const rawQuestions = canonical
337
+ ? meta?.['qwenQuestions']
338
+ : identifiedLegacy
339
+ ? rawInput?.['questions']
340
+ : undefined;
341
+ if (!Array.isArray(rawQuestions) ||
342
+ rawQuestions.length < 1 ||
343
+ rawQuestions.length > 4) {
344
+ return undefined;
345
+ }
346
+ const questions = [];
347
+ for (const [index, rawQuestion] of rawQuestions.entries()) {
348
+ if (!isRecord(rawQuestion)) {
349
+ return undefined;
350
+ }
351
+ const header = rawQuestion['header'];
352
+ const question = rawQuestion['question'];
353
+ const rawOptions = rawQuestion['options'];
354
+ const multiSelect = rawQuestion['multiSelect'];
355
+ if (typeof header !== 'string' ||
356
+ header.trim().length === 0 ||
357
+ typeof question !== 'string' ||
358
+ question.trim().length === 0 ||
359
+ !Array.isArray(rawOptions) ||
360
+ rawOptions.length < 2 ||
361
+ rawOptions.length > 4 ||
362
+ (multiSelect !== undefined && typeof multiSelect !== 'boolean')) {
363
+ return undefined;
364
+ }
365
+ const options = [];
366
+ for (const rawOption of rawOptions) {
367
+ if (!isRecord(rawOption) ||
368
+ typeof rawOption['label'] !== 'string' ||
369
+ rawOption['label'].trim().length === 0 ||
370
+ typeof rawOption['description'] !== 'string') {
371
+ return undefined;
372
+ }
373
+ options.push({
374
+ label: rawOption['label'],
375
+ description: rawOption['description'],
376
+ });
377
+ }
378
+ questions.push({
379
+ answerKey: String(index),
380
+ header,
381
+ question,
382
+ options,
383
+ multiSelect: multiSelect ?? false,
384
+ });
385
+ }
386
+ return questions;
387
+ }
388
+ async respondToUserInput(pending, response) {
389
+ if (pending.responsePromise) {
390
+ return pending.responsePromise;
391
+ }
392
+ if (this.pendingPermissions.get(pending.requestId) !== pending ||
393
+ !this.bridge.respondToPermission) {
394
+ return false;
395
+ }
396
+ pending.responsePromise = Promise.resolve()
397
+ .then(() => this.bridge.respondToPermission(pending.requestId, response))
398
+ .then((accepted) => {
399
+ this.removePendingPermission(pending.requestId, accepted
400
+ ? this.userInputSettlementReason(pending, response.outcome)
401
+ : 'cancelled');
402
+ return accepted;
403
+ }, (error) => {
404
+ this.removePendingPermission(pending.requestId, 'cancelled');
405
+ throw error;
406
+ });
407
+ return pending.responsePromise;
408
+ }
245
409
  permissionTargetForEvent(event) {
246
410
  const routeTarget = this.router.getTarget(event.sessionId);
247
411
  if (!routeTarget || routeTarget.channelName !== this.name) {
@@ -268,13 +432,18 @@ export class ChannelBase {
268
432
  return target;
269
433
  }
270
434
  dispatchPermissionResolved(event) {
271
- this.removePendingPermission(event.requestId);
435
+ const pending = this.pendingPermissions.get(event.requestId);
436
+ if (!pending) {
437
+ return;
438
+ }
439
+ this.removePendingPermission(event.requestId, this.userInputSettlementReason(pending, event.outcome));
272
440
  }
273
441
  constructor(name, config, bridge, options) {
274
442
  this.name = name;
275
443
  this.config = config;
276
444
  this.bridge = bridge;
277
445
  this.proxy = options?.proxy;
446
+ this.stateDir = options?.stateDir;
278
447
  this.identity = Object.freeze(this.resolveIdentity(name, config));
279
448
  this.memoryScope = Object.freeze(this.resolveMemoryScope(name, config));
280
449
  this.channelMemory = options?.channelMemory;
@@ -284,6 +453,7 @@ export class ChannelBase {
284
453
  join(getGlobalQwenDir(), 'channels', `${encodeURIComponent(name)}-group-history.jsonl`));
285
454
  this.loopController = options?.loopController;
286
455
  this.observedContacts = options?.observedContacts;
456
+ this.bridgeRecovery = options?.bridgeRecovery;
287
457
  this.groupGate = new GroupGate(config.groupPolicy, config.groups);
288
458
  this.dmGate = new DmGate(config.dmPolicy);
289
459
  // Scoped by the channel's workspace cwd: two workspaces reusing the same
@@ -322,6 +492,9 @@ export class ChannelBase {
322
492
  * logged, nothing more.
323
493
  */
324
494
  onTaskLifecycle(_event) { }
495
+ async presentUserInputRequest(_context) {
496
+ return { kind: 'unsupported' };
497
+ }
325
498
  emitTaskLifecycle(event) {
326
499
  try {
327
500
  const result = this.onTaskLifecycle(event);
@@ -351,6 +524,8 @@ export class ChannelBase {
351
524
  return;
352
525
  }
353
526
  active.cancellationEmitted = true;
527
+ const segment = this.closeOutputSegment(sessionId, active);
528
+ void this.notifyOutputSegmentEnd(active.chatId, sessionId, segment, 'cancelled');
354
529
  this.emitTaskLifecycle({
355
530
  ...this.lifecycleBase(active.chatId, sessionId, active.messageId),
356
531
  type: 'cancelled',
@@ -427,15 +602,72 @@ export class ChannelBase {
427
602
  return Boolean(this.config.identity || this.config.memoryScope);
428
603
  }
429
604
  lifecycleBase(chatId, sessionId, messageId) {
605
+ const active = this.activePrompts.get(sessionId);
430
606
  return {
431
607
  channelName: this.name,
432
608
  chatId,
433
609
  sessionId,
434
610
  ...(messageId ? { messageId } : {}),
611
+ ...(active?.runId ? { runId: active.runId } : {}),
612
+ ...(active?.owner ? { owner: active.owner } : {}),
435
613
  identity: this.identity,
436
614
  memoryScope: this.memoryScope,
437
615
  };
438
616
  }
617
+ outputSegmentContext(sessionId, active, segmentId, target) {
618
+ const resolvedTarget = target ??
619
+ (active.senderId
620
+ ? {
621
+ channelName: this.name,
622
+ chatId: active.chatId,
623
+ senderId: active.senderId,
624
+ ...(active.threadId ? { threadId: active.threadId } : {}),
625
+ ...(active.isGroup !== undefined
626
+ ? { isGroup: active.isGroup }
627
+ : {}),
628
+ }
629
+ : undefined);
630
+ if (!active.owner ||
631
+ !resolvedTarget ||
632
+ resolvedTarget.channelName !== this.name) {
633
+ return undefined;
634
+ }
635
+ return {
636
+ channelName: this.name,
637
+ sessionId,
638
+ runId: active.runId,
639
+ segmentId,
640
+ owner: active.owner,
641
+ target: resolvedTarget,
642
+ ...(active.messageId ? { messageId: active.messageId } : {}),
643
+ };
644
+ }
645
+ ensureOutputSegment(sessionId, active) {
646
+ if (!active.owner)
647
+ return undefined;
648
+ const segmentId = active.activeSegmentId ?? randomUUID();
649
+ const context = this.outputSegmentContext(sessionId, active, segmentId);
650
+ if (context)
651
+ active.activeSegmentId = segmentId;
652
+ return context;
653
+ }
654
+ closeOutputSegment(sessionId, active, target) {
655
+ const segmentId = active.activeSegmentId;
656
+ if (!segmentId)
657
+ return undefined;
658
+ active.activeSegmentId = undefined;
659
+ return this.outputSegmentContext(sessionId, active, segmentId, target);
660
+ }
661
+ async notifyOutputSegmentEnd(chatId, sessionId, segment, reason) {
662
+ if (!segment)
663
+ return;
664
+ try {
665
+ await this.onOutputSegmentEnd(chatId, sessionId, segment, reason);
666
+ }
667
+ catch (err) {
668
+ process.stderr.write(`[${this.name}] output segment boundary failed for session ${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`);
669
+ }
670
+ }
439
671
  supportsProactiveSend() {
440
672
  return false;
441
673
  }
@@ -690,6 +922,7 @@ export class ChannelBase {
690
922
  await this.loopController?.disable(job.id);
691
923
  throw new Error(`Loop ${job.id} target is no longer authorized.`);
692
924
  }
925
+ await this.waitForBridgeRecovery();
693
926
  const sessionId = await this.router.resolve(this.name, job.target.senderId, job.target.chatId, job.target.threadId, job.cwd, job.target.isGroup);
694
927
  const label = sanitizeQuotedText(job.label || job.id, 80);
695
928
  const createdBy = sanitizeSenderName(job.createdBy || 'unknown');
@@ -751,6 +984,7 @@ export class ChannelBase {
751
984
  doneResolve = resolve;
752
985
  });
753
986
  const promptState = {
987
+ runId: randomUUID(),
754
988
  cancelled: false,
755
989
  done,
756
990
  resolve: doneResolve,
@@ -809,6 +1043,7 @@ export class ChannelBase {
809
1043
  heldChunks.length = 0;
810
1044
  this.onResponseBoundary(job.target.chatId, sessionId);
811
1045
  };
1046
+ await this.waitForBridgeRecovery();
812
1047
  const promptBridge = this.bridge;
813
1048
  promptBridge.on('textChunk', onChunk);
814
1049
  promptBridge.on('responseBoundary', onResponseBoundary);
@@ -929,6 +1164,7 @@ export class ChannelBase {
929
1164
  }
930
1165
  async runWebhookTask(task, options = {}) {
931
1166
  const target = this.resolveWebhookTaskTarget(task);
1167
+ await this.waitForBridgeRecovery();
932
1168
  const sessionId = await this.router.resolve(this.name, target.senderId, target.chatId, target.threadId, this.config.cwd, target.isGroup, {
933
1169
  routingThreadId: this.webhookRoutingThreadId(task, target),
934
1170
  });
@@ -989,6 +1225,7 @@ export class ChannelBase {
989
1225
  doneResolve = resolve;
990
1226
  });
991
1227
  const promptState = {
1228
+ runId: randomUUID(),
992
1229
  cancelled: false,
993
1230
  done,
994
1231
  resolve: doneResolve,
@@ -1031,6 +1268,7 @@ export class ChannelBase {
1031
1268
  releaseHeldChunks();
1032
1269
  }
1033
1270
  };
1271
+ await this.waitForBridgeRecovery();
1034
1272
  const promptBridge = this.bridge;
1035
1273
  promptBridge.on('textChunk', onChunk);
1036
1274
  try {
@@ -1212,10 +1450,18 @@ export class ChannelBase {
1212
1450
  active.cancelled = true;
1213
1451
  this.stopActiveStreaming(active, sessionId, reason);
1214
1452
  this.dropCollectBuffer(sessionId);
1453
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
1215
1454
  this.emitTaskCancellation(active, sessionId, reason);
1216
1455
  return true;
1217
1456
  });
1218
1457
  }
1458
+ requestPromptRunCancellation(sessionId, runId, reason = 'cancel_command') {
1459
+ const active = this.activePrompts.get(sessionId);
1460
+ if (!active || active.runId !== runId) {
1461
+ return Promise.resolve(false);
1462
+ }
1463
+ return this.requestActivePromptCancellation(sessionId, reason);
1464
+ }
1219
1465
  dropCollectBuffer(sessionId) {
1220
1466
  const buffer = this.collectBuffers.get(sessionId);
1221
1467
  if (!buffer)
@@ -1310,7 +1556,12 @@ export class ChannelBase {
1310
1556
  * Override to implement progressive display (e.g., updating an AI card in-place).
1311
1557
  * Default: no-op (chunks are collected internally and delivered via onResponseComplete).
1312
1558
  */
1313
- onResponseChunk(_chatId, _chunk, _sessionId) { }
1559
+ onResponseChunk(_chatId, _chunk, _sessionId, _segment) { }
1560
+ onOutputSegmentEnd(chatId, sessionId, _segment, reason) {
1561
+ if (reason === 'response_boundary') {
1562
+ return this.onResponseBoundary(chatId, sessionId);
1563
+ }
1564
+ }
1314
1565
  /**
1315
1566
  * Called when the agent starts a new response segment for the same prompt.
1316
1567
  * Override to clear adapter-owned streaming buffers.
@@ -1322,12 +1573,33 @@ export class ChannelBase {
1322
1573
  const threadId = active?.threadId ?? target?.threadId;
1323
1574
  await this.sendThreadMessage(chatId, threadId, text);
1324
1575
  }
1576
+ /**
1577
+ * Adapter hook for delivery-only metadata. The response delivery path can read
1578
+ * the active prompt while it exists; adapters must not retain its raw content.
1579
+ */
1580
+ getResponseMessageId(sessionId) {
1581
+ return this.activePrompts.get(sessionId)?.messageId;
1582
+ }
1583
+ getResponseSenderId(sessionId) {
1584
+ return this.activePrompts.get(sessionId)?.senderId;
1585
+ }
1586
+ getResponseMetadata(sessionId) {
1587
+ return this.activePrompts.get(sessionId)?.metadata;
1588
+ }
1589
+ /**
1590
+ * Returns the active prompt's response thread while it remains available for
1591
+ * adapter delivery. Falls back to the session target after prompt cleanup.
1592
+ */
1593
+ getResponseThreadId(sessionId) {
1594
+ return (this.activePrompts.get(sessionId)?.threadId ??
1595
+ this.router.getTarget(sessionId)?.threadId);
1596
+ }
1325
1597
  /**
1326
1598
  * Called when the agent's full response is ready.
1327
1599
  * Override to customize delivery (e.g., finalize an AI card).
1328
1600
  * Default: sends the full response text.
1329
1601
  */
1330
- async onResponseComplete(chatId, fullText, sessionId) {
1602
+ async onResponseComplete(chatId, fullText, sessionId, _segment) {
1331
1603
  await this.sendResponseMessage(chatId, fullText, sessionId);
1332
1604
  }
1333
1605
  /**
@@ -1384,12 +1656,13 @@ export class ChannelBase {
1384
1656
  }
1385
1657
  return live;
1386
1658
  }
1387
- removePendingPermission(requestId) {
1659
+ removePendingPermission(requestId, reason = 'resolved_outside_presenter') {
1388
1660
  const pending = this.pendingPermissions.get(requestId);
1389
1661
  if (!pending) {
1390
1662
  return;
1391
1663
  }
1392
1664
  this.pendingPermissions.delete(requestId);
1665
+ this.settleUserInput(pending, reason);
1393
1666
  const chatKey = this.permissionChatKey(pending.target);
1394
1667
  const requestIds = this.pendingPermissionsByChat.get(chatKey);
1395
1668
  if (!requestIds) {
@@ -1403,17 +1676,48 @@ export class ChannelBase {
1403
1676
  this.pendingPermissionsByChat.set(chatKey, remaining);
1404
1677
  }
1405
1678
  }
1406
- removePendingPermissionsForSession(sessionId) {
1679
+ removePendingPermissionsForSession(sessionId, reason = 'cancelled') {
1407
1680
  const requestIds = Array.from(this.pendingPermissions)
1408
1681
  .filter(([, pending]) => pending.sessionId === sessionId)
1409
1682
  .map(([requestId]) => requestId);
1410
1683
  for (const requestId of requestIds) {
1411
- this.removePendingPermission(requestId);
1684
+ this.removePendingPermission(requestId, reason);
1412
1685
  }
1413
1686
  }
1414
1687
  clearPendingPermissions() {
1415
- this.pendingPermissions.clear();
1416
- this.pendingPermissionsByChat.clear();
1688
+ for (const requestId of Array.from(this.pendingPermissions.keys())) {
1689
+ this.removePendingPermission(requestId, 'cancelled');
1690
+ }
1691
+ }
1692
+ settleUserInput(pending, reason) {
1693
+ if (pending.settled) {
1694
+ return;
1695
+ }
1696
+ pending.settled = reason;
1697
+ const listeners = Array.from(pending.settlementListeners);
1698
+ pending.settlementListeners.clear();
1699
+ for (const listener of listeners) {
1700
+ try {
1701
+ listener(reason);
1702
+ }
1703
+ catch (err) {
1704
+ process.stderr.write(`[${this.name}] user input settlement listener failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
1705
+ }
1706
+ }
1707
+ }
1708
+ userInputSettlementReason(pending, outcome) {
1709
+ if (outcome?.outcome === 'cancelled') {
1710
+ return 'cancelled';
1711
+ }
1712
+ if (outcome?.outcome === 'selected') {
1713
+ const selected = pending.request.options.find((option) => option.optionId === outcome.optionId);
1714
+ if (selected?.kind === 'reject_once' ||
1715
+ (selected?.optionId === 'cancel' &&
1716
+ selected.kind === undefined)) {
1717
+ return 'cancelled';
1718
+ }
1719
+ }
1720
+ return 'resolved_outside_presenter';
1417
1721
  }
1418
1722
  pendingPermissionForEnvelope(envelope, args) {
1419
1723
  const trimmed = args.trim();
@@ -1447,6 +1751,8 @@ export class ChannelBase {
1447
1751
  canEnvelopeAnswerPendingPermission(envelope, pending) {
1448
1752
  return (pending.target.chatId === envelope.chatId &&
1449
1753
  pending.target.threadId === envelope.threadId &&
1754
+ (!pending.userInputPresented ||
1755
+ pending.target.senderId === envelope.senderId) &&
1450
1756
  (this.isSharedSessionTarget(pending.target) ||
1451
1757
  pending.target.senderId === envelope.senderId));
1452
1758
  }
@@ -1541,6 +1847,10 @@ export class ChannelBase {
1541
1847
  return true;
1542
1848
  }
1543
1849
  const { pending } = lookup;
1850
+ if (pending.userInputPresented && decision !== 'deny') {
1851
+ await this.sendMessage(envelope.chatId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.');
1852
+ return true;
1853
+ }
1544
1854
  const response = (() => {
1545
1855
  if (decision === 'deny') {
1546
1856
  return this.denialResponse(pending);
@@ -1560,7 +1870,9 @@ export class ChannelBase {
1560
1870
  }
1561
1871
  let accepted;
1562
1872
  try {
1563
- accepted = await this.bridge.respondToPermission(pending.requestId, response);
1873
+ accepted = pending.userInputPresented
1874
+ ? await this.respondToUserInput(pending, response)
1875
+ : await this.bridge.respondToPermission(pending.requestId, response);
1564
1876
  }
1565
1877
  catch (err) {
1566
1878
  this.removePendingPermission(pending.requestId);
@@ -1597,7 +1909,7 @@ export class ChannelBase {
1597
1909
  // already queued onto this session sees a stale generation and bails
1598
1910
  // instead of running bridge.prompt() against the cleared session.
1599
1911
  this.sessionGenerations.set(id, (this.sessionGenerations.get(id) ?? 0) + 1);
1600
- this.removePendingPermissionsForSession(id);
1912
+ this.removePendingPermissionsForSession(id, 'run_cancelled');
1601
1913
  // Cancel an in-flight turn (and drop its buffered follow-ups) before
1602
1914
  // purging, so a running prompt can't deliver a stale response into —
1603
1915
  // or resurrect via collect-drain — the just-cleared session.
@@ -3065,6 +3377,17 @@ export class ChannelBase {
3065
3377
  markPreflighted(envelope) {
3066
3378
  this.preflightedEnvelopes.add(envelope);
3067
3379
  }
3380
+ /** Wait until the currently active bridge recovery, if any, has completed. */
3381
+ async waitForBridgeRecovery() {
3382
+ let completedRecovery;
3383
+ while (true) {
3384
+ const bridgeRecovery = this.bridgeRecovery?.();
3385
+ if (!bridgeRecovery || bridgeRecovery === completedRecovery)
3386
+ return;
3387
+ await bridgeRecovery;
3388
+ completedRecovery = bridgeRecovery;
3389
+ }
3390
+ }
3068
3391
  /**
3069
3392
  * Process an inbound message after preflight gates have passed.
3070
3393
  *
@@ -3073,6 +3396,7 @@ export class ChannelBase {
3073
3396
  * already preflighted, such as during collect-buffer drain.
3074
3397
  */
3075
3398
  async processInbound(envelope) {
3399
+ await this.waitForBridgeRecovery();
3076
3400
  if (!this.preflightedEnvelopes.delete(envelope)) {
3077
3401
  throw new Error('processInbound called without a successful preflightInbound check.');
3078
3402
  }
@@ -3142,6 +3466,9 @@ export class ChannelBase {
3142
3466
  return;
3143
3467
  }
3144
3468
  }
3469
+ // Preprocessing above can await memory/command hooks; recovery may have
3470
+ // started since the entry check. Recheck immediately before session routing.
3471
+ await this.waitForBridgeRecovery();
3145
3472
  const sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
3146
3473
  // Bang (!) execution — a private 1:1 session has a single operator, so
3147
3474
  // direct shell execution stays allowed. Group/shared contexts were refused
@@ -3328,6 +3655,7 @@ export class ChannelBase {
3328
3655
  // turn at the channel level (cancelled is already set above), so
3329
3656
  // the event reflects that intent, not the bridge RPC outcome.
3330
3657
  this.emitTaskCancellation(active, sessionId, 'steer');
3658
+ this.removePendingPermissionsForSession(sessionId, 'run_cancelled');
3331
3659
  }
3332
3660
  // Diagnostic watchdog: if the predecessor turn is STILL the active prompt
3333
3661
  // after the wind-down bound, this steered turn is wedged behind a hung
@@ -3456,6 +3784,11 @@ export class ChannelBase {
3456
3784
  doneResolve = r;
3457
3785
  });
3458
3786
  const promptState = {
3787
+ runId: randomUUID(),
3788
+ owner: {
3789
+ kind: 'channel_user',
3790
+ id: envelope.senderId,
3791
+ },
3459
3792
  cancelled: false,
3460
3793
  done,
3461
3794
  resolve: doneResolve,
@@ -3465,6 +3798,7 @@ export class ChannelBase {
3465
3798
  messageId: envelope.messageId,
3466
3799
  senderId: envelope.senderId,
3467
3800
  senderName: envelope.senderName,
3801
+ metadata: envelope.metadata,
3468
3802
  };
3469
3803
  // This turn is now the single owner of the session's active-prompt slot.
3470
3804
  // (Steer no longer hands a still-active session to a replacement; only
@@ -3499,12 +3833,13 @@ export class ChannelBase {
3499
3833
  const releaseHeldChunks = () => {
3500
3834
  for (const held of heldChunks.splice(0)) {
3501
3835
  hasStreamedText = true;
3836
+ const segment = this.ensureOutputSegment(sessionId, promptState);
3502
3837
  this.emitTaskLifecycle({
3503
3838
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3504
3839
  type: 'text_chunk',
3505
3840
  chunk: held,
3506
3841
  });
3507
- this.onResponseChunk(envelope.chatId, held, sessionId);
3842
+ this.onResponseChunk(envelope.chatId, held, sessionId, segment);
3508
3843
  streamer?.push(held);
3509
3844
  }
3510
3845
  };
@@ -3525,9 +3860,13 @@ export class ChannelBase {
3525
3860
  }
3526
3861
  heldChunks.length = 0;
3527
3862
  hasStreamedText = false;
3528
- this.onResponseBoundary(envelope.chatId, sessionId);
3863
+ const segment = this.closeOutputSegment(sessionId, promptState);
3864
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'response_boundary');
3529
3865
  streamer?.stop();
3530
3866
  };
3867
+ // Queue wait and memory recall can outlive a bridge crash. Capture the
3868
+ // bridge only after the latest recovery has restored session routing.
3869
+ await this.waitForBridgeRecovery();
3531
3870
  const promptBridge = this.bridge;
3532
3871
  promptBridge.on('textChunk', onChunk);
3533
3872
  promptBridge.on('responseBoundary', onResponseBoundary);
@@ -3550,7 +3889,11 @@ export class ChannelBase {
3550
3889
  await streamer.flush();
3551
3890
  }
3552
3891
  else {
3553
- await this.onResponseComplete(envelope.chatId, response, sessionId);
3892
+ const segment = this.ensureOutputSegment(sessionId, promptState);
3893
+ await this.onResponseComplete(envelope.chatId, response, sessionId, segment);
3894
+ if (segment && promptState.activeSegmentId === segment.segmentId) {
3895
+ promptState.activeSegmentId = undefined;
3896
+ }
3554
3897
  }
3555
3898
  }
3556
3899
  // Once delivery started the turn's outcome is fixed — don't let a
@@ -3559,6 +3902,8 @@ export class ChannelBase {
3559
3902
  await this.settleCancelRequested(promptState);
3560
3903
  }
3561
3904
  if (!promptState.cancelled && !promptState.cancellationEmitted) {
3905
+ const segment = this.closeOutputSegment(sessionId, promptState);
3906
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'completed');
3562
3907
  this.emitTaskLifecycle({
3563
3908
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3564
3909
  type: 'completed',
@@ -3574,6 +3919,8 @@ export class ChannelBase {
3574
3919
  }
3575
3920
  if (!promptState.cancelled) {
3576
3921
  releaseHeldChunks();
3922
+ const segment = this.closeOutputSegment(sessionId, promptState);
3923
+ void this.notifyOutputSegmentEnd(envelope.chatId, sessionId, segment, 'failed');
3577
3924
  this.emitTaskLifecycle({
3578
3925
  ...this.lifecycleBase(envelope.chatId, sessionId, envelope.messageId),
3579
3926
  type: 'failed',
@@ -3587,6 +3934,9 @@ export class ChannelBase {
3587
3934
  const safeMessageId = sanitizeLogText(envelope.messageId ?? '', 64);
3588
3935
  process.stderr.write(`[${channel}] turn ${safeMessageId} threw after cancellation for session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
3589
3936
  }
3937
+ if (promptState.cancelled) {
3938
+ return;
3939
+ }
3590
3940
  throw err;
3591
3941
  }
3592
3942
  finally {