switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Observability + operator alerting for the Telegram send gate (#3084, PR 3/3).
3
+ *
4
+ * WHY
5
+ * ---
6
+ * PRs 1 and 2 built the gate (token buckets, priority shedding, degraded mode,
7
+ * restart-proof scoped flood windows) but a live ban is still SILENT: the
8
+ * counters sit in memory and a prolonged flood window is invisible until a user
9
+ * notices dead air. part3-design §6 closes that gap:
10
+ *
11
+ * 1. A low-frequency one-line stats summary to the gateway-supervisor stderr
12
+ * log (only when a counter changed — no log spam), plus a snapshot on every
13
+ * flood-window open / close.
14
+ * 2. An operator alert when a flood window has been open past a threshold
15
+ * (~60s), at-most-once per window.
16
+ *
17
+ * DELIVERABILITY OF THE ALERT (the tricky part)
18
+ * ---------------------------------------------
19
+ * The alert is a `critical` send, but it must NOT fight the very window it is
20
+ * reporting: sending a "you're flood-banned" card straight into an open GLOBAL
21
+ * ban just feeds the ban. So the observer distinguishes:
22
+ *
23
+ * - The operator chat is NOT covered by any open window (e.g. a `chat:<other>`
24
+ * ban while the operator's own chat is clear) → deliver IMMEDIATELY, and
25
+ * persist an `alertedAt` marker on the window record so a restart mid-window
26
+ * never re-alerts.
27
+ * - The operator chat IS covered (a `global` ban, or a ban on the operator's
28
+ * own chat) → do NOT send during the window. Remember it is owed and send a
29
+ * "was banned from X to Y" alert when the window CLOSES (or as soon as the
30
+ * operator chat becomes reachable again).
31
+ *
32
+ * STATUS OF THE IMMEDIATE PATH (today vs. after #3111)
33
+ * ----------------------------------------------------
34
+ * The immediate-delivery branch is present and unit-tested, but it does NOT
35
+ * fire in production yet: `openScopedWindowsForOpts` opens a coincident `global`
36
+ * window on every 429, so the operator chat is always "covered" while any ban is
37
+ * active and every alert defers to close. It becomes live once #3111 makes
38
+ * `onFloodWait` scope-precise. Until then the operator is alerted at window
39
+ * CLOSE — once per incident, all scopes coalesced into one card (M1, #3112).
40
+ *
41
+ * At-most-once is anchored by the persisted `alertedAt` (survives restart). The
42
+ * deferred close-alert is best-effort at-least-once: if the gateway is down for
43
+ * the entire tail of the window and the record is pruned before it is seen
44
+ * again, the close alert is dropped — acceptable, because the invariant that
45
+ * matters is "never RE-alert" (which `alertedAt` guarantees), not "always alert".
46
+ *
47
+ * DETERMINISM / TESTABILITY
48
+ * -------------------------
49
+ * Everything is driven by an injectable `Clock` and manual `tick()` calls; the
50
+ * gateway wires `tick()` to a periodic timer. Window IO and the alert send are
51
+ * injected, so the module unit-tests with no disk, timer, or bot.
52
+ */
53
+
54
+ import type { Clock, SendGateStats } from './send-gate.js'
55
+ import type { FloodWindowRecord } from './flood-circuit-breaker.js'
56
+
57
+ /** One-line, human-scannable summary of the gate counters + global fill. */
58
+ export function formatStatsLine(stats: SendGateStats): string {
59
+ const c = stats.global
60
+ const globalFill = Math.round(stats.fill.global * 10) / 10
61
+ const perChat = Object.keys(stats.fill.perChat).length
62
+ return (
63
+ `telegram gateway: send-gate stats: ` +
64
+ `sent=${c.sent} queued=${c.queued} coalesced=${c.coalesced} dropped=${c.dropped} ` +
65
+ `shed=${c.shed} expired=${c.expired} failedFast=${c.failedFast} ` +
66
+ `msgStates=${stats.messageStates} chatBuckets=${perChat} globalFill=${globalFill}\n`
67
+ )
68
+ }
69
+
70
+ /** True when any of the seven gate counters differ between two snapshots. */
71
+ export function countersChanged(a: SendGateStats['global'], b: SendGateStats['global']): boolean {
72
+ return (
73
+ a.sent !== b.sent ||
74
+ a.queued !== b.queued ||
75
+ a.coalesced !== b.coalesced ||
76
+ a.dropped !== b.dropped ||
77
+ a.shed !== b.shed ||
78
+ a.expired !== b.expired ||
79
+ a.failedFast !== b.failedFast
80
+ )
81
+ }
82
+
83
+ export interface StatsLoggerConfig {
84
+ /** Snapshot source (the gate's `stats()`). */
85
+ stats: () => SendGateStats
86
+ /** Where the one-line summary goes (gateway-supervisor stderr). */
87
+ log: (line: string) => void
88
+ /** Injectable clock. */
89
+ clock: Clock
90
+ /** Minimum ms between logged lines. Default 60_000. */
91
+ intervalMs?: number
92
+ }
93
+
94
+ export interface StatsLogger {
95
+ /**
96
+ * Emit the summary IFF (a) the gate is enabled, (b) at least `intervalMs`
97
+ * has elapsed since the last logged line, and (c) a counter changed since
98
+ * the last logged line. All three must hold — so a quiet gate is silent and
99
+ * a busy gate logs at most once per interval.
100
+ */
101
+ tick(): void
102
+ }
103
+
104
+ /** Low-frequency, change-gated stats logger (part3-design §6, item 1). */
105
+ export function createStatsLogger(config: StatsLoggerConfig): StatsLogger {
106
+ const intervalMs = config.intervalMs ?? 60_000
107
+ let lastLoggedMs = Number.NEGATIVE_INFINITY
108
+ let lastCounters: SendGateStats['global'] | null = null
109
+
110
+ return {
111
+ tick() {
112
+ const s = config.stats()
113
+ if (!s.enabled) return
114
+ const now = config.clock.now()
115
+ if (now - lastLoggedMs < intervalMs) return
116
+ // First-ever log always fires (no baseline); afterwards only on a change.
117
+ if (lastCounters && !countersChanged(lastCounters, s.global)) return
118
+ lastLoggedMs = now
119
+ lastCounters = { ...s.global }
120
+ config.log(formatStatsLine(s))
121
+ },
122
+ }
123
+ }
124
+
125
+ // ─── Flood-window observer: open/close snapshots + operator alerting ─────────
126
+
127
+ /** Alert-worthy scopes. `msg-edit:` windows are cosmetic and never alerted. */
128
+ function isAlertableScope(scopeKey: string): boolean {
129
+ return scopeKey === 'global' || scopeKey.startsWith('chat:') || scopeKey.startsWith('group:')
130
+ }
131
+
132
+ export interface FloodWindowObserverConfig {
133
+ /** Injectable clock. */
134
+ clock: Clock
135
+ /** Where snapshot lines go. */
136
+ log: (line: string) => void
137
+ /** Gate stats source (for the open/close snapshot line). */
138
+ stats: () => SendGateStats
139
+ /** Read the persisted scoped windows (expired pruned), e.g. `readFloodWindows`. */
140
+ readWindows: (now: number) => FloodWindowRecord[]
141
+ /** Persist an `alertedAt` marker on a window record (at-most-once anchor). */
142
+ markAlerted: (scopeKey: string, alertedAt: number) => void
143
+ /**
144
+ * Send ONE operator alert (a `critical` send to the operator chat). Rejections
145
+ * are swallowed by the observer so a failed alert never breaks the tick loop.
146
+ */
147
+ sendAlert: (text: string) => Promise<void>
148
+ /**
149
+ * Resolve the operator chat id (`allowFrom[0]`) fresh each tick — `allowFrom`
150
+ * can change at runtime. When it returns undefined, no chat is "covered", so
151
+ * every alertable window is treated as immediately deliverable to the operator.
152
+ */
153
+ operatorChatId?: () => string | undefined
154
+ /** Ms a window must be open before it earns an alert. Default 60_000. */
155
+ alertThresholdMs?: number
156
+ }
157
+
158
+ export interface FloodWindowObserver {
159
+ /** Poll persisted windows once: emit open/close snapshots + drive alerts. */
160
+ tick(): Promise<void>
161
+ }
162
+
163
+ /** Human-readable ISO-ish timestamp (UTC, second precision) for alert wording. */
164
+ function fmtTs(ms: number): string {
165
+ // Strip the milliseconds (`.\d{3}`) for second precision — this also covers
166
+ // the `.000` case, so no separate replace is needed.
167
+ return new Date(ms).toISOString().replace(/\.\d{3}/, '')
168
+ }
169
+
170
+ function fmtDur(ms: number): string {
171
+ const s = Math.round(ms / 1000)
172
+ if (s < 60) return `${s}s`
173
+ const m = Math.floor(s / 60)
174
+ const rem = s % 60
175
+ return rem ? `${m}m${rem}s` : `${m}m`
176
+ }
177
+
178
+ /**
179
+ * Flood-window observer (part3-design §6, item 2 + snapshots). Reads the
180
+ * persisted scoped windows each tick, diffs against the previous tick to detect
181
+ * opens / closes (snapshot log), and decides — per the deliverability rules in
182
+ * the module header — whether to alert the operator now, defer to window close,
183
+ * or stay quiet.
184
+ */
185
+ export function createFloodWindowObserver(
186
+ config: FloodWindowObserverConfig,
187
+ ): FloodWindowObserver {
188
+ const alertThresholdMs = config.alertThresholdMs ?? 60_000
189
+ // Full records seen on the previous tick, keyed by scope (close detection).
190
+ let lastSeen = new Map<string, FloodWindowRecord>()
191
+ // First tick after boot: any window already present was loaded from disk
192
+ // (a ban that predates this process), NOT freshly opened — so we log a single
193
+ // "loaded N windows" line instead of a per-scope OPENED snapshot (L3, #3112).
194
+ let firstTick = true
195
+ // Scopes whose alert could not be delivered during the window → owed on close.
196
+ const owedOnClose = new Set<string>()
197
+ // Close alerts owed but not yet deliverable, grouped per INCIDENT (an approx
198
+ // untilTs bucket). One real 429 opens several scopes (global + chat:<id> +
199
+ // group:<id>, all sharing one untilTs — see #3111's global over-suppression),
200
+ // which all close in the same tick; grouping collapses them into ONE operator
201
+ // "flood ban cleared" card listing every scope, instead of 2–3 near-identical
202
+ // cards per incident (M1, #3112).
203
+ // NOTE (L2, #3112): owedOnClose / pendingCloseByIncident are IN-MEMORY only —
204
+ // a restart during an open window drops the "owed" state and the deferred
205
+ // close-alert may be lost. This is the accepted at-least-once tradeoff
206
+ // documented in the module header; the invariant that matters (never RE-alert)
207
+ // is anchored on the persisted `alertedAt`, not on this state.
208
+ const pendingCloseByIncident = new Map<number, FloodWindowRecord[]>()
209
+
210
+ /** Bucket windows of one incident together by ~1s-rounded expiry. */
211
+ function incidentBucket(untilTs: number): number {
212
+ return Math.round(untilTs / 1000)
213
+ }
214
+
215
+ function coversOperator(w: FloodWindowRecord, operatorChatId: string | undefined): boolean {
216
+ if (w.scopeKey === 'global') return true
217
+ if (operatorChatId == null) return false
218
+ return (
219
+ w.scopeKey === `chat:${operatorChatId}` || w.scopeKey === `group:${operatorChatId}`
220
+ )
221
+ }
222
+
223
+ function snapshotSuffix(): string {
224
+ const c = config.stats().global
225
+ return (
226
+ `sent=${c.sent} shed=${c.shed} coalesced=${c.coalesced} ` +
227
+ `expired=${c.expired} failedFast=${c.failedFast}`
228
+ )
229
+ }
230
+
231
+ function openAlertText(w: FloodWindowRecord, now: number): string {
232
+ const openFor = fmtDur(now - w.observedAt)
233
+ return (
234
+ `⚠️ Telegram flood ban active (scope \`${w.scopeKey}\`). ` +
235
+ `Open for ${openFor}, expected to clear at ${fmtTs(w.untilTs)} UTC. ` +
236
+ `Outbound to that scope is being suppressed.`
237
+ )
238
+ }
239
+
240
+ /**
241
+ * ONE close-alert card for a whole incident. `recs` are the scopes of a
242
+ * single 429 (they share an ~untilTs bucket); we list every scope and span
243
+ * the interval as [earliest observedAt, latest untilTs] so the operator gets
244
+ * one card per incident rather than one per scope (M1, #3112).
245
+ */
246
+ function closeAlertText(recs: FloodWindowRecord[]): string {
247
+ const observedAt = Math.min(...recs.map((r) => r.observedAt))
248
+ const untilTs = Math.max(...recs.map((r) => r.untilTs))
249
+ const scopes = recs.map((r) => `\`${r.scopeKey}\``).join(', ')
250
+ const plural = recs.length > 1 ? 's' : ''
251
+ return (
252
+ `⚠️ Telegram flood ban cleared (scope${plural} ${scopes}). ` +
253
+ `The bot was banned from ${fmtTs(observedAt)} to ${fmtTs(untilTs)} UTC ` +
254
+ `(~${fmtDur(untilTs - observedAt)}). Some outbound messages during that ` +
255
+ `window were suppressed.`
256
+ )
257
+ }
258
+
259
+ async function tick(): Promise<void> {
260
+ const s = config.stats()
261
+ if (!s.enabled) return
262
+ const now = config.clock.now()
263
+ const operatorChatId = config.operatorChatId?.()
264
+ const windows = config.readWindows(now)
265
+ const current = new Map<string, FloodWindowRecord>()
266
+ for (const w of windows) current.set(w.scopeKey, w)
267
+ const operatorReachable = !windows.some((w) => coversOperator(w, operatorChatId))
268
+
269
+ if (firstTick) {
270
+ // Windows present on the very first tick were loaded at boot, not opened
271
+ // now — log ONE "loaded" line rather than a misleading OPENED snapshot per
272
+ // window (L3, #3112). Alerting for these still runs below (alertedAt-gated).
273
+ if (windows.length > 0) {
274
+ config.log(
275
+ `telegram gateway: send-gate flood observer loaded ${windows.length} ` +
276
+ `open flood window(s) at boot: ${windows.map((w) => w.scopeKey).join(', ')} ` +
277
+ `— ${snapshotSuffix()}\n`,
278
+ )
279
+ }
280
+ } else {
281
+ // Newly-opened scopes → snapshot.
282
+ for (const w of windows) {
283
+ if (!lastSeen.has(w.scopeKey)) {
284
+ config.log(
285
+ `telegram gateway: send-gate flood window OPENED scope=${w.scopeKey} ` +
286
+ `untilTs=${w.untilTs} src=${w.retryAfterSrc} — ${snapshotSuffix()}\n`,
287
+ )
288
+ }
289
+ }
290
+ }
291
+
292
+ // Closed scopes → snapshot, and queue any owed close-alert (grouped per
293
+ // incident so the operator gets ONE card listing all scopes — M1, #3112).
294
+ for (const [scope, prev] of lastSeen) {
295
+ if (current.has(scope)) continue
296
+ config.log(
297
+ `telegram gateway: send-gate flood window CLOSED scope=${scope} ` +
298
+ `(was open ${fmtDur(now - prev.observedAt)}) — ${snapshotSuffix()}\n`,
299
+ )
300
+ if (owedOnClose.has(scope) && isAlertableScope(scope) && prev.alertedAt == null) {
301
+ const bucket = incidentBucket(prev.untilTs)
302
+ const list = pendingCloseByIncident.get(bucket) ?? []
303
+ list.push(prev)
304
+ pendingCloseByIncident.set(bucket, list)
305
+ }
306
+ owedOnClose.delete(scope)
307
+ }
308
+
309
+ // Prolonged open windows → alert now (if deliverable) or defer to close.
310
+ for (const w of windows) {
311
+ if (!isAlertableScope(w.scopeKey)) continue
312
+ if (w.alertedAt != null) continue
313
+ if (now - w.observedAt < alertThresholdMs) continue
314
+ if (operatorReachable && !coversOperator(w, operatorChatId)) {
315
+ // NOTE (M1, #3112): this immediate-delivery branch is currently
316
+ // UNREACHABLE for recorder-produced state — `openScopedWindowsForOpts`
317
+ // opens a coincident `global` window on every 429, so `operatorReachable`
318
+ // is always false while any ban is active. It goes live once #3111 makes
319
+ // `onFloodWait` scope-precise. Kept + unit-tested as forward-looking.
320
+ // NOTE (L1, #3112): send-first, then persist `alertedAt`. A crash between
321
+ // the delivered card and the disk write re-alerts on restart — a benign
322
+ // duplicate. Deliberate: send-first guarantees an alert is never LOST,
323
+ // and never RE-alerting is the weaker of the two guarantees.
324
+ try {
325
+ await config.sendAlert(openAlertText(w, now))
326
+ config.markAlerted(w.scopeKey, now)
327
+ owedOnClose.delete(w.scopeKey)
328
+ } catch {
329
+ /* best-effort — a failed alert must not break the observer loop */
330
+ }
331
+ } else {
332
+ // Cannot deliver during the window → alert when it closes.
333
+ owedOnClose.add(w.scopeKey)
334
+ }
335
+ }
336
+
337
+ // Flush deferred close alerts once the operator chat is reachable again —
338
+ // ONE card per incident, listing every scope (M1, #3112).
339
+ if (operatorReachable && pendingCloseByIncident.size > 0) {
340
+ for (const [bucket, recs] of [...pendingCloseByIncident]) {
341
+ try {
342
+ await config.sendAlert(closeAlertText(recs))
343
+ pendingCloseByIncident.delete(bucket)
344
+ } catch {
345
+ /* keep it pending; retry next tick */
346
+ }
347
+ }
348
+ }
349
+
350
+ lastSeen = current
351
+ firstTick = false
352
+ }
353
+
354
+ return { tick }
355
+ }