@qwen-code/channel-base 0.21.0 → 0.21.1-preview.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.
@@ -226,7 +226,7 @@ export class ChannelBase {
226
226
  await this.pushProactive(target, text);
227
227
  }
228
228
  else {
229
- await this.sendMessage(target.chatId, text);
229
+ await this.sendThreadMessage(target.chatId, target.threadId, text);
230
230
  }
231
231
  }
232
232
  catch (err) {
@@ -307,6 +307,14 @@ export class ChannelBase {
307
307
  this.attachBridgeEvents(bridge);
308
308
  }
309
309
  }
310
+ /**
311
+ * Thread-targeted delivery. Polling adapters override this to post comments
312
+ * on a specific issue/PR. The default falls through to sendMessage(chatId,
313
+ * text), ignoring threadId — existing IM adapters are behaviorally unchanged.
314
+ */
315
+ async sendThreadMessage(chatId, _threadId, text) {
316
+ await this.sendMessage(chatId, text);
317
+ }
310
318
  /**
311
319
  * Adapter hook for task lifecycle events — the canonical way to track task
312
320
  * state (onPromptStart/onPromptEnd are retained for back-compat). The prompt
@@ -444,7 +452,7 @@ export class ChannelBase {
444
452
  if (target.threadId) {
445
453
  throw new Error('Channel does not support proactive loop messages for threaded targets.');
446
454
  }
447
- await this.sendMessage(target.chatId, text);
455
+ await this.sendThreadMessage(target.chatId, target.threadId, text);
448
456
  }
449
457
  async pushProactiveDelivery(target, text) {
450
458
  try {
@@ -640,6 +648,7 @@ export class ChannelBase {
640
648
  alreadyPrefixed: true,
641
649
  referencedText: undefined,
642
650
  attachments: undefined,
651
+ metadata: undefined,
643
652
  imageBase64: undefined,
644
653
  imageMimeType: undefined,
645
654
  };
@@ -1294,8 +1303,11 @@ export class ChannelBase {
1294
1303
  * Override to clear adapter-owned streaming buffers.
1295
1304
  */
1296
1305
  onResponseBoundary(_chatId, _sessionId) { }
1297
- async sendResponseMessage(chatId, text, _sessionId) {
1298
- await this.sendMessage(chatId, text);
1306
+ async sendResponseMessage(chatId, text, sessionId) {
1307
+ const active = this.activePrompts.get(sessionId);
1308
+ const target = this.router.getTarget(sessionId);
1309
+ const threadId = active?.threadId ?? target?.threadId;
1310
+ await this.sendThreadMessage(chatId, threadId, text);
1299
1311
  }
1300
1312
  /**
1301
1313
  * Called when the agent's full response is ready.
@@ -1320,23 +1332,23 @@ export class ChannelBase {
1320
1332
  // to authorized senders like /clear (auth gate only — no confirm step). A
1321
1333
  // non-shared (1:1) session is always authorized, so behavior is unchanged.
1322
1334
  if (!this.isAuthorizedForSharedSession(envelope)) {
1323
- await this.sendMessage(envelope.chatId, 'Only authorized members can cancel requests in this shared session.');
1335
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can cancel requests in this shared session.');
1324
1336
  return true;
1325
1337
  }
1326
1338
  const activeSessionId = this.findActiveSessionId(envelope);
1327
1339
  if (!activeSessionId) {
1328
- await this.sendMessage(envelope.chatId, 'No request is currently running.');
1340
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No request is currently running.');
1329
1341
  return true;
1330
1342
  }
1331
1343
  const active = this.activePrompts.get(activeSessionId);
1332
1344
  if (!active) {
1333
- await this.sendMessage(envelope.chatId, 'No request is currently running.');
1345
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No request is currently running.');
1334
1346
  return true;
1335
1347
  }
1336
1348
  // Single cancel state machine: adapter stop buttons and /cancel share
1337
1349
  // requestActivePromptCancellation so the two paths cannot drift.
1338
1350
  const cancelSucceeded = await this.requestActivePromptCancellation(activeSessionId, 'cancel_command');
1339
- await this.sendMessage(envelope.chatId, cancelSucceeded
1351
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, cancelSucceeded
1340
1352
  ? 'Cancelled current request.'
1341
1353
  : 'Failed to cancel current request.');
1342
1354
  return true;
@@ -1487,7 +1499,7 @@ export class ChannelBase {
1487
1499
  }
1488
1500
  async handlePermissionResponseCommand(envelope, args, decision) {
1489
1501
  if (!this.isAuthorizedForSharedSession(envelope)) {
1490
- await this.sendMessage(envelope.chatId, 'Only authorized members can answer permission requests in this shared session.');
1502
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can answer permission requests in this shared session.');
1491
1503
  return true;
1492
1504
  }
1493
1505
  const lookup = this.pendingPermissionForEnvelope(envelope, args);
@@ -1502,17 +1514,17 @@ export class ChannelBase {
1502
1514
  return `- ${sanitizeQuotedText(id, 128)}${title}`;
1503
1515
  })
1504
1516
  .join('\n');
1505
- await this.sendMessage(envelope.chatId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
1517
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
1506
1518
  return true;
1507
1519
  }
1508
1520
  if (lookup.kind === 'none') {
1509
- await this.sendMessage(envelope.chatId, lookup.explicit
1521
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lookup.explicit
1510
1522
  ? 'No pending permission request with that id for this chat.'
1511
1523
  : 'No pending permission request for this chat.');
1512
1524
  return true;
1513
1525
  }
1514
1526
  if (!this.bridge.respondToPermission) {
1515
- await this.sendMessage(envelope.chatId, 'Permission relay is not available for this session.');
1527
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Permission relay is not available for this session.');
1516
1528
  return true;
1517
1529
  }
1518
1530
  const { pending } = lookup;
@@ -1528,7 +1540,7 @@ export class ChannelBase {
1528
1540
  : undefined;
1529
1541
  })();
1530
1542
  if (!response) {
1531
- await this.sendMessage(envelope.chatId, decision === 'approve-always'
1543
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, decision === 'approve-always'
1532
1544
  ? 'This permission request has no always-allow option.'
1533
1545
  : 'This permission request has no approvable option.');
1534
1546
  return true;
@@ -1540,11 +1552,11 @@ export class ChannelBase {
1540
1552
  catch (err) {
1541
1553
  this.removePendingPermission(pending.requestId);
1542
1554
  process.stderr.write(`[${this.name}] permission response failed for request ${sanitizeLogText(pending.requestId, 128)}: ${this.lifecycleError(err)}\n`);
1543
- await this.sendMessage(envelope.chatId, 'Failed to answer the permission request.');
1555
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Failed to answer the permission request.');
1544
1556
  return true;
1545
1557
  }
1546
1558
  this.removePendingPermission(pending.requestId);
1547
- await this.sendMessage(envelope.chatId, accepted
1559
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, accepted
1548
1560
  ? decision === 'approve'
1549
1561
  ? 'Permission approved.'
1550
1562
  : decision === 'approve-always'
@@ -1647,10 +1659,10 @@ export class ChannelBase {
1647
1659
  this.sessionGenerations.delete(id);
1648
1660
  }
1649
1661
  }
1650
- await this.sendMessage(envelope.chatId, 'Session cleared. The next message starts a fresh conversation.');
1662
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Session cleared. The next message starts a fresh conversation.');
1651
1663
  }
1652
1664
  else {
1653
- await this.sendMessage(envelope.chatId, 'No active session to clear.');
1665
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No active session to clear.');
1654
1666
  }
1655
1667
  };
1656
1668
  // For a shared session, clearing it affects everyone who shares it: restrict
@@ -1659,11 +1671,11 @@ export class ChannelBase {
1659
1671
  // directly — there /clear only touches the caller's own session.
1660
1672
  const clearHandler = async (envelope, args) => {
1661
1673
  if (!this.isAuthorizedForSharedSession(envelope)) {
1662
- await this.sendMessage(envelope.chatId, 'Only authorized members can clear this shared session.');
1674
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can clear this shared session.');
1663
1675
  return true;
1664
1676
  }
1665
1677
  if (this.isSharedSession(envelope) && args.toLowerCase() !== 'confirm') {
1666
- await this.sendMessage(envelope.chatId, 'This clears the shared session for everyone who shares it. Re-send with "confirm" (e.g. /clear confirm) to proceed.');
1678
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'This clears the shared session for everyone who shares it. Re-send with "confirm" (e.g. /clear confirm) to proceed.');
1667
1679
  return true;
1668
1680
  }
1669
1681
  await doClear(envelope);
@@ -1680,7 +1692,7 @@ export class ChannelBase {
1680
1692
  // leaks the workspace basename, so non-members shouldn't see it either.
1681
1693
  this.registerCommand('who', async (envelope) => {
1682
1694
  if (!this.isAuthorizedForSharedSession(envelope)) {
1683
- await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
1695
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can view this shared session.');
1684
1696
  return true;
1685
1697
  }
1686
1698
  const active = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
@@ -1697,7 +1709,7 @@ export class ChannelBase {
1697
1709
  : envelope.isGroup
1698
1710
  ? ' (private to you)'
1699
1711
  : '';
1700
- await this.sendMessage(envelope.chatId, [
1712
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
1701
1713
  `Channel: ${this.name}`,
1702
1714
  // Identity/memory lines only for channels that opted in — keep
1703
1715
  // unconfigured channels' output unchanged.
@@ -1758,14 +1770,14 @@ export class ChannelBase {
1758
1770
  }
1759
1771
  }
1760
1772
  lines.push('', 'Send any text to chat with the agent.');
1761
- await this.sendMessage(envelope.chatId, lines.join('\n'));
1773
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lines.join('\n'));
1762
1774
  return true;
1763
1775
  });
1764
1776
  this.registerCommand('status', async (envelope) => {
1765
1777
  // For a shared session, gate it to authorized senders like /who — /status
1766
1778
  // reports session & access state, so non-members shouldn't read it either.
1767
1779
  if (!this.isAuthorizedForSharedSession(envelope)) {
1768
- await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
1780
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can view this shared session.');
1769
1781
  return true;
1770
1782
  }
1771
1783
  const hasSession = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
@@ -1781,18 +1793,18 @@ export class ChannelBase {
1781
1793
  ]
1782
1794
  : []),
1783
1795
  ];
1784
- await this.sendMessage(envelope.chatId, lines.join('\n'));
1796
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lines.join('\n'));
1785
1797
  return true;
1786
1798
  });
1787
1799
  this.registerCommand('loop', async (envelope, args) => this.handleLoopCommand(envelope, args));
1788
1800
  }
1789
1801
  async handleLoopCommand(envelope, args) {
1790
1802
  if (!this.loopController) {
1791
- await this.sendMessage(envelope.chatId, 'Loops are not available.');
1803
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Loops are not available.');
1792
1804
  return true;
1793
1805
  }
1794
1806
  if (!this.isAuthorizedForSharedSession(envelope)) {
1795
- await this.sendMessage(envelope.chatId, 'Only authorized members can use loops in this shared session.');
1807
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can use loops in this shared session.');
1796
1808
  return true;
1797
1809
  }
1798
1810
  const [subcommand = '', ...rest] = args.trim().split(/\s+/u);
@@ -1806,7 +1818,7 @@ export class ChannelBase {
1806
1818
  case 'cancel':
1807
1819
  return this.handleLoopCancel(envelope, rest[0]);
1808
1820
  default:
1809
- await this.sendMessage(envelope.chatId, 'Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>');
1821
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>');
1810
1822
  return true;
1811
1823
  }
1812
1824
  }
@@ -1814,33 +1826,33 @@ export class ChannelBase {
1814
1826
  if (!this.loopController)
1815
1827
  return true;
1816
1828
  if (!this.supportsProactiveSend()) {
1817
- await this.sendMessage(envelope.chatId, 'This channel does not support proactive loop messages.');
1829
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'This channel does not support proactive loop messages.');
1818
1830
  return true;
1819
1831
  }
1820
1832
  if (this.config.sessionScope === 'single') {
1821
- await this.sendMessage(envelope.chatId, 'Loops are not supported when sessionScope is single.');
1833
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Loops are not supported when sessionScope is single.');
1822
1834
  return true;
1823
1835
  }
1824
1836
  const parsed = parseLoopAddArgs(args);
1825
1837
  if (!parsed) {
1826
- await this.sendMessage(envelope.chatId, 'Usage: /loop add "<cron>" <prompt>');
1838
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop add "<cron>" <prompt>');
1827
1839
  return true;
1828
1840
  }
1829
1841
  try {
1830
1842
  this.loopController.validateCron(parsed.cron);
1831
1843
  }
1832
1844
  catch (err) {
1833
- await this.sendMessage(envelope.chatId, `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`);
1845
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`);
1834
1846
  return true;
1835
1847
  }
1836
1848
  const target = this.loopTargetFromEnvelope(envelope);
1837
1849
  if (!this.supportsProactiveTarget(target)) {
1838
- await this.sendMessage(envelope.chatId, 'This channel does not support proactive loop messages for this chat target.');
1850
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'This channel does not support proactive loop messages for this chat target.');
1839
1851
  return true;
1840
1852
  }
1841
1853
  const prompt = sanitizePromptText(parsed.prompt.trim());
1842
1854
  if (Array.from(prompt).length > MAX_LOOP_PROMPT_CHARS) {
1843
- await this.sendMessage(envelope.chatId, `Loop prompt is too long; keep it under ${MAX_LOOP_PROMPT_CHARS} characters.`);
1855
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Loop prompt is too long; keep it under ${MAX_LOOP_PROMPT_CHARS} characters.`);
1844
1856
  return true;
1845
1857
  }
1846
1858
  const input = {
@@ -1865,10 +1877,10 @@ export class ChannelBase {
1865
1877
  }
1866
1878
  }
1867
1879
  if (!job) {
1868
- await this.sendMessage(envelope.chatId, `Too many loops for this chat. Cancel an existing loop before adding another.`);
1880
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Too many loops for this chat. Cancel an existing loop before adding another.`);
1869
1881
  return true;
1870
1882
  }
1871
- await this.sendMessage(envelope.chatId, `Loop ${job.id}: ${job.cron}`);
1883
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Loop ${job.id}: ${job.cron}`);
1872
1884
  return true;
1873
1885
  }
1874
1886
  async createLoopFromTool(sessionId, input) {
@@ -1975,23 +1987,23 @@ export class ChannelBase {
1975
1987
  return true;
1976
1988
  const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
1977
1989
  if (jobs.length === 0) {
1978
- await this.sendMessage(envelope.chatId, 'No loops.');
1990
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No loops.');
1979
1991
  return true;
1980
1992
  }
1981
- await this.sendMessage(envelope.chatId, jobs.map((job) => this.formatLoopListLine(job)).join('\n'));
1993
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, jobs.map((job) => this.formatLoopListLine(job)).join('\n'));
1982
1994
  return true;
1983
1995
  }
1984
1996
  async handleLoopInspect(envelope, id) {
1985
1997
  if (!this.loopController)
1986
1998
  return true;
1987
1999
  if (!id) {
1988
- await this.sendMessage(envelope.chatId, 'Usage: /loop inspect <id>');
2000
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop inspect <id>');
1989
2001
  return true;
1990
2002
  }
1991
2003
  const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
1992
2004
  const job = jobs.find((candidate) => candidate.id === id);
1993
2005
  if (!job) {
1994
- await this.sendMessage(envelope.chatId, `No loop ${id}.`);
2006
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No loop ${id}.`);
1995
2007
  return true;
1996
2008
  }
1997
2009
  const lines = [
@@ -2013,7 +2025,7 @@ export class ChannelBase {
2013
2025
  lines.push(`Last result: ${job.lastResultPreview}`);
2014
2026
  }
2015
2027
  lines.push(`Prompt: ${job.prompt}`);
2016
- await this.sendMessage(envelope.chatId, lines.join('\n'));
2028
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lines.join('\n'));
2017
2029
  return true;
2018
2030
  }
2019
2031
  formatLoopListLine(job) {
@@ -2046,17 +2058,17 @@ export class ChannelBase {
2046
2058
  if (!this.loopController)
2047
2059
  return true;
2048
2060
  if (!id) {
2049
- await this.sendMessage(envelope.chatId, 'Usage: /loop cancel <id>');
2061
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop cancel <id>');
2050
2062
  return true;
2051
2063
  }
2052
2064
  const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
2053
2065
  const match = jobs.find((job) => job.id === id);
2054
2066
  if (!match) {
2055
- await this.sendMessage(envelope.chatId, `No loop ${id}.`);
2067
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No loop ${id}.`);
2056
2068
  return true;
2057
2069
  }
2058
2070
  const disabled = await this.loopController.disable(id);
2059
- await this.sendMessage(envelope.chatId, disabled ? `Cancelled loop ${id}.` : `Failed to cancel loop ${id}.`);
2071
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, disabled ? `Cancelled loop ${id}.` : `Failed to cancel loop ${id}.`);
2060
2072
  return true;
2061
2073
  }
2062
2074
  loopTargetFromEnvelope(envelope) {
@@ -2191,7 +2203,7 @@ export class ChannelBase {
2191
2203
  }
2192
2204
  async getChannelMemory(envelope) {
2193
2205
  if (!this.channelMemory) {
2194
- await this.sendMessage(envelope.chatId, 'Channel memory is not configured for this channel.');
2206
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Channel memory is not configured for this channel.');
2195
2207
  return undefined;
2196
2208
  }
2197
2209
  return this.channelMemory;
@@ -2211,7 +2223,7 @@ export class ChannelBase {
2211
2223
  }
2212
2224
  async handleChannelMemoryIntent(envelope, intent, options = {}) {
2213
2225
  if (intent.kind === 'no_match') {
2214
- await this.sendMessage(envelope.chatId, 'No matching channel memory entry.');
2226
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No matching channel memory entry.');
2215
2227
  return;
2216
2228
  }
2217
2229
  if (intent.kind === 'ambiguous') {
@@ -2224,11 +2236,11 @@ export class ChannelBase {
2224
2236
  }
2225
2237
  catch (error) {
2226
2238
  this.logChannelMemoryError('read', envelope, this.channelMemoryErrorMessage(error));
2227
- await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2239
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2228
2240
  return;
2229
2241
  }
2230
2242
  const selected = this.entriesForChannelMemoryIds(entries, intent.ids);
2231
- await this.sendMessage(envelope.chatId, [
2243
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
2232
2244
  'Multiple channel memory entries match:',
2233
2245
  ...this.renderChannelMemoryCandidates(selected),
2234
2246
  ].join('\n'));
@@ -2244,11 +2256,11 @@ export class ChannelBase {
2244
2256
  }
2245
2257
  catch (error) {
2246
2258
  this.logChannelMemoryError('read', envelope, this.channelMemoryErrorMessage(error));
2247
- await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2259
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2248
2260
  return;
2249
2261
  }
2250
2262
  const selected = this.entriesForChannelMemoryIds(entries, intent.ids);
2251
- await this.sendMessage(envelope.chatId, [
2263
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
2252
2264
  'Channel memory (page 1/1):',
2253
2265
  ...this.renderChannelMemoryCandidates(selected),
2254
2266
  ].join('\n'));
@@ -2263,7 +2275,7 @@ export class ChannelBase {
2263
2275
  ? this.takePendingChannelMemoryMutation(envelope, 'update')
2264
2276
  : this.takePendingChannelMemoryMutation(envelope, 'remove');
2265
2277
  if (!pending) {
2266
- await this.sendMessage(envelope.chatId, intent.kind === 'update_confirm'
2278
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, intent.kind === 'update_confirm'
2267
2279
  ? 'No pending channel memory update. Start a new update request first.'
2268
2280
  : 'No pending channel memory removal. Start a new removal request first.');
2269
2281
  return;
@@ -2291,17 +2303,17 @@ export class ChannelBase {
2291
2303
  catch (error) {
2292
2304
  const message = this.channelMemoryErrorMessage(error);
2293
2305
  this.logChannelMemoryError(isUpdate ? 'update' : 'remove', envelope, message);
2294
- await this.sendMessage(envelope.chatId, message === 'Channel memory entry changed'
2306
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, message === 'Channel memory entry changed'
2295
2307
  ? 'That channel memory entry changed since it was selected. View channel memory and start the operation again.'
2296
2308
  : `Failed to ${isUpdate ? 'update' : 'remove'} channel memory: ${this.channelMemoryUserErrorMessage()}`);
2297
2309
  return;
2298
2310
  }
2299
2311
  if (!changed) {
2300
- await this.sendMessage(envelope.chatId, `No channel memory entry ${pending.id}.`);
2312
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No channel memory entry ${pending.id}.`);
2301
2313
  return;
2302
2314
  }
2303
2315
  this.invalidateUnattendedMemory(envelope);
2304
- await this.sendMessage(envelope.chatId, `Channel memory ${pending.id} ${isUpdate ? 'updated' : 'removed'}.`);
2316
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory ${pending.id} ${isUpdate ? 'updated' : 'removed'}.`);
2305
2317
  return;
2306
2318
  }
2307
2319
  if (intent.kind === 'natural_update') {
@@ -2342,7 +2354,7 @@ export class ChannelBase {
2342
2354
  catch (error) {
2343
2355
  const message = this.channelMemoryErrorMessage(error);
2344
2356
  this.logChannelMemoryError('save', envelope, message);
2345
- await this.sendMessage(envelope.chatId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
2357
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
2346
2358
  return;
2347
2359
  }
2348
2360
  if (result.changed) {
@@ -2357,17 +2369,17 @@ export class ChannelBase {
2357
2369
  }
2358
2370
  if (result.added.length > 0) {
2359
2371
  const ids = result.added.map((entry) => entry.id);
2360
- await this.sendMessage(envelope.chatId, result.duplicateIds.length > 0
2372
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.duplicateIds.length > 0
2361
2373
  ? `Channel memory saved: ${ids.join(', ')}. Skipped duplicates: ${result.duplicateIds.join(', ')}.`
2362
2374
  : ids.length === 1
2363
2375
  ? `Channel memory ${ids[0]} saved.`
2364
2376
  : `Channel memory saved: ${ids.join(', ')}.`);
2365
2377
  }
2366
2378
  else if (result.duplicateIds.length > 0) {
2367
- await this.sendMessage(envelope.chatId, `Channel memory already contains ${result.duplicateIds.join(', ')}.`);
2379
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory already contains ${result.duplicateIds.join(', ')}.`);
2368
2380
  }
2369
2381
  else {
2370
- await this.sendMessage(envelope.chatId, 'Channel memory updated.');
2382
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Channel memory updated.');
2371
2383
  }
2372
2384
  return;
2373
2385
  }
@@ -2379,30 +2391,30 @@ export class ChannelBase {
2379
2391
  catch (error) {
2380
2392
  const message = this.channelMemoryErrorMessage(error);
2381
2393
  this.logChannelMemoryError('read', envelope, message);
2382
- await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2394
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2383
2395
  return;
2384
2396
  }
2385
2397
  if (intent.kind === 'inspect') {
2386
2398
  const entry = entries.find((candidate) => candidate.id === intent.id);
2387
- await this.sendMessage(envelope.chatId, entry
2399
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, entry
2388
2400
  ? `Channel memory ${entry.id}:\n${sanitizePromptText(entry.text).trim()}`
2389
2401
  : `No channel memory entry ${intent.id}.`);
2390
2402
  return;
2391
2403
  }
2392
2404
  const totalPages = Math.max(1, Math.ceil(entries.length / CHANNEL_MEMORY_PAGE_SIZE));
2393
2405
  if (intent.page > totalPages) {
2394
- await this.sendMessage(envelope.chatId, `Channel memory page ${intent.page} does not exist.`);
2406
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory page ${intent.page} does not exist.`);
2395
2407
  return;
2396
2408
  }
2397
2409
  if (entries.length === 0) {
2398
- await this.sendMessage(envelope.chatId, 'No channel memory saved.');
2410
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No channel memory saved.');
2399
2411
  return;
2400
2412
  }
2401
2413
  const pageStart = (intent.page - 1) * CHANNEL_MEMORY_PAGE_SIZE;
2402
2414
  const lines = entries
2403
2415
  .slice(pageStart, pageStart + CHANNEL_MEMORY_PAGE_SIZE)
2404
2416
  .map((entry) => this.renderChannelMemoryCandidate(entry));
2405
- await this.sendMessage(envelope.chatId, [`Channel memory (page ${intent.page}/${totalPages}):`, ...lines].join('\n'));
2417
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [`Channel memory (page ${intent.page}/${totalPages}):`, ...lines].join('\n'));
2406
2418
  return;
2407
2419
  }
2408
2420
  if (intent.kind === 'update' || intent.kind === 'remove') {
@@ -2420,21 +2432,21 @@ export class ChannelBase {
2420
2432
  catch (error) {
2421
2433
  const message = this.channelMemoryErrorMessage(error);
2422
2434
  this.logChannelMemoryError(isUpdate ? 'update' : 'remove', envelope, message);
2423
- await this.sendMessage(envelope.chatId, `Failed to ${isUpdate ? 'update' : 'remove'} channel memory: ${this.channelMemoryUserErrorMessage()}`);
2435
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to ${isUpdate ? 'update' : 'remove'} channel memory: ${this.channelMemoryUserErrorMessage()}`);
2424
2436
  return;
2425
2437
  }
2426
2438
  if (!changed) {
2427
- await this.sendMessage(envelope.chatId, `No channel memory entry ${id}.`);
2439
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No channel memory entry ${id}.`);
2428
2440
  return;
2429
2441
  }
2430
2442
  this.invalidateUnattendedMemory(envelope);
2431
- await this.sendMessage(envelope.chatId, `Channel memory ${id} ${isUpdate ? 'updated' : 'removed'}.`);
2443
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory ${id} ${isUpdate ? 'updated' : 'removed'}.`);
2432
2444
  return;
2433
2445
  }
2434
2446
  if (intent.kind === 'clear_confirm') {
2435
2447
  const pending = this.takePendingChannelMemoryMutation(envelope, 'clear');
2436
2448
  if (!pending) {
2437
- await this.sendMessage(envelope.chatId, 'No pending clear request. Say "清空记忆" first.');
2449
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No pending clear request. Say "清空记忆" first.');
2438
2450
  return;
2439
2451
  }
2440
2452
  let result;
@@ -2444,13 +2456,13 @@ export class ChannelBase {
2444
2456
  catch (error) {
2445
2457
  const message = this.channelMemoryErrorMessage(error);
2446
2458
  this.logChannelMemoryError('clear', envelope, message);
2447
- await this.sendMessage(envelope.chatId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
2459
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
2448
2460
  return;
2449
2461
  }
2450
2462
  if (result.changed) {
2451
2463
  this.invalidateUnattendedMemory(envelope);
2452
2464
  }
2453
- await this.sendMessage(envelope.chatId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
2465
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
2454
2466
  return;
2455
2467
  }
2456
2468
  const unhandled = intent;
@@ -2482,7 +2494,7 @@ export class ChannelBase {
2482
2494
  const key = this.channelMemoryPendingKey(envelope);
2483
2495
  this.pendingChannelMemoryMutationDeliveries.set(key, mutation);
2484
2496
  try {
2485
- await this.sendMessage(envelope.chatId, message);
2497
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, message);
2486
2498
  }
2487
2499
  catch (error) {
2488
2500
  if (this.pendingChannelMemoryMutationDeliveries.get(key) === mutation) {
@@ -2655,15 +2667,18 @@ export class ChannelBase {
2655
2667
  * Whether the resolved session is SHARED across senders. `single` collapses
2656
2668
  * the whole channel to one `__single__` session for EVERY sender — group OR
2657
2669
  * DM — so it is ALWAYS shared (even a DM maps to `__single__`). `thread` is
2658
- * shared only in a group (a DM maps to the lone caller's own chat). `user` is
2659
- * per-sender, never shared. Drives both the destructive-/clear confirm gate
2660
- * and the host-shell (`!`) gate.
2670
+ * shared only in a group (a DM maps to the lone caller's own chat).
2671
+ * `chat_thread` is always shared: it scopes by chat+thread, and a thread
2672
+ * (issue/PR discussion) can carry multiple participants even outside a
2673
+ * group. `user` is per-sender, never shared. Drives both the
2674
+ * destructive-/clear confirm gate and the host-shell (`!`) gate.
2661
2675
  */
2662
2676
  isSharedSession(envelope) {
2663
2677
  return this.isSharedSessionTarget(envelope);
2664
2678
  }
2665
2679
  isSharedSessionTarget(target) {
2666
2680
  return (this.config.sessionScope === 'single' ||
2681
+ this.config.sessionScope === 'chat_thread' ||
2667
2682
  (target.isGroup === true && this.config.sessionScope === 'thread'));
2668
2683
  }
2669
2684
  /**
@@ -2962,7 +2977,7 @@ export class ChannelBase {
2962
2977
  if (!result.allowed) {
2963
2978
  if (result.pairingCode !== undefined) {
2964
2979
  this.logPreflightRejected('sender_pairing_required');
2965
- return this.onPairingRequired(envelope.chatId, result.pairingCode)
2980
+ return this.onPairingRequired(envelope.chatId, result.pairingCode, envelope.threadId)
2966
2981
  .then(() => false)
2967
2982
  .catch((err) => {
2968
2983
  process.stderr.write(`[Channel:${this.name}] pairing notification failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
@@ -3103,13 +3118,13 @@ export class ChannelBase {
3103
3118
  process.stderr.write(`[${this.name}] blocked ! shell command from ${who} (sender ${envelope.senderId}) in chat ${sanitizeLogText(envelope.chatId, 64)}\n`);
3104
3119
  }
3105
3120
  if (envelope.isGroup) {
3106
- await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in group chats.');
3121
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Shell commands (`!`) are disabled in group chats.');
3107
3122
  return;
3108
3123
  }
3109
3124
  // A single-scope DM collapses every DM to one channel-wide session, so it
3110
3125
  // is multi-operator too despite not being a group.
3111
3126
  if (this.isSharedSession(envelope)) {
3112
- await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in shared sessions.');
3127
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Shell commands (`!`) are disabled in shared sessions.');
3113
3128
  return;
3114
3129
  }
3115
3130
  }
@@ -3131,10 +3146,10 @@ export class ChannelBase {
3131
3146
  const exitLine = result.exitCode !== null && result.exitCode !== 0
3132
3147
  ? `\nExit code: ${result.exitCode}`
3133
3148
  : '';
3134
- await this.sendMessage(envelope.chatId, `$ ${cmd}\n${output}${exitLine}`);
3149
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`);
3135
3150
  }
3136
3151
  catch (error) {
3137
- await this.sendMessage(envelope.chatId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`);
3152
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`);
3138
3153
  }
3139
3154
  return;
3140
3155
  }
@@ -3212,6 +3227,9 @@ export class ChannelBase {
3212
3227
  promptText = promptText + '\n\n' + filePaths.join('\n');
3213
3228
  }
3214
3229
  }
3230
+ if (envelope.metadata) {
3231
+ promptText = promptText + '\n\n' + sanitizePromptText(envelope.metadata);
3232
+ }
3215
3233
  // Resolve dispatch mode: per-group override → channel config → default
3216
3234
  const groupCfg = envelope.isGroup
3217
3235
  ? this.config.groups[envelope.chatId] || this.config.groups['*']
@@ -3628,6 +3646,7 @@ export class ChannelBase {
3628
3646
  // Clear attachments/references — already resolved in original text
3629
3647
  referencedText: undefined,
3630
3648
  attachments: undefined,
3649
+ metadata: undefined,
3631
3650
  imageBase64: undefined,
3632
3651
  imageMimeType: undefined,
3633
3652
  };
@@ -3643,12 +3662,12 @@ export class ChannelBase {
3643
3662
  this.sessionQueues.set(sessionId, current.catch(() => { }));
3644
3663
  await current;
3645
3664
  }
3646
- async onPairingRequired(chatId, code) {
3665
+ async onPairingRequired(chatId, code, threadId) {
3647
3666
  if (code) {
3648
- await this.sendMessage(chatId, `Your pairing code is: ${code}\n\nAsk the bot operator to approve you with:\n qwen channel pairing approve ${this.name} ${code}`);
3667
+ await this.sendThreadMessage(chatId, threadId, `Your pairing code is: ${code}\n\nAsk the bot operator to approve you with:\n qwen channel pairing approve ${this.name} ${code}`);
3649
3668
  }
3650
3669
  else {
3651
- await this.sendMessage(chatId, 'Too many pending pairing requests. Please try again later.');
3670
+ await this.sendThreadMessage(chatId, threadId, 'Too many pending pairing requests. Please try again later.');
3652
3671
  }
3653
3672
  }
3654
3673
  }