@qwen-code/channel-base 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/AcpBridge.d.ts +7 -3
  2. package/dist/AcpBridge.d.ts.map +1 -0
  3. package/dist/AcpBridge.js +37 -5
  4. package/dist/AcpBridge.js.map +1 -1
  5. package/dist/BlockStreamer.d.ts +1 -0
  6. package/dist/BlockStreamer.d.ts.map +1 -0
  7. package/dist/ChannelAgentBridge.d.ts +1 -0
  8. package/dist/ChannelAgentBridge.d.ts.map +1 -0
  9. package/dist/ChannelBase.d.ts +15 -5
  10. package/dist/ChannelBase.d.ts.map +1 -0
  11. package/dist/ChannelBase.js +113 -80
  12. package/dist/ChannelBase.js.map +1 -1
  13. package/dist/ChannelLoopScheduler.d.ts +1 -0
  14. package/dist/ChannelLoopScheduler.d.ts.map +1 -0
  15. package/dist/ChannelLoopStore.d.ts +1 -0
  16. package/dist/ChannelLoopStore.d.ts.map +1 -0
  17. package/dist/ChannelLoopTools.d.ts +1 -0
  18. package/dist/ChannelLoopTools.d.ts.map +1 -0
  19. package/dist/ChannelProactiveDeliveryError.d.ts +1 -0
  20. package/dist/ChannelProactiveDeliveryError.d.ts.map +1 -0
  21. package/dist/ChannelWebhookTask.d.ts +1 -0
  22. package/dist/ChannelWebhookTask.d.ts.map +1 -0
  23. package/dist/DaemonChannelBridge.d.ts +16 -1
  24. package/dist/DaemonChannelBridge.d.ts.map +1 -0
  25. package/dist/DaemonChannelBridge.js +75 -2
  26. package/dist/DaemonChannelBridge.js.map +1 -1
  27. package/dist/DmGate.d.ts +1 -0
  28. package/dist/DmGate.d.ts.map +1 -0
  29. package/dist/GroupGate.d.ts +1 -0
  30. package/dist/GroupGate.d.ts.map +1 -0
  31. package/dist/PairingStore.d.ts +1 -0
  32. package/dist/PairingStore.d.ts.map +1 -0
  33. package/dist/PollingChannelBase.d.ts +23 -0
  34. package/dist/PollingChannelBase.d.ts.map +1 -0
  35. package/dist/PollingChannelBase.js +107 -0
  36. package/dist/PollingChannelBase.js.map +1 -0
  37. package/dist/SenderGate.d.ts +2 -0
  38. package/dist/SenderGate.d.ts.map +1 -0
  39. package/dist/SenderGate.js +3 -0
  40. package/dist/SenderGate.js.map +1 -1
  41. package/dist/SessionRouter.d.ts +1 -0
  42. package/dist/SessionRouter.d.ts.map +1 -0
  43. package/dist/SessionRouter.js +4 -0
  44. package/dist/SessionRouter.js.map +1 -1
  45. package/dist/channel-memory-intent.d.ts +1 -0
  46. package/dist/channel-memory-intent.d.ts.map +1 -0
  47. package/dist/channel-memory-recall.d.ts +1 -0
  48. package/dist/channel-memory-recall.d.ts.map +1 -0
  49. package/dist/group-history-store.d.ts +1 -0
  50. package/dist/group-history-store.d.ts.map +1 -0
  51. package/dist/index.d.ts +4 -1
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +2 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/paths.d.ts +1 -0
  56. package/dist/paths.d.ts.map +1 -0
  57. package/dist/sanitize.d.ts +1 -0
  58. package/dist/sanitize.d.ts.map +1 -0
  59. package/dist/types.d.ts +12 -1
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js.map +1 -1
  62. package/package.json +1 -1
@@ -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
  };
@@ -1142,6 +1151,7 @@ export class ChannelBase {
1142
1151
  this.router.removeSessionId(sessionId);
1143
1152
  this.instructedSessions.delete(sessionId);
1144
1153
  this.unattendedMemorySessions.delete(sessionId);
1154
+ this.discardRetiredSession(promptBridge, sessionId, `timed-out loop ${jobId}`);
1145
1155
  process.stderr.write(`[${this.name}] retired timed out loop ${jobId} session ${sessionId} after cancel did not settle\n`);
1146
1156
  }
1147
1157
  }
@@ -1152,6 +1162,18 @@ export class ChannelBase {
1152
1162
  clearTimeout(graceTimer);
1153
1163
  }
1154
1164
  }
1165
+ discardRetiredSession(promptBridge, sessionId, reason) {
1166
+ const safeSessionId = sanitizeLogText(sessionId, 64);
1167
+ const safeReason = sanitizeLogText(reason, 128);
1168
+ try {
1169
+ void promptBridge.discardSession?.(sessionId).catch((err) => {
1170
+ process.stderr.write(`[${this.name}] failed to discard ${safeReason} session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
1171
+ });
1172
+ }
1173
+ catch (err) {
1174
+ process.stderr.write(`[${this.name}] failed to discard ${safeReason} session ${safeSessionId}: ${this.lifecycleError(err)}\n`);
1175
+ }
1176
+ }
1155
1177
  requestActivePromptCancellation(sessionId, reason = 'cancel_command') {
1156
1178
  const active = this.activePrompts.get(sessionId);
1157
1179
  if (!active) {
@@ -1294,8 +1316,11 @@ export class ChannelBase {
1294
1316
  * Override to clear adapter-owned streaming buffers.
1295
1317
  */
1296
1318
  onResponseBoundary(_chatId, _sessionId) { }
1297
- async sendResponseMessage(chatId, text, _sessionId) {
1298
- await this.sendMessage(chatId, text);
1319
+ async sendResponseMessage(chatId, text, sessionId) {
1320
+ const active = this.activePrompts.get(sessionId);
1321
+ const target = this.router.getTarget(sessionId);
1322
+ const threadId = active?.threadId ?? target?.threadId;
1323
+ await this.sendThreadMessage(chatId, threadId, text);
1299
1324
  }
1300
1325
  /**
1301
1326
  * Called when the agent's full response is ready.
@@ -1320,23 +1345,23 @@ export class ChannelBase {
1320
1345
  // to authorized senders like /clear (auth gate only — no confirm step). A
1321
1346
  // non-shared (1:1) session is always authorized, so behavior is unchanged.
1322
1347
  if (!this.isAuthorizedForSharedSession(envelope)) {
1323
- await this.sendMessage(envelope.chatId, 'Only authorized members can cancel requests in this shared session.');
1348
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can cancel requests in this shared session.');
1324
1349
  return true;
1325
1350
  }
1326
1351
  const activeSessionId = this.findActiveSessionId(envelope);
1327
1352
  if (!activeSessionId) {
1328
- await this.sendMessage(envelope.chatId, 'No request is currently running.');
1353
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No request is currently running.');
1329
1354
  return true;
1330
1355
  }
1331
1356
  const active = this.activePrompts.get(activeSessionId);
1332
1357
  if (!active) {
1333
- await this.sendMessage(envelope.chatId, 'No request is currently running.');
1358
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No request is currently running.');
1334
1359
  return true;
1335
1360
  }
1336
1361
  // Single cancel state machine: adapter stop buttons and /cancel share
1337
1362
  // requestActivePromptCancellation so the two paths cannot drift.
1338
1363
  const cancelSucceeded = await this.requestActivePromptCancellation(activeSessionId, 'cancel_command');
1339
- await this.sendMessage(envelope.chatId, cancelSucceeded
1364
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, cancelSucceeded
1340
1365
  ? 'Cancelled current request.'
1341
1366
  : 'Failed to cancel current request.');
1342
1367
  return true;
@@ -1487,7 +1512,7 @@ export class ChannelBase {
1487
1512
  }
1488
1513
  async handlePermissionResponseCommand(envelope, args, decision) {
1489
1514
  if (!this.isAuthorizedForSharedSession(envelope)) {
1490
- await this.sendMessage(envelope.chatId, 'Only authorized members can answer permission requests in this shared session.');
1515
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can answer permission requests in this shared session.');
1491
1516
  return true;
1492
1517
  }
1493
1518
  const lookup = this.pendingPermissionForEnvelope(envelope, args);
@@ -1502,17 +1527,17 @@ export class ChannelBase {
1502
1527
  return `- ${sanitizeQuotedText(id, 128)}${title}`;
1503
1528
  })
1504
1529
  .join('\n');
1505
- await this.sendMessage(envelope.chatId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
1530
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Multiple permission requests are pending for this chat. Reply with /${decision} <request-id>.\n${requestList}`);
1506
1531
  return true;
1507
1532
  }
1508
1533
  if (lookup.kind === 'none') {
1509
- await this.sendMessage(envelope.chatId, lookup.explicit
1534
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lookup.explicit
1510
1535
  ? 'No pending permission request with that id for this chat.'
1511
1536
  : 'No pending permission request for this chat.');
1512
1537
  return true;
1513
1538
  }
1514
1539
  if (!this.bridge.respondToPermission) {
1515
- await this.sendMessage(envelope.chatId, 'Permission relay is not available for this session.');
1540
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Permission relay is not available for this session.');
1516
1541
  return true;
1517
1542
  }
1518
1543
  const { pending } = lookup;
@@ -1528,7 +1553,7 @@ export class ChannelBase {
1528
1553
  : undefined;
1529
1554
  })();
1530
1555
  if (!response) {
1531
- await this.sendMessage(envelope.chatId, decision === 'approve-always'
1556
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, decision === 'approve-always'
1532
1557
  ? 'This permission request has no always-allow option.'
1533
1558
  : 'This permission request has no approvable option.');
1534
1559
  return true;
@@ -1540,11 +1565,11 @@ export class ChannelBase {
1540
1565
  catch (err) {
1541
1566
  this.removePendingPermission(pending.requestId);
1542
1567
  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.');
1568
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Failed to answer the permission request.');
1544
1569
  return true;
1545
1570
  }
1546
1571
  this.removePendingPermission(pending.requestId);
1547
- await this.sendMessage(envelope.chatId, accepted
1572
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, accepted
1548
1573
  ? decision === 'approve'
1549
1574
  ? 'Permission approved.'
1550
1575
  : decision === 'approve-always'
@@ -1646,11 +1671,12 @@ export class ChannelBase {
1646
1671
  // bumped value — reclaim it immediately.
1647
1672
  this.sessionGenerations.delete(id);
1648
1673
  }
1674
+ this.discardRetiredSession(this.bridge, id, 'cleared');
1649
1675
  }
1650
- await this.sendMessage(envelope.chatId, 'Session cleared. The next message starts a fresh conversation.');
1676
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Session cleared. The next message starts a fresh conversation.');
1651
1677
  }
1652
1678
  else {
1653
- await this.sendMessage(envelope.chatId, 'No active session to clear.');
1679
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No active session to clear.');
1654
1680
  }
1655
1681
  };
1656
1682
  // For a shared session, clearing it affects everyone who shares it: restrict
@@ -1659,11 +1685,11 @@ export class ChannelBase {
1659
1685
  // directly — there /clear only touches the caller's own session.
1660
1686
  const clearHandler = async (envelope, args) => {
1661
1687
  if (!this.isAuthorizedForSharedSession(envelope)) {
1662
- await this.sendMessage(envelope.chatId, 'Only authorized members can clear this shared session.');
1688
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can clear this shared session.');
1663
1689
  return true;
1664
1690
  }
1665
1691
  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.');
1692
+ 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
1693
  return true;
1668
1694
  }
1669
1695
  await doClear(envelope);
@@ -1680,7 +1706,7 @@ export class ChannelBase {
1680
1706
  // leaks the workspace basename, so non-members shouldn't see it either.
1681
1707
  this.registerCommand('who', async (envelope) => {
1682
1708
  if (!this.isAuthorizedForSharedSession(envelope)) {
1683
- await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
1709
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can view this shared session.');
1684
1710
  return true;
1685
1711
  }
1686
1712
  const active = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
@@ -1697,7 +1723,7 @@ export class ChannelBase {
1697
1723
  : envelope.isGroup
1698
1724
  ? ' (private to you)'
1699
1725
  : '';
1700
- await this.sendMessage(envelope.chatId, [
1726
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
1701
1727
  `Channel: ${this.name}`,
1702
1728
  // Identity/memory lines only for channels that opted in — keep
1703
1729
  // unconfigured channels' output unchanged.
@@ -1758,14 +1784,14 @@ export class ChannelBase {
1758
1784
  }
1759
1785
  }
1760
1786
  lines.push('', 'Send any text to chat with the agent.');
1761
- await this.sendMessage(envelope.chatId, lines.join('\n'));
1787
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lines.join('\n'));
1762
1788
  return true;
1763
1789
  });
1764
1790
  this.registerCommand('status', async (envelope) => {
1765
1791
  // For a shared session, gate it to authorized senders like /who — /status
1766
1792
  // reports session & access state, so non-members shouldn't read it either.
1767
1793
  if (!this.isAuthorizedForSharedSession(envelope)) {
1768
- await this.sendMessage(envelope.chatId, 'Only authorized members can view this shared session.');
1794
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can view this shared session.');
1769
1795
  return true;
1770
1796
  }
1771
1797
  const hasSession = this.router.hasSession(this.name, envelope.senderId, envelope.chatId, envelope.threadId);
@@ -1781,18 +1807,18 @@ export class ChannelBase {
1781
1807
  ]
1782
1808
  : []),
1783
1809
  ];
1784
- await this.sendMessage(envelope.chatId, lines.join('\n'));
1810
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lines.join('\n'));
1785
1811
  return true;
1786
1812
  });
1787
1813
  this.registerCommand('loop', async (envelope, args) => this.handleLoopCommand(envelope, args));
1788
1814
  }
1789
1815
  async handleLoopCommand(envelope, args) {
1790
1816
  if (!this.loopController) {
1791
- await this.sendMessage(envelope.chatId, 'Loops are not available.');
1817
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Loops are not available.');
1792
1818
  return true;
1793
1819
  }
1794
1820
  if (!this.isAuthorizedForSharedSession(envelope)) {
1795
- await this.sendMessage(envelope.chatId, 'Only authorized members can use loops in this shared session.');
1821
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Only authorized members can use loops in this shared session.');
1796
1822
  return true;
1797
1823
  }
1798
1824
  const [subcommand = '', ...rest] = args.trim().split(/\s+/u);
@@ -1806,7 +1832,7 @@ export class ChannelBase {
1806
1832
  case 'cancel':
1807
1833
  return this.handleLoopCancel(envelope, rest[0]);
1808
1834
  default:
1809
- await this.sendMessage(envelope.chatId, 'Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>');
1835
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>');
1810
1836
  return true;
1811
1837
  }
1812
1838
  }
@@ -1814,33 +1840,33 @@ export class ChannelBase {
1814
1840
  if (!this.loopController)
1815
1841
  return true;
1816
1842
  if (!this.supportsProactiveSend()) {
1817
- await this.sendMessage(envelope.chatId, 'This channel does not support proactive loop messages.');
1843
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'This channel does not support proactive loop messages.');
1818
1844
  return true;
1819
1845
  }
1820
1846
  if (this.config.sessionScope === 'single') {
1821
- await this.sendMessage(envelope.chatId, 'Loops are not supported when sessionScope is single.');
1847
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Loops are not supported when sessionScope is single.');
1822
1848
  return true;
1823
1849
  }
1824
1850
  const parsed = parseLoopAddArgs(args);
1825
1851
  if (!parsed) {
1826
- await this.sendMessage(envelope.chatId, 'Usage: /loop add "<cron>" <prompt>');
1852
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop add "<cron>" <prompt>');
1827
1853
  return true;
1828
1854
  }
1829
1855
  try {
1830
1856
  this.loopController.validateCron(parsed.cron);
1831
1857
  }
1832
1858
  catch (err) {
1833
- await this.sendMessage(envelope.chatId, `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`);
1859
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Invalid cron expression: ${err instanceof Error ? err.message : String(err)}`);
1834
1860
  return true;
1835
1861
  }
1836
1862
  const target = this.loopTargetFromEnvelope(envelope);
1837
1863
  if (!this.supportsProactiveTarget(target)) {
1838
- await this.sendMessage(envelope.chatId, 'This channel does not support proactive loop messages for this chat target.');
1864
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'This channel does not support proactive loop messages for this chat target.');
1839
1865
  return true;
1840
1866
  }
1841
1867
  const prompt = sanitizePromptText(parsed.prompt.trim());
1842
1868
  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.`);
1869
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Loop prompt is too long; keep it under ${MAX_LOOP_PROMPT_CHARS} characters.`);
1844
1870
  return true;
1845
1871
  }
1846
1872
  const input = {
@@ -1865,10 +1891,10 @@ export class ChannelBase {
1865
1891
  }
1866
1892
  }
1867
1893
  if (!job) {
1868
- await this.sendMessage(envelope.chatId, `Too many loops for this chat. Cancel an existing loop before adding another.`);
1894
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Too many loops for this chat. Cancel an existing loop before adding another.`);
1869
1895
  return true;
1870
1896
  }
1871
- await this.sendMessage(envelope.chatId, `Loop ${job.id}: ${job.cron}`);
1897
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Loop ${job.id}: ${job.cron}`);
1872
1898
  return true;
1873
1899
  }
1874
1900
  async createLoopFromTool(sessionId, input) {
@@ -1975,23 +2001,23 @@ export class ChannelBase {
1975
2001
  return true;
1976
2002
  const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
1977
2003
  if (jobs.length === 0) {
1978
- await this.sendMessage(envelope.chatId, 'No loops.');
2004
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No loops.');
1979
2005
  return true;
1980
2006
  }
1981
- await this.sendMessage(envelope.chatId, jobs.map((job) => this.formatLoopListLine(job)).join('\n'));
2007
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, jobs.map((job) => this.formatLoopListLine(job)).join('\n'));
1982
2008
  return true;
1983
2009
  }
1984
2010
  async handleLoopInspect(envelope, id) {
1985
2011
  if (!this.loopController)
1986
2012
  return true;
1987
2013
  if (!id) {
1988
- await this.sendMessage(envelope.chatId, 'Usage: /loop inspect <id>');
2014
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop inspect <id>');
1989
2015
  return true;
1990
2016
  }
1991
2017
  const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
1992
2018
  const job = jobs.find((candidate) => candidate.id === id);
1993
2019
  if (!job) {
1994
- await this.sendMessage(envelope.chatId, `No loop ${id}.`);
2020
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No loop ${id}.`);
1995
2021
  return true;
1996
2022
  }
1997
2023
  const lines = [
@@ -2013,7 +2039,7 @@ export class ChannelBase {
2013
2039
  lines.push(`Last result: ${job.lastResultPreview}`);
2014
2040
  }
2015
2041
  lines.push(`Prompt: ${job.prompt}`);
2016
- await this.sendMessage(envelope.chatId, lines.join('\n'));
2042
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, lines.join('\n'));
2017
2043
  return true;
2018
2044
  }
2019
2045
  formatLoopListLine(job) {
@@ -2046,17 +2072,17 @@ export class ChannelBase {
2046
2072
  if (!this.loopController)
2047
2073
  return true;
2048
2074
  if (!id) {
2049
- await this.sendMessage(envelope.chatId, 'Usage: /loop cancel <id>');
2075
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Usage: /loop cancel <id>');
2050
2076
  return true;
2051
2077
  }
2052
2078
  const jobs = await this.loopController.listForTarget(this.name, this.loopTargetFromEnvelope(envelope));
2053
2079
  const match = jobs.find((job) => job.id === id);
2054
2080
  if (!match) {
2055
- await this.sendMessage(envelope.chatId, `No loop ${id}.`);
2081
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No loop ${id}.`);
2056
2082
  return true;
2057
2083
  }
2058
2084
  const disabled = await this.loopController.disable(id);
2059
- await this.sendMessage(envelope.chatId, disabled ? `Cancelled loop ${id}.` : `Failed to cancel loop ${id}.`);
2085
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, disabled ? `Cancelled loop ${id}.` : `Failed to cancel loop ${id}.`);
2060
2086
  return true;
2061
2087
  }
2062
2088
  loopTargetFromEnvelope(envelope) {
@@ -2191,7 +2217,7 @@ export class ChannelBase {
2191
2217
  }
2192
2218
  async getChannelMemory(envelope) {
2193
2219
  if (!this.channelMemory) {
2194
- await this.sendMessage(envelope.chatId, 'Channel memory is not configured for this channel.');
2220
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Channel memory is not configured for this channel.');
2195
2221
  return undefined;
2196
2222
  }
2197
2223
  return this.channelMemory;
@@ -2211,7 +2237,7 @@ export class ChannelBase {
2211
2237
  }
2212
2238
  async handleChannelMemoryIntent(envelope, intent, options = {}) {
2213
2239
  if (intent.kind === 'no_match') {
2214
- await this.sendMessage(envelope.chatId, 'No matching channel memory entry.');
2240
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No matching channel memory entry.');
2215
2241
  return;
2216
2242
  }
2217
2243
  if (intent.kind === 'ambiguous') {
@@ -2224,11 +2250,11 @@ export class ChannelBase {
2224
2250
  }
2225
2251
  catch (error) {
2226
2252
  this.logChannelMemoryError('read', envelope, this.channelMemoryErrorMessage(error));
2227
- await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2253
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2228
2254
  return;
2229
2255
  }
2230
2256
  const selected = this.entriesForChannelMemoryIds(entries, intent.ids);
2231
- await this.sendMessage(envelope.chatId, [
2257
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
2232
2258
  'Multiple channel memory entries match:',
2233
2259
  ...this.renderChannelMemoryCandidates(selected),
2234
2260
  ].join('\n'));
@@ -2244,11 +2270,11 @@ export class ChannelBase {
2244
2270
  }
2245
2271
  catch (error) {
2246
2272
  this.logChannelMemoryError('read', envelope, this.channelMemoryErrorMessage(error));
2247
- await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2273
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2248
2274
  return;
2249
2275
  }
2250
2276
  const selected = this.entriesForChannelMemoryIds(entries, intent.ids);
2251
- await this.sendMessage(envelope.chatId, [
2277
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [
2252
2278
  'Channel memory (page 1/1):',
2253
2279
  ...this.renderChannelMemoryCandidates(selected),
2254
2280
  ].join('\n'));
@@ -2263,7 +2289,7 @@ export class ChannelBase {
2263
2289
  ? this.takePendingChannelMemoryMutation(envelope, 'update')
2264
2290
  : this.takePendingChannelMemoryMutation(envelope, 'remove');
2265
2291
  if (!pending) {
2266
- await this.sendMessage(envelope.chatId, intent.kind === 'update_confirm'
2292
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, intent.kind === 'update_confirm'
2267
2293
  ? 'No pending channel memory update. Start a new update request first.'
2268
2294
  : 'No pending channel memory removal. Start a new removal request first.');
2269
2295
  return;
@@ -2291,17 +2317,17 @@ export class ChannelBase {
2291
2317
  catch (error) {
2292
2318
  const message = this.channelMemoryErrorMessage(error);
2293
2319
  this.logChannelMemoryError(isUpdate ? 'update' : 'remove', envelope, message);
2294
- await this.sendMessage(envelope.chatId, message === 'Channel memory entry changed'
2320
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, message === 'Channel memory entry changed'
2295
2321
  ? 'That channel memory entry changed since it was selected. View channel memory and start the operation again.'
2296
2322
  : `Failed to ${isUpdate ? 'update' : 'remove'} channel memory: ${this.channelMemoryUserErrorMessage()}`);
2297
2323
  return;
2298
2324
  }
2299
2325
  if (!changed) {
2300
- await this.sendMessage(envelope.chatId, `No channel memory entry ${pending.id}.`);
2326
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No channel memory entry ${pending.id}.`);
2301
2327
  return;
2302
2328
  }
2303
2329
  this.invalidateUnattendedMemory(envelope);
2304
- await this.sendMessage(envelope.chatId, `Channel memory ${pending.id} ${isUpdate ? 'updated' : 'removed'}.`);
2330
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory ${pending.id} ${isUpdate ? 'updated' : 'removed'}.`);
2305
2331
  return;
2306
2332
  }
2307
2333
  if (intent.kind === 'natural_update') {
@@ -2342,7 +2368,7 @@ export class ChannelBase {
2342
2368
  catch (error) {
2343
2369
  const message = this.channelMemoryErrorMessage(error);
2344
2370
  this.logChannelMemoryError('save', envelope, message);
2345
- await this.sendMessage(envelope.chatId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
2371
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to save channel memory: ${this.channelMemoryUserErrorMessage()}`);
2346
2372
  return;
2347
2373
  }
2348
2374
  if (result.changed) {
@@ -2357,17 +2383,17 @@ export class ChannelBase {
2357
2383
  }
2358
2384
  if (result.added.length > 0) {
2359
2385
  const ids = result.added.map((entry) => entry.id);
2360
- await this.sendMessage(envelope.chatId, result.duplicateIds.length > 0
2386
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.duplicateIds.length > 0
2361
2387
  ? `Channel memory saved: ${ids.join(', ')}. Skipped duplicates: ${result.duplicateIds.join(', ')}.`
2362
2388
  : ids.length === 1
2363
2389
  ? `Channel memory ${ids[0]} saved.`
2364
2390
  : `Channel memory saved: ${ids.join(', ')}.`);
2365
2391
  }
2366
2392
  else if (result.duplicateIds.length > 0) {
2367
- await this.sendMessage(envelope.chatId, `Channel memory already contains ${result.duplicateIds.join(', ')}.`);
2393
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory already contains ${result.duplicateIds.join(', ')}.`);
2368
2394
  }
2369
2395
  else {
2370
- await this.sendMessage(envelope.chatId, 'Channel memory updated.');
2396
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Channel memory updated.');
2371
2397
  }
2372
2398
  return;
2373
2399
  }
@@ -2379,30 +2405,30 @@ export class ChannelBase {
2379
2405
  catch (error) {
2380
2406
  const message = this.channelMemoryErrorMessage(error);
2381
2407
  this.logChannelMemoryError('read', envelope, message);
2382
- await this.sendMessage(envelope.chatId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2408
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to read channel memory: ${this.channelMemoryUserErrorMessage()}`);
2383
2409
  return;
2384
2410
  }
2385
2411
  if (intent.kind === 'inspect') {
2386
2412
  const entry = entries.find((candidate) => candidate.id === intent.id);
2387
- await this.sendMessage(envelope.chatId, entry
2413
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, entry
2388
2414
  ? `Channel memory ${entry.id}:\n${sanitizePromptText(entry.text).trim()}`
2389
2415
  : `No channel memory entry ${intent.id}.`);
2390
2416
  return;
2391
2417
  }
2392
2418
  const totalPages = Math.max(1, Math.ceil(entries.length / CHANNEL_MEMORY_PAGE_SIZE));
2393
2419
  if (intent.page > totalPages) {
2394
- await this.sendMessage(envelope.chatId, `Channel memory page ${intent.page} does not exist.`);
2420
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory page ${intent.page} does not exist.`);
2395
2421
  return;
2396
2422
  }
2397
2423
  if (entries.length === 0) {
2398
- await this.sendMessage(envelope.chatId, 'No channel memory saved.');
2424
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No channel memory saved.');
2399
2425
  return;
2400
2426
  }
2401
2427
  const pageStart = (intent.page - 1) * CHANNEL_MEMORY_PAGE_SIZE;
2402
2428
  const lines = entries
2403
2429
  .slice(pageStart, pageStart + CHANNEL_MEMORY_PAGE_SIZE)
2404
2430
  .map((entry) => this.renderChannelMemoryCandidate(entry));
2405
- await this.sendMessage(envelope.chatId, [`Channel memory (page ${intent.page}/${totalPages}):`, ...lines].join('\n'));
2431
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, [`Channel memory (page ${intent.page}/${totalPages}):`, ...lines].join('\n'));
2406
2432
  return;
2407
2433
  }
2408
2434
  if (intent.kind === 'update' || intent.kind === 'remove') {
@@ -2420,21 +2446,21 @@ export class ChannelBase {
2420
2446
  catch (error) {
2421
2447
  const message = this.channelMemoryErrorMessage(error);
2422
2448
  this.logChannelMemoryError(isUpdate ? 'update' : 'remove', envelope, message);
2423
- await this.sendMessage(envelope.chatId, `Failed to ${isUpdate ? 'update' : 'remove'} channel memory: ${this.channelMemoryUserErrorMessage()}`);
2449
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to ${isUpdate ? 'update' : 'remove'} channel memory: ${this.channelMemoryUserErrorMessage()}`);
2424
2450
  return;
2425
2451
  }
2426
2452
  if (!changed) {
2427
- await this.sendMessage(envelope.chatId, `No channel memory entry ${id}.`);
2453
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `No channel memory entry ${id}.`);
2428
2454
  return;
2429
2455
  }
2430
2456
  this.invalidateUnattendedMemory(envelope);
2431
- await this.sendMessage(envelope.chatId, `Channel memory ${id} ${isUpdate ? 'updated' : 'removed'}.`);
2457
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Channel memory ${id} ${isUpdate ? 'updated' : 'removed'}.`);
2432
2458
  return;
2433
2459
  }
2434
2460
  if (intent.kind === 'clear_confirm') {
2435
2461
  const pending = this.takePendingChannelMemoryMutation(envelope, 'clear');
2436
2462
  if (!pending) {
2437
- await this.sendMessage(envelope.chatId, 'No pending clear request. Say "清空记忆" first.');
2463
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'No pending clear request. Say "清空记忆" first.');
2438
2464
  return;
2439
2465
  }
2440
2466
  let result;
@@ -2444,13 +2470,13 @@ export class ChannelBase {
2444
2470
  catch (error) {
2445
2471
  const message = this.channelMemoryErrorMessage(error);
2446
2472
  this.logChannelMemoryError('clear', envelope, message);
2447
- await this.sendMessage(envelope.chatId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
2473
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Failed to clear channel memory: ${this.channelMemoryUserErrorMessage()}`);
2448
2474
  return;
2449
2475
  }
2450
2476
  if (result.changed) {
2451
2477
  this.invalidateUnattendedMemory(envelope);
2452
2478
  }
2453
- await this.sendMessage(envelope.chatId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
2479
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, result.changed ? 'Channel memory cleared.' : 'No channel memory saved.');
2454
2480
  return;
2455
2481
  }
2456
2482
  const unhandled = intent;
@@ -2482,7 +2508,7 @@ export class ChannelBase {
2482
2508
  const key = this.channelMemoryPendingKey(envelope);
2483
2509
  this.pendingChannelMemoryMutationDeliveries.set(key, mutation);
2484
2510
  try {
2485
- await this.sendMessage(envelope.chatId, message);
2511
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, message);
2486
2512
  }
2487
2513
  catch (error) {
2488
2514
  if (this.pendingChannelMemoryMutationDeliveries.get(key) === mutation) {
@@ -2655,15 +2681,18 @@ export class ChannelBase {
2655
2681
  * Whether the resolved session is SHARED across senders. `single` collapses
2656
2682
  * the whole channel to one `__single__` session for EVERY sender — group OR
2657
2683
  * 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.
2684
+ * shared only in a group (a DM maps to the lone caller's own chat).
2685
+ * `chat_thread` is always shared: it scopes by chat+thread, and a thread
2686
+ * (issue/PR discussion) can carry multiple participants even outside a
2687
+ * group. `user` is per-sender, never shared. Drives both the
2688
+ * destructive-/clear confirm gate and the host-shell (`!`) gate.
2661
2689
  */
2662
2690
  isSharedSession(envelope) {
2663
2691
  return this.isSharedSessionTarget(envelope);
2664
2692
  }
2665
2693
  isSharedSessionTarget(target) {
2666
2694
  return (this.config.sessionScope === 'single' ||
2695
+ this.config.sessionScope === 'chat_thread' ||
2667
2696
  (target.isGroup === true && this.config.sessionScope === 'thread'));
2668
2697
  }
2669
2698
  /**
@@ -2962,7 +2991,7 @@ export class ChannelBase {
2962
2991
  if (!result.allowed) {
2963
2992
  if (result.pairingCode !== undefined) {
2964
2993
  this.logPreflightRejected('sender_pairing_required');
2965
- return this.onPairingRequired(envelope.chatId, result.pairingCode)
2994
+ return this.onPairingRequired(envelope.chatId, result.pairingCode, envelope.threadId)
2966
2995
  .then(() => false)
2967
2996
  .catch((err) => {
2968
2997
  process.stderr.write(`[Channel:${this.name}] pairing notification failed: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
@@ -3103,13 +3132,13 @@ export class ChannelBase {
3103
3132
  process.stderr.write(`[${this.name}] blocked ! shell command from ${who} (sender ${envelope.senderId}) in chat ${sanitizeLogText(envelope.chatId, 64)}\n`);
3104
3133
  }
3105
3134
  if (envelope.isGroup) {
3106
- await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in group chats.');
3135
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Shell commands (`!`) are disabled in group chats.');
3107
3136
  return;
3108
3137
  }
3109
3138
  // A single-scope DM collapses every DM to one channel-wide session, so it
3110
3139
  // is multi-operator too despite not being a group.
3111
3140
  if (this.isSharedSession(envelope)) {
3112
- await this.sendMessage(envelope.chatId, 'Shell commands (`!`) are disabled in shared sessions.');
3141
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, 'Shell commands (`!`) are disabled in shared sessions.');
3113
3142
  return;
3114
3143
  }
3115
3144
  }
@@ -3131,10 +3160,10 @@ export class ChannelBase {
3131
3160
  const exitLine = result.exitCode !== null && result.exitCode !== 0
3132
3161
  ? `\nExit code: ${result.exitCode}`
3133
3162
  : '';
3134
- await this.sendMessage(envelope.chatId, `$ ${cmd}\n${output}${exitLine}`);
3163
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `$ ${cmd}\n${output}${exitLine}`);
3135
3164
  }
3136
3165
  catch (error) {
3137
- await this.sendMessage(envelope.chatId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`);
3166
+ await this.sendThreadMessage(envelope.chatId, envelope.threadId, `Shell command failed: ${error instanceof Error ? error.message : String(error)}`);
3138
3167
  }
3139
3168
  return;
3140
3169
  }
@@ -3212,6 +3241,9 @@ export class ChannelBase {
3212
3241
  promptText = promptText + '\n\n' + filePaths.join('\n');
3213
3242
  }
3214
3243
  }
3244
+ if (envelope.metadata) {
3245
+ promptText = promptText + '\n\n' + sanitizePromptText(envelope.metadata);
3246
+ }
3215
3247
  // Resolve dispatch mode: per-group override → channel config → default
3216
3248
  const groupCfg = envelope.isGroup
3217
3249
  ? this.config.groups[envelope.chatId] || this.config.groups['*']
@@ -3628,6 +3660,7 @@ export class ChannelBase {
3628
3660
  // Clear attachments/references — already resolved in original text
3629
3661
  referencedText: undefined,
3630
3662
  attachments: undefined,
3663
+ metadata: undefined,
3631
3664
  imageBase64: undefined,
3632
3665
  imageMimeType: undefined,
3633
3666
  };
@@ -3643,12 +3676,12 @@ export class ChannelBase {
3643
3676
  this.sessionQueues.set(sessionId, current.catch(() => { }));
3644
3677
  await current;
3645
3678
  }
3646
- async onPairingRequired(chatId, code) {
3679
+ async onPairingRequired(chatId, code, threadId) {
3647
3680
  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}`);
3681
+ 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
3682
  }
3650
3683
  else {
3651
- await this.sendMessage(chatId, 'Too many pending pairing requests. Please try again later.');
3684
+ await this.sendThreadMessage(chatId, threadId, 'Too many pending pairing requests. Please try again later.');
3652
3685
  }
3653
3686
  }
3654
3687
  }