switchroom 0.14.40 → 0.14.42

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.
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.40";
49466
- var COMMIT_SHA = "d2d69140";
49465
+ var VERSION = "0.14.42";
49466
+ var COMMIT_SHA = "6da4313d";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.40",
3
+ "version": "0.14.42",
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": {
@@ -44777,6 +44777,12 @@ function resolveOutboundTopic(config, event) {
44777
44777
  return aliasToId(cfg, ADMIN_ALIAS) ?? cfg.default_topic_id;
44778
44778
  }
44779
44779
  }
44780
+ function topicForRecipient(args) {
44781
+ const { recipientChatId, resolvedTopic, supergroupChatId } = args;
44782
+ if (resolvedTopic == null || supergroupChatId == null)
44783
+ return;
44784
+ return String(recipientChatId) === String(supergroupChatId) ? resolvedTopic : undefined;
44785
+ }
44780
44786
 
44781
44787
  // ../src/agents/perf.ts
44782
44788
  import { existsSync as existsSync18, readFileSync as readFileSync14 } from "node:fs";
@@ -47263,11 +47269,17 @@ function decideInboundDelivery(input) {
47263
47269
  function createDeliveryQueue() {
47264
47270
  return { pending: new Map };
47265
47271
  }
47266
- function trackDelivery(q, key, inbound, now) {
47267
- q.pending.set(key, { key, inbound, lastAttemptAt: now });
47272
+ function trackDelivery(q, key, inbound, now, messageId = null) {
47273
+ q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now });
47268
47274
  }
47269
- function ackDelivery(q, key) {
47270
- return q.pending.delete(key);
47275
+ function ackDelivery(q, key, enqueueMessageId = null) {
47276
+ const entry = q.pending.get(key);
47277
+ if (!entry)
47278
+ return false;
47279
+ if (entry.messageId != null && entry.messageId !== enqueueMessageId)
47280
+ return false;
47281
+ q.pending.delete(key);
47282
+ return true;
47271
47283
  }
47272
47284
  function sweep(q, now, timeoutMs) {
47273
47285
  const redeliver = [];
@@ -47282,6 +47294,15 @@ function sweep(q, now, timeoutMs) {
47282
47294
  function forgetDelivery(q, key) {
47283
47295
  q.pending.delete(key);
47284
47296
  }
47297
+ function shouldTrackDelivery(input) {
47298
+ if (input.isSteering || input.isInterrupt)
47299
+ return false;
47300
+ if (input.hasSource)
47301
+ return false;
47302
+ if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0)
47303
+ return false;
47304
+ return true;
47305
+ }
47285
47306
 
47286
47307
  // gateway/pending-permission-decisions.ts
47287
47308
  var DEFAULT_PENDING_PERMISSION_CAP = 32;
@@ -51850,10 +51871,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51850
51871
  }
51851
51872
 
51852
51873
  // ../src/build-info.ts
51853
- var VERSION = "0.14.40";
51854
- var COMMIT_SHA = "d2d69140";
51855
- var COMMIT_DATE = "2026-06-02T08:59:46Z";
51856
- var LATEST_PR = 2090;
51874
+ var VERSION = "0.14.42";
51875
+ var COMMIT_SHA = "6da4313d";
51876
+ var COMMIT_DATE = "2026-06-02T22:14:58Z";
51877
+ var LATEST_PR = 2097;
51857
51878
  var COMMITS_AHEAD_OF_TAG = 0;
51858
51879
 
51859
51880
  // gateway/boot-version.ts
@@ -53028,7 +53049,9 @@ function markClaudeBusyForInbound(m) {
53028
53049
  return key;
53029
53050
  }
53030
53051
  var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
53031
- var DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15000;
53052
+ var _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS;
53053
+ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== "" ? Number(_deliveryTimeoutRaw) : 15000;
53054
+ var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
53032
53055
  var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53033
53056
  var deliveryQueue = createDeliveryQueue();
53034
53057
  function turnInFlightForGate() {
@@ -53344,14 +53367,18 @@ function postPermissionResumeMessage(opts) {
53344
53367
  timeoutMinutes: opts.timeoutMinutes
53345
53368
  });
53346
53369
  const turn = currentTurn;
53347
- const targets = turn != null ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }] : loadAccess().allowFrom.map((chatId) => ({
53348
- chatId,
53349
- threadId: resolveAgentOutboundTopic({
53370
+ const targets = turn != null ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }] : (() => {
53371
+ const sg = resolveAgentSupergroupChatId();
53372
+ const topic = resolveAgentOutboundTopic({
53350
53373
  kind: "permission",
53351
53374
  turnInitiated: false,
53352
53375
  originThreadId: undefined
53353
- })
53354
- }));
53376
+ });
53377
+ return loadAccess().allowFrom.map((chatId) => ({
53378
+ chatId,
53379
+ threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
53380
+ }));
53381
+ })();
53355
53382
  for (const { chatId, threadId } of targets) {
53356
53383
  swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
53357
53384
  parse_mode: "HTML",
@@ -54160,7 +54187,12 @@ async function redeliverStrandedInbound(p) {
54160
54187
  clearAgentComposer2({ agentName: selfAgent });
54161
54188
  } catch {}
54162
54189
  const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
54163
- if (!ok) {
54190
+ if (ok) {
54191
+ markClaudeBusyForInbound(p.inbound);
54192
+ if (!deliveryQueue.pending.has(p.key)) {
54193
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId);
54194
+ }
54195
+ } else {
54164
54196
  pendingInboundBuffer.push(selfAgent, p.inbound);
54165
54197
  forgetDelivery(deliveryQueue, p.key);
54166
54198
  }
@@ -54168,6 +54200,10 @@ async function redeliverStrandedInbound(p) {
54168
54200
  var _deliveryConfirmSweep = setInterval(() => {
54169
54201
  if (!DELIVERY_CONFIRM_ENABLED)
54170
54202
  return;
54203
+ if (currentTurn != null)
54204
+ return;
54205
+ if (pendingPermissions.size > 0 || pendingAskUser.size > 0)
54206
+ return;
54171
54207
  for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
54172
54208
  redeliverStrandedInbound(p);
54173
54209
  }
@@ -54453,11 +54489,13 @@ var ipcServer = createIpcServer({
54453
54489
  turnInitiated: activeTurn != null,
54454
54490
  originThreadId: activeTurn?.sessionThreadId
54455
54491
  });
54492
+ const permSupergroup = resolveAgentSupergroupChatId();
54456
54493
  for (const chat_id of access.allowFrom) {
54494
+ const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup });
54457
54495
  bot.api.sendMessage(chat_id, text, {
54458
54496
  parse_mode: "HTML",
54459
54497
  reply_markup: keyboard,
54460
- ...permTopic != null ? { message_thread_id: permTopic } : {}
54498
+ ...permThread != null ? { message_thread_id: permThread } : {}
54461
54499
  }).catch((e) => {
54462
54500
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}
54463
54501
  `);
@@ -54521,9 +54559,13 @@ var ipcServer = createIpcServer({
54521
54559
  if (operator === undefined)
54522
54560
  return null;
54523
54561
  const activeTurn = currentTurn;
54524
- const driveTopic = resolveAgentOutboundTopic({
54525
- kind: "hostd-approval",
54526
- originThreadId: activeTurn?.sessionThreadId
54562
+ const driveTopic = topicForRecipient({
54563
+ recipientChatId: operator,
54564
+ resolvedTopic: resolveAgentOutboundTopic({
54565
+ kind: "hostd-approval",
54566
+ originThreadId: activeTurn?.sessionThreadId
54567
+ }),
54568
+ supergroupChatId: resolveAgentSupergroupChatId()
54527
54569
  });
54528
54570
  return {
54529
54571
  chatId: operator,
@@ -54579,9 +54621,13 @@ var ipcServer = createIpcServer({
54579
54621
  if (operator === undefined)
54580
54622
  return null;
54581
54623
  const activeTurn = currentTurn;
54582
- const ms365Topic = resolveAgentOutboundTopic({
54583
- kind: "hostd-approval",
54584
- originThreadId: activeTurn?.sessionThreadId
54624
+ const ms365Topic = topicForRecipient({
54625
+ recipientChatId: operator,
54626
+ resolvedTopic: resolveAgentOutboundTopic({
54627
+ kind: "hostd-approval",
54628
+ originThreadId: activeTurn?.sessionThreadId
54629
+ }),
54630
+ supergroupChatId: resolveAgentSupergroupChatId()
54585
54631
  });
54586
54632
  return {
54587
54633
  chatId: operator,
@@ -54636,9 +54682,13 @@ var ipcServer = createIpcServer({
54636
54682
  if (operator === undefined)
54637
54683
  return null;
54638
54684
  const activeTurn = currentTurn;
54639
- const cfgTopic = resolveAgentOutboundTopic({
54640
- kind: "hostd-approval",
54641
- originThreadId: activeTurn?.sessionThreadId
54685
+ const cfgTopic = topicForRecipient({
54686
+ recipientChatId: operator,
54687
+ resolvedTopic: resolveAgentOutboundTopic({
54688
+ kind: "hostd-approval",
54689
+ originThreadId: activeTurn?.sessionThreadId
54690
+ }),
54691
+ supergroupChatId: resolveAgentSupergroupChatId()
54642
54692
  });
54643
54693
  return {
54644
54694
  chatId: operator,
@@ -56564,7 +56614,7 @@ function handleSessionEvent(ev) {
56564
56614
  };
56565
56615
  currentTurn = next;
56566
56616
  if (DELIVERY_CONFIRM_ENABLED) {
56567
- ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
56617
+ ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
56568
56618
  }
56569
56619
  shadowEmit({
56570
56620
  kind: "turnStart",
@@ -58000,12 +58050,24 @@ ${preBlock(write.output)}`;
58000
58050
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
58001
58051
  if (delivered) {
58002
58052
  const busyKey = markClaudeBusyForInbound(inboundMsg);
58003
- if (DELIVERY_CONFIRM_ENABLED) {
58004
- trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now());
58053
+ if (DELIVERY_CONFIRM_ENABLED && shouldTrackDelivery({
58054
+ isSteering,
58055
+ isInterrupt: interrupt.isInterrupt,
58056
+ hasSource: inboundMsg.meta?.source != null,
58057
+ effectiveText
58058
+ })) {
58059
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId));
58005
58060
  }
58006
58061
  }
58007
58062
  if (!delivered) {
58008
- pendingInboundBuffer.push(selfAgent, inboundMsg);
58063
+ if (shouldTrackDelivery({
58064
+ isSteering,
58065
+ isInterrupt: interrupt.isInterrupt,
58066
+ hasSource: inboundMsg.meta?.source != null,
58067
+ effectiveText
58068
+ })) {
58069
+ pendingInboundBuffer.push(selfAgent, inboundMsg);
58070
+ }
58009
58071
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
58010
58072
  swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u23F3 Agent is restarting \u2014 your message is queued and will be processed when it reconnects.", { ...threadOpts }), {
58011
58073
  chat_id,
@@ -58191,6 +58253,22 @@ function resolveAgentOutboundTopic(event) {
58191
58253
  return;
58192
58254
  }
58193
58255
  }
58256
+ function resolveAgentSupergroupChatId() {
58257
+ const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
58258
+ if (!agentName3)
58259
+ return;
58260
+ try {
58261
+ const cfg = loadConfig2();
58262
+ const rawAgent = cfg.agents?.[agentName3];
58263
+ if (!rawAgent)
58264
+ return;
58265
+ const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
58266
+ const tg = resolved.channels?.telegram;
58267
+ return tg?.chat_id != null ? String(tg.chat_id) : undefined;
58268
+ } catch {
58269
+ return;
58270
+ }
58271
+ }
58194
58272
  function stampUserRestartReason(reason) {
58195
58273
  try {
58196
58274
  writeCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH, {
@@ -251,7 +251,7 @@ import { handleInjectCommand } from './inject-handler.js'
251
251
  import { type BannerState } from '../slot-banner.js'
252
252
  import { refreshBanner } from '../slot-banner-driver.js'
253
253
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
254
- import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
254
+ import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
255
255
  import { readTurnUsages } from '../../src/agents/perf.js'
256
256
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
257
257
  import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
@@ -286,6 +286,7 @@ import {
286
286
  ackDelivery,
287
287
  sweep as sweepDeliveryQueue,
288
288
  forgetDelivery,
289
+ shouldTrackDelivery,
289
290
  type PendingDelivery,
290
291
  } from './inbound-delivery-confirm.js'
291
292
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
@@ -1342,7 +1343,15 @@ const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM
1342
1343
  // a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
1343
1344
  // (env) for tests/forensics; a too-low value re-delivers healthy slow turns
1344
1345
  // (duplicate turn), which is why the default is comfortably above ack latency.
1345
- const DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15_000
1346
+ const _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS
1347
+ const _deliveryTimeoutParsed =
1348
+ _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== '' ? Number(_deliveryTimeoutRaw) : 15_000
1349
+ // Clamp to a positive, finite value: a negative / zero / NaN env override would
1350
+ // make the sweep treat every tracked entry as stranded and re-deliver every
1351
+ // cycle forever (a self-inflicted re-delivery loop). To disable the feature,
1352
+ // use SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0, not a degenerate timeout.
1353
+ const DELIVERY_CONFIRM_TIMEOUT_MS =
1354
+ Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15_000
1346
1355
  const DELIVERY_CONFIRM_SWEEP_MS = 5_000
1347
1356
  const deliveryQueue = createDeliveryQueue<InboundMessage>()
1348
1357
 
@@ -2264,14 +2273,20 @@ function postPermissionResumeMessage(opts: {
2264
2273
  const targets: Array<{ chatId: string; threadId: number | undefined }> =
2265
2274
  turn != null
2266
2275
  ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2267
- : loadAccess().allowFrom.map(chatId => ({
2268
- chatId,
2269
- threadId: resolveAgentOutboundTopic({
2276
+ : (() => {
2277
+ const sg = resolveAgentSupergroupChatId()
2278
+ const topic = resolveAgentOutboundTopic({
2270
2279
  kind: 'permission',
2271
2280
  turnInitiated: false,
2272
2281
  originThreadId: undefined,
2273
- }),
2274
- }))
2282
+ })
2283
+ // allowFrom is normally operator DMs — attach the topic only to a
2284
+ // recipient that owns it (the supergroup), never a DM (marko wedge).
2285
+ return loadAccess().allowFrom.map(chatId => ({
2286
+ chatId,
2287
+ threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
2288
+ }))
2289
+ })()
2275
2290
  for (const { chatId, threadId } of targets) {
2276
2291
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
2277
2292
  void swallowingApiCall(
@@ -4106,7 +4121,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
4106
4121
  if (selfAgent) clearAgentComposer({ agentName: selfAgent })
4107
4122
  } catch { /* best-effort; re-deliver regardless */ }
4108
4123
  const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
4109
- if (!ok) {
4124
+ if (ok) {
4125
+ // Keep the #1556 gate coherent with the re-sent delivery, and survive an
4126
+ // ack that raced the `await import` above: only `enqueue` clears tracking,
4127
+ // so if a concurrent ack removed the entry, re-affirm it — never drop.
4128
+ // Both ops are idempotent.
4129
+ markClaudeBusyForInbound(p.inbound)
4130
+ if (!deliveryQueue.pending.has(p.key)) {
4131
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
4132
+ }
4133
+ } else {
4110
4134
  // Bridge offline between attempts — hand off to the offline buffer
4111
4135
  // (bridgeUp drains it) and stop tracking here; the spool owns it now.
4112
4136
  pendingInboundBuffer.push(selfAgent, p.inbound)
@@ -4115,6 +4139,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
4115
4139
  }
4116
4140
  const _deliveryConfirmSweep = setInterval(() => {
4117
4141
  if (!DELIVERY_CONFIRM_ENABLED) return
4142
+ // Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
4143
+ // by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
4144
+ // means a real turn is in flight — re-clearing the composer + re-sending now
4145
+ // would clobber it (the exact mid-turn wedge this queue exists to prevent). A
4146
+ // pending permission / ask_user prompt is likewise a live interaction. Defer:
4147
+ // leave the entry pending (it isn't acked) so the next idle sweep retries.
4148
+ // NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
4149
+ // stays set through a strand, so it is NOT a usable "idle" signal here.
4150
+ if (currentTurn != null) return
4151
+ if (pendingPermissions.size > 0 || pendingAskUser.size > 0) return
4118
4152
  for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
4119
4153
  void redeliverStrandedInbound(p)
4120
4154
  }
@@ -4636,16 +4670,20 @@ const ipcServer: IpcServer = createIpcServer({
4636
4670
  turnInitiated: activeTurn != null,
4637
4671
  originThreadId: activeTurn?.sessionThreadId,
4638
4672
  })
4673
+ const permSupergroup = resolveAgentSupergroupChatId()
4639
4674
  for (const chat_id of access.allowFrom) {
4640
4675
  // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
4641
4676
  // so the <b>/<i> tags render as formatting.
4642
- // PR4b emitter sweep opts now optionally carries
4643
- // message_thread_id when supergroup mode is on.
4677
+ // The resolved topic is valid only in the agent's supergroup — attach
4678
+ // it ONLY when this recipient IS that supergroup. allowFrom DMs get the
4679
+ // card thread-less; attaching a topic to a DM yields 400 "message thread
4680
+ // not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
4681
+ const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
4644
4682
  // allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
4645
4683
  void bot.api.sendMessage(chat_id, text, {
4646
4684
  parse_mode: 'HTML',
4647
4685
  reply_markup: keyboard,
4648
- ...(permTopic != null ? { message_thread_id: permTopic } : {}),
4686
+ ...(permThread != null ? { message_thread_id: permThread } : {}),
4649
4687
  }).catch(e => {
4650
4688
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
4651
4689
  })
@@ -4783,9 +4821,15 @@ const ipcServer: IpcServer = createIpcServer({
4783
4821
  // topic. Drive approval cards follow the originating turn
4784
4822
  // (operator-initiated tool call), admin alias fallback.
4785
4823
  const activeTurn = currentTurn
4786
- const driveTopic = resolveAgentOutboundTopic({
4787
- kind: 'hostd-approval',
4788
- originThreadId: activeTurn?.sessionThreadId,
4824
+ // Attach the topic only when `operator` IS the agent's supergroup —
4825
+ // operator DMs have no topics (marko brevo wedge, 2026-06-02).
4826
+ const driveTopic = topicForRecipient({
4827
+ recipientChatId: operator,
4828
+ resolvedTopic: resolveAgentOutboundTopic({
4829
+ kind: 'hostd-approval',
4830
+ originThreadId: activeTurn?.sessionThreadId,
4831
+ }),
4832
+ supergroupChatId: resolveAgentSupergroupChatId(),
4789
4833
  })
4790
4834
  return {
4791
4835
  chatId: operator,
@@ -4856,9 +4900,14 @@ const ipcServer: IpcServer = createIpcServer({
4856
4900
  // alias fallback for background cases. Same shape as hostd /
4857
4901
  // drive approvals below.
4858
4902
  const activeTurn = currentTurn
4859
- const ms365Topic = resolveAgentOutboundTopic({
4860
- kind: 'hostd-approval',
4861
- originThreadId: activeTurn?.sessionThreadId,
4903
+ // Topic valid only in the agent's supergroup — never on the operator DM.
4904
+ const ms365Topic = topicForRecipient({
4905
+ recipientChatId: operator,
4906
+ resolvedTopic: resolveAgentOutboundTopic({
4907
+ kind: 'hostd-approval',
4908
+ originThreadId: activeTurn?.sessionThreadId,
4909
+ }),
4910
+ supergroupChatId: resolveAgentSupergroupChatId(),
4862
4911
  })
4863
4912
  return {
4864
4913
  chatId: operator,
@@ -4935,9 +4984,14 @@ const ipcServer: IpcServer = createIpcServer({
4935
4984
  // returns undefined for non-supergroup agents → behavior
4936
4985
  // unchanged.
4937
4986
  const activeTurn = currentTurn
4938
- const cfgTopic = resolveAgentOutboundTopic({
4939
- kind: 'hostd-approval',
4940
- originThreadId: activeTurn?.sessionThreadId,
4987
+ // Topic valid only in the agent's supergroup — never on the operator DM.
4988
+ const cfgTopic = topicForRecipient({
4989
+ recipientChatId: operator,
4990
+ resolvedTopic: resolveAgentOutboundTopic({
4991
+ kind: 'hostd-approval',
4992
+ originThreadId: activeTurn?.sessionThreadId,
4993
+ }),
4994
+ supergroupChatId: resolveAgentSupergroupChatId(),
4941
4995
  })
4942
4996
  return {
4943
4997
  chatId: operator,
@@ -7945,9 +7999,14 @@ function handleSessionEvent(ev: SessionEvent): void {
7945
7999
  // re-delivery. `enqueue` carries the same chat/thread the inbound was
7946
8000
  // keyed on, so the key matches.
7947
8001
  if (DELIVERY_CONFIRM_ENABLED) {
8002
+ // Match on the source message id: `enqueue` fires for EVERY turn
8003
+ // start (cron / subagent-handback / vault-resume / restart-marker
8004
+ // too — see comment below), so a key-only ack would let a synthetic
8005
+ // turn clear a real user message still waiting under the same key.
7948
8006
  ackDelivery(
7949
8007
  deliveryQueue,
7950
8008
  chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
8009
+ ev.messageId,
7951
8010
  )
7952
8011
  }
7953
8012
  // PR3b-cutover: feed the authoritative turn-start to the delivery
@@ -10670,13 +10729,40 @@ async function handleInbound(
10670
10729
  const busyKey = markClaudeBusyForInbound(inboundMsg)
10671
10730
  // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
10672
10731
  // lands, the message stranded in the composer and the sweep re-delivers
10673
- // it. See inbound-delivery-confirm.ts.
10674
- if (DELIVERY_CONFIRM_ENABLED) {
10675
- trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now())
10732
+ // it. Track ONLY messages that produce an `enqueue` to ack against —
10733
+ // shouldTrackDelivery excludes steering / `!` interrupt (amend the running
10734
+ // turn), synthetic (meta.source) inbounds, and empty bodies, all of which
10735
+ // never enqueue and would re-deliver forever. The tracked messageId lets
10736
+ // the ack match only THIS message's enqueue (not a synthetic turn sharing
10737
+ // the key). See shouldTrackDelivery / ackDelivery (inbound-delivery-confirm.ts).
10738
+ if (
10739
+ DELIVERY_CONFIRM_ENABLED &&
10740
+ shouldTrackDelivery({
10741
+ isSteering,
10742
+ isInterrupt: interrupt.isInterrupt,
10743
+ hasSource: inboundMsg.meta?.source != null,
10744
+ effectiveText,
10745
+ })
10746
+ ) {
10747
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
10676
10748
  }
10677
10749
  }
10678
10750
  if (!delivered) {
10679
- pendingInboundBuffer.push(selfAgent, inboundMsg)
10751
+ // Only persist fresh user turns to the durable spool. Steering / `!`
10752
+ // interrupt / empty bodies are mid-turn amendments or no-ops that would
10753
+ // arrive orphaned if replayed as a fresh turn after a restart — drop them
10754
+ // (the restart notice below tells the user to re-send). Mirrors the
10755
+ // tracking + #1556-gate carve-outs.
10756
+ if (
10757
+ shouldTrackDelivery({
10758
+ isSteering,
10759
+ isInterrupt: interrupt.isInterrupt,
10760
+ hasSource: inboundMsg.meta?.source != null,
10761
+ effectiveText,
10762
+ })
10763
+ ) {
10764
+ pendingInboundBuffer.push(selfAgent, inboundMsg)
10765
+ }
10680
10766
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
10681
10767
  // #1075: thread-id-bearing — swallow via robustApiCall so a deleted
10682
10768
  // topic doesn't crash the gateway. Fire-and-forget; the inbound is
@@ -11007,6 +11093,30 @@ function resolveAgentOutboundTopic(
11007
11093
  }
11008
11094
  }
11009
11095
 
11096
+ /**
11097
+ * The agent's supergroup chat id (`channels.telegram.chat_id`) when it is in
11098
+ * supergroup-owned mode, else undefined. A forum topic id resolved by
11099
+ * {@link resolveAgentOutboundTopic} is valid ONLY in this chat — used by
11100
+ * {@link topicForRecipient} to decide whether an approval/permission card sent
11101
+ * to a given recipient (operator DMs vs the supergroup itself) may carry a
11102
+ * `message_thread_id`. Attaching a topic to a DM is the marko brevo wedge
11103
+ * (2026-06-02): the card fails with "message thread not found" and auto-denies.
11104
+ */
11105
+ function resolveAgentSupergroupChatId(): string | undefined {
11106
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
11107
+ if (!agentName) return undefined
11108
+ try {
11109
+ const cfg = loadSwitchroomConfig()
11110
+ const rawAgent = cfg.agents?.[agentName]
11111
+ if (!rawAgent) return undefined
11112
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
11113
+ const tg = resolved.channels?.telegram as { chat_id?: string | number } | undefined
11114
+ return tg?.chat_id != null ? String(tg.chat_id) : undefined
11115
+ } catch {
11116
+ return undefined
11117
+ }
11118
+ }
11119
+
11010
11120
  /**
11011
11121
  * Stamp a user-facing restart reason into the clean-shutdown marker
11012
11122
  * (same file the SIGTERM handler writes to and the next session greeting
@@ -35,6 +35,14 @@ export interface PendingDelivery<M> {
35
35
  readonly key: string
36
36
  /** The exact inbound to re-send until claude acks it. */
37
37
  readonly inbound: M
38
+ /**
39
+ * Source message id of the tracked inbound (stringified Telegram
40
+ * `message_id`), or null if unknown. The `enqueue` ack matches on THIS so a
41
+ * synthetic-source turn (cron / resume / vault / reaction) that shares the
42
+ * chatKey can't false-ack — and silently drop — a real user message still
43
+ * waiting to land. See ackDelivery.
44
+ */
45
+ readonly messageId: string | null
38
46
  /** When the latest delivery attempt was made (unix-ms). */
39
47
  lastAttemptAt: number
40
48
  }
@@ -51,22 +59,44 @@ export function createDeliveryQueue<M>(): DeliveryQueue<M> {
51
59
  * Track a freshly-delivered inbound, awaiting claude's `enqueue` ack.
52
60
  * Overwrites any prior pending for the key — the #1556 gate serialises per
53
61
  * key, so a later inbound supersedes an earlier un-acked one for that key.
62
+ * `messageId` (stringified Telegram message_id) lets the ack match only the
63
+ * enqueue that belongs to THIS message; pass null when unknown.
54
64
  */
55
65
  export function trackDelivery<M>(
56
66
  q: DeliveryQueue<M>,
57
67
  key: string,
58
68
  inbound: M,
59
69
  now: number,
70
+ messageId: string | null = null,
60
71
  ): void {
61
- q.pending.set(key, { key, inbound, lastAttemptAt: now })
72
+ q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now })
62
73
  }
63
74
 
64
75
  /**
65
- * Ack a delivery — call from the `enqueue` session-event (claude started the
66
- * turn, so the message landed). Returns true if a pending entry was cleared.
76
+ * Ack a delivery — call from the `enqueue` session-event (claude started a
77
+ * turn). `enqueue` fires for EVERY turn start regardless of source (user
78
+ * inbound, cron, subagent-handback, vault-resume, restart-marker), so acking
79
+ * purely by chatKey would let a synthetic-source turn clear — and thus
80
+ * silently drop — a real user message still waiting under the same key. So
81
+ * ack ONLY when the enqueue's source message id matches the tracked one.
82
+ *
83
+ * Matching rule: if we recorded a messageId for the pending entry, require the
84
+ * enqueue's `enqueueMessageId` to equal it. If we never recorded one (legacy /
85
+ * defensive null), fall back to key-only ack. Returns true if an entry was
86
+ * cleared.
67
87
  */
68
- export function ackDelivery<M>(q: DeliveryQueue<M>, key: string): boolean {
69
- return q.pending.delete(key)
88
+ export function ackDelivery<M>(
89
+ q: DeliveryQueue<M>,
90
+ key: string,
91
+ enqueueMessageId: string | null = null,
92
+ ): boolean {
93
+ const entry = q.pending.get(key)
94
+ if (!entry) return false
95
+ // A different message started this turn — don't ack ours (it may still be
96
+ // waiting to land; the sweep will re-deliver it if it stranded).
97
+ if (entry.messageId != null && entry.messageId !== enqueueMessageId) return false
98
+ q.pending.delete(key)
99
+ return true
70
100
  }
71
101
 
72
102
  /**
@@ -94,3 +124,37 @@ export function sweep<M>(
94
124
  export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
95
125
  q.pending.delete(key)
96
126
  }
127
+
128
+ /**
129
+ * Should this delivered inbound be tracked for ack/re-delivery?
130
+ *
131
+ * Track a delivery iff it is a fresh user turn that will produce exactly one
132
+ * `enqueue` to ack against. Everything that does NOT enqueue must be excluded,
133
+ * or the sweep re-delivers it forever (re-clearing the composer every cycle):
134
+ *
135
+ * - `isSteering` / `isInterrupt` — the #1556 gate's carve-outs: delivered
136
+ * mid-turn to AMEND the running turn, so they never start a fresh turn and
137
+ * never emit `enqueue`.
138
+ * - `hasSource` — synthetic inbounds (cron / vault-resume / subagent-handback
139
+ * / reaction) carry a `meta.source`; they enqueue under their own semantics
140
+ * and must never be tracked as if they were a queued user turn.
141
+ * - empty `effectiveText` — an empty body (e.g. `/queue` with no text) is
142
+ * silently dropped by claude's auto-submit and never enqueues, so tracking
143
+ * it is a pure re-delivery loop (a self-inflicted DoS on the queue).
144
+ *
145
+ * Mirror the gate's carve-outs here so tracking is exactly the set of messages
146
+ * that produce an `enqueue`.
147
+ */
148
+ export function shouldTrackDelivery(input: {
149
+ isSteering: boolean
150
+ isInterrupt: boolean
151
+ hasSource?: boolean
152
+ effectiveText?: string
153
+ }): boolean {
154
+ if (input.isSteering || input.isInterrupt) return false
155
+ if (input.hasSource) return false
156
+ // Gate on empty text only when the caller actually provided it (undefined =
157
+ // "not supplied", left untracked-gated so existing callers keep their behaviour).
158
+ if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0) return false
159
+ return true
160
+ }
@@ -4,6 +4,7 @@ import {
4
4
  ackDelivery,
5
5
  createDeliveryQueue,
6
6
  forgetDelivery,
7
+ shouldTrackDelivery,
7
8
  sweep,
8
9
  trackDelivery,
9
10
  type DeliveryQueue,
@@ -107,3 +108,73 @@ describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () =>
107
108
  expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
108
109
  })
109
110
  })
111
+
112
+ // Regression for the cross-source ACK collision (silent drop): `enqueue` fires
113
+ // for EVERY turn start regardless of source. A synthetic-source turn (cron /
114
+ // resume / vault / reaction) that shares the chatKey of a real user message
115
+ // still waiting to land would, with a key-only ack, clear — and silently drop
116
+ // — that user message. The ack must match on the tracked message id.
117
+ describe('ackDelivery — message-id-matched (cross-source false-ack guard)', () => {
118
+ it('acks when the enqueue message id matches the tracked one', () => {
119
+ const q = fresh()
120
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
121
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
122
+ expect(q.pending.size).toBe(0)
123
+ })
124
+
125
+ it('does NOT ack when a different (synthetic-source) turn enqueues under the same key', () => {
126
+ const q = fresh()
127
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
128
+ // a cron / resume turn for the same chat enqueues first, with its own id
129
+ expect(ackDelivery(q, 'chat:_', '1716123456789')).toBe(false)
130
+ // the user message is still tracked — it strands → gets re-delivered, not dropped
131
+ expect(q.pending.size).toBe(1)
132
+ expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1)
133
+ // and its own enqueue (matching id) later acks it cleanly
134
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
135
+ expect(q.pending.size).toBe(0)
136
+ })
137
+
138
+ it('does NOT ack when the enqueue carries no message id but the tracked one has one', () => {
139
+ const q = fresh()
140
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
141
+ expect(ackDelivery(q, 'chat:_', null)).toBe(false)
142
+ expect(q.pending.size).toBe(1)
143
+ })
144
+
145
+ it('falls back to key-only ack when no message id was recorded (legacy/defensive)', () => {
146
+ const q = fresh()
147
+ trackDelivery(q, 'chat:_', { text: 'x' }, 0) // no messageId
148
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
149
+ expect(q.pending.size).toBe(0)
150
+ })
151
+ })
152
+
153
+ // Regression for the steer/interrupt re-delivery loop: steering and `!`
154
+ // interrupt inbounds amend the running turn and never emit `enqueue`, so they
155
+ // must NOT be tracked (else the sweep re-delivers them forever). Only
156
+ // fresh-turn messages — which DO enqueue — are tracked.
157
+ describe('shouldTrackDelivery — only fresh-turn messages are tracked', () => {
158
+ it('tracks a normal (non-steering, non-interrupt) message', () => {
159
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false })).toBe(true)
160
+ })
161
+ it('does NOT track a /steer message (amends the turn — never acks)', () => {
162
+ expect(shouldTrackDelivery({ isSteering: true, isInterrupt: false })).toBe(false)
163
+ })
164
+ it('does NOT track a ! interrupt message (amends the turn — never acks)', () => {
165
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: true })).toBe(false)
166
+ })
167
+ it('does NOT track when both flags set (defensive)', () => {
168
+ expect(shouldTrackDelivery({ isSteering: true, isInterrupt: true })).toBe(false)
169
+ })
170
+ it('does NOT track a synthetic (meta.source) inbound — cron/resume/vault/reaction enqueue under their own semantics', () => {
171
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, hasSource: true })).toBe(false)
172
+ })
173
+ it('does NOT track an empty-body message (e.g. `/queue` with no text) — never enqueues, would re-deliver forever', () => {
174
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: '' })).toBe(false)
175
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: ' ' })).toBe(false)
176
+ })
177
+ it('tracks a normal message when effectiveText is provided and non-empty', () => {
178
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: 'draft the email' })).toBe(true)
179
+ })
180
+ })
@@ -55,8 +55,15 @@ describe("uat: rapid follow-ups — steering vs queued classification", () => {
55
55
  (m) => {
56
56
  const txt = m.text;
57
57
  const mentionsMd5 = /\bmd5\b/i.test(txt);
58
+ // Steer narration: the agent acknowledges amending the in-flight
59
+ // task. Accept the phrasings the model actually uses — including
60
+ // "Switched to MD5 per your update/follow-up" (the 2026-06-02
61
+ // canary reply that the old regex wrongly rejected). Anchored on
62
+ // "per your <qualifier>" / continuation language so it stays
63
+ // distinct from the QUEUED path (a fresh answer with no such
64
+ // course-correction narration).
58
65
  const narratesSteer =
59
- /↪️|\bsteer(ing)?\b|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
66
+ /↪️|\bsteer(ing)?\b|switch(?:ed|ing)? to \w+ per your (?:update|follow-?up|guidance|request|steer)|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
60
67
  txt,
61
68
  );
62
69
  return mentionsMd5 && narratesSteer;