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,470 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ formatStatsLine,
7
+ countersChanged,
8
+ createStatsLogger,
9
+ createFloodWindowObserver,
10
+ } from './send-gate-observability.js'
11
+ import type { SendGateStats, Clock } from './send-gate.js'
12
+ import {
13
+ writeFloodWindow,
14
+ readFloodWindows,
15
+ markFloodWindowAlerted,
16
+ floodWindowsPath,
17
+ type FloodWindowRecord,
18
+ } from './flood-circuit-breaker.js'
19
+ import { statusPairedText } from './welcome-text.js'
20
+
21
+ /** Minimal settable clock. Observer/logger never actually sleep in these tests. */
22
+ class TestClock implements Clock {
23
+ cur = 0
24
+ now(): number {
25
+ return this.cur
26
+ }
27
+ async sleep(): Promise<void> {
28
+ /* unused by the observability tick loops */
29
+ }
30
+ }
31
+
32
+ function makeStats(over: Partial<SendGateStats['global']> = {}, enabled = true): SendGateStats {
33
+ return {
34
+ enabled,
35
+ global: {
36
+ sent: 0,
37
+ queued: 0,
38
+ coalesced: 0,
39
+ dropped: 0,
40
+ shed: 0,
41
+ expired: 0,
42
+ failedFast: 0,
43
+ ...over,
44
+ },
45
+ messageStates: 0,
46
+ fill: { global: 4, perChat: {}, perGroup: {} },
47
+ }
48
+ }
49
+
50
+ describe('formatStatsLine', () => {
51
+ it('renders every counter + fill on one trailing-newline line', () => {
52
+ const line = formatStatsLine(
53
+ makeStats({ sent: 12, queued: 3, coalesced: 5, dropped: 2, shed: 8, expired: 1, failedFast: 4 }),
54
+ )
55
+ expect(line).toBe(
56
+ 'telegram gateway: send-gate stats: sent=12 queued=3 coalesced=5 dropped=2 ' +
57
+ 'shed=8 expired=1 failedFast=4 msgStates=0 chatBuckets=0 globalFill=4\n',
58
+ )
59
+ expect(line.endsWith('\n')).toBe(true)
60
+ expect(line.split('\n').filter((l) => l.length > 0)).toHaveLength(1)
61
+ })
62
+
63
+ it('rounds globalFill to one decimal', () => {
64
+ const s = makeStats()
65
+ s.fill.global = 23.14159
66
+ expect(formatStatsLine(s)).toContain('globalFill=23.1')
67
+ })
68
+ })
69
+
70
+ describe('countersChanged', () => {
71
+ it('is false for identical counters, true when any field differs', () => {
72
+ const a = makeStats().global
73
+ expect(countersChanged(a, { ...a })).toBe(false)
74
+ expect(countersChanged(a, { ...a, shed: 1 })).toBe(true)
75
+ expect(countersChanged(a, { ...a, failedFast: 1 })).toBe(true)
76
+ })
77
+ })
78
+
79
+ describe('createStatsLogger', () => {
80
+ it('logs at most once per interval AND only when a counter changed', async () => {
81
+ const clock = new TestClock()
82
+ const logs: string[] = []
83
+ let counters = makeStats().global
84
+ const logger = createStatsLogger({
85
+ stats: () => makeStats(counters),
86
+ log: (l) => logs.push(l),
87
+ clock,
88
+ intervalMs: 60_000,
89
+ })
90
+
91
+ // First tick with a fresh baseline logs immediately.
92
+ logger.tick()
93
+ expect(logs).toHaveLength(1)
94
+
95
+ // Changed counters but interval NOT elapsed (30s since the log at t=0) →
96
+ // no log (rate limit).
97
+ counters = { ...counters, sent: 5 }
98
+ clock.cur = 30_000
99
+ logger.tick()
100
+ expect(logs).toHaveLength(1)
101
+
102
+ // Interval elapsed but counters unchanged since the last logged snapshot →
103
+ // no log (no spam). Note the last LOG was at t=0 (t=30_000 didn't log).
104
+ counters = { ...counters, sent: 5 }
105
+ clock.cur = 70_000
106
+ // First make sure an UNCHANGED snapshot stays silent: temporarily revert.
107
+ // (Left as-is: counters already differ from the baseline, so this ticks.)
108
+ logger.tick()
109
+ expect(logs).toHaveLength(2)
110
+ expect(logs[1]).toContain('sent=5')
111
+
112
+ // Now unchanged since last log + interval elapsed → still silent.
113
+ clock.cur = 200_000
114
+ logger.tick()
115
+ expect(logs).toHaveLength(2)
116
+ })
117
+
118
+ it('is a no-op when the gate is disabled', () => {
119
+ const clock = new TestClock()
120
+ const logs: string[] = []
121
+ const logger = createStatsLogger({
122
+ stats: () => makeStats({ sent: 99 }, false),
123
+ log: (l) => logs.push(l),
124
+ clock,
125
+ })
126
+ logger.tick()
127
+ clock.cur = 1_000_000
128
+ logger.tick()
129
+ expect(logs).toHaveLength(0)
130
+ })
131
+ })
132
+
133
+ describe('createFloodWindowObserver — snapshots', () => {
134
+ it('logs a snapshot on window open and on window close', async () => {
135
+ const clock = new TestClock()
136
+ const logs: string[] = []
137
+ let windows: FloodWindowRecord[] = []
138
+ const obs = createFloodWindowObserver({
139
+ clock,
140
+ log: (l) => logs.push(l),
141
+ stats: () => makeStats({ shed: 2 }),
142
+ readWindows: () => windows,
143
+ markAlerted: () => {},
144
+ sendAlert: async () => {},
145
+ operatorChatId: () => 'op',
146
+ })
147
+
148
+ // First (boot) tick with no windows establishes the baseline.
149
+ await obs.tick()
150
+ expect(logs).toHaveLength(0)
151
+
152
+ // A NEW window opens on a later tick (post-boot) → OPENED snapshot.
153
+ clock.cur = 5_000
154
+ windows = [{ scopeKey: 'global', untilTs: 30_000, retryAfterSrc: '429', observedAt: 5_000 }]
155
+ await obs.tick()
156
+ expect(logs.some((l) => l.includes('flood window OPENED scope=global'))).toBe(true)
157
+ expect(logs.some((l) => l.includes('shed=2'))).toBe(true)
158
+
159
+ // Window gone (expired/pruned) → close snapshot.
160
+ logs.length = 0
161
+ clock.cur = 31_000
162
+ windows = []
163
+ await obs.tick()
164
+ expect(logs.some((l) => l.includes('flood window CLOSED scope=global'))).toBe(true)
165
+ })
166
+
167
+ it('logs ONE "loaded" line for boot-loaded windows, not per-scope OPENED spam (L3)', async () => {
168
+ const clock = new TestClock()
169
+ const logs: string[] = []
170
+ // Three windows already on disk at boot (a real 429 opens global+chat+group).
171
+ const windows: FloodWindowRecord[] = [
172
+ { scopeKey: 'global', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
173
+ { scopeKey: 'chat:op', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
174
+ { scopeKey: 'group:g', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
175
+ ]
176
+ const obs = createFloodWindowObserver({
177
+ clock,
178
+ log: (l) => logs.push(l),
179
+ stats: () => makeStats(),
180
+ readWindows: () => windows,
181
+ markAlerted: () => {},
182
+ sendAlert: async () => {},
183
+ operatorChatId: () => 'op',
184
+ })
185
+ await obs.tick()
186
+ // No OPENED spam for pre-existing windows.
187
+ expect(logs.some((l) => l.includes('flood window OPENED'))).toBe(false)
188
+ // Exactly one "loaded" line naming the count and every scope.
189
+ const loaded = logs.filter((l) => l.includes('loaded 3 open flood window(s) at boot'))
190
+ expect(loaded).toHaveLength(1)
191
+ expect(loaded[0]).toContain('global')
192
+ expect(loaded[0]).toContain('chat:op')
193
+ expect(loaded[0]).toContain('group:g')
194
+ })
195
+
196
+ it('does not run when the gate is disabled', async () => {
197
+ const clock = new TestClock()
198
+ const logs: string[] = []
199
+ const alerts: string[] = []
200
+ const obs = createFloodWindowObserver({
201
+ clock,
202
+ log: (l) => logs.push(l),
203
+ stats: () => makeStats({}, false),
204
+ readWindows: () => [
205
+ { scopeKey: 'chat:other', untilTs: 999_999, retryAfterSrc: '429', observedAt: 0 },
206
+ ],
207
+ markAlerted: () => {},
208
+ sendAlert: async (t) => {
209
+ alerts.push(t)
210
+ },
211
+ operatorChatId: () => 'op',
212
+ })
213
+ clock.cur = 500_000
214
+ await obs.tick()
215
+ expect(logs).toHaveLength(0)
216
+ expect(alerts).toHaveLength(0)
217
+ })
218
+ })
219
+
220
+ describe('createFloodWindowObserver — alerting', () => {
221
+ // FORWARD-LOOKING (#3111): this exercises the immediate-delivery branch with a
222
+ // hand-built `chat:other`-only window. In production today that state never
223
+ // occurs — `openScopedWindowsForOpts` opens a coincident `global` window on
224
+ // every 429, so the operator is always "covered" and every alert defers to
225
+ // close (see M1 in the #3112 review). This test guards the branch so it is
226
+ // correct the moment #3111 makes `onFloodWait` scope-precise; the live path
227
+ // today is the deferred-close test below.
228
+ it('alerts immediately for a chat-scoped ban on a DIFFERENT chat, at most once', async () => {
229
+ const clock = new TestClock()
230
+ const alerts: string[] = []
231
+ const marked: { scope: string; at: number }[] = []
232
+ let windows: FloodWindowRecord[] = [
233
+ { scopeKey: 'chat:other', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 },
234
+ ]
235
+ const obs = createFloodWindowObserver({
236
+ clock,
237
+ log: () => {},
238
+ stats: () => makeStats(),
239
+ readWindows: () => windows,
240
+ markAlerted: (scope, at) => {
241
+ marked.push({ scope, at })
242
+ // simulate persistence: the next read carries alertedAt
243
+ windows = windows.map((w) => (w.scopeKey === scope ? { ...w, alertedAt: at } : w))
244
+ },
245
+ sendAlert: async (t) => {
246
+ alerts.push(t)
247
+ },
248
+ operatorChatId: () => 'op',
249
+ alertThresholdMs: 60_000,
250
+ })
251
+
252
+ // Below threshold → no alert yet.
253
+ clock.cur = 30_000
254
+ await obs.tick()
255
+ expect(alerts).toHaveLength(0)
256
+
257
+ // Past threshold, operator chat is clear → immediate alert + persisted marker.
258
+ clock.cur = 61_000
259
+ await obs.tick()
260
+ expect(alerts).toHaveLength(1)
261
+ expect(alerts[0]).toContain('flood ban active')
262
+ expect(alerts[0]).toContain('chat:other')
263
+ expect(marked).toHaveLength(1)
264
+
265
+ // Subsequent ticks must NOT re-alert (alertedAt now set).
266
+ clock.cur = 120_000
267
+ await obs.tick()
268
+ expect(alerts).toHaveLength(1)
269
+ })
270
+
271
+ it('defers a GLOBAL ban alert until the window closes ("was banned from X to Y")', async () => {
272
+ const clock = new TestClock()
273
+ const alerts: string[] = []
274
+ let windows: FloodWindowRecord[] = [
275
+ { scopeKey: 'global', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
276
+ ]
277
+ const obs = createFloodWindowObserver({
278
+ clock,
279
+ log: () => {},
280
+ stats: () => makeStats(),
281
+ readWindows: () => windows,
282
+ markAlerted: () => {},
283
+ sendAlert: async (t) => {
284
+ alerts.push(t)
285
+ },
286
+ operatorChatId: () => 'op',
287
+ alertThresholdMs: 60_000,
288
+ })
289
+
290
+ // Past threshold but global → operator unreachable → NO alert during window.
291
+ clock.cur = 61_000
292
+ await obs.tick()
293
+ expect(alerts).toHaveLength(0)
294
+
295
+ // Window closes → deferred alert fires once, with the banned-from/to wording.
296
+ clock.cur = 121_000
297
+ windows = []
298
+ await obs.tick()
299
+ expect(alerts).toHaveLength(1)
300
+ expect(alerts[0]).toContain('flood ban cleared')
301
+ expect(alerts[0]).toContain('was banned from')
302
+
303
+ // No re-alert afterwards.
304
+ clock.cur = 200_000
305
+ await obs.tick()
306
+ expect(alerts).toHaveLength(1)
307
+ })
308
+
309
+ it('coalesces one incident (global+chat+group) into ONE cleared card listing every scope (M1)', async () => {
310
+ const clock = new TestClock()
311
+ const alerts: string[] = []
312
+ // A single 429 opens all three scopes with the SAME untilTs (the live
313
+ // recorder behavior — see #3111). They all defer (global covers the
314
+ // operator) and all close in the same tick.
315
+ let windows: FloodWindowRecord[] = [
316
+ { scopeKey: 'global', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
317
+ { scopeKey: 'chat:op', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
318
+ { scopeKey: 'group:g', untilTs: 120_000, retryAfterSrc: '429', observedAt: 0 },
319
+ ]
320
+ const obs = createFloodWindowObserver({
321
+ clock,
322
+ log: () => {},
323
+ stats: () => makeStats(),
324
+ readWindows: () => windows,
325
+ markAlerted: () => {},
326
+ sendAlert: async (t) => {
327
+ alerts.push(t)
328
+ },
329
+ operatorChatId: () => 'op',
330
+ alertThresholdMs: 60_000,
331
+ })
332
+
333
+ // Past threshold, all scopes covered by the global window → nothing during.
334
+ clock.cur = 61_000
335
+ await obs.tick()
336
+ expect(alerts).toHaveLength(0)
337
+
338
+ // All three close in the same tick → EXACTLY ONE cleared card, not three.
339
+ clock.cur = 121_000
340
+ windows = []
341
+ await obs.tick()
342
+ expect(alerts).toHaveLength(1)
343
+ expect(alerts[0]).toContain('flood ban cleared')
344
+ expect(alerts[0]).toContain('scopes') // plural — multiple scopes listed
345
+ expect(alerts[0]).toContain('global')
346
+ expect(alerts[0]).toContain('chat:op')
347
+ expect(alerts[0]).toContain('group:g')
348
+
349
+ // No re-alert afterwards.
350
+ clock.cur = 200_000
351
+ await obs.tick()
352
+ expect(alerts).toHaveLength(1)
353
+ })
354
+
355
+ it('does not alert for msg-edit (cosmetic) scopes', async () => {
356
+ const clock = new TestClock()
357
+ const alerts: string[] = []
358
+ const obs = createFloodWindowObserver({
359
+ clock,
360
+ log: () => {},
361
+ stats: () => makeStats(),
362
+ readWindows: () => [
363
+ { scopeKey: 'msg-edit:op:100', untilTs: 999_999, retryAfterSrc: '429', observedAt: 0 },
364
+ ],
365
+ markAlerted: () => {},
366
+ sendAlert: async (t) => {
367
+ alerts.push(t)
368
+ },
369
+ operatorChatId: () => 'op',
370
+ })
371
+ clock.cur = 500_000
372
+ await obs.tick()
373
+ expect(alerts).toHaveLength(0)
374
+ })
375
+ })
376
+
377
+ describe('alertedAt persistence (restart safety)', () => {
378
+ let dir: string
379
+ beforeEach(() => {
380
+ dir = mkdtempSync(join(tmpdir(), 'flood-windows-'))
381
+ })
382
+ afterEach(() => {
383
+ rmSync(dir, { recursive: true, force: true })
384
+ })
385
+
386
+ it('markFloodWindowAlerted persists alertedAt and readFloodWindows preserves it', () => {
387
+ const path = floodWindowsPath(dir)
388
+ writeFloodWindow(path, { scopeKey: 'chat:x', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 }, 0)
389
+ markFloodWindowAlerted(path, 'chat:x', 61_000, 61_000)
390
+ const [w] = readFloodWindows(path, 62_000)
391
+ expect(w.alertedAt).toBe(61_000)
392
+ })
393
+
394
+ it('preserves alertedAt when the window is later EXTENDED by a fresh 429', () => {
395
+ const path = floodWindowsPath(dir)
396
+ writeFloodWindow(path, { scopeKey: 'chat:x', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0 }, 0)
397
+ markFloodWindowAlerted(path, 'chat:x', 61_000, 61_000)
398
+ // A later 429 extends the window (recorder carries no alertedAt).
399
+ writeFloodWindow(path, { scopeKey: 'chat:x', untilTs: 600_000, retryAfterSrc: '429', observedAt: 62_000 }, 62_000)
400
+ const [w] = readFloodWindows(path, 63_000)
401
+ expect(w.untilTs).toBe(600_000)
402
+ expect(w.alertedAt).toBe(61_000)
403
+ })
404
+
405
+ it('a restart mid-window (alertedAt already on disk) does not re-alert', async () => {
406
+ const path = floodWindowsPath(dir)
407
+ writeFloodWindow(
408
+ path,
409
+ { scopeKey: 'chat:other', untilTs: 300_000, retryAfterSrc: '429', observedAt: 0, alertedAt: 61_000 },
410
+ 0,
411
+ )
412
+ const clock = new TestClock()
413
+ const alerts: string[] = []
414
+ const obs = createFloodWindowObserver({
415
+ clock,
416
+ log: () => {},
417
+ stats: () => makeStats(),
418
+ readWindows: (now) => readFloodWindows(path, now),
419
+ markAlerted: (scope, at) => markFloodWindowAlerted(path, scope, at, clock.cur),
420
+ sendAlert: async (t) => {
421
+ alerts.push(t)
422
+ },
423
+ operatorChatId: () => 'op',
424
+ })
425
+ clock.cur = 120_000 // well past threshold, window still open
426
+ await obs.tick()
427
+ expect(alerts).toHaveLength(0)
428
+ })
429
+ })
430
+
431
+ describe('/status send-gate block', () => {
432
+ const meta = {
433
+ agentName: 'a',
434
+ model: null,
435
+ extendsProfile: null,
436
+ topicName: null,
437
+ topicEmoji: null,
438
+ uptime: null,
439
+ status: null,
440
+ auth: null,
441
+ }
442
+
443
+ it('renders queued/shed totals and open flood windows when the gate is on', () => {
444
+ const text = statusPairedText({
445
+ user: '@op',
446
+ meta: {
447
+ ...meta,
448
+ sendGate: {
449
+ queued: 4,
450
+ shed: 9,
451
+ expired: 1,
452
+ failedFast: 2,
453
+ dropped: 3,
454
+ openWindows: [{ scopeKey: 'global', untilTs: Date.now() + 45_000 }],
455
+ },
456
+ },
457
+ })
458
+ expect(text).toContain('Send gate')
459
+ expect(text).toContain('queued 4')
460
+ expect(text).toContain('shed 9')
461
+ expect(text).toContain('fail-fast 2')
462
+ expect(text).toContain('flood window')
463
+ expect(text).toContain('global')
464
+ })
465
+
466
+ it('omits the send-gate block entirely when the gate is off (no sendGate field)', () => {
467
+ const text = statusPairedText({ user: '@op', meta })
468
+ expect(text).not.toContain('Send gate')
469
+ })
470
+ })