switchroom 0.13.19 → 0.13.21

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.
@@ -47331,8 +47331,8 @@ var {
47331
47331
  } = import__.default;
47332
47332
 
47333
47333
  // src/build-info.ts
47334
- var VERSION = "0.13.19";
47335
- var COMMIT_SHA = "de154395";
47334
+ var VERSION = "0.13.21";
47335
+ var COMMIT_SHA = "4183c5df";
47336
47336
 
47337
47337
  // src/cli/agent.ts
47338
47338
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.19",
3
+ "version": "0.13.21",
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": {
@@ -23,11 +23,11 @@ The 👀→🤔→🔥→👍 status reaction and the typing indicator are *ambi
23
23
 
24
24
  **Follow-ups while a turn is in flight.** Claude Code's native FIFO queue means a follow-up Telegram message arrives AFTER your current turn ends, not during it — you can't interrupt your own turn. Every follow-up becomes the next prompt you see. The plugin enriches the `<channel>` meta so you can classify correctly:
25
25
 
26
- - `steering="true"` — prior turn was in progress and the user did NOT use `/queue`. Treat as a course-correction or addendum on the next action. Continue the original task, incorporating the new guidance.
27
- - `queued="true"` — the user typed `/queue ` or `/q ` (the prefix is stripped from the body you see). Treat as a new, independent task. Do NOT reference the in-flight work start fresh.
26
+ - `queued="true"` — DEFAULT for mid-turn follow-ups (no prefix). Treat as a new, independent task. Do NOT reference the in-flight work start fresh. Also fires when the user typed `/queue ` or `/q ` (legacy alias; the prefix is stripped from the body you see).
27
+ - `steering="true"` — the user typed `/steer ` or `/s ` (the prefix is stripped from the body you see). Treat as a course-correction or addendum on the in-flight work. Continue the original task, incorporating the new guidance.
28
28
  - `prior_turn_in_progress="true"`, `seconds_since_turn_start="N"`, `prior_assistant_preview="..."` — auxiliary context on the prior turn so you can decide which of the above applies when ambiguous. `prior_assistant_preview` is the first ~200 chars of your most recent reply in this chat, HTML tags stripped.
29
29
 
30
- If both `queued` and `steering` are somehow present, `queued` wins (explicit beats inferred). If `prior_turn_in_progress="true"` is set without either flag (shouldn't happen but defensive), treat the message as a follow-up related to your last reply.
30
+ If both `queued` and `steering` are somehow present, `steering` wins (explicit opt-in beats default). If `prior_turn_in_progress="true"` is set without either flag (shouldn't happen but defensive), treat the message as a follow-up related to your last reply.
31
31
 
32
32
  **Self-narrate the classification.** At the top of your reply for any `steering` or `queued` message, include a brief italic one-liner so the user can correct you — e.g. `_↪️ treating as steer on the prior task_` or `_📥 queued as a new task_`.
33
33
 
@@ -30563,7 +30563,7 @@ class OutboundDedupCache {
30563
30563
  constructor(opts = {}) {
30564
30564
  this.ttlMs = opts.ttlMs ?? DEFAULT_DEDUP_TTL_MS;
30565
30565
  }
30566
- record(chatId, threadId, text, now) {
30566
+ record(chatId, threadId, text, now, turnKey = null) {
30567
30567
  if (text.length < DEDUP_MIN_CONTENT_LEN)
30568
30568
  return;
30569
30569
  const key = makeKey(chatId, threadId);
@@ -30572,11 +30572,12 @@ class OutboundDedupCache {
30572
30572
  list.push({
30573
30573
  hash: normalizeForDedup(text),
30574
30574
  ts: now,
30575
- preview: text.slice(0, 80)
30575
+ preview: text.slice(0, 80),
30576
+ turnKey
30576
30577
  });
30577
30578
  this.entries.set(key, list);
30578
30579
  }
30579
- check(chatId, threadId, text, now) {
30580
+ check(chatId, threadId, text, now, turnKey = null) {
30580
30581
  if (text.length < DEDUP_MIN_CONTENT_LEN)
30581
30582
  return null;
30582
30583
  const key = makeKey(chatId, threadId);
@@ -30586,9 +30587,12 @@ class OutboundDedupCache {
30586
30587
  this.evict(list, now);
30587
30588
  const candidateHash = normalizeForDedup(text);
30588
30589
  for (const entry of list) {
30589
- if (entry.hash === candidateHash) {
30590
- return { matched: true, preview: entry.preview, ageMs: now - entry.ts };
30590
+ if (entry.hash !== candidateHash)
30591
+ continue;
30592
+ if (turnKey != null && entry.turnKey != null && entry.turnKey !== turnKey) {
30593
+ continue;
30591
30594
  }
30595
+ return { matched: true, preview: entry.preview, ageMs: now - entry.ts };
30592
30596
  }
30593
30597
  return null;
30594
30598
  }
@@ -40355,6 +40359,90 @@ function getOpenTags(html) {
40355
40359
  return tagStack;
40356
40360
  }
40357
40361
 
40362
+ // text-voice-scrub.ts
40363
+ var NULL = "\x00";
40364
+ var FENCE_PH = `${NULL}VS_FENCE`;
40365
+ var INLINE_PH = `${NULL}VS_INLINE`;
40366
+ var HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
40367
+ var HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
40368
+ var URL_PH = `${NULL}VS_URL`;
40369
+ var URL_RE = /https?:\/\/\S+/g;
40370
+ function enabled4() {
40371
+ const v = process.env.SWITCHROOM_DISABLE_VOICE_SCRUB;
40372
+ return !(v === "1" || v === "true");
40373
+ }
40374
+ function park(text) {
40375
+ const parts = [];
40376
+ let parked = text;
40377
+ parked = parked.replace(/```[\s\S]*?```/g, (m) => {
40378
+ const idx = parts.length;
40379
+ parts.push({ prefix: FENCE_PH, idx, raw: m });
40380
+ return `${FENCE_PH}${idx}${NULL}`;
40381
+ });
40382
+ parked = parked.replace(/<pre>[\s\S]*?<\/pre>/gi, (m) => {
40383
+ const idx = parts.length;
40384
+ parts.push({ prefix: HTML_PRE_PH, idx, raw: m });
40385
+ return `${HTML_PRE_PH}${idx}${NULL}`;
40386
+ });
40387
+ parked = parked.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, (m) => {
40388
+ const idx = parts.length;
40389
+ parts.push({ prefix: HTML_CODE_PH, idx, raw: m });
40390
+ return `${HTML_CODE_PH}${idx}${NULL}`;
40391
+ });
40392
+ parked = parked.replace(/`[^`\n]+`/g, (m) => {
40393
+ const idx = parts.length;
40394
+ parts.push({ prefix: INLINE_PH, idx, raw: m });
40395
+ return `${INLINE_PH}${idx}${NULL}`;
40396
+ });
40397
+ parked = parked.replace(URL_RE, (m) => {
40398
+ const idx = parts.length;
40399
+ parts.push({ prefix: URL_PH, idx, raw: m });
40400
+ return `${URL_PH}${idx}${NULL}`;
40401
+ });
40402
+ return { parked, parts };
40403
+ }
40404
+ function restore(text, parts) {
40405
+ let restored = text;
40406
+ for (let i = parts.length - 1;i >= 0; i--) {
40407
+ const p = parts[i];
40408
+ restored = restored.replace(`${p.prefix}${p.idx}${NULL}`, () => p.raw);
40409
+ }
40410
+ return restored;
40411
+ }
40412
+ function replaceDashes(text) {
40413
+ let replaced = 0;
40414
+ let out = text;
40415
+ out = out.replace(/(\S) [\u2014\u2013] (\S)/g, (_m, before, after) => {
40416
+ replaced++;
40417
+ const sentenceStart = /[A-Z]/.test(after);
40418
+ return sentenceStart ? `${before}. ${after}` : `${before}, ${after}`;
40419
+ });
40420
+ out = out.replace(/ [\u2014\u2013](\s*\n)/g, (_m, ws) => {
40421
+ replaced++;
40422
+ return `.${ws}`;
40423
+ });
40424
+ out = out.replace(/(\w)[\u2014\u2013](\w)/g, (_m, before, after) => {
40425
+ replaced++;
40426
+ return `${before}, ${after}`;
40427
+ });
40428
+ out = out.replace(/[\u2014\u2013]/g, () => {
40429
+ replaced++;
40430
+ return "-";
40431
+ });
40432
+ return { out, replaced };
40433
+ }
40434
+ function scrubVoice(text) {
40435
+ if (!enabled4() || text.length === 0) {
40436
+ return { scrubbed: text, replaced: 0 };
40437
+ }
40438
+ const { parked, parts } = park(text);
40439
+ const { out, replaced } = replaceDashes(parked);
40440
+ if (replaced === 0) {
40441
+ return { scrubbed: text, replaced: 0 };
40442
+ }
40443
+ return { scrubbed: restore(out, parts), replaced };
40444
+ }
40445
+
40358
40446
  // telegram-button-constraints.ts
40359
40447
  var TELEGRAM_BUTTON_LIMITS = {
40360
40448
  TEXT_MAX: 64,
@@ -40903,7 +40991,7 @@ function shouldShowHandoffLine() {
40903
40991
  return v.toLowerCase() !== "false";
40904
40992
  }
40905
40993
  function formatHandoffLine(topic, format) {
40906
- const prefix = "\u21a9\ufe0f Picked up where we left off \u2014 ";
40994
+ const prefix = "\u21a9\ufe0f Picked up where we left off, ";
40907
40995
  if (format === "html") {
40908
40996
  return `<i>${prefix}${escapeHtml6(topic)}</i>
40909
40997
 
@@ -41072,6 +41160,7 @@ function flushOnAgentDisconnect(deps) {
41072
41160
  activeDraftParseModes,
41073
41161
  clearActiveReactions: clearActiveReactions3,
41074
41162
  disposeProgressDriver,
41163
+ onDanglingTurnsSwept,
41075
41164
  log
41076
41165
  } = deps;
41077
41166
  if (agentName3 == null) {
@@ -41085,6 +41174,15 @@ function flushOnAgentDisconnect(deps) {
41085
41174
  activeTurnStartedAt.delete(key);
41086
41175
  }
41087
41176
  clearActiveReactions3();
41177
+ const danglingKeys = [...activeTurnStartedAt.keys()];
41178
+ if (danglingKeys.length > 0) {
41179
+ for (const k of danglingKeys) {
41180
+ activeTurnStartedAt.delete(k);
41181
+ activeReactionMsgIds.delete(k);
41182
+ }
41183
+ log(`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` + `post-bridge-death (controller loop missed \u2014 setDone raced disconnect)`);
41184
+ onDanglingTurnsSwept?.(danglingKeys);
41185
+ }
41088
41186
  disposeProgressDriver();
41089
41187
  for (const [key, stream] of activeDraftStreams.entries()) {
41090
41188
  if (!stream.isFinal())
@@ -44380,7 +44478,11 @@ function purgeStaleTurnsForChat(chatId, keys, purger) {
44380
44478
 
44381
44479
  // gateway/inbound-delivery-gate.ts
44382
44480
  function decideInboundDelivery(input) {
44383
- if (input.turnInFlight && !input.isSteering)
44481
+ if (input.isSteering)
44482
+ return "deliver";
44483
+ if (input.isInterrupt === true)
44484
+ return "deliver";
44485
+ if (input.turnInFlight)
44384
44486
  return "buffer-until-idle";
44385
44487
  return "deliver";
44386
44488
  }
@@ -44639,9 +44741,9 @@ function transition(state3, event) {
44639
44741
 
44640
44742
  // gateway/inbound-delivery-machine-shadow.ts
44641
44743
  var state3 = initialState();
44642
- var enabled4 = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== "0";
44744
+ var enabled5 = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== "0";
44643
44745
  function shadowEmit(event) {
44644
- if (!enabled4)
44746
+ if (!enabled5)
44645
44747
  return [];
44646
44748
  try {
44647
44749
  const result = transition(state3, event);
@@ -44699,12 +44801,12 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
44699
44801
  }
44700
44802
 
44701
44803
  // gateway/inbound-delivery-machine-dispatch.ts
44702
- var enabled5 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
44804
+ var enabled6 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
44703
44805
  function isDispatchEnabled() {
44704
- return enabled5;
44806
+ return enabled6;
44705
44807
  }
44706
44808
  function dispatchEffects(effects, ctx) {
44707
- if (!enabled5)
44809
+ if (!enabled6)
44708
44810
  return;
44709
44811
  for (const effect of effects) {
44710
44812
  dispatchOne(effect, ctx);
@@ -46618,7 +46720,7 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
46618
46720
  db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
46619
46721
  log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
46620
46722
  }
46621
- function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall) {
46723
+ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished) {
46622
46724
  try {
46623
46725
  const stat = fs2.statSync(entry.filePath);
46624
46726
  if (stat.size < tail.cursor) {
@@ -46723,6 +46825,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
46723
46825
  }
46724
46826
  tail.hasEmittedStart = startState.hasEmittedStart;
46725
46827
  } catch (err) {
46828
+ const code = err.code;
46829
+ if (code === "ENOENT" || code === "EACCES") {
46830
+ log?.(`subagent-watcher: JSONL vanished for ${entry.agentId} (${code}) \u2014 deregistering`);
46831
+ onFileVanished?.(entry.agentId, code);
46832
+ return;
46833
+ }
46726
46834
  log?.(`subagent-watcher: read error ${entry.agentId}: ${err.message}`);
46727
46835
  }
46728
46836
  }
@@ -46829,7 +46937,7 @@ function startSubagentWatcher(config) {
46829
46937
  return;
46830
46938
  readSubTail(entry2, t, nowFn(), (desc) => {
46831
46939
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
46832
- }, fs2, log, db2, parentStateDir, config.onUnstall);
46940
+ }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
46833
46941
  maybySendStateTransition(agentId);
46834
46942
  });
46835
46943
  } catch (err) {
@@ -47075,7 +47183,7 @@ function startSubagentWatcher(config) {
47075
47183
  continue;
47076
47184
  readSubTail(entry, tail, n, (desc) => {
47077
47185
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
47078
- }, fs2, log, db2, parentStateDir, config.onUnstall);
47186
+ }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
47079
47187
  maybySendStateTransition(agentId);
47080
47188
  }
47081
47189
  checkStalls();
@@ -48207,10 +48315,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48207
48315
  }
48208
48316
 
48209
48317
  // ../src/build-info.ts
48210
- var VERSION = "0.13.19";
48211
- var COMMIT_SHA = "de154395";
48212
- var COMMIT_DATE = "2026-05-23T07:08:03Z";
48213
- var LATEST_PR = 1682;
48318
+ var VERSION = "0.13.21";
48319
+ var COMMIT_SHA = "4183c5df";
48320
+ var COMMIT_DATE = "2026-05-23T21:59:16Z";
48321
+ var LATEST_PR = 1692;
48214
48322
  var COMMITS_AHEAD_OF_TAG = 0;
48215
48323
 
48216
48324
  // gateway/boot-version.ts
@@ -49994,6 +50102,12 @@ startTimer({
49994
50102
  emitRuntimeMetric(event);
49995
50103
  },
49996
50104
  onFrameworkFallback: async (ctx) => {
50105
+ if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
50106
+ process.stderr.write(`telegram gateway: silence-poke framework-fallback late-fire skipped \u2014 ` + `turn ended cleanly during silence window chat=${ctx.chatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs}
50107
+ `);
50108
+ endTurn(ctx.key);
50109
+ return;
50110
+ }
49997
50111
  let text = null;
49998
50112
  const upd = inFlightUpdate;
49999
50113
  if (upd != null) {
@@ -50260,6 +50374,13 @@ var ipcServer = createIpcServer({
50260
50374
  disposeProgressDriver: () => {
50261
50375
  progressDriver?.dispose?.({ preservePending: true });
50262
50376
  },
50377
+ onDanglingTurnsSwept: () => {
50378
+ if (currentTurn != null) {
50379
+ process.stderr.write(`telegram gateway: disconnect-flush nulled currentTurn (bridge died with turn in flight)
50380
+ `);
50381
+ currentTurn = null;
50382
+ }
50383
+ },
50263
50384
  log: (msg) => process.stderr.write(`${msg}
50264
50385
  `)
50265
50386
  });
@@ -50650,11 +50771,23 @@ async function executeReply(args) {
50650
50771
  if (rawText == null || rawText === "")
50651
50772
  throw new Error("reply: text is required and cannot be empty");
50652
50773
  let text = repairEscapedWhitespace(rawText);
50774
+ {
50775
+ const scrub = scrubVoice(text);
50776
+ if (scrub.replaced > 0) {
50777
+ text = scrub.scrubbed;
50778
+ emitRuntimeMetric({
50779
+ kind: "voice_scrub_applied",
50780
+ chatKey: statusKey(chat_id, args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
50781
+ replaced: scrub.replaced,
50782
+ site: "reply"
50783
+ });
50784
+ }
50785
+ }
50653
50786
  process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}
50654
50787
  `);
50655
50788
  {
50656
50789
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
50657
- const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now());
50790
+ const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null);
50658
50791
  if (dup != null) {
50659
50792
  process.stderr.write(`telegram gateway: reply: deduped (#546) chatId=${chat_id} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
50660
50793
  `);
@@ -50845,7 +50978,7 @@ ${url}`;
50845
50978
  })) {
50846
50979
  turn2.finalAnswerDelivered = true;
50847
50980
  }
50848
- outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now());
50981
+ outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
50849
50982
  silentAnchorEditDone = true;
50850
50983
  } catch (err) {
50851
50984
  process.stderr.write(`telegram gateway: silent-reply auto-edit failed, falling back to fresh send: ${err instanceof Error ? err.message : String(err)}
@@ -51052,7 +51185,7 @@ ${url}`;
51052
51185
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
51053
51186
  `);
51054
51187
  if (sentIds.length > 0) {
51055
- outboundDedup.record(chat_id, threadId, text, Date.now());
51188
+ outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null);
51056
51189
  }
51057
51190
  return { content: [{ type: "text", text: result }] };
51058
51191
  }
@@ -51062,11 +51195,23 @@ async function executeStreamReply(args) {
51062
51195
  throw new Error("stream_reply: chat_id is required");
51063
51196
  if (args.text == null || args.text === "")
51064
51197
  throw new Error("stream_reply: text is required and cannot be empty");
51198
+ {
51199
+ const scrub = scrubVoice(args.text);
51200
+ if (scrub.replaced > 0) {
51201
+ args.text = scrub.scrubbed;
51202
+ emitRuntimeMetric({
51203
+ kind: "voice_scrub_applied",
51204
+ chatKey: statusKey(args.chat_id, args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
51205
+ replaced: scrub.replaced,
51206
+ site: "stream_reply"
51207
+ });
51208
+ }
51209
+ }
51065
51210
  if (args.done === true) {
51066
51211
  const sChatId = args.chat_id;
51067
51212
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
51068
51213
  const sText = args.text;
51069
- const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now());
51214
+ const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null);
51070
51215
  if (dup != null) {
51071
51216
  process.stderr.write(`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
51072
51217
  `);
@@ -51158,7 +51303,7 @@ async function executeStreamReply(args) {
51158
51303
  if (args.done === true && result.messageId != null) {
51159
51304
  const sChatId = args.chat_id;
51160
51305
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
51161
- outboundDedup.record(sChatId, sThreadId, args.text, Date.now());
51306
+ outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
51162
51307
  noteOutbound3(statusKey(sChatId, sThreadId), {
51163
51308
  messageId: result.messageId,
51164
51309
  text: args.text
@@ -51710,7 +51855,19 @@ async function executeEditMessage(args) {
51710
51855
  const editAccess = loadAccess();
51711
51856
  const editConfigMode = editAccess.parseMode ?? "html";
51712
51857
  const editFormat = args.format ?? editConfigMode;
51713
- const editRawText = repairEscapedWhitespace(args.text);
51858
+ let editRawText = repairEscapedWhitespace(args.text);
51859
+ {
51860
+ const scrub = scrubVoice(editRawText);
51861
+ if (scrub.replaced > 0) {
51862
+ editRawText = scrub.scrubbed;
51863
+ emitRuntimeMetric({
51864
+ kind: "voice_scrub_applied",
51865
+ chatKey: statusKey(args.chat_id, undefined),
51866
+ replaced: scrub.replaced,
51867
+ site: "edit_message"
51868
+ });
51869
+ }
51870
+ }
51714
51871
  let editParseMode;
51715
51872
  let editText;
51716
51873
  if (editFormat === "html") {
@@ -52058,10 +52215,10 @@ function handleSessionEvent(ev) {
52058
52215
  }
52059
52216
  },
52060
52217
  checkDedup: (text) => {
52061
- return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now()) != null;
52218
+ return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null;
52062
52219
  },
52063
52220
  recordDedup: (text) => {
52064
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now());
52221
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null);
52065
52222
  },
52066
52223
  recordOutbound: ({ messageId, text }) => {
52067
52224
  if (!HISTORY_ENABLED)
@@ -52152,7 +52309,7 @@ function handleSessionEvent(ev) {
52152
52309
  streamFinalizedAsAnswer = true;
52153
52310
  turn.finalAnswerDelivered = true;
52154
52311
  try {
52155
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now());
52312
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now(), turn.registryKey ?? null);
52156
52313
  } catch {}
52157
52314
  if (HISTORY_ENABLED) {
52158
52315
  try {
@@ -52252,10 +52409,22 @@ function handleSessionEvent(ev) {
52252
52409
  return;
52253
52410
  }
52254
52411
  if (flushDecision.kind === "flush") {
52255
- const capturedText = flushDecision.text;
52412
+ let capturedText = flushDecision.text;
52256
52413
  const backstopChatId = chatId;
52257
52414
  const backstopThreadId = threadId;
52258
52415
  const backstopCtrl = ctrl;
52416
+ {
52417
+ const scrub = scrubVoice(capturedText);
52418
+ if (scrub.replaced > 0) {
52419
+ capturedText = scrub.scrubbed;
52420
+ emitRuntimeMetric({
52421
+ kind: "voice_scrub_applied",
52422
+ chatKey: statusKey(backstopChatId, backstopThreadId),
52423
+ replaced: scrub.replaced,
52424
+ site: "turn_flush"
52425
+ });
52426
+ }
52427
+ }
52259
52428
  turn.finalAnswerDelivered = true;
52260
52429
  const cardTakeover = progressDriver?.takeOverCard({
52261
52430
  chatId: backstopChatId,
@@ -52347,7 +52516,7 @@ function handleSessionEvent(ev) {
52347
52516
  });
52348
52517
  } catch {}
52349
52518
  }
52350
- outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now());
52519
+ outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
52351
52520
  if (backstopCtrl)
52352
52521
  backstopCtrl.setDone();
52353
52522
  if (backstopCardTurnKey != null) {
@@ -53237,7 +53406,8 @@ ${preBlock(write.output)}`;
53237
53406
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
53238
53407
  if (decideInboundDelivery({
53239
53408
  turnInFlight: turnInFlightAtReceipt,
53240
- isSteering
53409
+ isSteering,
53410
+ isInterrupt: interrupt.isInterrupt
53241
53411
  }) === "buffer-until-idle") {
53242
53412
  pendingInboundBuffer.push(selfAgent, inboundMsg);
53243
53413
  process.stderr.write(`telegram gateway: inbound held mid-turn agent=${selfAgent} chat=${chat_id} msg=${msgId ?? "-"} \u2014 will flush on turn-complete
@@ -48,6 +48,18 @@ export interface DisconnectFlushDeps<Ctrl extends { setDone: () => void }, Strea
48
48
  /** Progress driver — disposed with `preservePending: true` for sub-agent JTBDs (#393). */
49
49
  disposeProgressDriver: () => void
50
50
 
51
+ /** Optional: called when the registered-agent disconnect found dangling
52
+ * `activeTurnStartedAt` entries the controller loop did not clear (i.e.
53
+ * `setDone()` already ran on the canonical reply path, leaving
54
+ * `activeStatusReactions` empty but `activeTurnStartedAt` populated).
55
+ * The gateway uses this to null its module-scope `currentTurn` — the
56
+ * bridge that owned that turn just died. Without this, the next
57
+ * inbound is "held mid-turn" against a ghost (the 2026-05-23 audit
58
+ * found ~14 such events / 3 days / 9 agents).
59
+ *
60
+ * No-op if no dangling keys are found. */
61
+ onDanglingTurnsSwept?: (purgedKeys: string[]) => void
62
+
51
63
  /** Logger — receives the one-line decision trace. */
52
64
  log: (msg: string) => void
53
65
  }
@@ -70,6 +82,7 @@ export function flushOnAgentDisconnect<
70
82
  activeDraftParseModes,
71
83
  clearActiveReactions,
72
84
  disposeProgressDriver,
85
+ onDanglingTurnsSwept,
73
86
  log,
74
87
  } = deps
75
88
 
@@ -91,6 +104,30 @@ export function flushOnAgentDisconnect<
91
104
  }
92
105
  clearActiveReactions()
93
106
 
107
+ // Defense-in-depth — sweep any `activeTurnStartedAt` keys the controller
108
+ // loop above did not touch. The bridge has crashed; any turn it owned is
109
+ // dead by definition, regardless of whether `activeStatusReactions`
110
+ // still tracks it. The race that motivates this: `setDone()` already
111
+ // fired on the canonical reply path (clearing the reaction controller)
112
+ // BUT the disconnect arrived BEFORE `purgeReactionTracking` ran the
113
+ // `activeTurnStartedAt.delete` line for that key. Without this sweep,
114
+ // the key orphans, and the next inbound is "held mid-turn" against
115
+ // a ghost — surfacing as the held-mid-turn / `currentTurn_nulled=true`
116
+ // wedge symptom documented in feedback_5min_restart_wedge memo and
117
+ // measured at ~14 events / 3 days / 9 agents (2026-05-23 audit).
118
+ const danglingKeys = [...activeTurnStartedAt.keys()]
119
+ if (danglingKeys.length > 0) {
120
+ for (const k of danglingKeys) {
121
+ activeTurnStartedAt.delete(k)
122
+ activeReactionMsgIds.delete(k)
123
+ }
124
+ log(
125
+ `telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
126
+ `post-bridge-death (controller loop missed — setDone raced disconnect)`,
127
+ )
128
+ onDanglingTurnsSwept?.(danglingKeys)
129
+ }
130
+
94
131
  // Stop coalesce timers that could emit into a finalized draft stream, but
95
132
  // preserve chats with pendingCompletion=true — those have background
96
133
  // sub-agents that legitimately outlive the parent bridge disconnect. The