switchroom 0.19.27 → 0.19.28

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 (32) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +129 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +5 -2
  5. package/dist/cli/switchroom.js +796 -35
  6. package/dist/host-control/main.js +130 -9
  7. package/dist/vault/approvals/kernel-server.js +129 -8
  8. package/dist/vault/broker/server.js +129 -8
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +70 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +568 -49
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  27. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  28. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  31. package/vendor/hindsight-memory/settings.json +1 -1
  32. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -17267,6 +17267,7 @@ var init_operator_events = __esm(() => {
17267
17267
  "credentials-invalid",
17268
17268
  "credit-exhausted",
17269
17269
  "provider-credit-exhausted",
17270
+ "mcp-dependency-blocked",
17270
17271
  "proxy-misconfig"
17271
17272
  ]);
17272
17273
  });
@@ -165,8 +165,46 @@
165
165
  * `maxTightenLevel`) and decays one level at a time, so sustained pressure
166
166
  * ratchets the cosmetic rate down towards the floor of 1/window.
167
167
  *
168
+ * ── 2026-07-28: the reserve could not fire, and the reply paid (#3885) ────
169
+ * Points 3 and 5 above, and the escalating backoff, combined into a defect
170
+ * that hit the operator directly: during a 12-agent staggered restart that drew
171
+ * **three** genuine 429s, one chat logged 792 fuse events in an hour against a
172
+ * 35-50/hr baseline, shed 221 typing indicators, deferred 18 reactions, and
173
+ * deferred real replies long enough that the MCP `reply` tool's 60s timeout
174
+ * fired — so the agent retried and the operator got the same answer twice. One
175
+ * ~1900-character reply was lost outright.
176
+ *
177
+ * Three 3-second 429s ratchet `tightenLevel` to 3, and each one also writes
178
+ * `flood-wait.json`, which pinned `levelAt` at `maxTightenLevel` outright. At
179
+ * level 4 the shared per-chat budget is `max(1, floor(20 * 0.5^4))` = **1 call
180
+ * per minute for the entire chat**. The reserve that exists for precisely this
181
+ * case could not help, because it was a fixed count subtracted from the BASE:
182
+ * `max(1, 20 - 8)` = 12, tightened to 1 — the same 1 the reply had. Reply and
183
+ * repaint ended up with an identical budget, which is the opposite of a
184
+ * reservation. Three changes:
185
+ *
186
+ * 6. **The reserve is a PROPORTION, re-derived per window.** `cosmeticTotalMax`
187
+ * computes it against the EFFECTIVE ceiling, so "8 of 20" means "40% of
188
+ * whatever the budget currently is", and at least one slot is always
189
+ * reserved. When the effective budget shrinks to the reserve, cosmetic
190
+ * traffic gets 0 and every remaining slot belongs to a reply.
191
+ * 7. **A hard floor under `critical`.** `perChatCriticalMinPerWindow`
192
+ * (default 3) is a rate no tightening may cross, on every per-chat tier.
193
+ * Shedding a typing indicator under pressure is correct; holding the
194
+ * answer past the caller's own timeout is not — it does not save a call,
195
+ * it doubles it.
196
+ * 8. **A persisted flood window tightens IN PROPORTION to what remains**,
197
+ * through the same severity ladder `noteFlood` uses, instead of jumping
198
+ * straight to `maxTightenLevel`. `makeFloodWaitRecorder` writes the marker
199
+ * for every 429 including a 3-second nudge, so the flat jump reintroduced
200
+ * on the persisted path exactly the "a nudge and a 4.4h ban are the same
201
+ * signal" defect that #3856 had just fixed on the in-memory path.
202
+ *
168
203
  * Every ceiling is operator-overridable via env — see
169
- * {@link editFloodFuseConfigFromEnv}. Previously none of them were.
204
+ * {@link editFloodFuseConfigFromEnv}. Previously none of them were, and until
205
+ * #3885 the tightening CURVE (`maxTightenLevel`, `tightenFactor`) still was
206
+ * not, which is why the only available workaround for the above was to inflate
207
+ * the base ceiling fleet-wide.
170
208
  */
171
209
 
172
210
  import { createHash } from 'node:crypto'
@@ -302,8 +340,29 @@ export interface EditFloodFuseConfig {
302
340
  * consume. Default 8. This is the reply-starvation guarantee: whatever the
303
341
  * repaint surfaces do, at least this many calls per window remain available
304
342
  * to an actual answer.
343
+ *
344
+ * Read as a PROPORTION of the base, not an absolute count (#3885). The
345
+ * reserve used to be a fixed number subtracted from the base ceiling, which
346
+ * made it arithmetically dead under tightening: at `maxTightenLevel` the base
347
+ * 20 collapses to an effective 1, and `1 - 8` clamps to the same floor of 1
348
+ * that cosmetic traffic already had — so reply and repaint ended up with an
349
+ * identical budget in exactly the situation the reserve exists for. It is now
350
+ * re-derived against the EFFECTIVE ceiling every window, and always leaves at
351
+ * least one slot reserved.
305
352
  */
306
353
  perChatReplyReserve?: number
354
+ /**
355
+ * Absolute floor, per `perChatWindowMs`, on `critical`-class traffic in one
356
+ * chat — the operator's actual answer, an approval card, a reaction. Default
357
+ * 3. No amount of tightening may take a chat below this: a reply held past
358
+ * the caller's own timeout is not "shedding under pressure", it is a lost
359
+ * answer plus a retry that sends it twice (#3885). Cosmetic traffic has no
360
+ * floor and is still shed to zero, which is the correct trade.
361
+ *
362
+ * Capped by the tier's own base ceiling, so an operator who deliberately
363
+ * configures a base below this floor still gets what they configured.
364
+ */
365
+ perChatCriticalMinPerWindow?: number
307
366
  /**
308
367
  * Ceiling on EVERY outbound call made with this bot token, across all chats
309
368
  * and including the chat-less ones. Default 25 per `perTokenWindowMs`
@@ -340,7 +399,11 @@ export interface EditFloodFuseConfig {
340
399
  tightenFactor?: number
341
400
  /** How long ONE level of tightening stays in force before decaying. Default 10 minutes. */
342
401
  tightenMs?: number
343
- /** Cap on compounding 429 tightening levels. Default 4 (⇒ 0.5^4 = 1/16). */
402
+ /**
403
+ * Cap on compounding 429 tightening levels. Default 4 (⇒ 0.5^4 = 1/16).
404
+ * Operator-overridable via `SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL` (#3885);
405
+ * `0` disables tightening entirely.
406
+ */
344
407
  maxTightenLevel?: number
345
408
  /**
346
409
  * Remaining ms of a KNOWN-OPEN Telegram flood window, or 0 when none — the
@@ -389,6 +452,18 @@ export interface EditFloodFuseStats {
389
452
  cosmeticPerMessageCeiling: number
390
453
  /** Live COSMETIC per-chat ceiling (post-AIMD). */
391
454
  cosmeticPerChatCeiling: number
455
+ /**
456
+ * Live shared per-chat budget available to COSMETIC traffic (post-AIMD, after
457
+ * the reply reserve). Goes to 0 under heavy tightening — that is the reserve
458
+ * working, not a fault.
459
+ */
460
+ cosmeticPerChatTotalCeiling: number
461
+ /**
462
+ * Live shared per-chat budget available to CRITICAL traffic (post-AIMD, after
463
+ * the critical floor). The direct measure of #3885: this must never drop
464
+ * below `perChatCriticalMinPerWindow`, at any tighten level.
465
+ */
466
+ criticalPerChatTotalCeiling: number
392
467
  /**
393
468
  * Chat-targeted calls charged by the DEFAULT-DENY rule — every one of these
394
469
  * was completely unmetered before #3855. A non-zero value here is the direct
@@ -430,8 +505,18 @@ export const EDIT_FLOOD_FUSE_DEFAULTS = {
430
505
  * edits together — so this is the only window that reflects the real budget.
431
506
  */
432
507
  perChatTotalMaxPerWindow: 20,
433
- /** 8 of those 20 slots/min are unreachable by cosmetic traffic. */
508
+ /**
509
+ * 8 of those 20 slots/min are unreachable by cosmetic traffic — i.e. 40% of
510
+ * whatever the EFFECTIVE ceiling is, re-derived per window (#3885).
511
+ */
434
512
  perChatReplyReserve: 8,
513
+ /**
514
+ * 3/60s. The hard floor on `critical` traffic in a chat. Sized against the
515
+ * thing that broke: the MCP `reply` tool times out at 60s, so a chat that can
516
+ * pass fewer than a couple of criticals a minute turns one answer into a
517
+ * timeout, a retry, and a duplicate message.
518
+ */
519
+ perChatCriticalMinPerWindow: 3,
435
520
  /**
436
521
  * 25 per second across the whole bot token. Telegram enforces ~30/s at the
437
522
  * token level and the ban it issues is token-scoped; 25 leaves headroom for
@@ -460,6 +545,20 @@ function envInt(raw: string | undefined): number | undefined {
460
545
  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
461
546
  }
462
547
 
548
+ /**
549
+ * Parse a multiplicative-decrease factor from env: a real in (0, 1].
550
+ *
551
+ * Rejects 0 and negatives (which would zero every ceiling) and anything above
552
+ * 1 (which would make a 429 LOOSEN the fuse). An out-of-range value is treated
553
+ * as unset rather than clamped — silently reinterpreting an operator's number
554
+ * is how a fuse ends up at a rate nobody chose.
555
+ */
556
+ function envFactor(raw: string | undefined): number | undefined {
557
+ if (raw == null || raw.trim() === '') return undefined
558
+ const n = Number(raw)
559
+ return Number.isFinite(n) && n > 0 && n <= 1 ? n : undefined
560
+ }
561
+
463
562
  /**
464
563
  * Operator config surface. Before the 2026-07-27 incident the fuse had exactly
465
564
  * one knob — `SWITCHROOM_EDIT_FUSE=0`, which turns the whole failsafe OFF —
@@ -478,8 +577,16 @@ export function editFloodFuseConfigFromEnv(
478
577
  assign('cosmeticPerChatMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN))
479
578
  assign('perChatTotalMaxPerWindow', envInt(env.SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN))
480
579
  assign('perChatReplyReserve', envInt(env.SWITCHROOM_CHAT_REPLY_RESERVE))
580
+ assign('perChatCriticalMinPerWindow', envInt(env.SWITCHROOM_CHAT_CRITICAL_MIN_PER_MIN))
481
581
  assign('perTokenMaxPerWindow', envInt(env.SWITCHROOM_TOKEN_MAX_PER_SEC))
482
582
  assign('maxDeferMs', envInt(env.SWITCHROOM_EDIT_FUSE_MAX_DEFER_MS))
583
+ // #3885 — the tightening curve itself is now operator-tunable. Before this,
584
+ // `SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN` was the ONLY lever on it, so an
585
+ // operator whose chats were collapsing under `factor^maxLevel` had to inflate
586
+ // the BASE ceiling fleet-wide to compensate — raising the untightened rate
587
+ // (the one that earns bans) to fix the tightened one.
588
+ assign('maxTightenLevel', envInt(env.SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL))
589
+ assign('tightenFactor', envFactor(env.SWITCHROOM_EDIT_FUSE_TIGHTEN_FACTOR))
483
590
  // #3856 — durable ban awareness. The fuse reads the SAME persisted marker
484
591
  // (`flood-wait.json`) that `robustApiCall` and the outbox sweep consult, so a
485
592
  // restart mid-ban comes back tightened instead of at full rate. Wired here
@@ -529,12 +636,21 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
529
636
  perChatEditMax, config.cosmeticPerChatMaxPerWindow ?? D.cosmeticPerChatMaxPerWindow)
530
637
  const perChatTotalMax = config.perChatTotalMaxPerWindow ?? D.perChatTotalMaxPerWindow
531
638
  const perChatWindowMs = config.perChatWindowMs ?? D.perChatWindowMs
532
- // Clamped so a misconfigured reserve can never make the cosmetic allowance
533
- // negative (deadlocking every card) or eat the whole budget.
639
+ // Clamped so a misconfigured reserve can never eat more than the whole
640
+ // budget. Note this clamp is on the BASE only — the reserve that actually
641
+ // binds is re-derived per window by `cosmeticTotalMax` (#3885).
534
642
  const perChatReplyReserve = Math.min(
535
643
  Math.max(0, config.perChatReplyReserve ?? D.perChatReplyReserve),
536
644
  Math.max(0, perChatTotalMax - 1),
537
645
  )
646
+ /**
647
+ * The reserve as a FRACTION of the base. This is the whole #3885 fix: the
648
+ * reserve has to mean "40% of the chat's budget belongs to replies", not "8
649
+ * calls", because 8 is meaningless once the effective budget is 1.
650
+ */
651
+ const replyReserveFraction = perChatTotalMax > 0 ? perChatReplyReserve / perChatTotalMax : 0
652
+ const perChatCriticalMin = Math.max(
653
+ 0, config.perChatCriticalMinPerWindow ?? D.perChatCriticalMinPerWindow)
538
654
  const perTokenMax = Math.max(1, config.perTokenMaxPerWindow ?? D.perTokenMaxPerWindow)
539
655
  const perTokenWindowMs = Math.max(1, config.perTokenWindowMs ?? D.perTokenWindowMs)
540
656
  // Only ever the PUBLIC bot id (or an irreversible hash) reaches this key.
@@ -602,12 +718,29 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
602
718
  tightenLevel--
603
719
  tightenedUntil = tightenLevel > 0 ? tightenedUntil + tightenMs : 0
604
720
  }
605
- // #3856 — a KNOWN-OPEN persisted window pins the fuse at its tightest
606
- // regardless of in-memory state. This is what survives a restart: the
607
- // process forgets, the marker on disk does not. It does not MUTATE
608
- // `tightenLevel`, so when the window closes the fuse returns to whatever
609
- // this process actually earned rather than staying pinned.
610
- if (persistedFloodRemainingMs(now) > 0) return maxTightenLevel
721
+ // #3856 — a KNOWN-OPEN persisted window pins the fuse regardless of
722
+ // in-memory state. This is what survives a restart: the process forgets,
723
+ // the marker on disk does not. It does not MUTATE `tightenLevel`, so when
724
+ // the window closes the fuse returns to whatever this process actually
725
+ // earned rather than staying pinned.
726
+ //
727
+ // #3885 — but pinned at a level PROPORTIONAL to the window still open, not
728
+ // unconditionally at `maxTightenLevel`. `flood-wait.json` is written for
729
+ // EVERY 429 including a routine 3-second nudge (`makeFloodWaitRecorder` →
730
+ // `computeFloodWait`), so the flat jump meant a 3s burst nudge and a 4.4h
731
+ // ban produced the identical maximal response — the exact asymmetry #3856
732
+ // fixed for the in-memory path and left in place here. The remaining window
733
+ // is graded through the SAME severity ladder `noteFlood` uses, so a big ban
734
+ // still pins at maximum and a nudge costs one level.
735
+ //
736
+ // Clamped by `maxTightenLevel` like every other path: `tightenStepFor`
737
+ // returns a fixed 1..3 for the smaller bands, so an operator who sets
738
+ // `SWITCHROOM_EDIT_FUSE_MAX_TIGHTEN_LEVEL=0` to disable tightening entirely
739
+ // must not find the fuse tightening anyway the moment a marker exists.
740
+ const remainingMs = persistedFloodRemainingMs(now)
741
+ if (remainingMs > 0) {
742
+ return Math.max(tightenLevel, Math.min(maxTightenLevel, tightenStepFor(remainingMs / 1000)))
743
+ }
611
744
  return tightenLevel
612
745
  }
613
746
 
@@ -626,6 +759,45 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
626
759
  return Math.max(1, Math.floor(base * Math.pow(tightenFactor, level)))
627
760
  }
628
761
 
762
+ /**
763
+ * The ceiling a given CLASS sees on a tier whose base is `base` (#3885).
764
+ *
765
+ * `critical` traffic — the operator's answer, an approval card, a reaction —
766
+ * gets an absolute floor that tightening cannot cross. Everything else takes
767
+ * the tightened ceiling as-is, so shedding still happens; it just happens to
768
+ * the traffic that can afford it.
769
+ *
770
+ * The floor is capped by `base` so it can only ever RAISE a tightened ceiling
771
+ * back towards what the operator configured, never above it.
772
+ */
773
+ function classCeiling(base: number, cls: OutboundClass, now: number): number {
774
+ const eff = ceiling(base, now)
775
+ if (cls !== 'critical') return eff
776
+ return Math.max(eff, Math.min(base, perChatCriticalMin))
777
+ }
778
+
779
+ /**
780
+ * The shared per-chat budget COSMETIC traffic may reach, re-derived against
781
+ * the EFFECTIVE ceiling (#3885).
782
+ *
783
+ * Returns 0 when the effective budget has shrunk to the reserve — at that
784
+ * point every remaining slot belongs to replies, and a repaint waits for the
785
+ * window or is shed. That is the intended behaviour under real flood
786
+ * pressure: the operator loses refresh frequency, never the answer.
787
+ */
788
+ function cosmeticTotalMax(now: number): number {
789
+ const eff = ceiling(perChatTotalMax, now)
790
+ if (replyReserveFraction <= 0) return eff
791
+ const reserve = Math.min(eff, Math.max(1, Math.round(eff * replyReserveFraction)))
792
+ return Math.max(0, eff - reserve)
793
+ }
794
+
795
+ /** Shared per-chat budget resolver, per class, evaluated at admission time. */
796
+ function totalMaxFor(cls: OutboundClass): (now: number) => number {
797
+ if (cls === 'cosmetic') return cosmeticTotalMax
798
+ return (now: number) => classCeiling(perChatTotalMax, cls, now)
799
+ }
800
+
629
801
  function win(key: string): Window {
630
802
  let w = windows.get(key)
631
803
  if (w === undefined) {
@@ -668,6 +840,14 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
668
840
  /** ms until `w` has room, or 0 if it has room now. */
669
841
  function waitFor(w: Window, now: number, windowMs: number, max: number): number {
670
842
  prune(w, now, windowMs)
843
+ // A ceiling of 0 is reachable now that the reply reserve is derived against
844
+ // the EFFECTIVE budget (#3885): under heavy tightening the whole remaining
845
+ // budget belongs to replies, so cosmetic traffic has none. There is no
846
+ // "oldest slot" to wait behind in that case, so wait out the window rather
847
+ // than indexing an empty array (which would produce NaN and admit).
848
+ if (max <= 0) {
849
+ return w.ts.length > 0 ? Math.max(1, w.ts[0]! + windowMs - now) : Math.max(1, windowMs)
850
+ }
671
851
  if (w.ts.length < max) return 0
672
852
  return Math.max(1, w.ts[0]! + windowMs - now)
673
853
  }
@@ -770,7 +950,16 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
770
950
  * Returns the reserved timestamp, or null when the call must be dropped.
771
951
  */
772
952
  async function awaitRoom(
773
- key: string, windowMs: number, max: number, method: string, mode: WaitMode, cls: OutboundClass,
953
+ key: string, windowMs: number,
954
+ /**
955
+ * The ceiling, resolved at EVERY loop iteration rather than once at entry.
956
+ * It has to be a function: the effective ceiling depends on the tighten
957
+ * level, the tighten level decays with time, and a call can sit in this
958
+ * loop for `maxDeferMs`. Passing a number would pin a waiter to the ceiling
959
+ * that was in force when it arrived.
960
+ */
961
+ maxFor: (now: number) => number,
962
+ method: string, mode: WaitMode, cls: OutboundClass,
774
963
  /**
775
964
  * Consulted ONLY at the defer deadline for `mode: 'drop'`. Returning false
776
965
  * converts the drop into a late release: this call is the last thing that
@@ -805,7 +994,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
805
994
  let counted = false
806
995
  for (;;) {
807
996
  const now = clock.now()
808
- const wait = waitFor(w, now, windowMs, ceiling(max, now))
997
+ const wait = waitFor(w, now, windowMs, maxFor(now))
809
998
  if (wait === 0) {
810
999
  w.ts.push(now)
811
1000
  return now
@@ -897,22 +1086,29 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
897
1086
  // It cannot take a per-chat slot (there is no chat to charge), but it does
898
1087
  // consume the bot token's global allowance, which is the thing Telegram
899
1088
  // actually bans. Charge it there and pass.
1089
+ // The token tier is per-SECOND and global; a class floor there would be
1090
+ // meaningless (even at maximum tightening it admits 60/min), so it takes
1091
+ // the plain tightened ceiling.
1092
+ const tokenMaxFor = (t: number): number => ceiling(perTokenMax, t)
1093
+
900
1094
  if (chat == null) {
901
1095
  counters.chatless++
902
- await awaitRoom(tokenKey, perTokenWindowMs, perTokenMax, method, 'release', cls, undefined, deadline)
1096
+ await awaitRoom(tokenKey, perTokenWindowMs, tokenMaxFor, method, 'release', cls, undefined, deadline)
903
1097
  return runObserved(next)
904
1098
  }
905
1099
 
906
1100
  const totalKey = `t:${chat}`
907
- // Cosmetic traffic may only reach `perChatTotalMax - perChatReplyReserve`;
908
- // the remaining slots stay available to a real reply no matter how hot the
909
- // repaint surfaces are. This reservation is the reply-starvation guarantee.
910
- const totalMaxFor = (c: OutboundClass): number =>
911
- c === 'cosmetic' ? Math.max(1, perChatTotalMax - perChatReplyReserve) : perChatTotalMax
1101
+ // Cosmetic traffic may only reach the effective ceiling MINUS the reply
1102
+ // reserve; the remaining slots stay available to a real reply no matter how
1103
+ // hot the repaint surfaces are, and `critical` additionally cannot be
1104
+ // tightened below `perChatCriticalMinPerWindow`. Together these are the
1105
+ // reply-starvation guarantee, and unlike the pre-#3885 shape they hold at
1106
+ // every tighten level rather than only at level 0.
1107
+ const chatTotalMax = totalMaxFor(cls)
912
1108
 
913
1109
  /** Final tier for every chat-targeted path: the token-scoped ceiling. */
914
1110
  const passToken = async (): Promise<R> => {
915
- await awaitRoom(tokenKey, perTokenWindowMs, perTokenMax, method, 'release', cls, undefined, deadline)
1111
+ await awaitRoom(tokenKey, perTokenWindowMs, tokenMaxFor, method, 'release', cls, undefined, deadline)
916
1112
  return runObserved(next)
917
1113
  }
918
1114
 
@@ -925,7 +1121,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
925
1121
  try {
926
1122
  const msgSlot = await awaitRoom(
927
1123
  msgKey, perMessageWindowMs,
928
- cls === 'cosmetic' ? cosmeticPerMessageMax : perMessageMax,
1124
+ cls === 'cosmetic'
1125
+ ? (t) => ceiling(cosmeticPerMessageMax, t)
1126
+ : (t) => classCeiling(perMessageMax, cls, t),
929
1127
  method, 'supersede', cls, undefined, deadline,
930
1128
  )
931
1129
  if (msgSlot === null) return DROPPED_RESULT as unknown as R
@@ -941,7 +1139,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
941
1139
  const chatKey = `ce:${chat}`
942
1140
  const chatSlot = await awaitRoom(
943
1141
  chatKey, perChatWindowMs,
944
- cls === 'cosmetic' ? cosmeticPerChatMax : perChatEditMax,
1142
+ cls === 'cosmetic'
1143
+ ? (t) => ceiling(cosmeticPerChatMax, t)
1144
+ : (t) => classCeiling(perChatEditMax, cls, t),
945
1145
  method, 'drop', cls, dropGuard, deadline, lateKey,
946
1146
  )
947
1147
  if (chatSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
@@ -951,7 +1151,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
951
1151
  // metering. A non-cosmetic edit is RELEASED late rather than dropped:
952
1152
  // an approval card or answer finalisation has no newer frame coming.
953
1153
  const totalSlot = await awaitRoom(
954
- totalKey, perChatWindowMs, totalMaxFor(cls), method,
1154
+ totalKey, perChatWindowMs, chatTotalMax, method,
955
1155
  cls === 'cosmetic' ? 'drop' : 'release', cls, dropGuard, deadline, lateKey,
956
1156
  )
957
1157
  if (totalSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
@@ -965,8 +1165,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
965
1165
  // Sends create user-visible output and are NEVER dropped — only paced.
966
1166
  // The shared budget's reserve is what guarantees they still have room
967
1167
  // when the repaint surfaces are saturated.
968
- await awaitRoom(`cs:${chat}`, perChatWindowMs, perChatSendMax, method, 'release', cls, undefined, deadline)
969
- await awaitRoom(totalKey, perChatWindowMs, totalMaxFor(cls), method, 'release', cls, undefined, deadline)
1168
+ await awaitRoom(`cs:${chat}`, perChatWindowMs,
1169
+ (t) => classCeiling(perChatSendMax, cls, t), method, 'release', cls, undefined, deadline)
1170
+ await awaitRoom(totalKey, perChatWindowMs, chatTotalMax, method, 'release', cls, undefined, deadline)
970
1171
  return passToken()
971
1172
  }
972
1173
 
@@ -982,14 +1183,14 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
982
1183
  // short deadline also keeps a typing ping from occupying a 30s hold.
983
1184
  const actionDeadline = Math.min(deadline, now + chatActionMaxDeferMs)
984
1185
  const slot = await awaitRoom(
985
- totalKey, perChatWindowMs, totalMaxFor(cls), method, 'drop', cls, undefined, actionDeadline,
1186
+ totalKey, perChatWindowMs, chatTotalMax, method, 'drop', cls, undefined, actionDeadline,
986
1187
  )
987
1188
  if (slot === null) return DROPPED_RESULT as unknown as R
988
1189
  return passToken()
989
1190
  }
990
1191
  // Durable, user-visible effect (a deletion, a pin, a reaction): paced,
991
1192
  // never dropped — the same contract sends get.
992
- await awaitRoom(totalKey, perChatWindowMs, totalMaxFor(cls), method, 'release', cls, undefined, deadline)
1193
+ await awaitRoom(totalKey, perChatWindowMs, chatTotalMax, method, 'release', cls, undefined, deadline)
993
1194
  return passToken()
994
1195
  }
995
1196
 
@@ -1026,6 +1227,8 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1026
1227
  perMessageCeiling: ceiling(perMessageMax, now),
1027
1228
  cosmeticPerMessageCeiling: ceiling(cosmeticPerMessageMax, now),
1028
1229
  cosmeticPerChatCeiling: ceiling(cosmeticPerChatMax, now),
1230
+ cosmeticPerChatTotalCeiling: cosmeticTotalMax(now),
1231
+ criticalPerChatTotalCeiling: classCeiling(perChatTotalMax, 'critical', now),
1029
1232
  meteredByDefault: counters.meteredByDefault,
1030
1233
  chatless: counters.chatless,
1031
1234
  perTokenCeiling: ceiling(perTokenMax, now),
@@ -766,6 +766,7 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
766
766
  `(grant \`${id}\`). ` +
767
767
  `Re-run /vault audit to act on remaining denials.`
768
768
  await finalizeCallback(ctx, {
769
+ apiCall: robustApiCall,
769
770
  ackText: '✅ Grant minted',
770
771
  newText: baseText ? `${baseText}${statusLine}` : statusLine,
771
772
  // No synthInbound — operator-only flow. The granted agent picks
@@ -1909,6 +1910,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
1909
1910
  `\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
1910
1911
  `The current proposed key is \`${pending.key}\`.`
1911
1912
  await finalizeCallback(ctx, {
1913
+ apiCall: robustApiCall,
1912
1914
  ackText: 'Send the new key name as your next message.',
1913
1915
  newText: baseText ? `${baseText}${statusLine}` : statusLine,
1914
1916
  })
@@ -2512,6 +2514,7 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
2512
2514
  const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
2513
2515
  if (msgId != null) {
2514
2516
  await finalizeCallback(ctx, {
2517
+ apiCall: robustApiCall,
2515
2518
  ackText: '✅ Grant created',
2516
2519
  newText: successText,
2517
2520
  // No synthInbound — operator-only flow.
@@ -2933,6 +2936,7 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
2933
2936
  // scrollback shows the dismissal.
2934
2937
  const status = `\n\n✗ _Dismissed by operator._`
2935
2938
  await finalizeCallback(ctx, {
2939
+ apiCall: robustApiCall,
2936
2940
  ackText: 'Dismissed',
2937
2941
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
2938
2942
  // No synthInbound — dismiss is operator-only, no model in loop.
@@ -2947,6 +2951,7 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
2947
2951
  // status line is the announcement, no separate reply needed.
2948
2952
  const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
2949
2953
  await finalizeCallback(ctx, {
2954
+ apiCall: robustApiCall,
2950
2955
  ackText: `Restarting ${agent}…`,
2951
2956
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
2952
2957
  })
@@ -2973,6 +2978,7 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
2973
2978
  // collapsed card.
2974
2979
  const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
2975
2980
  await finalizeCallback(ctx, {
2981
+ apiCall: robustApiCall,
2976
2982
  ackText: `Starting reauth for ${agent}…`,
2977
2983
  newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
2978
2984
  synthInbound: async () => {
@@ -404,6 +404,7 @@ import {
404
404
  type OperatorEvent,
405
405
  type OperatorEventKind,
406
406
  } from '../operator-events.js'
407
+ import { createMcpFailureHook } from './mcp-failure-hook.js'
407
408
  import { pendingUserNoticeGate } from '../pending-user-notice.js'
408
409
  import { recordOperatorEvent } from '../operator-events-history.js'
409
410
  import {
@@ -14007,7 +14008,13 @@ function surfaceConsolidationLegibility(
14007
14008
  })
14008
14009
  }
14009
14010
 
14011
+ /** Blocked-paid-dependency alerting — policy in `mcp-credential-failure.ts`,
14012
+ * seam in `gateway/mcp-failure-hook.ts`. Tees the SAME
14013
+ * `emitGatewayOperatorEvent` path so there is ONE operator-alert mechanism. */
14014
+ const noteMcpDependencyFailure = createMcpFailureHook({ agent: AGENT_NAME, emit: emitGatewayOperatorEvent })
14015
+
14010
14016
  function handleSessionEvent(ev: SessionEvent): void {
14017
+ noteMcpDependencyFailure(ev)
14011
14018
  handleSessionEventCore(gatewayStreamRenderDeps(), ev)
14012
14019
  }
14013
14020
 
@@ -21948,7 +21955,7 @@ bot.on('callback_query:data', async ctx => {
21948
21955
  // ALREADY dispatched above (independently of this host round-trip)
21949
21956
  // so the turn never blocked — finalizeCallback here only edits the
21950
21957
  // card; no synthInbound (would double-fire the verdict).
21951
- await finalizeCallback(ctx, {
21958
+ await finalizeCallback(ctx, { apiCall: robustApiCall,
21952
21959
  ackText: 'Rule applied for this session; saving durably in background…'.slice(0, 200),
21953
21960
  newText: baseText ? `${baseText}\n\n${interimLabel}` : interimLabel,
21954
21961
  })
@@ -22260,7 +22267,7 @@ bot.on('callback_query:data', async ctx => {
22260
22267
  // decision. Operator could re-tap and flip Deny → Allow after the
22261
22268
  // permission was already broadcast. Routing through finalizeCallback
22262
22269
  // strips the keyboard atomically with the status-line edit.
22263
- await finalizeCallback(ctx, {
22270
+ await finalizeCallback(ctx, { apiCall: robustApiCall,
22264
22271
  ackText: ackText.slice(0, 200),
22265
22272
  newText: baseText ? `${baseText}\n\n${labelWithResume}` : labelWithResume,
22266
22273
  synthInbound: () => {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * mcp-failure-hook.ts — the gateway-side wiring that turns a blocked PAID MCP
3
+ * DEPENDENCY into an operator alert.
4
+ *
5
+ * Ken: "If perplexity fails for any agent we should have hard warning via
6
+ * message here per other failures."
7
+ *
8
+ * MCP providers (Perplexity, Eraser, Brevo, Postiz, Meta/Google Ads,
9
+ * Cloudflare) never touch LiteLLM, so the operator-event path that catches an
10
+ * OpenRouter 402 never sees them — an expired Perplexity key would otherwise
11
+ * die as a transient red step in the live feed and nobody would learn the fleet
12
+ * had lost a paid capability.
13
+ *
14
+ * This tees the SAME `emitGatewayOperatorEvent` path (redact → per-kind
15
+ * cooldown → operator-only audience via `OPERATOR_ACTIONABLE_KINDS` → brief
16
+ * user notice), so there is ONE operator-alert mechanism in switchroom, not
17
+ * two. All the policy — what counts as a key problem, what stays silent, the
18
+ * 6-hour re-notify cadence — lives in `../mcp-credential-failure.ts`; this file
19
+ * is only the seam.
20
+ *
21
+ * Extracted from `gateway.ts` rather than inlined there, per the
22
+ * switchroom#2996 anti-inflation ratchet (`scripts/check-gateway-line-ratchet.mjs`).
23
+ */
24
+
25
+ import type { SessionEvent } from '../session-tail.js'
26
+ import type { OperatorEvent } from '../operator-events.js'
27
+ import { McpFailureWatcher, renderMcpFailureDetail } from '../mcp-credential-failure.js'
28
+
29
+ export interface McpFailureHookDeps {
30
+ agent: string
31
+ emit: (event: OperatorEvent) => void
32
+ now?: () => number
33
+ }
34
+
35
+ /**
36
+ * Build the `handleSessionEvent` side-hook. One watcher per gateway process,
37
+ * captured in the closure.
38
+ *
39
+ * Silent by default: only a credit/credential/quota class raises anything, and
40
+ * only once per (server, class) per re-notify window. A bad query, a 404, a
41
+ * timeout, a transient 429 or a successful call all return without emitting.
42
+ *
43
+ * Never throws — alerting is a side channel and must not break the live feed.
44
+ */
45
+ export function createMcpFailureHook(deps: McpFailureHookDeps): (ev: SessionEvent) => void {
46
+ const watcher = new McpFailureWatcher()
47
+ const clock = deps.now ?? (() => Date.now())
48
+ return function noteMcpDependencyFailure(ev: SessionEvent): void {
49
+ try {
50
+ if (ev.kind === 'tool_use') {
51
+ watcher.onToolUse(ev.toolUseId, ev.toolName)
52
+ return
53
+ }
54
+ if (ev.kind !== 'tool_result') return
55
+ const alert = watcher.onToolResult({
56
+ toolUseId: ev.toolUseId,
57
+ isError: ev.isError,
58
+ errorText: ev.errorText,
59
+ agent: deps.agent,
60
+ now: clock(),
61
+ })
62
+ if (alert == null) return
63
+ deps.emit({
64
+ kind: 'mcp-dependency-blocked',
65
+ agent: deps.agent,
66
+ detail: renderMcpFailureDetail(alert),
67
+ suggestedActions: [],
68
+ firstSeenAt: new Date(clock()),
69
+ })
70
+ } catch {
71
+ // Side channel — never let alerting break the live feed.
72
+ }
73
+ }
74
+ }