switchroom 0.14.55 → 0.14.57

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.
@@ -49463,8 +49463,8 @@ var {
49463
49463
  } = import__.default;
49464
49464
 
49465
49465
  // src/build-info.ts
49466
- var VERSION = "0.14.55";
49467
- var COMMIT_SHA = "dc589fea";
49466
+ var VERSION = "0.14.57";
49467
+ var COMMIT_SHA = "ddb0b353";
49468
49468
 
49469
49469
  // src/cli/agent.ts
49470
49470
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.55",
3
+ "version": "0.14.57",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -78,7 +78,7 @@ const mcp = new Server(
78
78
  '',
79
79
  'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
80
80
  '',
81
- 'If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic — no need to pass message_thread_id manually unless you want to override.',
81
+ 'If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic — no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
82
82
  '',
83
83
  'The default format is "html" — write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
84
84
  '',
@@ -104,6 +104,7 @@ const TOOL_SCHEMAS = [
104
104
  reply_to: { type: 'string', description: 'Message ID to thread under. Overrides the default (latest inbound).' },
105
105
  quote: { type: 'boolean', description: 'Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set.' },
106
106
  message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified.' },
107
+ origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
107
108
  files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each.' },
108
109
  format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
109
110
  disable_web_page_preview: { type: 'boolean', description: 'Disable link preview thumbnails. Default: true.' },
@@ -143,6 +144,7 @@ const TOOL_SCHEMAS = [
143
144
  text: { type: 'string', description: 'Full text snapshot. NOT a delta — pass the complete current content each call.' },
144
145
  done: { type: 'boolean', description: 'Must be true. Posts this text as the final answer for the turn and locks the message.' },
145
146
  message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
147
+ origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
146
148
  format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
147
149
  reply_to: { type: 'string', description: 'Message ID to quote-reply to. Overrides the default (latest inbound).' },
148
150
  quote: { type: 'boolean', description: 'Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set.' },
@@ -24571,7 +24571,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
24571
24571
  "",
24572
24572
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24573
24573
  "",
24574
- "If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override.",
24574
+ "If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
24575
24575
  "",
24576
24576
  'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
24577
24577
  "",
@@ -24593,6 +24593,7 @@ var TOOL_SCHEMAS = [
24593
24593
  reply_to: { type: "string", description: "Message ID to thread under. Overrides the default (latest inbound)." },
24594
24594
  quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set." },
24595
24595
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
24596
+ origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24596
24597
  files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each." },
24597
24598
  format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24598
24599
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
@@ -24631,6 +24632,7 @@ var TOOL_SCHEMAS = [
24631
24632
  text: { type: "string", description: "Full text snapshot. NOT a delta \u2014 pass the complete current content each call." },
24632
24633
  done: { type: "boolean", description: "Must be true. Posts this text as the final answer for the turn and locks the message." },
24633
24634
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." },
24635
+ origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24634
24636
  format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24635
24637
  reply_to: { type: "string", description: "Message ID to quote-reply to. Overrides the default (latest inbound)." },
24636
24638
  quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set." },
@@ -47347,6 +47347,34 @@ function decideInboundDelivery(input) {
47347
47347
  return "deliver";
47348
47348
  }
47349
47349
 
47350
+ // gateway/serialize-drain-gate.ts
47351
+ function mayDrainBufferedInbound(input) {
47352
+ if (input.turnInFlight)
47353
+ return false;
47354
+ if (!input.enabled)
47355
+ return true;
47356
+ const delivered = input.endingTurnFinalAnswerDelivered;
47357
+ if (delivered == null)
47358
+ return true;
47359
+ return delivered === true;
47360
+ }
47361
+ function shouldArmNoReplyDrain(input) {
47362
+ if (!input.enabled)
47363
+ return false;
47364
+ if (input.finalAnswerDelivered === true)
47365
+ return false;
47366
+ return input.bufferedDepth > 0;
47367
+ }
47368
+
47369
+ // gateway/answer-thread-resolve.ts
47370
+ function resolveAnswerThreadId(input) {
47371
+ if (input.explicitThreadId != null)
47372
+ return input.explicitThreadId;
47373
+ if (input.originResolved)
47374
+ return input.originThreadId;
47375
+ return input.liveThreadId;
47376
+ }
47377
+
47350
47378
  // gateway/inbound-delivery-confirm.ts
47351
47379
  function createDeliveryQueue() {
47352
47380
  return { pending: new Map };
@@ -52167,10 +52195,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52167
52195
  }
52168
52196
 
52169
52197
  // ../src/build-info.ts
52170
- var VERSION = "0.14.55";
52171
- var COMMIT_SHA = "dc589fea";
52172
- var COMMIT_DATE = "2026-06-03T21:05:15Z";
52173
- var LATEST_PR = 2136;
52198
+ var VERSION = "0.14.57";
52199
+ var COMMIT_SHA = "ddb0b353";
52200
+ var COMMIT_DATE = "2026-06-03T22:37:37Z";
52201
+ var LATEST_PR = 2140;
52174
52202
  var COMMITS_AHEAD_OF_TAG = 0;
52175
52203
 
52176
52204
  // gateway/boot-version.ts
@@ -53340,6 +53368,7 @@ if (!STATIC)
53340
53368
  var chatThreadMap = new Map;
53341
53369
  var activeStatusReactions = new Map;
53342
53370
  var activeReactionMsgIds = new Map;
53371
+ var queuedStatusMsgIds = new Map;
53343
53372
  var deferredDoneReactions = new DeferredDoneReactions({
53344
53373
  countRunningWorkers: () => countRunningWorkers(),
53345
53374
  getActive: (key) => activeStatusReactions.get(key),
@@ -53367,6 +53396,13 @@ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw
53367
53396
  var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
53368
53397
  var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53369
53398
  var deliveryQueue = createDeliveryQueue();
53399
+ var SERIALIZE_UNTIL_REPLIED_ENABLED = process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== "0";
53400
+ var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
53401
+ var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
53402
+ var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
53403
+ var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
53404
+ var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
53405
+ var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
53370
53406
  function turnInFlightForGate() {
53371
53407
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
53372
53408
  }
@@ -53384,6 +53420,68 @@ var lastPtyPreviewByChat = new Map;
53384
53420
  var progressUpdateLastSent = new Map;
53385
53421
  var progressUpdateTurnCount = new Map;
53386
53422
  var currentTurn = null;
53423
+ var RECENT_TURNS_MAX = 32;
53424
+ var recentTurnsById = new Map;
53425
+ function rememberRecentTurn(turn) {
53426
+ recentTurnsById.set(turn.turnId, turn);
53427
+ while (recentTurnsById.size > RECENT_TURNS_MAX) {
53428
+ const oldest = recentTurnsById.keys().next().value;
53429
+ if (oldest === undefined)
53430
+ break;
53431
+ recentTurnsById.delete(oldest);
53432
+ }
53433
+ }
53434
+ function deriveTurnId(chatId, threadId, messageId) {
53435
+ if (messageId == null || messageId === "" || String(messageId) === "0")
53436
+ return null;
53437
+ return `${chatKey2(chatId, threadId ?? null)}#${messageId}`;
53438
+ }
53439
+ function findTurnByOriginId(originTurnId) {
53440
+ if (originTurnId == null || originTurnId === "")
53441
+ return null;
53442
+ if (currentTurn != null && currentTurn.turnId === originTurnId)
53443
+ return currentTurn;
53444
+ return recentTurnsById.get(originTurnId) ?? null;
53445
+ }
53446
+ function postQueuedStatus(chatId, bufferedThread, inFlightThread) {
53447
+ if (!QUEUED_STATUS_UX_ENABLED)
53448
+ return;
53449
+ const key = statusKey(chatId, bufferedThread);
53450
+ if (queuedStatusMsgIds.has(key))
53451
+ return;
53452
+ const otherTopic = inFlightThread != null ? `another topic` : `another conversation`;
53453
+ const text = `\u23F3 Queued \u2014 replying in ${otherTopic} first, then I'll get to this.`;
53454
+ (async () => {
53455
+ const sent = await swallowingApiCall(() => bot.api.sendMessage(chatId, text, { message_thread_id: bufferedThread }), { chat_id: chatId, verb: "queued-status.post", threadId: bufferedThread });
53456
+ const messageId = sent?.message_id;
53457
+ if (typeof messageId !== "number")
53458
+ return;
53459
+ if (queuedStatusMsgIds.has(key)) {
53460
+ swallowingApiCall(() => bot.api.deleteMessage(chatId, messageId), { chat_id: chatId, verb: "queued-status.post-race-cleanup", threadId: bufferedThread });
53461
+ return;
53462
+ }
53463
+ queuedStatusMsgIds.set(key, { chatId, threadId: bufferedThread, messageId });
53464
+ })();
53465
+ }
53466
+ function promoteQueuedStatus(chatId, thread) {
53467
+ if (!QUEUED_STATUS_UX_ENABLED)
53468
+ return;
53469
+ if (thread == null)
53470
+ return;
53471
+ const key = statusKey(chatId, thread);
53472
+ const entry = queuedStatusMsgIds.get(key);
53473
+ if (entry == null)
53474
+ return;
53475
+ swallowingApiCall(() => bot.api.editMessageText(chatId, entry.messageId, "\u270D\uFE0F On it \u2014 replying now.", {}), { chat_id: chatId, verb: "queued-status.promote", threadId: thread });
53476
+ }
53477
+ function reapQueuedStatus(chatId, thread) {
53478
+ const key = statusKey(chatId, thread ?? null);
53479
+ const entry = queuedStatusMsgIds.get(key);
53480
+ if (entry == null)
53481
+ return;
53482
+ queuedStatusMsgIds.delete(key);
53483
+ swallowingApiCall(() => bot.api.deleteMessage(chatId, entry.messageId), { chat_id: chatId, verb: "queued-status.reap", ...entry.threadId != null ? { threadId: entry.threadId } : {} });
53484
+ }
53387
53485
  var toolFlightTracker = new ToolFlightTracker;
53388
53486
  var pendingDeferredInterrupt = null;
53389
53487
  async function fireDeferredInterrupt(reason) {
@@ -53448,6 +53546,52 @@ function statusKey(chatId, threadId) {
53448
53546
  function streamKey3(chatId, threadId) {
53449
53547
  return chatKey2(chatId, threadId);
53450
53548
  }
53549
+ function performBufferDrain(reason) {
53550
+ const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
53551
+ if (pendingInboundBuffer.depth(selfAgentForFlush) <= 0)
53552
+ return;
53553
+ const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => {
53554
+ const d = ipcServer.sendToAgent(selfAgentForFlush, m);
53555
+ if (d)
53556
+ markClaudeBusyForInbound(m);
53557
+ return d;
53558
+ }, inboundSpool, trackRedeliveredInbound);
53559
+ if (fr.redelivered > 0) {
53560
+ process.stderr.write(`telegram gateway: ${reason} flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53561
+ `);
53562
+ }
53563
+ }
53564
+ function drainBufferedIfAllowed(endingTurn, reason) {
53565
+ if (!mayDrainBufferedInbound({
53566
+ turnInFlight: turnInFlightForGate(),
53567
+ endingTurnFinalAnswerDelivered: endingTurn?.finalAnswerDelivered ?? null,
53568
+ enabled: SERIALIZE_UNTIL_REPLIED_ENABLED
53569
+ })) {
53570
+ return;
53571
+ }
53572
+ performBufferDrain(reason);
53573
+ }
53574
+ function armNoReplyDrainTimer(turn) {
53575
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
53576
+ if (!shouldArmNoReplyDrain({
53577
+ enabled: SERIALIZE_UNTIL_REPLIED_ENABLED,
53578
+ finalAnswerDelivered: turn.finalAnswerDelivered,
53579
+ bufferedDepth: pendingInboundBuffer.depth(selfAgent)
53580
+ })) {
53581
+ return;
53582
+ }
53583
+ if (turn.noReplyDrainTimer != null) {
53584
+ clearTimeout(turn.noReplyDrainTimer);
53585
+ turn.noReplyDrainTimer = null;
53586
+ }
53587
+ turn.noReplyDrainTimer = setTimeout(() => {
53588
+ turn.noReplyDrainTimer = null;
53589
+ process.stderr.write(`telegram gateway: no-reply bounded drain (${SERIALIZE_NOREPLY_DRAIN_MS}ms) \u2014 ` + `turn ${turn.turnId} ended without a reply; force-draining buffered inbound
53590
+ `);
53591
+ performBufferDrain("no-reply-bounded-drain");
53592
+ }, SERIALIZE_NOREPLY_DRAIN_MS);
53593
+ turn.noReplyDrainTimer.unref?.();
53594
+ }
53451
53595
  function purgeReactionTracking(key, endingTurn) {
53452
53596
  const outboundEmitted = endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
53453
53597
  shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
@@ -53455,6 +53599,14 @@ function purgeReactionTracking(key, endingTurn) {
53455
53599
  activeStatusReactions.delete(key);
53456
53600
  activeReactionMsgIds.delete(key);
53457
53601
  activeTurnStartedAt.delete(key);
53602
+ if (endingTurn != null) {
53603
+ reapQueuedStatus(endingTurn.sessionChatId, endingTurn.sessionThreadId);
53604
+ } else {
53605
+ const pqChatId = chatIdOfChatKey(key);
53606
+ const pqThreadPart = key.slice(pqChatId.length + 1);
53607
+ const pqThread = pqThreadPart === "_" || pqThreadPart === "" ? null : Number(pqThreadPart);
53608
+ reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? pqThread : undefined);
53609
+ }
53458
53610
  claudeBusyKeys.delete(key);
53459
53611
  if (endingTurn != null) {
53460
53612
  stopTurnTypingLoop(endingTurn.sessionChatId, endingTurn.sessionThreadId ?? null);
@@ -53469,20 +53621,8 @@ function purgeReactionTracking(key, endingTurn) {
53469
53621
  if (agentDir != null)
53470
53622
  removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId);
53471
53623
  }
53624
+ drainBufferedIfAllowed(endingTurn, "turn-complete");
53472
53625
  if (!turnInFlightForGate()) {
53473
- const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
53474
- if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
53475
- const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => {
53476
- const d = ipcServer.sendToAgent(selfAgentForFlush, m);
53477
- if (d)
53478
- markClaudeBusyForInbound(m);
53479
- return d;
53480
- }, inboundSpool, trackRedeliveredInbound);
53481
- if (fr.redelivered > 0) {
53482
- process.stderr.write(`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53483
- `);
53484
- }
53485
- }
53486
53626
  if (pendingRestarts.size > 0) {
53487
53627
  for (const [agentName3, _timestamp] of pendingRestarts.entries()) {
53488
53628
  triggerSelfRestart(agentName3, "turn-complete-pending-restart");
@@ -53493,33 +53633,24 @@ function purgeReactionTracking(key, endingTurn) {
53493
53633
  }
53494
53634
  }
53495
53635
  }
53496
- function releaseTurnBufferGate(key) {
53636
+ function releaseTurnBufferGate(key, endingTurn) {
53497
53637
  if (!activeTurnStartedAt.has(key))
53498
53638
  return;
53499
53639
  activeTurnStartedAt.delete(key);
53500
53640
  claudeBusyKeys.delete(key);
53501
53641
  shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
53502
- if (!turnInFlightForGate()) {
53503
- const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
53504
- if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
53505
- const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => {
53506
- const d = ipcServer.sendToAgent(selfAgentForFlush, m);
53507
- if (d)
53508
- markClaudeBusyForInbound(m);
53509
- return d;
53510
- }, inboundSpool, trackRedeliveredInbound);
53511
- if (fr.redelivered > 0) {
53512
- process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53513
- `);
53514
- }
53515
- }
53516
- }
53642
+ drainBufferedIfAllowed(endingTurn, "reply-released-gate");
53517
53643
  }
53518
53644
  function endCurrentTurnAtomic(turn) {
53519
53645
  if (currentTurn !== turn)
53520
53646
  return;
53521
53647
  currentTurn = null;
53648
+ if (turn.noReplyDrainTimer != null) {
53649
+ clearTimeout(turn.noReplyDrainTimer);
53650
+ turn.noReplyDrainTimer = null;
53651
+ }
53522
53652
  purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn);
53653
+ armNoReplyDrainTimer(turn);
53523
53654
  }
53524
53655
  function maybeProactiveCompact() {
53525
53656
  if (compactDispatching)
@@ -55207,7 +55338,9 @@ if (!STATIC) {
55207
55338
  }
55208
55339
  inboundSpool?.sweepEscalations((e) => {
55209
55340
  const chat = e.msg.chatId;
55210
- const threadOpts = typeof e.msg.meta?.threadId === "string" && e.msg.meta.threadId ? { message_thread_id: Number(e.msg.meta.threadId) } : {};
55341
+ const escThread = typeof e.msg.meta?.threadId === "string" && e.msg.meta.threadId ? Number(e.msg.meta.threadId) : undefined;
55342
+ const threadOpts = escThread != null ? { message_thread_id: escThread } : {};
55343
+ reapQueuedStatus(chat, escThread);
55211
55344
  swallowingApiCall(() => bot.api.sendMessage(chat, "\u26A0\uFE0F I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-spool-escalation" });
55212
55345
  });
55213
55346
  }, IDLE_DRAIN_INTERVAL_MS).unref();
@@ -55422,7 +55555,19 @@ ${url}`;
55422
55555
  effectiveText = text;
55423
55556
  }
55424
55557
  assertAllowedChat(chat_id);
55425
- let threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
55558
+ let threadId;
55559
+ if (TURN_ORIGIN_ROUTING_ENABLED) {
55560
+ const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
55561
+ const originTurn = findTurnByOriginId(args.origin_turn_id);
55562
+ threadId = resolveAnswerThreadId({
55563
+ explicitThreadId: Number.isFinite(explicit) ? explicit : undefined,
55564
+ originResolved: originTurn != null,
55565
+ originThreadId: originTurn?.sessionThreadId,
55566
+ liveThreadId: turn?.sessionThreadId
55567
+ });
55568
+ } else {
55569
+ threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
55570
+ }
55426
55571
  if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
55427
55572
  try {
55428
55573
  const latest = getLatestInboundMessageId(chat_id, threadId ?? null);
@@ -55746,7 +55891,10 @@ ${url}`;
55746
55891
  turn.finalAnswerDelivered = true;
55747
55892
  finalizeStatusReaction(chat_id, threadId, "done");
55748
55893
  }
55749
- releaseTurnBufferGate(statusKey(chat_id, threadId));
55894
+ releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
55895
+ if (turn?.finalAnswerDelivered === true) {
55896
+ reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId);
55897
+ }
55750
55898
  }
55751
55899
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
55752
55900
  `);
@@ -55761,8 +55909,21 @@ async function executeStreamReply(args) {
55761
55909
  throw new Error("stream_reply: chat_id is required");
55762
55910
  if (args.text == null || args.text === "")
55763
55911
  throw new Error("stream_reply: text is required and cannot be empty");
55764
- if (args.message_thread_id == null && turn?.sessionThreadId != null) {
55765
- args.message_thread_id = String(turn.sessionThreadId);
55912
+ if (args.message_thread_id == null) {
55913
+ let injected;
55914
+ if (TURN_ORIGIN_ROUTING_ENABLED) {
55915
+ const originTurn = findTurnByOriginId(args.origin_turn_id);
55916
+ injected = resolveAnswerThreadId({
55917
+ explicitThreadId: undefined,
55918
+ originResolved: originTurn != null,
55919
+ originThreadId: originTurn?.sessionThreadId,
55920
+ liveThreadId: turn?.sessionThreadId
55921
+ });
55922
+ } else {
55923
+ injected = turn?.sessionThreadId;
55924
+ }
55925
+ if (injected != null)
55926
+ args.message_thread_id = String(injected);
55766
55927
  }
55767
55928
  args.text = redactOutboundText(args.text, "stream_reply");
55768
55929
  {
@@ -55900,7 +56061,10 @@ async function executeStreamReply(args) {
55900
56061
  {
55901
56062
  const sChat = args.chat_id;
55902
56063
  const sThread = resolveThreadId(sChat, args.message_thread_id);
55903
- releaseTurnBufferGate(statusKey(sChat, sThread));
56064
+ releaseTurnBufferGate(statusKey(sChat, sThread), turn ?? undefined);
56065
+ if (turn?.finalAnswerDelivered === true) {
56066
+ reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId);
56067
+ }
55904
56068
  }
55905
56069
  return { content: [{ type: "text", text: `${result.status} (id: ${result.messageId ?? "pending"})` }] };
55906
56070
  }
@@ -56926,7 +57090,7 @@ async function drainActivitySummary(turn) {
56926
57090
  turn.activityInFlight = null;
56927
57091
  }
56928
57092
  }
56929
- function clearActivitySummary(turn) {
57093
+ function clearActivitySummary(turn, finalHtmlOverride) {
56930
57094
  const chat = turn.sessionChatId;
56931
57095
  const thread = turn.sessionThreadId;
56932
57096
  const inFlight = turn.activityInFlight ?? Promise.resolve();
@@ -56944,7 +57108,7 @@ function clearActivitySummary(turn) {
56944
57108
  }
56945
57109
  return;
56946
57110
  }
56947
- const finalHtml = composeTurnActivity(turn, true);
57111
+ const finalHtml = finalHtmlOverride !== undefined ? finalHtmlOverride : composeTurnActivity(turn, true);
56948
57112
  if (finalHtml == null)
56949
57113
  return;
56950
57114
  try {
@@ -56974,9 +57138,11 @@ function handleSessionEvent(ev) {
56974
57138
  prior.answerStream = null;
56975
57139
  }
56976
57140
  const startedAt = Date.now();
57141
+ const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined;
57142
+ const turnId = deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId) ?? `${chatKey2(ev.chatId, enqThreadIdNum ?? null)}#synthetic-${startedAt}`;
56977
57143
  const next = {
56978
57144
  sessionChatId: ev.chatId,
56979
- sessionThreadId: ev.threadId != null ? Number(ev.threadId) : undefined,
57145
+ sessionThreadId: enqThreadIdNum,
56980
57146
  sourceMessageId: ev.messageId != null && /^\d+$/.test(ev.messageId) ? Number(ev.messageId) : null,
56981
57147
  startedAt,
56982
57148
  gatewayReceiveAt: startedAt,
@@ -56987,7 +57153,9 @@ function handleSessionEvent(ev) {
56987
57153
  silentAnchorText: "",
56988
57154
  capturedText: [],
56989
57155
  orphanedReplyTimeoutId: null,
57156
+ turnId,
56990
57157
  registryKey: null,
57158
+ noReplyDrainTimer: null,
56991
57159
  lastAssistantMsgId: null,
56992
57160
  lastAssistantDone: false,
56993
57161
  toolCallCount: 0,
@@ -57001,6 +57169,8 @@ function handleSessionEvent(ev) {
57001
57169
  isDm: isDmChatId(ev.chatId)
57002
57170
  };
57003
57171
  currentTurn = next;
57172
+ rememberRecentTurn(next);
57173
+ promoteQueuedStatus(ev.chatId, enqThreadIdNum);
57004
57174
  if (DELIVERY_CONFIRM_ENABLED) {
57005
57175
  ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
57006
57176
  }
@@ -58342,6 +58512,8 @@ ${preBlock(write.output)}`;
58342
58512
  } catch {}
58343
58513
  }
58344
58514
  }
58515
+ const originTurnId = deriveTurnId(chat_id, messageThreadId ?? null, msgId);
58516
+ const topicScope = TOPIC_FRAMING_ENABLED && messageThreadId != null ? "This message belongs to the current topic only \u2014 answer ONLY this question, in this topic. Do not also answer a pending message from another topic." : undefined;
58345
58517
  const inboundMsg = {
58346
58518
  type: "inbound",
58347
58519
  chatId: chat_id,
@@ -58366,6 +58538,8 @@ ${preBlock(write.output)}`;
58366
58538
  user_id: String(from.id),
58367
58539
  ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
58368
58540
  ...messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {},
58541
+ ...originTurnId != null ? { origin_turn_id: originTurnId } : {},
58542
+ ...topicScope != null ? { topic_scope: topicScope } : {},
58369
58543
  ...imagePath ? { image_path: imagePath } : {},
58370
58544
  ...replyToMessageId != null ? { reply_to_message_id: String(replyToMessageId) } : {},
58371
58545
  ...replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {},
@@ -58419,6 +58593,10 @@ ${preBlock(write.output)}`;
58419
58593
  pendingInboundBuffer.push(selfAgent, inboundMsg);
58420
58594
  process.stderr.write(`telegram gateway: inbound held mid-turn agent=${selfAgent} chat=${chat_id} msg=${msgId ?? "-"} \u2014 will flush on turn-complete
58421
58595
  `);
58596
+ const inFlightThread = currentTurn?.sessionThreadId;
58597
+ if (QUEUED_STATUS_UX_ENABLED && !isDmChatId(chat_id) && messageThreadId != null && messageThreadId !== inFlightThread) {
58598
+ postQueuedStatus(chat_id, messageThreadId, inFlightThread);
58599
+ }
58422
58600
  return;
58423
58601
  }
58424
58602
  if (selfAgent) {
@@ -63238,16 +63416,18 @@ var didOneTimeSetup = false;
63238
63416
  const isBackground = dispatch.isBackground;
63239
63417
  if (!isBackground) {
63240
63418
  const turn = currentTurn;
63241
- const removed = turn != null && turn.foregroundSubAgents.delete(agentId);
63242
- if (turn != null && removed) {
63419
+ if (turn != null && turn.foregroundSubAgents.has(agentId)) {
63243
63420
  const action = foregroundFinishAction({
63244
- removed,
63421
+ removed: true,
63245
63422
  replyCalled: turn.replyCalled,
63246
- remainingForeground: turn.foregroundSubAgents.size
63423
+ remainingForeground: turn.foregroundSubAgents.size - 1
63247
63424
  });
63248
63425
  if (action === "handoff-clear") {
63249
- clearActivitySummary(turn);
63426
+ const finalHtml = composeTurnActivity(turn, true);
63427
+ turn.foregroundSubAgents.delete(agentId);
63428
+ clearActivitySummary(turn, finalHtml);
63250
63429
  } else if (action === "recompose") {
63430
+ turn.foregroundSubAgents.delete(agentId);
63251
63431
  const rendered = composeTurnActivity(turn);
63252
63432
  if (rendered != null) {
63253
63433
  turn.activityPendingRender = rendered;
@@ -24268,7 +24268,7 @@ var init_bridge = __esm(async () => {
24268
24268
  "",
24269
24269
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24270
24270
  "",
24271
- "If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override.",
24271
+ "If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
24272
24272
  "",
24273
24273
  'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
24274
24274
  "",
@@ -24290,6 +24290,7 @@ var init_bridge = __esm(async () => {
24290
24290
  reply_to: { type: "string", description: "Message ID to thread under. Overrides the default (latest inbound)." },
24291
24291
  quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set." },
24292
24292
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
24293
+ origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24293
24294
  files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each." },
24294
24295
  format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24295
24296
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
@@ -24328,6 +24329,7 @@ var init_bridge = __esm(async () => {
24328
24329
  text: { type: "string", description: "Full text snapshot. NOT a delta \u2014 pass the complete current content each call." },
24329
24330
  done: { type: "boolean", description: "Must be true. Posts this text as the final answer for the turn and locks the message." },
24330
24331
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." },
24332
+ origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24331
24333
  format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24332
24334
  reply_to: { type: "string", description: "Message ID to quote-reply to. Overrides the default (latest inbound)." },
24333
24335
  quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set." },
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Turn-origin answer-thread resolution (multitopic reply-routing,
3
+ * component 3).
4
+ *
5
+ * Pure decision: which forum-topic thread should an ANSWER reply
6
+ * (`reply` / `stream_reply`) land in?
7
+ *
8
+ * ## The bug this closes
9
+ *
10
+ * In a forum supergroup one sequential `claude` CLI owns every topic with
11
+ * a singleton `currentTurn`. The Brevo turn's reply landed ~42s after its
12
+ * turn-end event; by then `currentTurn` had flipped to the Meta turn.
13
+ * `executeReply` captured `const turn = currentTurn` at execution and, when
14
+ * the model omitted `message_thread_id`, resolved the thread from
15
+ * `turn.sessionThreadId` (Meta's thread) — so Brevo's answer landed in
16
+ * Meta. A successor turn stole a predecessor's late reply.
17
+ *
18
+ * ## The precedence (answer paths)
19
+ *
20
+ * 1. An explicit `message_thread_id` the MODEL passed wins outright —
21
+ * the model is asserting the destination topic.
22
+ * 2. Else the ORIGIN turn's thread: the turn matched by the reply's
23
+ * `origin_turn_id` (the meta field the model echoes back). This is
24
+ * authoritative even when `currentTurn` has flipped, because the
25
+ * origin turn is looked up in a recently-ended registry.
26
+ * 3. Else the LIVE turn's thread — but ONLY when the live turn IS the
27
+ * origin turn (no flip happened) OR no origin turn could be resolved
28
+ * at all (origin id absent/unknown; legacy / pre-stamp path).
29
+ * 4. Else (origin resolved AND it differs from the live turn) we pin to
30
+ * the origin thread and explicitly DO NOT fall through to the chat's
31
+ * last-seen `chatThreadMap` thread. For answer surfaces the chat
32
+ * last-seen heuristic is exactly what produced the wrong-topic bug.
33
+ *
34
+ * The `chatThreadMap` last-seen fallback is preserved for NON-answer
35
+ * surfaces (`send_typing`, `forward_message`, `progress_update`) by NOT
36
+ * routing them through this function — they keep calling `resolveThreadId`
37
+ * directly.
38
+ */
39
+
40
+ export interface AnswerThreadInput {
41
+ /** Explicit `message_thread_id` the model passed (already coerced to a
42
+ * number), or undefined when omitted. */
43
+ explicitThreadId?: number | undefined
44
+ /** Thread of the turn matched by `origin_turn_id`, or undefined when the
45
+ * origin turn is a DM (no thread). Only meaningful when
46
+ * `originResolved` is true. */
47
+ originThreadId?: number | undefined
48
+ /** Whether an origin turn was resolved at all. Distinguishes
49
+ * "origin turn exists and its thread is undefined (a DM origin)" from
50
+ * "no origin turn" — both surface as `originThreadId === undefined`. */
51
+ originResolved: boolean
52
+ /** Thread of the LIVE `currentTurn` at execution time, or undefined
53
+ * (no live turn, or a DM live turn). The legacy (#1664) fallback when
54
+ * no origin turn is resolvable. */
55
+ liveThreadId?: number | undefined
56
+ }
57
+
58
+ /**
59
+ * Pure. Returns the thread id to send the answer to, or undefined for the
60
+ * main chat (DM / no thread).
61
+ *
62
+ * Precedence: explicit model thread → origin turn's thread (authoritative
63
+ * across a currentTurn flip; this is the wrong-topic fix) → live turn's
64
+ * thread (legacy #1664 fallback when origin can't be resolved). Crucially
65
+ * the chat last-seen `chatThreadMap` heuristic is NOT in this chain — that
66
+ * heuristic is what produced the Brevo→Meta wrong-topic bug, so answer
67
+ * paths never reach it.
68
+ */
69
+ export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefined {
70
+ // (1) explicit model thread wins.
71
+ if (input.explicitThreadId != null) return input.explicitThreadId
72
+ // (2) origin turn resolved → pin to its thread (authoritative even when
73
+ // currentTurn has flipped to a successor). A DM origin yields
74
+ // undefined, which is correct.
75
+ if (input.originResolved) return input.originThreadId
76
+ // (3) no origin resolved (legacy / pre-stamp / evicted) → fall back to
77
+ // the live turn's thread, the existing turn-pinned behaviour (#1664).
78
+ return input.liveThreadId
79
+ }