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.
@@ -154,6 +154,7 @@ const SILENT_END_FALLBACK_TEXT =
154
154
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
155
155
  'message may not have been answered. Please try asking again.'
156
156
  import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
157
+ import { scrubVoice } from '../text-voice-scrub.js'
157
158
  import {
158
159
  validateInlineKeyboard,
159
160
  type AnyButton,
@@ -3083,6 +3084,30 @@ silencePoke.startTimer({
3083
3084
  emitRuntimeMetric(event)
3084
3085
  },
3085
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
+
3086
3111
  // Deterministic in-flight update status (klanker incident). If this
3087
3112
  // gateway dispatched an update_apply that's still running, the
3088
3113
  // recurring framework fallback carries hostd's REAL phase + elapsed
@@ -3578,6 +3603,18 @@ const ipcServer: IpcServer = createIpcServer({
3578
3603
  // scripts/check-plugin-references.mjs (TS2722).
3579
3604
  progressDriver?.dispose?.({ preservePending: true })
3580
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
+ },
3581
3618
  log: (msg) => process.stderr.write(`${msg}\n`),
3582
3619
  })
3583
3620
  },
@@ -4197,6 +4234,26 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4197
4234
  const rawText = args.text as string | undefined
4198
4235
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
4199
4236
  let text = repairEscapedWhitespace(rawText)
4237
+ // Voice scrub (#1683): replace em / en dashes with commas / periods.
4238
+ // Runs BEFORE outboundDedup so retries see the scrubbed key, and
4239
+ // BEFORE markdownToHtml so code-block content is correctly parked
4240
+ // by the scrubber's own placeholder pass (otherwise the html
4241
+ // converter would have already escaped/parked code, and the scrub
4242
+ // would see only the parked placeholders). Kill switch:
4243
+ // `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
4244
+ {
4245
+ const scrub = scrubVoice(text)
4246
+ if (scrub.replaced > 0) {
4247
+ text = scrub.scrubbed
4248
+ emitRuntimeMetric({
4249
+ kind: 'voice_scrub_applied',
4250
+ chatKey: statusKey(chat_id, args.message_thread_id != null
4251
+ ? Number(args.message_thread_id) : undefined),
4252
+ replaced: scrub.replaced,
4253
+ site: 'reply',
4254
+ })
4255
+ }
4256
+ }
4200
4257
  process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
4201
4258
 
4202
4259
  // #546 dedup check: was this content just sent via turn-flush or
@@ -4206,7 +4263,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4206
4263
  // late-replies with different content sail through.
4207
4264
  {
4208
4265
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
4209
- const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now())
4266
+ const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null)
4210
4267
  if (dup != null) {
4211
4268
  process.stderr.write(
4212
4269
  `telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
@@ -4540,6 +4597,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4540
4597
  threadId,
4541
4598
  decision.mergedText,
4542
4599
  Date.now(),
4600
+ turn?.registryKey ?? null,
4543
4601
  )
4544
4602
 
4545
4603
  silentAnchorEditDone = true
@@ -4864,7 +4922,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4864
4922
  // calls with this same content within DEFAULT_DEDUP_TTL_MS will
4865
4923
  // be suppressed.
4866
4924
  if (sentIds.length > 0) {
4867
- outboundDedup.record(chat_id, threadId, text, Date.now())
4925
+ outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null)
4868
4926
  }
4869
4927
  return { content: [{ type: 'text', text: result }] }
4870
4928
  }
@@ -4875,6 +4933,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
4875
4933
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
4876
4934
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
4877
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
+
4878
4961
  // #546 dedup check: stream_reply done=true is the most-common
4879
4962
  // retry shape — claude-code re-emits the final-text call when
4880
4963
  // the previous bridge missed the ack. If turn-flush already sent
@@ -4885,7 +4968,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
4885
4968
  const sChatId = args.chat_id as string
4886
4969
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
4887
4970
  const sText = args.text as string
4888
- const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now())
4971
+ const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
4889
4972
  if (dup != null) {
4890
4973
  process.stderr.write(
4891
4974
  `telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
@@ -5049,7 +5132,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5049
5132
  if (args.done === true && result.messageId != null) {
5050
5133
  const sChatId = args.chat_id as string
5051
5134
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
5052
- outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
5135
+ outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
5053
5136
  // #1445 cross-turn pending-async ambient. The terminal stream_reply
5054
5137
  // (done=true) is the user-visible anchor for any cross-turn wait
5055
5138
  // that follows. Capture it so if this turn ends with a pending
@@ -5842,7 +5925,23 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
5842
5925
  const editAccess = loadAccess()
5843
5926
  const editConfigMode = editAccess.parseMode ?? 'html'
5844
5927
  const editFormat = (args.format as string | undefined) ?? editConfigMode
5845
- const editRawText = repairEscapedWhitespace(args.text as string)
5928
+ let editRawText = repairEscapedWhitespace(args.text as string)
5929
+ // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
5930
+ // are how silent-anchor and progress-update mutate already-sent
5931
+ // bubbles, so without this an edit can re-introduce dashes the
5932
+ // original send had scrubbed out.
5933
+ {
5934
+ const scrub = scrubVoice(editRawText)
5935
+ if (scrub.replaced > 0) {
5936
+ editRawText = scrub.scrubbed
5937
+ emitRuntimeMetric({
5938
+ kind: 'voice_scrub_applied',
5939
+ chatKey: statusKey(args.chat_id as string, undefined),
5940
+ replaced: scrub.replaced,
5941
+ site: 'edit_message',
5942
+ })
5943
+ }
5944
+ }
5846
5945
  let editParseMode: 'HTML' | 'MarkdownV2' | undefined
5847
5946
  let editText: string
5848
5947
  if (editFormat === 'html') {
@@ -6345,10 +6444,10 @@ function handleSessionEvent(ev: SessionEvent): void {
6345
6444
  // threadId come from the captured `turn` snapshot, stable for
6346
6445
  // the lifetime of the stream.
6347
6446
  checkDedup: (text: string) => {
6348
- 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
6349
6448
  },
6350
6449
  recordDedup: (text: string) => {
6351
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now())
6450
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
6352
6451
  },
6353
6452
  // #648 — write answer-stream materializations into the SQLite
6354
6453
  // history buffer so get_recent_messages can surface them. Guard
@@ -6509,6 +6608,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6509
6608
  turn.sessionThreadId,
6510
6609
  streamedFinalText,
6511
6610
  Date.now(),
6611
+ turn.registryKey ?? null,
6512
6612
  )
6513
6613
  } catch { /* best-effort */ }
6514
6614
  if (HISTORY_ENABLED) {
@@ -6678,11 +6778,31 @@ function handleSessionEvent(ev: SessionEvent): void {
6678
6778
  }
6679
6779
 
6680
6780
  if (flushDecision.kind === 'flush') {
6681
- const capturedText = flushDecision.text
6781
+ let capturedText = flushDecision.text
6682
6782
  const backstopChatId = chatId
6683
6783
  const backstopThreadId = threadId
6684
6784
  const backstopCtrl = ctrl
6685
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
+
6686
6806
  // #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
6687
6807
  // returns 'reply-called' otherwise). It legitimately delivers the
6688
6808
  // model's terminal text as the answer, so the turn IS answered.
@@ -6874,6 +6994,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6874
6994
  backstopThreadId,
6875
6995
  capturedText,
6876
6996
  Date.now(),
6997
+ currentTurn?.registryKey ?? null,
6877
6998
  )
6878
6999
  if (backstopCtrl) backstopCtrl.setDone()
6879
7000
  // Unpin the card. completeTurn cleans up pinMgr's per-turn
@@ -8418,6 +8539,15 @@ async function handleInbound(
8418
8539
  decideInboundDelivery({
8419
8540
  turnInFlight: turnInFlightAtReceipt,
8420
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,
8421
8551
  }) === 'buffer-until-idle'
8422
8552
  ) {
8423
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
  }
@@ -5,7 +5,7 @@
5
5
  * (written by the summarizer Stop hook). On the FIRST assistant reply
6
6
  * of the new session the plugin prepends a subtle one-liner:
7
7
  *
8
- * ↩️ Picked up where we left off <topic>
8
+ * ↩️ Picked up where we left off, <topic>
9
9
  *
10
10
  * The sidecar is consumed (read + deleted) so the line only fires once.
11
11
  * All helpers here are filesystem-only or env-only — no Telegram side
@@ -175,7 +175,13 @@ export function formatHandoffLine(
175
175
  topic: string,
176
176
  format: HandoffFormat,
177
177
  ): string {
178
- const prefix = "↩️ Picked up where we left off — ";
178
+ // Comma instead of em-dash: the framework-emitted prefix is
179
+ // concatenated AFTER scrubVoice runs on the model body (gateway.ts
180
+ // executeReply), so any em-dash here bypasses the v0.13.20 voice
181
+ // scrub. Replacing at the template source is one mechanical change
182
+ // that closes the dominant residual em-dash leak (16 of 17 dashed
183
+ // messages on test-harness were this template per 2026-05-24 audit).
184
+ const prefix = "↩️ Picked up where we left off, ";
179
185
  if (format === "html") {
180
186
  return `<i>${prefix}${escapeHtml(topic)}</i>\n\n`;
181
187
  }
@@ -57,6 +57,16 @@ interface DedupEntry {
57
57
  /** First 80 chars of the original (un-normalized) text — for
58
58
  * operator-facing log lines that show what got deduped. */
59
59
  preview: string
60
+ /** The `currentTurn.registryKey` at record time, or `null` if the
61
+ * recording site had no turn context. Threaded through so check()
62
+ * can distinguish within-turn retries (#546 bug class — keep
63
+ * protecting) from cross-turn coincidences (2026-05-23 audit found
64
+ * identical mid-turn + final replies across two turns ~30s apart
65
+ * silently swallowing the second turn's answer; the user gets
66
+ * no response to their second question). Null on either side
67
+ * matches as before, preserving the boot-time / edge-case behaviour
68
+ * the original tests pin. */
69
+ turnKey: string | null
60
70
  }
61
71
 
62
72
  /**
@@ -75,8 +85,21 @@ export class OutboundDedupCache {
75
85
  /** Record an outbound message. Caller should invoke this after a
76
86
  * successful send, regardless of which path sent it (turn-flush,
77
87
  * executeReply, executeStreamReply, etc.). Short content is not
78
- * recorded — see DEDUP_MIN_CONTENT_LEN. */
79
- record(chatId: string, threadId: number | undefined, text: string, now: number): void {
88
+ * recorded — see DEDUP_MIN_CONTENT_LEN.
89
+ *
90
+ * `turnKey` lets check() tell within-turn retries (the #546 race
91
+ * this module exists to catch) apart from cross-turn coincidences
92
+ * (a user asking similar questions in different turns). Pass
93
+ * `null` if the recording site has no turn context — that matches
94
+ * legacy behaviour and is what the early-boot / fallback callers
95
+ * pass. */
96
+ record(
97
+ chatId: string,
98
+ threadId: number | undefined,
99
+ text: string,
100
+ now: number,
101
+ turnKey: string | null = null,
102
+ ): void {
80
103
  if (text.length < DEDUP_MIN_CONTENT_LEN) return
81
104
  const key = makeKey(chatId, threadId)
82
105
  const list = this.entries.get(key) ?? []
@@ -85,6 +108,7 @@ export class OutboundDedupCache {
85
108
  hash: normalizeForDedup(text),
86
109
  ts: now,
87
110
  preview: text.slice(0, 80),
111
+ turnKey,
88
112
  })
89
113
  this.entries.set(key, list)
90
114
  }
@@ -92,12 +116,24 @@ export class OutboundDedupCache {
92
116
  /** Check whether the given text was already sent recently to the
93
117
  * same chat. Returns the matched entry's preview + age on hit, or
94
118
  * null on miss. Caller decides what to do with the answer
95
- * (skip-send, log, etc.). */
119
+ * (skip-send, log, etc.).
120
+ *
121
+ * Cross-turn carve-out (2026-05-23 fix): when both sides of a hash
122
+ * match carry non-null `turnKey` AND those keys differ, treat as
123
+ * miss. The duplicate-reply race this module was built for (#546)
124
+ * is strictly within-turn (the same turn's buffered text replays
125
+ * via a stream_reply retry), so within-turn retries continue to
126
+ * hit. A user typing two similar prompts back-to-back used to lose
127
+ * the second turn's reply because the hashes collided across
128
+ * turns; that no longer happens. Null on EITHER side (legacy /
129
+ * no-turn-context callers) still matches — preserves backward
130
+ * compatibility with the original test suite + early-boot paths. */
96
131
  check(
97
132
  chatId: string,
98
133
  threadId: number | undefined,
99
134
  text: string,
100
135
  now: number,
136
+ turnKey: string | null = null,
101
137
  ): { matched: true; preview: string; ageMs: number } | null {
102
138
  if (text.length < DEDUP_MIN_CONTENT_LEN) return null
103
139
  const key = makeKey(chatId, threadId)
@@ -106,9 +142,19 @@ export class OutboundDedupCache {
106
142
  this.evict(list, now)
107
143
  const candidateHash = normalizeForDedup(text)
108
144
  for (const entry of list) {
109
- if (entry.hash === candidateHash) {
110
- return { matched: true, preview: entry.preview, ageMs: now - entry.ts }
145
+ if (entry.hash !== candidateHash) continue
146
+ // Cross-turn carve-out: distinct, non-null turnKeys on both
147
+ // sides ⇒ different turns ⇒ not a #546 retry. Skip past this
148
+ // entry and keep scanning (a same-turn match later in the list
149
+ // should still hit).
150
+ if (
151
+ turnKey != null
152
+ && entry.turnKey != null
153
+ && entry.turnKey !== turnKey
154
+ ) {
155
+ continue
111
156
  }
157
+ return { matched: true, preview: entry.preview, ageMs: now - entry.ts }
112
158
  }
113
159
  return null
114
160
  }
@@ -142,6 +142,28 @@ export type RuntimeMetricEvent =
142
142
  key: string
143
143
  sinceFirstPingMs: number
144
144
  }
145
+ /**
146
+ * Voice scrubber engaged: em / en dashes were rewritten to commas /
147
+ * periods on an outbound reply. Each event is a soft-layer policy
148
+ * violation the framework caught (SOUL.md.hbs "never use em-dashes"
149
+ * is the soft layer, this scrub is the hard layer). Fleet-wide
150
+ * trend over weeks shows whether the soft prompt is gaining or
151
+ * losing ground; a per-agent spike is prompt drift on that agent.
152
+ *
153
+ * chatKey → `<chatId>:<threadIdOrEmpty>` (statusKey shape)
154
+ * replaced → count of dashes rewritten in this single message
155
+ * site → which reply path saw the scrub (executeReply / edit / answer-stream)
156
+ */
157
+ | {
158
+ kind: 'voice_scrub_applied'
159
+ chatKey: string
160
+ replaced: number
161
+ // `stream_reply` and `turn_flush` added in v0.13.21 — modern
162
+ // Claude routes most multi-paragraph replies through the
163
+ // answer-stream / draft-stream path, bypassing the v0.13.20
164
+ // executeReply scrub site. The two new sites close that gap.
165
+ site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream' | 'stream_reply' | 'turn_flush'
166
+ }
145
167
 
146
168
  /**
147
169
  * The JSONL sink lives under the runtime state dir so it's per-agent
@@ -459,7 +459,10 @@ function backfillJsonlAgentId(
459
459
  log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
460
460
  }
461
461
 
462
- function readSubTail(
462
+ // Exported for unit-testing the ENOENT/EACCES deregister path
463
+ // (telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts).
464
+ // Not intended for consumption by other modules.
465
+ export function readSubTail(
463
466
  entry: WorkerEntry,
464
467
  tail: SubTail,
465
468
  now: number,
@@ -472,6 +475,14 @@ function readSubTail(
472
475
  * previously-stalled entry. Closes the resume edge the schema doc
473
476
  * has always promised. */
474
477
  onUnstall?: (agentId: string, description: string) => void,
478
+ /** Fires when the JSONL file is no longer accessible (ENOENT — file
479
+ * reaped by Claude Code when the parent session ends; EACCES —
480
+ * permission change mid-poll). The caller deregisters the entry so
481
+ * the 1s poll loop stops re-statting a dead path. Without this
482
+ * callback, every poll re-emits the error log line — on 2026-05-23
483
+ * the clerk agent logged 540k ENOENT lines in 3 days (30/sec
484
+ * sustained) AND leaked one fs.watch FD per stranded entry. */
485
+ onFileVanished?: (agentId: string, code: 'ENOENT' | 'EACCES') => void,
475
486
  ): void {
476
487
  try {
477
488
  const stat = fs.statSync(entry.filePath)
@@ -639,6 +650,17 @@ function readSubTail(
639
650
  }
640
651
  tail.hasEmittedStart = startState.hasEmittedStart
641
652
  } catch (err) {
653
+ const code = (err as NodeJS.ErrnoException).code
654
+ if (code === 'ENOENT' || code === 'EACCES') {
655
+ // JSONL is gone (Claude Code reaped the parent session's
656
+ // subagents/ dir) or permission flipped under us. Deregister the
657
+ // entry so the periodic poll stops re-emitting this same line
658
+ // forever. Logged ONCE per agent — operators can still audit
659
+ // which entries got reaped without 30 lines/sec of noise.
660
+ log?.(`subagent-watcher: JSONL vanished for ${entry.agentId} (${code}) — deregistering`)
661
+ onFileVanished?.(entry.agentId, code)
662
+ return
663
+ }
642
664
  log?.(`subagent-watcher: read error ${entry.agentId}: ${(err as Error).message}`)
643
665
  }
644
666
  }
@@ -841,7 +863,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
841
863
  if (!entry || !t) return
842
864
  readSubTail(entry, t, nowFn(), (desc) => {
843
865
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
844
- }, fs, log, db, parentStateDir, config.onUnstall)
866
+ }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
845
867
  maybySendStateTransition(agentId)
846
868
  })
847
869
  } catch (err) {
@@ -1179,7 +1201,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1179
1201
  if (!tail) continue
1180
1202
  readSubTail(entry, tail, n, (desc) => {
1181
1203
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1182
- }, fs, log, db, parentStateDir, config.onUnstall)
1204
+ }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
1183
1205
  maybySendStateTransition(agentId)
1184
1206
  }
1185
1207
 
@@ -142,3 +142,117 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
142
142
  expect(deps.activeDraftParseModes.size).toBe(0)
143
143
  })
144
144
  })
145
+
146
+ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)', () => {
147
+ // The race that motivates this: the canonical reply path fires
148
+ // `setDone()` on the StatusReactionController BEFORE purgeReactionTracking
149
+ // runs `activeTurnStartedAt.delete(key)`. If the bridge crashes between
150
+ // those two steps, the controller loop sees an EMPTY activeStatusReactions
151
+ // (already cleared by setDone) but activeTurnStartedAt still has the key.
152
+ // Without the sweep, that key orphans and the next inbound is "held mid-
153
+ // turn" against a ghost.
154
+
155
+ it('sweeps activeTurnStartedAt keys the controller loop missed', () => {
156
+ // Construct the exact race: activeStatusReactions is EMPTY (setDone
157
+ // already cleared it on the reply path) but activeTurnStartedAt still
158
+ // has an entry.
159
+ const onDanglingTurnsSwept = vi.fn()
160
+ const clearActiveReactions = vi.fn()
161
+ const disposeProgressDriver = vi.fn()
162
+ const log = vi.fn()
163
+ const deps = {
164
+ agentName: 'clerk',
165
+ activeStatusReactions: new Map<string, FakeCtrl>(),
166
+ activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>([
167
+ ['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
168
+ ]),
169
+ activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
170
+ activeDraftStreams: new Map<string, FakeStream>(),
171
+ activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
172
+ clearActiveReactions,
173
+ disposeProgressDriver,
174
+ onDanglingTurnsSwept,
175
+ log,
176
+ }
177
+
178
+ flushOnAgentDisconnect(deps)
179
+
180
+ // The sweep fired and cleared the dangling entry.
181
+ expect(deps.activeTurnStartedAt.size).toBe(0)
182
+ expect(deps.activeReactionMsgIds.size).toBe(0)
183
+ expect(onDanglingTurnsSwept).toHaveBeenCalledTimes(1)
184
+ expect(onDanglingTurnsSwept.mock.calls[0][0]).toEqual(['ghost:thr:msg'])
185
+ // The log line names what happened so the operator can audit.
186
+ expect(
187
+ log.mock.calls.some((c: unknown[]) =>
188
+ typeof c[0] === 'string' && /swept .* dangling turn/.test(c[0]),
189
+ ),
190
+ ).toBe(true)
191
+ })
192
+
193
+ it('does not fire the sweep when the controller loop already cleaned up everything', () => {
194
+ // Normal-path disconnect: activeStatusReactions had entries, the
195
+ // controller loop ran setDone + delete on each, activeTurnStartedAt
196
+ // is already empty by the end of the loop. No dangling to sweep.
197
+ const { spies, deps } = makeDeps('clerk')
198
+ const onDanglingTurnsSwept = vi.fn()
199
+ const depsWithCallback = { ...deps, onDanglingTurnsSwept }
200
+
201
+ flushOnAgentDisconnect(depsWithCallback)
202
+
203
+ // Controller loop already cleaned both entries.
204
+ expect(deps.activeTurnStartedAt.size).toBe(0)
205
+ // Callback NOT fired — nothing left to sweep after the loop.
206
+ expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
207
+ // Regression: the existing setDone path still works.
208
+ expect(spies.setDoneA).toHaveBeenCalledTimes(1)
209
+ expect(spies.setDoneB).toHaveBeenCalledTimes(1)
210
+ })
211
+
212
+ it('does NOT sweep for anonymous disconnects (no agent registered)', () => {
213
+ // Critical regression guard: the sweep MUST be gated by the
214
+ // agentName-null early-return. Anonymous one-shot IPC clients
215
+ // (recall.py, etc.) disconnect constantly and must never touch
216
+ // turn state.
217
+ const onDanglingTurnsSwept = vi.fn()
218
+ const deps = {
219
+ agentName: null,
220
+ activeStatusReactions: new Map<string, FakeCtrl>(),
221
+ activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
222
+ activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
223
+ activeDraftStreams: new Map<string, FakeStream>(),
224
+ activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
225
+ clearActiveReactions: vi.fn(),
226
+ disposeProgressDriver: vi.fn(),
227
+ onDanglingTurnsSwept,
228
+ log: vi.fn(),
229
+ }
230
+
231
+ flushOnAgentDisconnect(deps)
232
+
233
+ // Anonymous disconnect: turn state preserved, sweep callback not fired.
234
+ expect(deps.activeTurnStartedAt.size).toBe(1)
235
+ expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
236
+ })
237
+
238
+ it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
239
+ // Backward-compat guard — existing callers that don't pass the new
240
+ // callback still work without runtime error.
241
+ const deps = {
242
+ agentName: 'clerk',
243
+ activeStatusReactions: new Map<string, FakeCtrl>(),
244
+ activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
245
+ activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
246
+ activeDraftStreams: new Map<string, FakeStream>(),
247
+ activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
248
+ clearActiveReactions: vi.fn(),
249
+ disposeProgressDriver: vi.fn(),
250
+ // onDanglingTurnsSwept intentionally omitted.
251
+ log: vi.fn(),
252
+ }
253
+
254
+ expect(() => flushOnAgentDisconnect(deps)).not.toThrow()
255
+ // The sweep still happens, just without the callback observation.
256
+ expect(deps.activeTurnStartedAt.size).toBe(0)
257
+ })
258
+ })