@qwen-code/channel-base 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,6 +82,8 @@ const LOOP_ADD_RE = /^"([^"]+)"\s+(.+)$/su;
82
82
  const MAX_LOOP_JOBS_PER_TARGET = 10;
83
83
  const MAX_LOOP_PROMPT_CHARS = 4000;
84
84
  const MAX_DISPLAY_PROJECTION_CHARS = 8000;
85
+ // Mirrors BTW_MAX_INPUT_LENGTH in core without adding core to channel-base.
86
+ const CHANNEL_BTW_MAX_INPUT_LENGTH = 4096;
85
87
  function parseLoopAddArgs(args) {
86
88
  const match = args.trim().match(LOOP_ADD_RE);
87
89
  if (!match)
@@ -134,6 +136,7 @@ export class ChannelBase {
134
136
  sessionQueues = new Map();
135
137
  queuedTurns = new Map();
136
138
  namedTurnBindings = new WeakMap();
139
+ inboundErrorSourceLabels = new WeakMap();
137
140
  registerBridgeEvents;
138
141
  bridgeRecovery;
139
142
  /**
@@ -146,6 +149,7 @@ export class ChannelBase {
146
149
  pendingChannelMemoryMutationDeliveries = new Map();
147
150
  /** Per-session active prompt tracking for dispatch modes. */
148
151
  activePrompts = new Map();
152
+ activeBtw = new Map();
149
153
  /** Per-session message buffer for collect mode. */
150
154
  collectBuffers = new Map();
151
155
  preflightedEnvelopes = new WeakSet();
@@ -202,17 +206,46 @@ export class ChannelBase {
202
206
  this.onToolCall(chatId, event);
203
207
  }
204
208
  async dispatchBackgroundResponse(sessionId, text) {
205
- const target = this.router.getTarget(sessionId);
209
+ let target = this.router.getTarget(sessionId);
206
210
  if (!target ||
207
211
  target.channelName !== this.name ||
208
212
  text.trim().length === 0) {
209
213
  return;
210
214
  }
215
+ let sourceLabel;
216
+ if (this.namedSessions) {
217
+ const presentation = await this.namedSessions.resolvePresentation(sessionId);
218
+ const currentTarget = this.router.getTarget(sessionId);
219
+ const currentPresentation = this.namedSessions.presentation(sessionId);
220
+ if (!presentation ||
221
+ presentation.status !== 'open' ||
222
+ !currentTarget ||
223
+ !this.router.isSessionLive(sessionId) ||
224
+ !currentPresentation ||
225
+ currentPresentation.status !== 'open' ||
226
+ currentPresentation.taskName !== presentation.taskName ||
227
+ !this.sameTaskOwner(target, currentTarget) ||
228
+ !this.sameTaskOwner(currentTarget, currentPresentation.target)) {
229
+ throw new Error('Named background response ownership is unavailable.');
230
+ }
231
+ target = currentTarget;
232
+ sourceLabel = this.createSourceLabel(presentation, target);
233
+ }
211
234
  if (this.supportsProactiveSend() && this.supportsProactiveTarget(target)) {
212
- await this.pushProactive(target, text);
235
+ if (sourceLabel) {
236
+ await this.pushProactive(target, text, sourceLabel);
237
+ }
238
+ else {
239
+ await this.pushProactive(target, text);
240
+ }
213
241
  return;
214
242
  }
215
- await this.deliverBackgroundReply(target.chatId, text, sessionId);
243
+ if (sourceLabel) {
244
+ await this.deliverBackgroundReply(target.chatId, text, sessionId, sourceLabel);
245
+ }
246
+ else {
247
+ await this.deliverBackgroundReply(target.chatId, text, sessionId);
248
+ }
216
249
  }
217
250
  /**
218
251
  * Fallback delivery of a background response when proactive send is
@@ -220,8 +253,132 @@ export class ChannelBase {
220
253
  * stay out of turn-scoped streaming state, for example) override only this
221
254
  * step instead of re-implementing the whole dispatch flow.
222
255
  */
223
- async deliverBackgroundReply(chatId, text, sessionId) {
224
- await this.sendResponseMessage(chatId, text, sessionId);
256
+ async deliverBackgroundReply(chatId, text, sessionId, sourceLabel) {
257
+ await this.sendResponseMessage(chatId, text, sessionId, sourceLabel);
258
+ }
259
+ async handleBtw(envelope, sessionId, question, sourceLabel) {
260
+ const target = this.router.getTarget(sessionId);
261
+ if (!target || target.channelName !== this.name) {
262
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Could not resolve the current task for /btw.', sourceLabel);
263
+ return;
264
+ }
265
+ const running = this.activeBtw.get(sessionId);
266
+ if (running) {
267
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `BTW #${running.id} is still running for this task.`, sourceLabel);
268
+ return;
269
+ }
270
+ const reference = this.namedSessions?.presentation(sessionId);
271
+ const request = {
272
+ id: randomUUID().slice(0, 8),
273
+ bridge: this.bridge,
274
+ controller: new AbortController(),
275
+ target: { ...target },
276
+ chatId: envelope.chatId,
277
+ ...(envelope.threadId ? { threadId: envelope.threadId } : {}),
278
+ ...(sourceLabel ? { sourceLabel } : {}),
279
+ ...(reference?.status === 'open' ? { taskName: reference.taskName } : {}),
280
+ };
281
+ this.activeBtw.set(sessionId, request);
282
+ try {
283
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `BTW #${request.id} received. The main task will continue.`, sourceLabel);
284
+ }
285
+ catch (error) {
286
+ if (this.activeBtw.get(sessionId) === request) {
287
+ this.cancelBtw(sessionId);
288
+ }
289
+ throw error;
290
+ }
291
+ if (!this.isBtwCurrent(sessionId, request)) {
292
+ if (this.activeBtw.get(sessionId) === request) {
293
+ this.cancelBtw(sessionId);
294
+ }
295
+ return;
296
+ }
297
+ void this.deliverBtw(sessionId, question, request).catch((error) => {
298
+ process.stderr.write(`[${this.name}] BTW delivery failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(error)}\n`);
299
+ });
300
+ }
301
+ async deliverBtw(sessionId, question, request) {
302
+ let message;
303
+ try {
304
+ let result;
305
+ try {
306
+ result = await request.bridge.btw(sessionId, question, request.controller.signal);
307
+ if (result.sessionId !== sessionId) {
308
+ throw new Error('BTW response session did not match the request');
309
+ }
310
+ const answer = result.answer?.trim();
311
+ message = answer
312
+ ? `BTW #${request.id}\n\n${answer}`
313
+ : `BTW #${request.id}\n\nNo answer is available from the current conversation context.`;
314
+ }
315
+ catch (error) {
316
+ if (request.controller.signal.aborted)
317
+ return;
318
+ process.stderr.write(`[${this.name}] BTW request failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(error)}\n`);
319
+ message = `BTW #${request.id} failed. Please try again.`;
320
+ }
321
+ if (!this.isBtwCurrent(sessionId, request))
322
+ return;
323
+ try {
324
+ await this.sendThreadMessage(request.chatId, request.threadId, message, request.sourceLabel);
325
+ }
326
+ catch (error) {
327
+ try {
328
+ await this.sendThreadMessage(request.chatId, request.threadId, `BTW #${request.id} failed. Please try again.`, request.sourceLabel);
329
+ }
330
+ catch {
331
+ // Best effort only; the original delivery failure is logged by the caller.
332
+ }
333
+ throw error;
334
+ }
335
+ }
336
+ finally {
337
+ if (this.activeBtw.get(sessionId) === request) {
338
+ this.activeBtw.delete(sessionId);
339
+ }
340
+ }
341
+ }
342
+ isBtwCurrent(sessionId, request) {
343
+ if (request.controller.signal.aborted ||
344
+ this.activeBtw.get(sessionId) !== request ||
345
+ this.bridge !== request.bridge ||
346
+ !this.router.isSessionLive(sessionId)) {
347
+ return false;
348
+ }
349
+ const currentTarget = this.router.getTarget(sessionId);
350
+ // Compare owner + thread only: SessionRouter.promoteTargetToGroup flips
351
+ // the live target's isGroup whenever any group envelope or loop/webhook
352
+ // target resolves the same routing key, which changes neither the
353
+ // conversation nor the delivery destination, so it must not void an
354
+ // acknowledged answer. The named-task branch applies the same tolerance to
355
+ // the registry's creation-time snapshot, which keeps the pre-promotion
356
+ // isGroup value.
357
+ if (!currentTarget ||
358
+ !this.sameTaskOwner(request.target, currentTarget) ||
359
+ request.target.threadId !== currentTarget.threadId) {
360
+ return false;
361
+ }
362
+ if (!request.taskName)
363
+ return true;
364
+ const reference = this.namedSessions?.presentation(sessionId);
365
+ return (reference?.status === 'open' &&
366
+ reference.taskName === request.taskName &&
367
+ this.sameTaskOwner(currentTarget, reference.target) &&
368
+ currentTarget.threadId === reference.target.threadId);
369
+ }
370
+ cancelBtw(sessionId) {
371
+ const request = this.activeBtw.get(sessionId);
372
+ if (!request)
373
+ return;
374
+ this.activeBtw.delete(sessionId);
375
+ request.controller.abort();
376
+ }
377
+ cancelAllBtw() {
378
+ const requests = Array.from(this.activeBtw.values());
379
+ this.activeBtw.clear();
380
+ for (const request of requests)
381
+ request.controller.abort();
225
382
  }
226
383
  async dispatchPermissionRequest(event) {
227
384
  const target = this.permissionTargetForEvent(event);
@@ -236,12 +393,70 @@ export class ChannelBase {
236
393
  }
237
394
  return;
238
395
  }
396
+ let sourceLabel;
397
+ let taskName;
398
+ if (this.namedSessions) {
399
+ let presentation;
400
+ try {
401
+ presentation = await this.namedSessions.resolvePresentation(event.sessionId);
402
+ }
403
+ catch (err) {
404
+ try {
405
+ await this.bridge.respondToPermission?.(event.requestId, {
406
+ outcome: { outcome: 'cancelled' },
407
+ });
408
+ }
409
+ catch (respondErr) {
410
+ process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
411
+ }
412
+ throw err;
413
+ }
414
+ const currentTarget = this.permissionTargetForEvent(event);
415
+ const currentPresentation = this.namedSessions.presentation(event.sessionId);
416
+ if (!presentation ||
417
+ presentation.status !== 'open' ||
418
+ !currentTarget ||
419
+ !this.router.isSessionLive(event.sessionId) ||
420
+ !currentPresentation ||
421
+ currentPresentation.status !== 'open' ||
422
+ currentPresentation.taskName !== presentation.taskName ||
423
+ !this.sameTaskOwner(target, currentTarget) ||
424
+ !this.sameTaskOwner(currentTarget, currentPresentation.target)) {
425
+ try {
426
+ await this.bridge.respondToPermission?.(event.requestId, {
427
+ outcome: { outcome: 'cancelled' },
428
+ });
429
+ }
430
+ catch (respondErr) {
431
+ process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
432
+ }
433
+ return;
434
+ }
435
+ const active = this.activePrompts.get(event.sessionId);
436
+ sourceLabel = active
437
+ ? active.sourceLabel
438
+ : this.createSourceLabel(presentation, target);
439
+ if (!sourceLabel) {
440
+ try {
441
+ await this.bridge.respondToPermission?.(event.requestId, {
442
+ outcome: { outcome: 'cancelled' },
443
+ });
444
+ }
445
+ catch (respondErr) {
446
+ process.stderr.write(`[${this.name}] permission cancellation failed for request ${sanitizeLogText(event.requestId, 128)}: ${this.lifecycleError(respondErr)}\n`);
447
+ }
448
+ return;
449
+ }
450
+ taskName = presentation.taskName;
451
+ }
239
452
  this.removePendingPermission(event.requestId);
240
453
  const pending = {
241
454
  requestId: event.requestId,
242
455
  sessionId: event.sessionId,
243
456
  target,
244
457
  request: event.request,
458
+ ...(sourceLabel ? { sourceLabel } : {}),
459
+ ...(taskName ? { taskName } : {}),
245
460
  settlementListeners: new Set(),
246
461
  };
247
462
  this.pendingPermissions.set(event.requestId, pending);
@@ -258,10 +473,10 @@ export class ChannelBase {
258
473
  if (target.threadId !== undefined &&
259
474
  this.supportsProactiveSend() &&
260
475
  this.supportsProactiveTarget(target)) {
261
- await this.pushProactive(target, text);
476
+ await this.pushProactive(target, text, pending.sourceLabel);
262
477
  }
263
478
  else {
264
- await this.sendThreadMessage(target.chatId, target.threadId, text);
479
+ await this.sendThreadMessage(target.chatId, target.threadId, text, pending.sourceLabel);
265
480
  }
266
481
  }
267
482
  catch (err) {
@@ -296,6 +511,7 @@ export class ChannelBase {
296
511
  runId: active.runId,
297
512
  owner: active.owner,
298
513
  target: pending.target,
514
+ ...(pending.sourceLabel ? { sourceLabel: pending.sourceLabel } : {}),
299
515
  ...(precedingSegment
300
516
  ? { precedingSegmentId: precedingSegment.segmentId }
301
517
  : {}),
@@ -516,8 +732,8 @@ export class ChannelBase {
516
732
  * on a specific issue/PR. The default falls through to sendMessage(chatId,
517
733
  * text), ignoring threadId — existing IM adapters are behaviorally unchanged.
518
734
  */
519
- async sendThreadMessage(chatId, _threadId, text) {
520
- await this.sendMessage(chatId, text);
735
+ async sendThreadMessage(chatId, _threadId, text, sourceLabel) {
736
+ await this.sendMessage(chatId, this.formatAttributedText(text, sourceLabel));
521
737
  }
522
738
  /**
523
739
  * Adapter hook for task lifecycle events — the canonical way to track task
@@ -673,6 +889,7 @@ export class ChannelBase {
673
889
  segmentId,
674
890
  owner: active.owner,
675
891
  target: resolvedTarget,
892
+ ...(active.sourceLabel ? { sourceLabel: active.sourceLabel } : {}),
676
893
  ...(active.messageId ? { messageId: active.messageId } : {}),
677
894
  };
678
895
  }
@@ -714,11 +931,11 @@ export class ChannelBase {
714
931
  supportsProactiveWebhookTarget(target) {
715
932
  return this.supportsProactiveTarget(target);
716
933
  }
717
- async pushProactive(target, text) {
934
+ async pushProactive(target, text, sourceLabel) {
718
935
  if (target.threadId) {
719
936
  throw new Error('Channel does not support proactive loop messages for threaded targets.');
720
937
  }
721
- await this.sendThreadMessage(target.chatId, target.threadId, text);
938
+ await this.sendThreadMessage(target.chatId, target.threadId, text, sourceLabel);
722
939
  }
723
940
  async pushProactiveDelivery(target, text) {
724
941
  try {
@@ -931,6 +1148,7 @@ export class ChannelBase {
931
1148
  }
932
1149
  /** Replace the bridge instance (used after crash recovery restart). */
933
1150
  setBridge(bridge) {
1151
+ this.cancelAllBtw();
934
1152
  if (this.registerBridgeEvents) {
935
1153
  this.detachBridgeEvents(this.bridge);
936
1154
  }
@@ -1427,6 +1645,7 @@ export class ChannelBase {
1427
1645
  }),
1428
1646
  ]);
1429
1647
  if (!cancelled) {
1648
+ this.cancelBtw(sessionId);
1430
1649
  this.router.removeSessionId(sessionId);
1431
1650
  this.instructedSessions.delete(sessionId);
1432
1651
  this.unattendedMemorySessions.delete(sessionId);
@@ -1559,6 +1778,7 @@ export class ChannelBase {
1559
1778
  }
1560
1779
  onToolCall(_chatId, _event) { }
1561
1780
  onSessionDied(sessionId) {
1781
+ this.cancelBtw(sessionId);
1562
1782
  this.router.handleSessionDied(sessionId);
1563
1783
  this.instructedSessions.delete(sessionId);
1564
1784
  this.unattendedMemorySessions.delete(sessionId);
@@ -1608,11 +1828,11 @@ export class ChannelBase {
1608
1828
  * Override to clear adapter-owned streaming buffers.
1609
1829
  */
1610
1830
  onResponseBoundary(_chatId, _sessionId) { }
1611
- async sendResponseMessage(chatId, text, sessionId) {
1831
+ async sendResponseMessage(chatId, text, sessionId, sourceLabel) {
1612
1832
  const active = this.activePrompts.get(sessionId);
1613
1833
  const target = this.router.getTarget(sessionId);
1614
1834
  const threadId = active?.threadId ?? target?.threadId;
1615
- await this.sendThreadMessage(chatId, threadId, text);
1835
+ await this.sendThreadMessage(chatId, threadId, text, sourceLabel ?? active?.sourceLabel);
1616
1836
  }
1617
1837
  /**
1618
1838
  * Adapter hook for delivery-only metadata. The response delivery path can read
@@ -1627,6 +1847,12 @@ export class ChannelBase {
1627
1847
  getResponseMetadata(sessionId) {
1628
1848
  return this.activePrompts.get(sessionId)?.metadata;
1629
1849
  }
1850
+ getResponseSourceLabel(sessionId) {
1851
+ return this.activePrompts.get(sessionId)?.sourceLabel;
1852
+ }
1853
+ getInboundErrorSourceLabel(envelope) {
1854
+ return this.inboundErrorSourceLabels.get(envelope);
1855
+ }
1630
1856
  /**
1631
1857
  * Returns the active prompt's response thread while it remains available for
1632
1858
  * adapter delivery. Falls back to the session target after prompt cleanup.
@@ -1640,8 +1866,8 @@ export class ChannelBase {
1640
1866
  * Override to customize delivery (e.g., finalize an AI card).
1641
1867
  * Default: sends the full response text.
1642
1868
  */
1643
- async onResponseComplete(chatId, fullText, sessionId, _segment) {
1644
- await this.sendResponseMessage(chatId, fullText, sessionId);
1869
+ async onResponseComplete(chatId, fullText, sessionId, segment) {
1870
+ await this.sendResponseMessage(chatId, fullText, sessionId, segment?.sourceLabel);
1645
1871
  }
1646
1872
  /**
1647
1873
  * Register a slash command handler. Subclasses can call this to add
@@ -1762,7 +1988,7 @@ export class ChannelBase {
1762
1988
  }
1763
1989
  return 'resolved_outside_presenter';
1764
1990
  }
1765
- pendingPermissionForEnvelope(envelope, args) {
1991
+ pendingPermissionForEnvelope(envelope, args, selectedSessionId) {
1766
1992
  const trimmed = args.trim();
1767
1993
  if (trimmed) {
1768
1994
  const explicit = this.pendingPermissions.get(trimmed);
@@ -1779,6 +2005,8 @@ export class ChannelBase {
1779
2005
  const matching = requestIds
1780
2006
  .map((id) => this.pendingPermissions.get(id))
1781
2007
  .filter((pending) => pending !== undefined &&
2008
+ (selectedSessionId === undefined ||
2009
+ pending.sessionId === selectedSessionId) &&
1782
2010
  this.canEnvelopeAnswerPendingPermission(envelope, pending));
1783
2011
  if (matching.length === 0) {
1784
2012
  return { kind: 'none', explicit: false };
@@ -1801,29 +2029,91 @@ export class ChannelBase {
1801
2029
  }
1802
2030
  formatPermissionRequest(pending) {
1803
2031
  const { toolCall } = pending.request;
1804
- const title = sanitizeQuotedText(toolCall.title || 'Tool use', 160);
2032
+ const parameters = this.permissionParameterSummary(toolCall);
2033
+ const approveLabel = this.permissionOptionLabel(this.approvalOption(pending), 'allow once');
1805
2034
  const alwaysOption = this.approvalAlwaysOption(pending);
2035
+ const denyLabel = this.permissionOptionLabel(this.denialOption(pending), 'deny');
2036
+ const requestSuffix = pending.taskName ? ` ${pending.requestId}` : '';
2037
+ const replyPadding = pending.taskName
2038
+ ? { approve: ' ', always: ' ', deny: ' ' }
2039
+ : { approve: ' ', always: ' ', deny: ' ' };
1806
2040
  const replies = [
1807
- '/approve allow once',
1808
- ...(alwaysOption ? [`/approve-always ${alwaysOption.label}`] : []),
1809
- '/deny deny',
2041
+ `/approve${requestSuffix}${replyPadding.approve}${approveLabel}`,
2042
+ ...(alwaysOption
2043
+ ? [
2044
+ `/approve-always${requestSuffix}${replyPadding.always}${alwaysOption.label}`,
2045
+ ]
2046
+ : []),
2047
+ `/deny${requestSuffix}${replyPadding.deny}${denyLabel}`,
1810
2048
  ];
1811
- const lines = [
2049
+ return [
1812
2050
  'Permission required to run a tool',
2051
+ ...(pending.taskName ? [`Request: ${pending.requestId}`] : []),
1813
2052
  '',
1814
- 'Command:',
1815
- title,
2053
+ `Tool: ${this.permissionToolName(toolCall)}`,
2054
+ `Action: ${this.permissionTitle(toolCall)}`,
2055
+ ...(parameters ? [`Parameters: ${parameters}`] : []),
1816
2056
  '',
1817
2057
  'Reply with:',
1818
2058
  ...replies,
1819
- ];
1820
- return lines.join('\n');
2059
+ ].join('\n');
1821
2060
  }
1822
- approvalOptionId(pending) {
2061
+ permissionTitle(toolCall) {
2062
+ const rawTitle = typeof toolCall.title === 'string' ? toolCall.title : undefined;
2063
+ return sanitizeQuotedText(rawTitle || '', 160).trim() || 'Tool use';
2064
+ }
2065
+ permissionToolName(toolCall) {
2066
+ const rawToolCall = toolCall;
2067
+ const meta = isRecord(rawToolCall['_meta'])
2068
+ ? rawToolCall['_meta']
2069
+ : undefined;
2070
+ for (const candidate of [meta?.['toolName'], rawToolCall['kind']]) {
2071
+ if (typeof candidate !== 'string')
2072
+ continue;
2073
+ const name = sanitizeQuotedText(candidate, 120).trim();
2074
+ if (name)
2075
+ return name;
2076
+ }
2077
+ return 'unknown';
2078
+ }
2079
+ permissionParameterSummary(toolCall) {
2080
+ const rawToolCall = toolCall;
2081
+ const rawInput = isRecord(rawToolCall['rawInput'])
2082
+ ? rawToolCall['rawInput']
2083
+ : undefined;
2084
+ if (!rawInput)
2085
+ return undefined;
2086
+ const entries = Object.entries(rawInput);
2087
+ if (entries.length === 0)
2088
+ return undefined;
2089
+ const visible = entries.slice(0, 4).map(([key, value]) => {
2090
+ const safeKey = sanitizeQuotedText(key, 48).trim() || 'unknown';
2091
+ if (Array.isArray(value)) {
2092
+ return `${safeKey} (${value.length} ${value.length === 1 ? 'item' : 'items'})`;
2093
+ }
2094
+ if (isRecord(value)) {
2095
+ return `${safeKey} (object)`;
2096
+ }
2097
+ return safeKey;
2098
+ });
2099
+ if (entries.length > visible.length) {
2100
+ visible.push(`+${entries.length - visible.length} more`);
2101
+ }
2102
+ return visible.join(', ');
2103
+ }
2104
+ permissionOptionLabel(option, fallback) {
2105
+ const rawLabel = typeof option?.name === 'string' ? option.name : '';
2106
+ const label = sanitizeQuotedText(rawLabel, 160).trim();
2107
+ return label || fallback;
2108
+ }
2109
+ approvalOption(pending) {
1823
2110
  const options = pending.request.options;
1824
- return (options.find((option) => option.kind === 'allow_once')?.optionId ??
2111
+ return (options.find((option) => option.kind === 'allow_once') ??
1825
2112
  options.find((option) => option.optionId === 'proceed_once' &&
1826
- option.kind === undefined)?.optionId);
2113
+ option.kind === undefined));
2114
+ }
2115
+ approvalOptionId(pending) {
2116
+ return this.approvalOption(pending)?.optionId;
1827
2117
  }
1828
2118
  approvalAlwaysOption(pending) {
1829
2119
  const options = pending.request.options.filter((option) => option.kind === 'allow_always');
@@ -1835,7 +2125,7 @@ export class ChannelBase {
1835
2125
  }
1836
2126
  return {
1837
2127
  optionId: option.optionId,
1838
- label: this.approvalAlwaysLabel(option),
2128
+ label: this.permissionOptionLabel(option, this.approvalAlwaysLabel(option)),
1839
2129
  };
1840
2130
  }
1841
2131
  findScopedAlwaysOption(options, scope) {
@@ -1851,29 +2141,45 @@ export class ChannelBase {
1851
2141
  return 'always allow';
1852
2142
  }
1853
2143
  denialResponse(pending) {
1854
- const option = pending.request.options.find((candidate) => candidate.kind === 'reject_once') ??
1855
- pending.request.options.find((candidate) => candidate.optionId === 'cancel' &&
1856
- candidate.kind === undefined);
2144
+ const option = this.denialOption(pending);
1857
2145
  if (option) {
1858
2146
  return { outcome: { outcome: 'selected', optionId: option.optionId } };
1859
2147
  }
1860
2148
  return { outcome: { outcome: 'cancelled' } };
1861
2149
  }
2150
+ denialOption(pending) {
2151
+ return (pending.request.options.find((candidate) => candidate.kind === 'reject_once') ??
2152
+ pending.request.options.find((candidate) => candidate.optionId === 'cancel' &&
2153
+ candidate.kind === undefined));
2154
+ }
1862
2155
  async handlePermissionResponseCommand(envelope, args, decision) {
1863
2156
  if (!this.isAuthorizedForSharedSession(envelope)) {
1864
2157
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can answer permission requests in this shared session.');
1865
2158
  return true;
1866
2159
  }
1867
- const lookup = this.pendingPermissionForEnvelope(envelope, args);
2160
+ const namedSessions = this.namedSessions;
2161
+ const bareNamedCommand = namedSessions !== undefined && args.trim() === '';
2162
+ let selectedTask;
2163
+ if (bareNamedCommand) {
2164
+ try {
2165
+ selectedTask = await namedSessions.current(this.namedSessionOwner(envelope));
2166
+ }
2167
+ catch (error) {
2168
+ await this.sendNamedSessionError(envelope, error);
2169
+ return true;
2170
+ }
2171
+ }
2172
+ const lookup = this.pendingPermissionForEnvelope(envelope, args, bareNamedCommand ? (selectedTask?.sessionId ?? null) : undefined);
1868
2173
  if (lookup.kind === 'ambiguous') {
1869
2174
  const requestList = lookup.requestIds
1870
2175
  .slice(0, 6)
1871
2176
  .map((id) => {
1872
2177
  const pending = this.pendingPermissions.get(id);
1873
2178
  const title = pending
1874
- ? `: ${sanitizeQuotedText(pending.request.toolCall.title || 'Tool use', 160)}`
2179
+ ? `: ${this.permissionTitle(pending.request.toolCall)}`
1875
2180
  : '';
1876
- return `- ${sanitizeQuotedText(id, 128)}${title}`;
2181
+ const task = pending?.taskName ? `Task ${pending.taskName} — ` : '';
2182
+ return `- ${task}${sanitizeQuotedText(id, 128)}${title}`;
1877
2183
  })
1878
2184
  .join('\n');
1879
2185
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
@@ -1882,16 +2188,20 @@ export class ChannelBase {
1882
2188
  if (lookup.kind === 'none') {
1883
2189
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, lookup.explicit
1884
2190
  ? 'No pending permission request with that id for this chat.'
1885
- : 'No pending permission request for this chat.');
2191
+ : selectedTask
2192
+ ? `No pending permission request for selected task "${selectedTask.name}". Use an explicit request ID to answer another task.`
2193
+ : this.namedSessions
2194
+ ? 'No task is currently selected. Use an explicit request ID to answer a named task.'
2195
+ : 'No pending permission request for this chat.');
1886
2196
  return true;
1887
2197
  }
2198
+ const { pending } = lookup;
1888
2199
  if (!this.bridge.respondToPermission) {
1889
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Permission relay is not available for this session.');
2200
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Permission relay is not available for this session.', pending.sourceLabel);
1890
2201
  return true;
1891
2202
  }
1892
- const { pending } = lookup;
1893
2203
  if (pending.userInputPresented && decision !== 'deny') {
1894
- await this.sendMessage(envelope.chatId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.');
2204
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Submit this question through its interactive card, or use /deny [request-id] to cancel it.', pending.sourceLabel);
1895
2205
  return true;
1896
2206
  }
1897
2207
  const response = (() => {
@@ -1908,7 +2218,7 @@ export class ChannelBase {
1908
2218
  if (!response) {
1909
2219
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, decision === 'approve-always'
1910
2220
  ? 'This permission request has no always-allow option.'
1911
- : 'This permission request has no approvable option.');
2221
+ : 'This permission request has no approvable option.', pending.sourceLabel);
1912
2222
  return true;
1913
2223
  }
1914
2224
  let accepted;
@@ -1920,7 +2230,7 @@ export class ChannelBase {
1920
2230
  catch (err) {
1921
2231
  this.removePendingPermission(pending.requestId);
1922
2232
  process.stderr.write(`[${this.name}] permission response failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
1923
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Failed to answer the permission request.');
2233
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Failed to answer the permission request.', pending.sourceLabel);
1924
2234
  return true;
1925
2235
  }
1926
2236
  this.removePendingPermission(pending.requestId);
@@ -1930,7 +2240,7 @@ export class ChannelBase {
1930
2240
  : decision === 'approve-always'
1931
2241
  ? 'Permission approved always.'
1932
2242
  : 'Permission denied.'
1933
- : 'Permission request is no longer pending.');
2243
+ : 'Permission request is no longer pending.', pending.sourceLabel);
1934
2244
  return true;
1935
2245
  }
1936
2246
  namedSessionOwner(envelope) {
@@ -2001,7 +2311,7 @@ export class ChannelBase {
2001
2311
  if (this.bypassesNamedTurnBinding(envelope))
2002
2312
  return true;
2003
2313
  try {
2004
- const sessionId = await namedSessions.resolve(this.namedSessionOwner(envelope), undefined, (resolvedSessionId) => {
2314
+ const sessionId = await namedSessions.resolve(this.namedSessionOwner(envelope), (resolvedSessionId) => {
2005
2315
  const binding = this.bindNamedTurn(envelope, resolvedSessionId);
2006
2316
  return () => this.releaseNamedTurnBinding(binding);
2007
2317
  });
@@ -2090,7 +2400,7 @@ export class ChannelBase {
2090
2400
  }
2091
2401
  case 'new': {
2092
2402
  if (parts.includes('--worktree')) {
2093
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Worktree tasks are not available in Part 2 yet. Create a shared task with /session new <name>.');
2403
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Worktree tasks are planned for Part 4. Create a shared task with /session new <name>.');
2094
2404
  return true;
2095
2405
  }
2096
2406
  if (parts.length !== 1)
@@ -2109,15 +2419,42 @@ export class ChannelBase {
2109
2419
  case 'close': {
2110
2420
  if (parts.length !== 1)
2111
2421
  break;
2422
+ const closing = await namedSessions.lookup(owner, parts[0]);
2112
2423
  const result = await namedSessions.close(owner, parts[0]);
2424
+ if (closing)
2425
+ this.cancelBtw(closing.sessionId);
2113
2426
  await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.active
2114
2427
  ? `Closed task "${result.closed.name}". Selected "${result.active.name}".`
2115
2428
  : `Closed task "${result.closed.name}". No task is selected.`);
2116
2429
  return true;
2117
2430
  }
2118
- case 'cancel':
2119
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Named task cancellation is not available in Part 2 yet. Wait for the selected task to finish before switching; Telegram users can use /cancel.');
2431
+ case 'cancel': {
2432
+ if (parts.length > 1)
2433
+ break;
2434
+ const taskName = parts[0];
2435
+ const task = taskName
2436
+ ? await namedSessions.lookup(owner, taskName)
2437
+ : await namedSessions.current(owner);
2438
+ if (!task) {
2439
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, taskName
2440
+ ? `Task "${sanitizeQuotedText(taskName, 32)}" was not found.`
2441
+ : 'No task is currently selected.');
2442
+ return true;
2443
+ }
2444
+ if (task.status !== 'open') {
2445
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Task "${task.name}" is closed.`);
2446
+ return true;
2447
+ }
2448
+ if (!this.activePrompts.has(task.sessionId)) {
2449
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No request is currently running for task "${task.name}".`);
2450
+ return true;
2451
+ }
2452
+ const cancelled = await this.requestActivePromptCancellation(task.sessionId, 'cancel_command');
2453
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, cancelled
2454
+ ? `Cancelled task "${task.name}".`
2455
+ : `Failed to cancel task "${task.name}".`);
2120
2456
  return true;
2457
+ }
2121
2458
  default:
2122
2459
  break;
2123
2460
  }
@@ -2151,6 +2488,7 @@ export class ChannelBase {
2151
2488
  this.clearPendingGroupHistory(envelope);
2152
2489
  if (removedIds.length > 0) {
2153
2490
  for (const id of removedIds) {
2491
+ this.cancelBtw(id);
2154
2492
  // Audit: clearing a SHARED session wipes the conversation for every
2155
2493
  // participant, so record who triggered it (sanitized display name +
2156
2494
  // stable senderId) and which session — mirrors the file's stderr audit
@@ -2266,6 +2604,7 @@ export class ChannelBase {
2266
2604
  this.registerCommand('clear', clearHandler);
2267
2605
  this.registerCommand('reset', clearHandler);
2268
2606
  this.registerCommand('new', clearHandler);
2607
+ this.registerCommand('btw', () => Promise.resolve(false));
2269
2608
  if (this.namedSessions) {
2270
2609
  this.registerCommand('sessions', (envelope, args) => this.handleNamedSessionsCommand(envelope, args));
2271
2610
  this.registerCommand('session', (envelope, args) => this.handleNamedSessionCommand(envelope, args));
@@ -2325,10 +2664,15 @@ export class ChannelBase {
2325
2664
  '/approve [request-id] — Approve a pending permission request',
2326
2665
  '/approve-always [request-id] — Always approve a pending permission request',
2327
2666
  '/deny [request-id] — Deny a pending permission request',
2667
+ ...(this.bridge.btw
2668
+ ? [
2669
+ '/btw <question> — Ask a side question without interrupting the current task',
2670
+ ]
2671
+ : []),
2328
2672
  ...(this.namedSessions
2329
2673
  ? [
2330
2674
  '/sessions [all] — List your named tasks',
2331
- '/session current|new|use|close — Manage your named tasks',
2675
+ '/session current|new|use|close|cancel — Manage your named tasks',
2332
2676
  ]
2333
2677
  : []),
2334
2678
  ];
@@ -2341,6 +2685,7 @@ export class ChannelBase {
2341
2685
  'approve',
2342
2686
  'approve-always',
2343
2687
  'deny',
2688
+ 'btw',
2344
2689
  'remember-channel',
2345
2690
  'channel-memory',
2346
2691
  'forget-channel',
@@ -2356,9 +2701,13 @@ export class ChannelBase {
2356
2701
  }
2357
2702
  }
2358
2703
  const sessionId = await this.currentSessionId(envelope);
2359
- const agentCommands = sessionId
2704
+ const agentCommands = (sessionId
2360
2705
  ? this.getAgentCommandsForSession(sessionId)
2361
- : this.bridge.availableCommands;
2706
+ : this.bridge.availableCommands).filter((command) => !this.commands.has(command.name) ||
2707
+ // `btw` is registered unconditionally but only handled locally when
2708
+ // the bridge supports it. Without that capability the agent's entry
2709
+ // is the working one, so it must stay listed.
2710
+ (command.name === 'btw' && !this.bridge.btw));
2362
2711
  if (agentCommands.length > 0) {
2363
2712
  lines.push('', 'Agent commands (forwarded to Qwen Code):');
2364
2713
  for (const cmd of agentCommands) {
@@ -3758,6 +4107,67 @@ export class ChannelBase {
3758
4107
  return undefined;
3759
4108
  }
3760
4109
  }
4110
+ formatAttributedText(text, sourceLabel) {
4111
+ if (!sourceLabel || text.trim().length === 0)
4112
+ return text;
4113
+ return `${sourceLabel} ${text}`;
4114
+ }
4115
+ formatMarkdownAttributedText(text, sourceLabel) {
4116
+ const escapedLabel = sourceLabel?.replace(/([\\`*_[\]{}()#+\-.!|>~])/gu, '\\$1');
4117
+ if (!escapedLabel || text.trim().length === 0)
4118
+ return text;
4119
+ return `${escapedLabel}\n${text}`;
4120
+ }
4121
+ sameTaskOwner(a, b) {
4122
+ return (a.channelName === b.channelName &&
4123
+ a.chatId === b.chatId &&
4124
+ a.senderId === b.senderId);
4125
+ }
4126
+ createSourceLabel(reference, target, active) {
4127
+ const activeName = active?.senderName
4128
+ ? this.sanitizeSourceSender(active.senderName)
4129
+ : undefined;
4130
+ const senderLabel = (activeName && activeName !== 'unknown' ? activeName : undefined) ??
4131
+ this.observedSenderLabel(target.chatId, target.senderId) ??
4132
+ this.sanitizeSourceSender(target.senderId || 'unknown');
4133
+ const isGroup = active?.isGroup ?? target.isGroup ?? true;
4134
+ return isGroup
4135
+ ? `[${senderLabel} · ${reference.taskName}]`
4136
+ : `[${reference.taskName}]`;
4137
+ }
4138
+ observedSenderLabel(chatId, senderId) {
4139
+ const graph = this.persistedObservedContacts();
4140
+ if (!graph)
4141
+ return undefined;
4142
+ const sources = [
4143
+ graph.groups
4144
+ .filter((group) => group.id === chatId)
4145
+ .flatMap((group) => group.users)
4146
+ .filter((user) => user.id === senderId),
4147
+ graph.users.filter((user) => user.id === senderId),
4148
+ ];
4149
+ for (const source of sources) {
4150
+ const candidates = source.sort((a, b) => b.lastObservedAt.localeCompare(a.lastObservedAt));
4151
+ for (const candidate of candidates) {
4152
+ const label = this.sanitizeSourceSender(candidate.label);
4153
+ if (label !== 'unknown')
4154
+ return label;
4155
+ }
4156
+ }
4157
+ return undefined;
4158
+ }
4159
+ sanitizeSourceSender(value) {
4160
+ return sanitizeSenderName(value).replace(/\s+/gu, ' ').trim() || 'unknown';
4161
+ }
4162
+ sourceLabelForTurn(sessionId, envelope) {
4163
+ const reference = this.namedSessions?.presentation(sessionId);
4164
+ if (!reference || reference.status !== 'open')
4165
+ return undefined;
4166
+ return this.createSourceLabel(reference, reference.target, {
4167
+ isGroup: envelope.isGroup,
4168
+ senderName: envelope.senderName,
4169
+ });
4170
+ }
3761
4171
  markPreflighted(envelope) {
3762
4172
  this.preflightedEnvelopes.add(envelope);
3763
4173
  }
@@ -3794,12 +4204,16 @@ export class ChannelBase {
3794
4204
  // control chars cannot reach the session-bus echo, recorded transcript,
3795
4205
  // or session previews.
3796
4206
  const displayText = sanitizeDisplayText(envelope.displayText ?? envelope.text, MAX_DISPLAY_PROJECTION_CHARS);
3797
- let memoryIntent = parseChannelMemoryIntent(envelope.text);
4207
+ const parsed = this.parseCommand(envelope.text);
4208
+ let memoryIntent = parsed?.command === 'btw'
4209
+ ? null
4210
+ : parseChannelMemoryIntent(envelope.text);
3798
4211
  let memoryIntentFromClassifier = false;
3799
4212
  if (memoryIntent?.kind === 'update' || memoryIntent?.kind === 'remove') {
3800
4213
  this.deletePendingChannelMemoryMutation(envelope);
3801
4214
  }
3802
4215
  if (!memoryIntent &&
4216
+ parsed?.command !== 'btw' &&
3803
4217
  this.shouldClassifyChannelMemoryIntent(envelope.text)) {
3804
4218
  memoryIntent = await this.classifyChannelMemoryIntent(envelope);
3805
4219
  memoryIntentFromClassifier = memoryIntent !== null;
@@ -3821,7 +4235,7 @@ export class ChannelBase {
3821
4235
  }
3822
4236
  }
3823
4237
  // 3. Slash command handling — before session/agent routing
3824
- const parsed = this.parseCommand(envelope.text);
4238
+ let btwQuestion;
3825
4239
  if (parsed) {
3826
4240
  const handler = this.commands.get(parsed.command);
3827
4241
  if (handler) {
@@ -3830,6 +4244,29 @@ export class ChannelBase {
3830
4244
  return;
3831
4245
  }
3832
4246
  // Unrecognized commands fall through to the agent
4247
+ // Intercept /btw only where the bridge can answer it out of band. With no
4248
+ // btw capability this is not a locally handled command at all: it falls
4249
+ // through to the agent, which serves /btw as its own slash command, the
4250
+ // same path it took before this interception existed.
4251
+ if (parsed.command === 'btw' && this.bridge.btw) {
4252
+ if (!this.isAuthorizedForSharedSession(envelope)) {
4253
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can use /btw in this shared session.');
4254
+ return;
4255
+ }
4256
+ btwQuestion = parsed.args.trim();
4257
+ if (!btwQuestion) {
4258
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /btw <question>');
4259
+ return;
4260
+ }
4261
+ if (btwQuestion.length > CHANNEL_BTW_MAX_INPUT_LENGTH) {
4262
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `BTW questions are limited to ${CHANNEL_BTW_MAX_INPUT_LENGTH} characters.`);
4263
+ return;
4264
+ }
4265
+ if (envelope.imageBase64 || envelope.attachments?.length) {
4266
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, '/btw supports text-only questions.');
4267
+ return;
4268
+ }
4269
+ }
3833
4270
  }
3834
4271
  // 3.5. Bang (!) shell command — refuse outside a private 1:1 chat BEFORE
3835
4272
  // resolving a session, so a refused command never creates or persists one.
@@ -3870,20 +4307,21 @@ export class ChannelBase {
3870
4307
  return;
3871
4308
  }
3872
4309
  try {
3873
- sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope), namedTurn.sessionId);
4310
+ const resumed = await this.namedSessions.resumeReserved(this.namedSessionOwner(envelope), namedTurn.sessionId);
4311
+ sessionId = resumed ? namedTurn.sessionId : undefined;
3874
4312
  }
3875
4313
  catch (error) {
3876
4314
  await this.sendNamedSessionError(envelope, error);
3877
4315
  return;
3878
4316
  }
3879
4317
  if (!sessionId) {
3880
- process.stderr.write(`[${this.name}] dropped collected turn from ${envelope.senderId} for session ${namedTurn.sessionId}: selected task changed before it ran\n`);
4318
+ process.stderr.write(`[${this.name}] dropped collected turn from ${envelope.senderId} for session ${namedTurn.sessionId}: reserved task is no longer available\n`);
3881
4319
  return;
3882
4320
  }
3883
4321
  }
3884
4322
  else if (this.namedSessions) {
3885
4323
  try {
3886
- sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope), undefined, (resolvedSessionId) => {
4324
+ sessionId = await this.namedSessions.resolve(this.namedSessionOwner(envelope), (resolvedSessionId) => {
3887
4325
  const binding = this.bindNamedTurn(envelope, resolvedSessionId);
3888
4326
  namedTurn = binding;
3889
4327
  return () => this.releaseNamedTurnBinding(binding);
@@ -3901,6 +4339,17 @@ export class ChannelBase {
3901
4339
  else {
3902
4340
  sessionId = await this.router.resolve(this.name, envelope.senderId, envelope.chatId, envelope.threadId, this.config.cwd, envelope.isGroup);
3903
4341
  }
4342
+ const sourceLabel = this.namedSessions
4343
+ ? this.sourceLabelForTurn(sessionId, envelope)
4344
+ : undefined;
4345
+ if (this.namedSessions && !sourceLabel) {
4346
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Could not identify the selected task. Use /sessions, select it again, and retry.');
4347
+ return;
4348
+ }
4349
+ if (btwQuestion !== undefined) {
4350
+ await this.handleBtw(envelope, sessionId, btwQuestion, sourceLabel);
4351
+ return;
4352
+ }
3904
4353
  // Bang (!) execution — a private 1:1 session has a single operator, so
3905
4354
  // direct shell execution stays allowed. Group/shared contexts were refused
3906
4355
  // above, before the session was resolved.
@@ -3918,10 +4367,10 @@ export class ChannelBase {
3918
4367
  const exitLine = result.exitCode !== null && result.exitCode !== 0
3919
4368
  ? `\nExit code: ${result.exitCode}`
3920
4369
  : '';
3921
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`);
4370
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`, sourceLabel);
3922
4371
  }
3923
4372
  catch (error) {
3924
- await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`);
4373
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, sourceLabel);
3925
4374
  }
3926
4375
  return;
3927
4376
  }
@@ -4268,6 +4717,7 @@ export class ChannelBase {
4268
4717
  senderId: envelope.senderId,
4269
4718
  senderName: envelope.senderName,
4270
4719
  metadata: envelope.metadata,
4720
+ sourceLabel,
4271
4721
  };
4272
4722
  // This turn is now the single owner of the session's active-prompt slot.
4273
4723
  // (Steer no longer hands a still-active session to a replacement; only
@@ -4290,7 +4740,7 @@ export class ChannelBase {
4290
4740
  minChars: this.config.blockStreamingChunk?.minChars ?? 400,
4291
4741
  maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000,
4292
4742
  idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500,
4293
- send: (text) => this.sendResponseMessage(envelope.chatId, text, sessionId),
4743
+ send: (text) => this.sendResponseMessage(envelope.chatId, text, sessionId, sourceLabel),
4294
4744
  })
4295
4745
  : null;
4296
4746
  promptState.stopStreaming = () => streamer?.stop();
@@ -4408,6 +4858,9 @@ export class ChannelBase {
4408
4858
  if (promptState.cancelled) {
4409
4859
  return;
4410
4860
  }
4861
+ if (sourceLabel) {
4862
+ this.inboundErrorSourceLabels.set(envelope, sourceLabel);
4863
+ }
4411
4864
  throw err;
4412
4865
  }
4413
4866
  finally {