switchroom 0.19.14 → 0.19.16

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.
Files changed (49) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +31 -2
  6. package/telegram-plugin/dist/gateway/gateway.js +1690 -932
  7. package/telegram-plugin/dist/server.js +31 -2
  8. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  9. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  10. package/telegram-plugin/gateway/gateway.ts +10 -57
  11. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  13. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  14. package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
  15. package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
  16. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  17. package/telegram-plugin/gateway/stream-render.ts +107 -15
  18. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  19. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  20. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  21. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  22. package/telegram-plugin/narrative-flush.ts +35 -0
  23. package/telegram-plugin/outbox.ts +73 -3
  24. package/telegram-plugin/session-tail.ts +88 -1
  25. package/telegram-plugin/shown-ledger.ts +145 -0
  26. package/telegram-plugin/silence-poke.ts +118 -1
  27. package/telegram-plugin/silent-end.ts +42 -0
  28. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  29. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  30. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  31. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  32. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  34. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  35. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  36. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  37. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  38. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  40. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  41. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  42. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  43. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  44. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  45. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  46. package/telegram-plugin/tts-normalize.ts +12 -0
  47. package/telegram-plugin/turn-flush-safety.ts +66 -53
  48. package/telegram-plugin/voice-normalize-text.ts +100 -0
  49. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -122,11 +122,13 @@ export interface DeliveredEntry {
122
122
  tgMessageId?: number
123
123
  ts: number
124
124
  /**
125
- * #3510 instrumentation: which machine delivered — the outbox sweep, or the
125
+ * #3510 instrumentation: which machine delivered — the outbox sweep, the
126
126
  * gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
127
- * captured-prose bridge). Absent on pre-#3510 journal lines.
127
+ * captured-prose bridge), or the turn-end / answer-ready quiescence flush
128
+ * (`'flush'`, added in the #3513 exactly-once-among-backstops follow-up).
129
+ * Absent on pre-#3510 journal lines.
128
130
  */
129
- deliverySource?: 'sweep' | 'reply-tool'
131
+ deliverySource?: 'sweep' | 'reply-tool' | 'flush'
130
132
  /** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
131
133
  replyAlreadyDeliveredThisTurn?: boolean
132
134
  }
@@ -307,6 +309,59 @@ export function outboxAlreadyDelivered(nonce: string, stateDir?: string): boolea
307
309
  return readDeliveredNonces(stateDir).has(nonce)
308
310
  }
309
311
 
312
+ /**
313
+ * True iff a journal entry is a prior BACKSTOP delivery (turn-flush E1/E2,
314
+ * captured-prose bridge E3, outbox sweep E4) — NOT an explicit E0 `reply` /
315
+ * `stream_reply` send (#3513 follow-up, MF2).
316
+ *
317
+ * - `deliverySource === 'sweep'` → E4 backstop.
318
+ * - `deliverySource === 'flush'` → E1/E2 backstop.
319
+ * - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
320
+ * false` → E3 captured-prose bridge (a backstop; the bridge only fires when
321
+ * NO genuine final answer was delivered this turn).
322
+ * - `deliverySource === 'reply-tool'` AND `replyAlreadyDeliveredThisTurn ===
323
+ * true` → an explicit E0 reply send — NOT a backstop. E0 replies are
324
+ * ungated by design (a turn may send N of them), so they must NOT satisfy a
325
+ * backstop's exactly-once guard (else the guard would eat a legitimate
326
+ * #3510 trailing recap or a multi-reply turn's later bridge).
327
+ *
328
+ * A pre-#3510 line with no `deliverySource` is treated conservatively as NOT a
329
+ * backstop (fail open — never suppress a backstop on ambiguous provenance).
330
+ */
331
+ export function isBackstopDeliveredEntry(e: DeliveredEntry): boolean {
332
+ if (e.deliverySource === 'sweep' || e.deliverySource === 'flush') return true
333
+ if (e.deliverySource === 'reply-tool' && e.replyAlreadyDeliveredThisTurn === false) return true
334
+ return false
335
+ }
336
+
337
+ /**
338
+ * True iff `nonce` already has a prior BACKSTOP delivery journaled (see
339
+ * `isBackstopDeliveredEntry`). This is the BACKSTOP-SCOPED exactly-once read the
340
+ * turn-flush (E1/E2) and captured-prose bridge (E3) consult before delivering —
341
+ * unlike `outboxAlreadyDelivered` (any journal line) it does NOT count an
342
+ * explicit E0 reply, so it can never suppress a legitimate second explicit
343
+ * message (#3513 follow-up, MF2).
344
+ */
345
+ export function backstopAlreadyDelivered(nonce: string, stateDir?: string): boolean {
346
+ if (nonce == null || nonce === '') return false
347
+ const path = join(resolveOutboxDir(stateDir), JOURNAL_FILE)
348
+ if (!existsSync(path)) return false
349
+ try {
350
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
351
+ if (!line) continue
352
+ try {
353
+ const e = JSON.parse(line) as DeliveredEntry
354
+ if (e.turnNonce === nonce && isBackstopDeliveredEntry(e)) return true
355
+ } catch {
356
+ /* skip corrupt line */
357
+ }
358
+ }
359
+ } catch {
360
+ /* best-effort */
361
+ }
362
+ return false
363
+ }
364
+
310
365
  /**
311
366
  * Max delivered-keys kept in the journal. On exceeding `JOURNAL_ROTATE_AT` lines
312
367
  * the journal is compacted down to the newest `JOURNAL_KEEP` entries — bounding
@@ -378,6 +433,7 @@ export type OutboxSweepAction =
378
433
  | 'skip-quiet'
379
434
  | 'skip-dedup'
380
435
  | 'skip-unroutable'
436
+ | 'skip-ephemeral-shown'
381
437
 
382
438
  export interface OutboxSweepDecision {
383
439
  action: OutboxSweepAction
@@ -408,6 +464,18 @@ export function decideOutboxSweep(input: {
408
464
  routePrefix?: string
409
465
  quietMs?: number
410
466
  maxAgeMs?: number
467
+ /**
468
+ * #3513 (correction 1): was this record's text marked ephemeral-shown on the
469
+ * progress card for its turnNonce (the durable shown-ledger)? A hit means the
470
+ * block was already assigned to the ephemeral surface — the single-surface
471
+ * invariant forbids ANY delivery machine, including this out-of-process late
472
+ * sweep, from ALSO delivering it to chat. The ledger only ever contains
473
+ * STRUCTURAL narration (correction 4: never a possibly-terminal answer), so
474
+ * this can never suppress a genuine answer. Checked here (in the backstop
475
+ * decision itself) rather than only at a send seam, because the sweep bypasses
476
+ * `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
477
+ */
478
+ shownLedgerHit?: boolean
411
479
  }): OutboxSweepDecision {
412
480
  const {
413
481
  record,
@@ -418,8 +486,10 @@ export function decideOutboxSweep(input: {
418
486
  routePrefix = '',
419
487
  quietMs = OUTBOX_QUIET_MS,
420
488
  maxAgeMs = OUTBOX_MAX_AGE_MS,
489
+ shownLedgerHit = false,
421
490
  } = input
422
491
  if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
492
+ if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
423
493
  const age = now - record.createdAt
424
494
  if (age < quietMs) return { action: 'skip-quiet' }
425
495
  if (textAlreadyDelivered) return { action: 'skip-dedup' }
@@ -134,7 +134,23 @@ export type SessionEvent =
134
134
  // (naive summing across lines over-counts). Null messageId → un-dedupable,
135
135
  // counted as-is. Mirrors `sub_agent_usage` but for the parent's OWN tokens.
136
136
  | { kind: 'usage'; messageId: string | null; totalTokens: number }
137
- | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
137
+ | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string
138
+ /**
139
+ * #3519 sharpen: the claude-CLI background-task id when THIS tool_result
140
+ * is the launch acknowledgement of a shell moved to the background (a
141
+ * foreground Bash that exceeded the CLI foreground window, or an explicit
142
+ * run_in_background:true). Sourced PRIMARILY from the structured
143
+ * top-level `toolUseResult.backgroundTaskId` field (version-robust), with
144
+ * a regex on the `content` string as a secondary. Absent on ordinary
145
+ * (completed-in-foreground) tool_results. Marks the shell ALIVE. */
146
+ backgroundTaskId?: string }
147
+ /**
148
+ * #3519 sharpen: a claude-CLI `<task-notification>` — the proactive
149
+ * completion signal the CLI enqueues when a backgrounded shell finishes
150
+ * (`<status>completed</status>`) or errors. Marks the shell DEAD, restoring
151
+ * ~300s wedge recovery once the launching bash is no longer running.
152
+ */
153
+ | { kind: 'task_notification'; taskId: string; status: string }
138
154
  // `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
139
155
  // the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
140
156
  // deterministic quiescence-flush signal, which — unlike the orphaned-reply
@@ -259,6 +275,60 @@ function extractToolResultErrorText(content: unknown): string {
259
275
  return ''
260
276
  }
261
277
 
278
+ /**
279
+ * #3519 sharpen — ALIVE marker (primary): read the claude-CLI background-task
280
+ * id off the structured, sibling top-level `toolUseResult.backgroundTaskId`
281
+ * field of a `type:"user"` transcript line. This is the version-robust source
282
+ * (a named JSON field, not prose). Real shape (carrie session
283
+ * a6d2d33a-…, v2.1.197, line 109):
284
+ * "toolUseResult":{…,"backgroundTaskId":"bxa4sv3dq"}
285
+ * Returns the id, or null when the line carries no backgrounded shell.
286
+ */
287
+ export function parseBackgroundTaskId(obj: Record<string, unknown>): string | null {
288
+ const tur = obj.toolUseResult
289
+ if (typeof tur === 'object' && tur != null) {
290
+ const id = (tur as Record<string, unknown>).backgroundTaskId
291
+ if (typeof id === 'string' && id.length > 0) return id
292
+ }
293
+ return null
294
+ }
295
+
296
+ /**
297
+ * #3519 sharpen — ALIVE marker (secondary): match the launch STRING in the
298
+ * tool_result `content` when the structured field is absent (older CLI, or a
299
+ * shape change that keeps the human string). Real bytes (same line 109):
300
+ * "Command running in background with ID: bxa4sv3dq. Output is being written…"
301
+ * DELIBERATELY the fallback, not the primary — if BOTH miss (CLI changed the
302
+ * string too) the caller degrades to the 900s-bounded sawBash guard. Accepts
303
+ * the same string|content-block shapes as extractToolResultErrorText.
304
+ */
305
+ export function parseBackgroundLaunchString(content: unknown): string | null {
306
+ const text = typeof content === 'string'
307
+ ? content
308
+ : extractToolResultErrorText(content)
309
+ const m = text.match(/Command running in background with ID: (\w+)/)
310
+ return m != null ? m[1] : null
311
+ }
312
+
313
+ /**
314
+ * #3519 sharpen — DEAD marker: parse a claude-CLI `<task-notification>` block.
315
+ * The CLI enqueues this proactively when a backgrounded shell finishes. Real
316
+ * bytes (carrie session a6d2d33a-…, v2.1.197, line 175 queue-operation enqueue
317
+ * content, and mirrored line 180 attachment):
318
+ * "<task-notification>\n<task-id>bxa4sv3dq</task-id>\n…\n<status>completed</status>\n…"
319
+ * Returns {taskId,status} when both tags are present, else null (so an
320
+ * ordinary inbound enqueue falls through to the normal user-turn path).
321
+ */
322
+ export function parseTaskNotification(
323
+ content: string,
324
+ ): { taskId: string; status: string } | null {
325
+ if (!content.includes('<task-notification>')) return null
326
+ const idM = content.match(/<task-id>([^<]+)<\/task-id>/)
327
+ const stM = content.match(/<status>([^<]+)<\/status>/)
328
+ if (idM == null || stM == null) return null
329
+ return { taskId: idM[1].trim(), status: stM[1].trim() }
330
+ }
331
+
262
332
  /**
263
333
  * THE single text→narrative projection primitive. Both projectTranscriptLine
264
334
  * and projectSubagentLine derive their text events through this helper so
@@ -481,6 +551,15 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
481
551
  const op = obj.operation as string | undefined
482
552
  if (op === 'enqueue') {
483
553
  const content = (obj.content as string | undefined) ?? ''
554
+ // #3519 sharpen: a `<task-notification>` is NOT a real inbound user
555
+ // turn — it is the claude CLI's proactive background-shell completion
556
+ // signal, enqueued as a synthetic command. Project it as the DEAD
557
+ // marker so the liveness registry can drop the shell (restoring ~300s
558
+ // wedge recovery) rather than mis-reading it as a user message.
559
+ const notif = parseTaskNotification(content)
560
+ if (notif != null) {
561
+ return [{ kind: 'task_notification', taskId: notif.taskId, status: notif.status }]
562
+ }
484
563
  const { chatId, messageId, threadId } = parseChannelMeta(content)
485
564
  return [{ kind: 'enqueue', chatId, messageId, threadId, rawContent: content }]
486
565
  }
@@ -578,6 +657,11 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
578
657
  const message = obj.message as Record<string, unknown> | undefined
579
658
  const content = message?.content as Array<Record<string, unknown>> | undefined
580
659
  if (!Array.isArray(content)) return []
660
+ // #3519 sharpen: the background-launch id is a per-LINE fact carried on
661
+ // the sibling top-level `toolUseResult.backgroundTaskId` (version-robust
662
+ // structured field), with the launch STRING as a secondary. Parsed once
663
+ // and attached to this line's tool_result event to mark the shell ALIVE.
664
+ const backgroundTaskId = parseBackgroundTaskId(obj)
581
665
  const events: SessionEvent[] = []
582
666
  for (const c of content) {
583
667
  if (c.type === 'tool_result') {
@@ -588,6 +672,9 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
588
672
  toolName: null,
589
673
  isError,
590
674
  errorText: isError ? extractToolResultErrorText(c.content) : undefined,
675
+ backgroundTaskId: backgroundTaskId
676
+ ?? parseBackgroundLaunchString(c.content)
677
+ ?? undefined,
591
678
  })
592
679
  }
593
680
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * shown-ledger.ts — the durable "this block was surfaced on the ephemeral
3
+ * progress card, never deliver it to chat" mark (switchroom#3513 §4).
4
+ *
5
+ * ## Role in the single-surface invariant
6
+ * Every trailing plain-text block a turn produces is assigned to exactly one
7
+ * surface: the delivered chat answer, the ephemeral progress card, or
8
+ * suppression. In-process (E1/E2 turn-flush, the card) that assignment is made
9
+ * once by the shared structural classifier (`hooks/narration-classify.mjs`).
10
+ * But the out-of-process backstops — the Stop-hook captured-prose bridge (E3)
11
+ * and the durable outbox heartbeat sweep (E4) — run in a separate process / on
12
+ * a later tick and cannot share an in-memory decision. So the component that
13
+ * paints a block as ephemeral narration appends `{turnNonce, hash}` here, and
14
+ * E3/E4 treat a ledger hit exactly like a silent marker: not deliverable.
15
+ *
16
+ * ## Correction 4 — only structural narration is ever marked
17
+ * The writer (the mid-turn narrative paint in `gateway/narrative-lane.ts`) marks
18
+ * ONLY blocks that are provably followed by more turn activity (a new narrative
19
+ * block or a tool call), i.e. structural narration — NEVER a possibly-terminal
20
+ * block (the timer-paint / turn-end paint that could turn out to be the real
21
+ * answer). This keeps the invariant fail-open for answers: a genuine unsent
22
+ * answer is never in the ledger, so a ledger check can never suppress it (a drop
23
+ * is worse than a duplicate). The structural rule, not the ledger, is the sole
24
+ * guard for the answer path.
25
+ *
26
+ * ## Envelope-bearing-only (documented should-fix, #3513 §8)
27
+ * The ledger is keyed by the turnNonce (`deriveTurnId` → `${chatKey}#${msgId}`),
28
+ * which is reachable-matching ONLY for envelope-bearing turns — `deriveTurnId`
29
+ * returns null without a message_id (`gateway/derive-turn-id.ts`), and the
30
+ * outbox falls back to a sha nonce. So the ledger is a best-effort belt for
31
+ * envelope-bearing turns (the common Telegram-inbound shape); for envelope-less
32
+ * turns (handback / background / cron) the structural classifier is the sole
33
+ * guard. The writer skips a null turnId; the readers simply miss and fall
34
+ * through to the structural rule — never a false suppression.
35
+ *
36
+ * Best-effort throughout: a missing / unwritable ledger degrades to the
37
+ * structural-rule-only behaviour (leak possible, drop impossible).
38
+ */
39
+
40
+ import {
41
+ existsSync,
42
+ mkdirSync,
43
+ readFileSync,
44
+ renameSync,
45
+ writeFileSync,
46
+ appendFileSync,
47
+ } from 'node:fs'
48
+ import { join } from 'node:path'
49
+ import { resolveOutboxDir } from './outbox.js'
50
+ import { ledgerHashHex } from './hooks/narration-classify.mjs'
51
+
52
+ const SHOWN_LEDGER_FILE = 'shown-ledger.jsonl'
53
+
54
+ /** Bounded growth — compact to the newest KEEP entries past ROTATE_AT lines. */
55
+ export const SHOWN_LEDGER_KEEP = 2_000
56
+ export const SHOWN_LEDGER_ROTATE_AT = 4_000
57
+
58
+ /** One appended shown-block entry. */
59
+ export interface ShownLedgerEntry {
60
+ turnNonce: string
61
+ hash: string
62
+ ts: number
63
+ }
64
+
65
+ export function shownLedgerPath(stateDir?: string): string {
66
+ return join(resolveOutboxDir(stateDir), SHOWN_LEDGER_FILE)
67
+ }
68
+
69
+ /**
70
+ * Mark a block as ephemeral-shown for `turnNonce`. Correction 4: callers MUST
71
+ * only pass blocks that are structural narration (followed by more turn
72
+ * activity), never a possibly-terminal block. A null/empty turnNonce is ignored
73
+ * (envelope-less turn — the structural rule is the sole guard there).
74
+ * Best-effort; never throws.
75
+ */
76
+ export function appendShownBlock(
77
+ turnNonce: string | null,
78
+ text: string,
79
+ stateDir?: string,
80
+ now: number = Date.now(),
81
+ ): void {
82
+ if (turnNonce == null || turnNonce === '') return
83
+ const trimmed = typeof text === 'string' ? text.trim() : ''
84
+ if (trimmed.length === 0) return
85
+ const dir = resolveOutboxDir(stateDir)
86
+ const path = shownLedgerPath(stateDir)
87
+ try {
88
+ mkdirSync(dir, { recursive: true })
89
+ const entry: ShownLedgerEntry = { turnNonce, hash: ledgerHashHex(trimmed), ts: now }
90
+ appendFileSync(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
91
+ compactIfLarge(path)
92
+ } catch {
93
+ /* best-effort */
94
+ }
95
+ }
96
+
97
+ /** The set of block hashes marked ephemeral-shown for `turnNonce`. */
98
+ export function readShownHashes(turnNonce: string | null, stateDir?: string): Set<string> {
99
+ const set = new Set<string>()
100
+ if (turnNonce == null || turnNonce === '') return set
101
+ const path = shownLedgerPath(stateDir)
102
+ if (!existsSync(path)) return set
103
+ try {
104
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
105
+ if (!line) continue
106
+ try {
107
+ const e = JSON.parse(line) as ShownLedgerEntry
108
+ if (e.turnNonce === turnNonce && typeof e.hash === 'string') set.add(e.hash)
109
+ } catch {
110
+ /* skip corrupt line */
111
+ }
112
+ }
113
+ } catch {
114
+ /* best-effort */
115
+ }
116
+ return set
117
+ }
118
+
119
+ /**
120
+ * Was `text` marked ephemeral-shown for `turnNonce`? The out-of-process
121
+ * suppression check consumed by the E3 captured-prose decision and the E4 sweep.
122
+ */
123
+ export function isShownBlock(
124
+ turnNonce: string | null,
125
+ text: string,
126
+ stateDir?: string,
127
+ ): boolean {
128
+ if (turnNonce == null || turnNonce === '') return false
129
+ const trimmed = typeof text === 'string' ? text.trim() : ''
130
+ if (trimmed.length === 0) return false
131
+ return readShownHashes(turnNonce, stateDir).has(ledgerHashHex(trimmed))
132
+ }
133
+
134
+ function compactIfLarge(path: string): void {
135
+ try {
136
+ const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.length > 0)
137
+ if (lines.length <= SHOWN_LEDGER_ROTATE_AT) return
138
+ const kept = lines.slice(lines.length - SHOWN_LEDGER_KEEP)
139
+ const tmp = `${path}.${process.pid}.compact`
140
+ writeFileSync(tmp, kept.join('\n') + '\n', { mode: 0o600 })
141
+ renameSync(tmp, path)
142
+ } catch {
143
+ /* best-effort */
144
+ }
145
+ }
@@ -93,6 +93,34 @@ export interface SilencePokeState {
93
93
  * clock — the design choice in this module's header is preserved.
94
94
  * We only enrich the fallback TEXT, not the timing. */
95
95
  inFlightTools: Map<string, { name: string; startedAt: number; label: string | null }>
96
+ /**
97
+ * #3519: true once ANY `Bash` tool_use has been observed in this turn.
98
+ * A foreground `Bash` that exceeds the claude-CLI foreground window is
99
+ * auto-moved to the background: its `tool_result` returns to the model
100
+ * (so `inFlightTools` empties and `isLegitimatelyWorking()`'s foreground
101
+ * / async-dispatch checks all go false), yet the process keeps running
102
+ * and the model sits silent waiting on it. That silent gap is invisible
103
+ * to every existing "still working" signal, so the 300s fallback fired
104
+ * mid-work — nulling `currentTurn`, tearing down the pinned progress
105
+ * card, and letting the next tool burst mint a BRAND-NEW card (the
106
+ * stacked-cards bug). Arming this flag on the first Bash of the turn lets
107
+ * the fallback defer such gaps. Turn-scoped (set here, only cleared by a
108
+ * fresh `startTurn`); bounded by `fallbackHardCeiling` so a genuinely
109
+ * wedged bash-turn still unwedges at the ceiling. Does NOT reset the
110
+ * silence clock — a real reply / feed edit still does that; this only
111
+ * gates the terminal teardown. */
112
+ sawBashThisTurn: boolean
113
+ /**
114
+ * #3519 sharpen: claude-CLI background shells PROVEN alive right now. A
115
+ * shell is added on its launch marker (structured `backgroundTaskId`, via
116
+ * `noteBackgroundShellAlive`) and removed when the CLI proactively reports
117
+ * it done (`<task-notification>` completed/failed) or the model `KillShell`s
118
+ * it (via `noteBackgroundShellDead`). Non-empty ⇒ a process is running, so
119
+ * defer the 300s teardown. Empty ⇒ nothing running, so a FINISHED bash no
120
+ * longer defers — restoring ~300s wedge recovery that the coarse
121
+ * `sawBashThisTurn` guard held to 900s. Turn-scoped (cleared by startTurn),
122
+ * bounded by `fallbackHardCeiling` like every other defer. */
123
+ aliveShells: Set<string>
96
124
  }
97
125
 
98
126
  export interface ThresholdsMs {
@@ -217,6 +245,21 @@ const state = new Map<string, SilencePokeState>()
217
245
  let timer: ReturnType<typeof setInterval> | null = null
218
246
  let activeDeps: SilencePokeDeps | null = null
219
247
 
248
+ /**
249
+ * #3519 sharpen — deterministic SAFE-DEGRADATION latch (process-scoped).
250
+ * Flipped true the first time `noteBackgroundShellAlive` fires, i.e. the
251
+ * moment the session-tail marker parser resolves a real `backgroundTaskId`
252
+ * against the live claude CLI. Its purpose is to distinguish two look-alike
253
+ * states that both present as "a Bash ran but no shell is registered alive":
254
+ * • CLI markers work, the bash simply FINISHED → trust the empty alive-set,
255
+ * let the 300s fallback fire (fast wedge recovery restored); and
256
+ * • CLI changed its markers so the parser never matches → we CANNOT tell a
257
+ * live auto-backgrounded bash from a finished one, so fall back to the
258
+ * coarse 900s-bounded `sawBashThisTurn` guard (never stack, never hang).
259
+ * Once ANY marker has parsed, the CLI is proven compatible and the alive-set
260
+ * is authoritative. Never seen ⇒ stay conservative. Reset by tests only. */
261
+ let bgMarkerParserConfirmed = false
262
+
220
263
  /**
221
264
  * True iff the kill switch is OFF. Re-read every call so tests can
222
265
  * toggle process.env without reloading the module.
@@ -238,9 +281,40 @@ export function startTurn(key: string, now: number): void {
238
281
  fallbackFired: false,
239
282
  floorFired: false,
240
283
  inFlightTools: new Map(),
284
+ sawBashThisTurn: false,
285
+ aliveShells: new Set(),
241
286
  })
242
287
  }
243
288
 
289
+ /**
290
+ * #3519 sharpen: register a claude-CLI background shell as ALIVE for `key`.
291
+ * Called by the gateway when session-tail resolves a `backgroundTaskId` on a
292
+ * tool_result (a foreground Bash auto-moved to the background, or an explicit
293
+ * run_in_background:true). Flips the process-scoped parser-confirmed latch so
294
+ * the safe-degradation path knows the CLI markers are compatible. No-op when
295
+ * the key has no live turn (the launch outlived its turn — the cross-turn
296
+ * ambient owns that case, not the 300s teardown).
297
+ */
298
+ export function noteBackgroundShellAlive(key: string, shellId: string): void {
299
+ bgMarkerParserConfirmed = true
300
+ const s = state.get(key)
301
+ if (s == null) return
302
+ s.aliveShells.add(shellId)
303
+ }
304
+
305
+ /**
306
+ * #3519 sharpen: mark a background shell DEAD for `key`. Called by the gateway
307
+ * on a `<task-notification>` (completed/failed) or a `KillShell`. Idempotent —
308
+ * removing an unknown id (already cleared, or launched in a prior turn) is a
309
+ * no-op. Once the set empties, a subsequent >300s silence is a real wedge and
310
+ * the fallback fires at ~300s.
311
+ */
312
+ export function noteBackgroundShellDead(key: string, shellId: string): void {
313
+ const s = state.get(key)
314
+ if (s == null) return
315
+ s.aliveShells.delete(shellId)
316
+ }
317
+
244
318
  /**
245
319
  * Record a fresh user-visible outbound message (reply or stream_reply
246
320
  * first-emit). Resets the silence clock so the 300s fallback is measured
@@ -313,6 +387,14 @@ export function noteToolStart(
313
387
  const s = state.get(key)
314
388
  if (s == null) return
315
389
  s.inFlightTools.set(toolUseId, { name, startedAt: now, label })
390
+ // #3519: arm the background-bash defer on the first Bash of the turn.
391
+ // A foreground Bash can be auto-moved to the background by the claude CLI
392
+ // once it crosses the foreground window; its tool_result then returns
393
+ // (draining inFlightTools) while the process keeps running and the model
394
+ // goes silent waiting on it. That gap is invisible to every other "still
395
+ // working" signal, so without this the 300s fallback tore down the pinned
396
+ // card mid-work and the next burst minted a fresh one (stacked cards).
397
+ if (name === 'Bash') s.sawBashThisTurn = true
316
398
  }
317
399
 
318
400
  /**
@@ -541,8 +623,13 @@ function tick(now: number): void {
541
623
  // 2. Legacy `deferFallbackWhileToolInFlight` boolean — covers only
542
624
  // `inFlightTools.size > 0`; kept for test fixtures that set it
543
625
  // directly without wiring the callback.
626
+ // 3. #3519 `sawBashThisTurn` — covers the claude-CLI-side background
627
+ // bash gap the two paths above are blind to (foreground Bash moved
628
+ // to background: tool_result returned, process still running, model
629
+ // silent). Independent of the callback so it holds even when
630
+ // `isLegitimatelyWorking()` returns false.
544
631
  //
545
- // In both cases: `continue` WITHOUT setting fallbackFired so the next
632
+ // In all cases: `continue` WITHOUT setting fallbackFired so the next
546
633
  // tick re-checks. Once the work signal clears and the turn stays silent
547
634
  // past the base threshold, or the ceiling is crossed, the fallback fires.
548
635
  const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
@@ -551,6 +638,30 @@ function tick(now: number): void {
551
638
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
552
639
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
553
640
  if (activeDeps.isLegitimatelyWorking(key)) continue
641
+ // #3519 sharpen: even when the callback reports "not working", a
642
+ // `Bash` earlier this turn may have been auto-moved to the CLI-side
643
+ // background (its tool_result returned, so every foreground /
644
+ // async-dispatch signal is false, yet the process is alive and the
645
+ // model sits silent on it) — the gap `isLegitimatelyWorking` is
646
+ // blind to by construction. Two-layer defer, sharp then safe:
647
+ //
648
+ // (1) PROVEN-alive — a background shell registered from its
649
+ // structured `backgroundTaskId` launch marker and not yet
650
+ // reported dead (`<task-notification>` completed / KillShell).
651
+ // A process is running RIGHT NOW, so defer. When it finishes,
652
+ // the alive-set empties and the fallback fires at ~300s —
653
+ // restoring fast wedge recovery the coarse guard held to 900s.
654
+ if (s.aliveShells.size > 0) continue
655
+ // (2) SAFE DEGRADATION — only while the CLI markers have NEVER
656
+ // parsed (`!bgMarkerParserConfirmed`): we cannot then tell a
657
+ // live auto-backgrounded bash from a finished one, so fall
658
+ // back to the coarse turn-scoped `sawBashThisTurn` guard —
659
+ // 900s-bounded by `fallbackHardCeiling` (never stacks, never
660
+ // hangs). Once ANY marker has parsed, the CLI is proven
661
+ // compatible, this layer switches off, and layer (1) alone
662
+ // governs. Scoped to the modern callback-wired path so the
663
+ // legacy defer-off / defer-bool fixtures keep their semantics.
664
+ if (!bgMarkerParserConfirmed && s.sawBashThisTurn) continue
554
665
  } else if (!forceDisable && activeDeps.deferFallbackWhileToolInFlight === true && s.inFlightTools.size > 0) {
555
666
  continue
556
667
  }
@@ -657,4 +768,10 @@ export function __getStateForTests(key: string): SilencePokeState | undefined {
657
768
  export function __resetAllForTests(): void {
658
769
  state.clear()
659
770
  stopTimer()
771
+ bgMarkerParserConfirmed = false
772
+ }
773
+
774
+ /** Test-only: peek at the process-scoped #3519 safe-degradation latch. */
775
+ export function __bgMarkerParserConfirmedForTests(): boolean {
776
+ return bgMarkerParserConfirmed
660
777
  }
@@ -86,6 +86,29 @@ export interface SilentEndDeps {
86
86
  hasOutboundDeliveredSince?: (chatId: string, sinceMs: number, threadId?: number | null) => boolean
87
87
  /** Wall-clock now, ms. Defaults to `Date.now`; injectable for tests. */
88
88
  now?: () => number
89
+ /**
90
+ * #3513 (correction 1): was `text` marked ephemeral-shown on the progress card
91
+ * for `turnNonce` (the durable shown-ledger)? Injected so the captured-prose
92
+ * bridge (E3) — which sends via `bot.api.sendMessage` directly, bypassing
93
+ * `normalizeOutboundBody` — refuses to deliver a block that was already
94
+ * assigned to the ephemeral surface. The ledger only ever holds STRUCTURAL
95
+ * narration (correction 4: never a possibly-terminal answer), so this can
96
+ * never suppress a genuine answer. Omitted → the check is skipped (the
97
+ * structural classifier remains the guard).
98
+ */
99
+ isBlockShown?: (turnNonce: string | null | undefined, text: string) => boolean
100
+ /**
101
+ * #3513 follow-up (MF2): has a prior BACKSTOP already delivered this turn's
102
+ * answer, per the durable delivered-keys journal? Injected so the captured-
103
+ * prose bridge (E3) enforces exactly-once-among-backstops DURABLY — if the
104
+ * turn-flush backstop (E1/E2, `deliverySource:'flush'`) or the outbox sweep
105
+ * (E4, `'sweep'`) already delivered this nonce, the bridge must NOT deliver a
106
+ * second copy, even across a process boundary the in-memory ledger can't see.
107
+ * This counts ONLY backstop deliveries (via `backstopAlreadyDelivered`), never
108
+ * an explicit E0 reply (#3510 recap), so a legitimate later explicit reply is
109
+ * unaffected. Omitted → the check is skipped (never suppress on doubt).
110
+ */
111
+ backstopDeliveredNonceHit?: (turnNonce: string | null | undefined) => boolean
89
112
  }
90
113
 
91
114
  /**
@@ -306,6 +329,8 @@ export interface CapturedProseDecision {
306
329
  | 'turnkey-mismatch'
307
330
  | 'turnid-mismatch'
308
331
  | 'no-substantive-prose'
332
+ | 'ephemeral-shown'
333
+ | 'already-delivered'
309
334
  }
310
335
 
311
336
  /**
@@ -352,6 +377,23 @@ export function decideCapturedProseDelivery(
352
377
  }
353
378
  const text = typeof state.pendingText === 'string' ? state.pendingText : ''
354
379
  if (text.trim().length < minChars) return { deliver: false, reason: 'no-substantive-prose' }
380
+ // #3513: the persisted prose was already surfaced on the ephemeral progress
381
+ // card for this turn (durable shown-ledger) — the single-surface invariant
382
+ // forbids the bridge from ALSO delivering it to chat. The ledger only holds
383
+ // structural narration (never a possibly-terminal answer), so this can never
384
+ // suppress a genuine answer.
385
+ if (deps?.isBlockShown?.(state.turnId, text) === true) {
386
+ return { deliver: false, reason: 'ephemeral-shown' }
387
+ }
388
+ // #3513 follow-up (MF2): exactly-once-among-backstops. If a prior backstop
389
+ // (turn-flush E1/E2 or the outbox sweep E4) already delivered THIS turn's
390
+ // answer per the durable journal, the bridge must not deliver a duplicate —
391
+ // even across the process boundary the in-memory dedup can't see. Counts only
392
+ // backstop deliveries, never an explicit E0 reply, so a genuine later explicit
393
+ // reply is never blocked here.
394
+ if (deps?.backstopDeliveredNonceHit?.(state.turnId) === true) {
395
+ return { deliver: false, reason: 'already-delivered' }
396
+ }
355
397
  return { deliver: true, text, reason: 'captured-prose' }
356
398
  }
357
399