switchroom 0.13.20 → 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.20";
47335
- var COMMIT_SHA = "9962efb4";
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.20",
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
  }
@@ -40987,7 +40991,7 @@ function shouldShowHandoffLine() {
40987
40991
  return v.toLowerCase() !== "false";
40988
40992
  }
40989
40993
  function formatHandoffLine(topic, format) {
40990
- const prefix = "\u21a9\ufe0f Picked up where we left off \u2014 ";
40994
+ const prefix = "\u21a9\ufe0f Picked up where we left off, ";
40991
40995
  if (format === "html") {
40992
40996
  return `<i>${prefix}${escapeHtml6(topic)}</i>
40993
40997
 
@@ -41156,6 +41160,7 @@ function flushOnAgentDisconnect(deps) {
41156
41160
  activeDraftParseModes,
41157
41161
  clearActiveReactions: clearActiveReactions3,
41158
41162
  disposeProgressDriver,
41163
+ onDanglingTurnsSwept,
41159
41164
  log
41160
41165
  } = deps;
41161
41166
  if (agentName3 == null) {
@@ -41169,6 +41174,15 @@ function flushOnAgentDisconnect(deps) {
41169
41174
  activeTurnStartedAt.delete(key);
41170
41175
  }
41171
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
+ }
41172
41186
  disposeProgressDriver();
41173
41187
  for (const [key, stream] of activeDraftStreams.entries()) {
41174
41188
  if (!stream.isFinal())
@@ -44464,7 +44478,11 @@ function purgeStaleTurnsForChat(chatId, keys, purger) {
44464
44478
 
44465
44479
  // gateway/inbound-delivery-gate.ts
44466
44480
  function decideInboundDelivery(input) {
44467
- if (input.turnInFlight && !input.isSteering)
44481
+ if (input.isSteering)
44482
+ return "deliver";
44483
+ if (input.isInterrupt === true)
44484
+ return "deliver";
44485
+ if (input.turnInFlight)
44468
44486
  return "buffer-until-idle";
44469
44487
  return "deliver";
44470
44488
  }
@@ -46702,7 +46720,7 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
46702
46720
  db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
46703
46721
  log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
46704
46722
  }
46705
- function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall) {
46723
+ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished) {
46706
46724
  try {
46707
46725
  const stat = fs2.statSync(entry.filePath);
46708
46726
  if (stat.size < tail.cursor) {
@@ -46807,6 +46825,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
46807
46825
  }
46808
46826
  tail.hasEmittedStart = startState.hasEmittedStart;
46809
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
+ }
46810
46834
  log?.(`subagent-watcher: read error ${entry.agentId}: ${err.message}`);
46811
46835
  }
46812
46836
  }
@@ -46913,7 +46937,7 @@ function startSubagentWatcher(config) {
46913
46937
  return;
46914
46938
  readSubTail(entry2, t, nowFn(), (desc) => {
46915
46939
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
46916
- }, fs2, log, db2, parentStateDir, config.onUnstall);
46940
+ }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
46917
46941
  maybySendStateTransition(agentId);
46918
46942
  });
46919
46943
  } catch (err) {
@@ -47159,7 +47183,7 @@ function startSubagentWatcher(config) {
47159
47183
  continue;
47160
47184
  readSubTail(entry, tail, n, (desc) => {
47161
47185
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
47162
- }, fs2, log, db2, parentStateDir, config.onUnstall);
47186
+ }, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
47163
47187
  maybySendStateTransition(agentId);
47164
47188
  }
47165
47189
  checkStalls();
@@ -48291,10 +48315,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48291
48315
  }
48292
48316
 
48293
48317
  // ../src/build-info.ts
48294
- var VERSION = "0.13.20";
48295
- var COMMIT_SHA = "9962efb4";
48296
- var COMMIT_DATE = "2026-05-23T08:29:36Z";
48297
- var LATEST_PR = 1684;
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;
48298
48322
  var COMMITS_AHEAD_OF_TAG = 0;
48299
48323
 
48300
48324
  // gateway/boot-version.ts
@@ -50078,6 +50102,12 @@ startTimer({
50078
50102
  emitRuntimeMetric(event);
50079
50103
  },
50080
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
+ }
50081
50111
  let text = null;
50082
50112
  const upd = inFlightUpdate;
50083
50113
  if (upd != null) {
@@ -50344,6 +50374,13 @@ var ipcServer = createIpcServer({
50344
50374
  disposeProgressDriver: () => {
50345
50375
  progressDriver?.dispose?.({ preservePending: true });
50346
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
+ },
50347
50384
  log: (msg) => process.stderr.write(`${msg}
50348
50385
  `)
50349
50386
  });
@@ -50750,7 +50787,7 @@ async function executeReply(args) {
50750
50787
  `);
50751
50788
  {
50752
50789
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
50753
- const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now());
50790
+ const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null);
50754
50791
  if (dup != null) {
50755
50792
  process.stderr.write(`telegram gateway: reply: deduped (#546) chatId=${chat_id} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
50756
50793
  `);
@@ -50941,7 +50978,7 @@ ${url}`;
50941
50978
  })) {
50942
50979
  turn2.finalAnswerDelivered = true;
50943
50980
  }
50944
- outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now());
50981
+ outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
50945
50982
  silentAnchorEditDone = true;
50946
50983
  } catch (err) {
50947
50984
  process.stderr.write(`telegram gateway: silent-reply auto-edit failed, falling back to fresh send: ${err instanceof Error ? err.message : String(err)}
@@ -51148,7 +51185,7 @@ ${url}`;
51148
51185
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
51149
51186
  `);
51150
51187
  if (sentIds.length > 0) {
51151
- outboundDedup.record(chat_id, threadId, text, Date.now());
51188
+ outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null);
51152
51189
  }
51153
51190
  return { content: [{ type: "text", text: result }] };
51154
51191
  }
@@ -51158,11 +51195,23 @@ async function executeStreamReply(args) {
51158
51195
  throw new Error("stream_reply: chat_id is required");
51159
51196
  if (args.text == null || args.text === "")
51160
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
+ }
51161
51210
  if (args.done === true) {
51162
51211
  const sChatId = args.chat_id;
51163
51212
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
51164
51213
  const sText = args.text;
51165
- const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now());
51214
+ const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null);
51166
51215
  if (dup != null) {
51167
51216
  process.stderr.write(`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
51168
51217
  `);
@@ -51254,7 +51303,7 @@ async function executeStreamReply(args) {
51254
51303
  if (args.done === true && result.messageId != null) {
51255
51304
  const sChatId = args.chat_id;
51256
51305
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
51257
- outboundDedup.record(sChatId, sThreadId, args.text, Date.now());
51306
+ outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
51258
51307
  noteOutbound3(statusKey(sChatId, sThreadId), {
51259
51308
  messageId: result.messageId,
51260
51309
  text: args.text
@@ -52166,10 +52215,10 @@ function handleSessionEvent(ev) {
52166
52215
  }
52167
52216
  },
52168
52217
  checkDedup: (text) => {
52169
- 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;
52170
52219
  },
52171
52220
  recordDedup: (text) => {
52172
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now());
52221
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null);
52173
52222
  },
52174
52223
  recordOutbound: ({ messageId, text }) => {
52175
52224
  if (!HISTORY_ENABLED)
@@ -52260,7 +52309,7 @@ function handleSessionEvent(ev) {
52260
52309
  streamFinalizedAsAnswer = true;
52261
52310
  turn.finalAnswerDelivered = true;
52262
52311
  try {
52263
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now());
52312
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now(), turn.registryKey ?? null);
52264
52313
  } catch {}
52265
52314
  if (HISTORY_ENABLED) {
52266
52315
  try {
@@ -52360,10 +52409,22 @@ function handleSessionEvent(ev) {
52360
52409
  return;
52361
52410
  }
52362
52411
  if (flushDecision.kind === "flush") {
52363
- const capturedText = flushDecision.text;
52412
+ let capturedText = flushDecision.text;
52364
52413
  const backstopChatId = chatId;
52365
52414
  const backstopThreadId = threadId;
52366
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
+ }
52367
52428
  turn.finalAnswerDelivered = true;
52368
52429
  const cardTakeover = progressDriver?.takeOverCard({
52369
52430
  chatId: backstopChatId,
@@ -52455,7 +52516,7 @@ function handleSessionEvent(ev) {
52455
52516
  });
52456
52517
  } catch {}
52457
52518
  }
52458
- outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now());
52519
+ outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
52459
52520
  if (backstopCtrl)
52460
52521
  backstopCtrl.setDone();
52461
52522
  if (backstopCardTurnKey != null) {
@@ -53345,7 +53406,8 @@ ${preBlock(write.output)}`;
53345
53406
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
53346
53407
  if (decideInboundDelivery({
53347
53408
  turnInFlight: turnInFlightAtReceipt,
53348
- isSteering
53409
+ isSteering,
53410
+ isInterrupt: interrupt.isInterrupt
53349
53411
  }) === "buffer-until-idle") {
53350
53412
  pendingInboundBuffer.push(selfAgent, inboundMsg);
53351
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
@@ -3084,6 +3084,30 @@ silencePoke.startTimer({
3084
3084
  emitRuntimeMetric(event)
3085
3085
  },
3086
3086
  onFrameworkFallback: async (ctx) => {
3087
+ // Late-fire short-circuit (2026-05-23 audit finding). The fallback
3088
+ // can race a clean turn-end: the model's actual reply lands inside
3089
+ // the silence window's final ~50ms, the canonical turn-end path
3090
+ // clears `activeTurnStartedAt` and nulls `currentTurn`, and then
3091
+ // this handler fires anyway. Without this check we emit a noisy
3092
+ // "still working…" ping to the user (right after they got their
3093
+ // real reply) AND a misleading "ended wedged turn ... currentTurn_
3094
+ // nulled=false drained_buffered=0/0" log line. The 7-day audit
3095
+ // showed this race accounts for ~90% of all framework_fallback log
3096
+ // events (124 of 138 `currentTurn_nulled=false` cases). Distinct
3097
+ // log line so observability still tracks the fact that the silence
3098
+ // crossed threshold; the wedge counter is no longer polluted.
3099
+ if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
3100
+ process.stderr.write(
3101
+ `telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
3102
+ `turn ended cleanly during silence window ` +
3103
+ `chat=${ctx.chatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs}\n`,
3104
+ )
3105
+ // Tell silence-poke this chat-thread is finished so the next
3106
+ // arming doesn't carry stale state.
3107
+ silencePoke.endTurn(ctx.key)
3108
+ return
3109
+ }
3110
+
3087
3111
  // Deterministic in-flight update status (klanker incident). If this
3088
3112
  // gateway dispatched an update_apply that's still running, the
3089
3113
  // recurring framework fallback carries hostd's REAL phase + elapsed
@@ -3579,6 +3603,18 @@ const ipcServer: IpcServer = createIpcServer({
3579
3603
  // scripts/check-plugin-references.mjs (TS2722).
3580
3604
  progressDriver?.dispose?.({ preservePending: true })
3581
3605
  },
3606
+ // When dangling activeTurnStartedAt keys were swept (setDone raced
3607
+ // disconnect), the module-scope `currentTurn` may also point at the
3608
+ // dead bridge's turn. Null it so the next inbound starts a fresh
3609
+ // turn instead of inheriting a ghost.
3610
+ onDanglingTurnsSwept: () => {
3611
+ if (currentTurn != null) {
3612
+ process.stderr.write(
3613
+ `telegram gateway: disconnect-flush nulled currentTurn (bridge died with turn in flight)\n`,
3614
+ )
3615
+ currentTurn = null
3616
+ }
3617
+ },
3582
3618
  log: (msg) => process.stderr.write(`${msg}\n`),
3583
3619
  })
3584
3620
  },
@@ -4227,7 +4263,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4227
4263
  // late-replies with different content sail through.
4228
4264
  {
4229
4265
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
4230
- const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now())
4266
+ const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null)
4231
4267
  if (dup != null) {
4232
4268
  process.stderr.write(
4233
4269
  `telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
@@ -4561,6 +4597,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4561
4597
  threadId,
4562
4598
  decision.mergedText,
4563
4599
  Date.now(),
4600
+ turn?.registryKey ?? null,
4564
4601
  )
4565
4602
 
4566
4603
  silentAnchorEditDone = true
@@ -4885,7 +4922,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4885
4922
  // calls with this same content within DEFAULT_DEDUP_TTL_MS will
4886
4923
  // be suppressed.
4887
4924
  if (sentIds.length > 0) {
4888
- outboundDedup.record(chat_id, threadId, text, Date.now())
4925
+ outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null)
4889
4926
  }
4890
4927
  return { content: [{ type: 'text', text: result }] }
4891
4928
  }
@@ -4896,6 +4933,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
4896
4933
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
4897
4934
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
4898
4935
 
4936
+ // Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
4937
+ // uses the answer-stream / draft-stream path for multi-paragraph
4938
+ // replies — the model emits via stream_reply and the original
4939
+ // PR #1683 scrub site (executeReply) never sees the text. klanker's
4940
+ // 2026-05-24 log showed model output with em-dashes routed via
4941
+ // stream_reply done=true, materializing as sendMessage with no
4942
+ // scrub. Mirror the executeReply pattern here: scrub BEFORE the
4943
+ // outbound-dedup check (so retries see the scrubbed key) and
4944
+ // mutate args.text so all downstream consumers (the stream-
4945
+ // controller, dedup record, history record) see the scrubbed
4946
+ // version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
4947
+ {
4948
+ const scrub = scrubVoice(args.text as string)
4949
+ if (scrub.replaced > 0) {
4950
+ args.text = scrub.scrubbed
4951
+ emitRuntimeMetric({
4952
+ kind: 'voice_scrub_applied',
4953
+ chatKey: statusKey(args.chat_id as string, args.message_thread_id != null
4954
+ ? Number(args.message_thread_id) : undefined),
4955
+ replaced: scrub.replaced,
4956
+ site: 'stream_reply',
4957
+ })
4958
+ }
4959
+ }
4960
+
4899
4961
  // #546 dedup check: stream_reply done=true is the most-common
4900
4962
  // retry shape — claude-code re-emits the final-text call when
4901
4963
  // the previous bridge missed the ack. If turn-flush already sent
@@ -4906,7 +4968,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
4906
4968
  const sChatId = args.chat_id as string
4907
4969
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
4908
4970
  const sText = args.text as string
4909
- const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now())
4971
+ const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
4910
4972
  if (dup != null) {
4911
4973
  process.stderr.write(
4912
4974
  `telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
@@ -5070,7 +5132,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5070
5132
  if (args.done === true && result.messageId != null) {
5071
5133
  const sChatId = args.chat_id as string
5072
5134
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
5073
- outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
5135
+ outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
5074
5136
  // #1445 cross-turn pending-async ambient. The terminal stream_reply
5075
5137
  // (done=true) is the user-visible anchor for any cross-turn wait
5076
5138
  // that follows. Capture it so if this turn ends with a pending
@@ -6382,10 +6444,10 @@ function handleSessionEvent(ev: SessionEvent): void {
6382
6444
  // threadId come from the captured `turn` snapshot, stable for
6383
6445
  // the lifetime of the stream.
6384
6446
  checkDedup: (text: string) => {
6385
- return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now()) != null
6447
+ return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null
6386
6448
  },
6387
6449
  recordDedup: (text: string) => {
6388
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now())
6450
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
6389
6451
  },
6390
6452
  // #648 — write answer-stream materializations into the SQLite
6391
6453
  // history buffer so get_recent_messages can surface them. Guard
@@ -6546,6 +6608,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6546
6608
  turn.sessionThreadId,
6547
6609
  streamedFinalText,
6548
6610
  Date.now(),
6611
+ turn.registryKey ?? null,
6549
6612
  )
6550
6613
  } catch { /* best-effort */ }
6551
6614
  if (HISTORY_ENABLED) {
@@ -6715,11 +6778,31 @@ function handleSessionEvent(ev: SessionEvent): void {
6715
6778
  }
6716
6779
 
6717
6780
  if (flushDecision.kind === 'flush') {
6718
- const capturedText = flushDecision.text
6781
+ let capturedText = flushDecision.text
6719
6782
  const backstopChatId = chatId
6720
6783
  const backstopThreadId = threadId
6721
6784
  const backstopCtrl = ctrl
6722
6785
 
6786
+ // Voice scrub (PR #1683 follow-up). Turn-flush is the path
6787
+ // that fires when the model emits raw transcript text WITHOUT
6788
+ // calling reply / stream_reply. That captured text bypasses
6789
+ // PR #1683's executeReply scrub site entirely and is delivered
6790
+ // via sendMessage / editMessageText directly. Scrub the
6791
+ // capturedText before markdownToHtml so em-dashes never reach
6792
+ // the wire. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
6793
+ {
6794
+ const scrub = scrubVoice(capturedText)
6795
+ if (scrub.replaced > 0) {
6796
+ capturedText = scrub.scrubbed
6797
+ emitRuntimeMetric({
6798
+ kind: 'voice_scrub_applied',
6799
+ chatKey: statusKey(backstopChatId, backstopThreadId),
6800
+ replaced: scrub.replaced,
6801
+ site: 'turn_flush',
6802
+ })
6803
+ }
6804
+ }
6805
+
6723
6806
  // #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
6724
6807
  // returns 'reply-called' otherwise). It legitimately delivers the
6725
6808
  // model's terminal text as the answer, so the turn IS answered.
@@ -6911,6 +6994,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6911
6994
  backstopThreadId,
6912
6995
  capturedText,
6913
6996
  Date.now(),
6997
+ currentTurn?.registryKey ?? null,
6914
6998
  )
6915
6999
  if (backstopCtrl) backstopCtrl.setDone()
6916
7000
  // Unpin the card. completeTurn cleans up pinMgr's per-turn
@@ -8455,6 +8539,15 @@ async function handleInbound(
8455
8539
  decideInboundDelivery({
8456
8540
  turnInFlight: turnInFlightAtReceipt,
8457
8541
  isSteering,
8542
+ // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
8543
+ // must bypass the "buffer-until-turn-complete" gate because the
8544
+ // SIGINT'd turn often doesn't emit turn_complete, leaving the
8545
+ // body stranded in pendingInboundBuffer indefinitely. The
8546
+ // `interrupt` const is computed at the start of handleInbound
8547
+ // (line ~7606) and remains in scope here. When the user fires
8548
+ // `!`-with-body, this delivers the body as a fresh inbound to
8549
+ // the freshly-killed bridge.
8550
+ isInterrupt: interrupt.isInterrupt,
8458
8551
  }) === 'buffer-until-idle'
8459
8552
  ) {
8460
8553
  pendingInboundBuffer.push(selfAgent, inboundMsg)
@@ -53,6 +53,27 @@
53
53
  * mid-turn — that is the whole point of the steering feature (redirect
54
54
  * the agent while it works). Steering messages keep immediate delivery.
55
55
  * The wedge only ever affected the queued-mid-turn default path.
56
+ *
57
+ * ## Interrupt-marker is also exempt (2026-05-24 fix)
58
+ *
59
+ * An inbound prefixed with `!` invokes the interrupt path
60
+ * (`gateway.ts:handleInbound` parse + `tmux send-keys C-c` to the
61
+ * bridge). The SIGINT kills the in-flight turn at the SDK level — but
62
+ * the killed turn does NOT always emit `turn_complete`. Without that
63
+ * event, the turn-complete buffer-flush never fires, and the
64
+ * post-SIGINT inbound body (the `!` replacement instruction) rots in
65
+ * `pendingInboundBuffer` indefinitely.
66
+ *
67
+ * 2026-05-24 live UAT trace: user fires `! actually reply hello`,
68
+ * SIGINT delivered, killed turn never emits `turn_complete`, buffer
69
+ * stays full, user sees no response. The Phase-3 audit had this UAT
70
+ * `describe.skip`'d as "real interrupt-marker wedge or prompt-shape
71
+ * issue" — confirmed real.
72
+ *
73
+ * Resolution: bypass the gate for interrupt inbounds. The interrupt
74
+ * carve-out is a peer of `isSteering` — both are "intentional
75
+ * mid-turn delivery" cases. Caller passes the interrupt flag from the
76
+ * inbound parse; the gate returns `'deliver'` immediately.
56
77
  */
57
78
 
58
79
  export interface InboundDeliveryGateInput {
@@ -63,6 +84,14 @@ export interface InboundDeliveryGateInput {
63
84
  /** This inbound carried an explicit `/steer` (`/s`) prefix and is an
64
85
  * intentional mid-turn redirect. */
65
86
  isSteering: boolean
87
+ /** This inbound was parsed by `parseInterruptMarker` as a `!`-prefixed
88
+ * interrupt request. The gateway has already (or is about to) deliver
89
+ * the SIGINT to claude via tmux send-keys; the body of the message
90
+ * (post-`!`) is the user's replacement instruction. Without this
91
+ * carve-out, the body rots in pendingInboundBuffer because the
92
+ * SIGINT'd turn doesn't reliably emit turn_complete to drain the
93
+ * buffer. Optional + defaults false for backward compat. */
94
+ isInterrupt?: boolean
66
95
  }
67
96
 
68
97
  export type InboundDeliveryDecision =
@@ -73,13 +102,17 @@ export type InboundDeliveryDecision =
73
102
  | 'buffer-until-idle'
74
103
 
75
104
  /**
76
- * Pure. The ONLY condition that defers delivery is "a turn is in flight
77
- * AND this is not a steering message". Everything else delivers
78
- * immediately (idle submits at once; steering intentional mid-turn).
105
+ * Pure. Defers delivery ONLY when a turn is in flight AND this inbound
106
+ * is neither steering nor an interrupt. Idle → deliver. Steering deliver
107
+ * (intentional mid-turn redirect). Interrupt deliver (the `!`
108
+ * carve-out — see header doc; the killed turn may never drain the
109
+ * buffer, so we must not buffer in the first place).
79
110
  */
80
111
  export function decideInboundDelivery(
81
112
  input: InboundDeliveryGateInput,
82
113
  ): InboundDeliveryDecision {
83
- if (input.turnInFlight && !input.isSteering) return 'buffer-until-idle'
114
+ if (input.isSteering) return 'deliver'
115
+ if (input.isInterrupt === true) return 'deliver'
116
+ if (input.turnInFlight) return 'buffer-until-idle'
84
117
  return 'deliver'
85
118
  }