switchroom 0.18.13 → 0.18.15

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 (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -94,6 +94,13 @@ import {
94
94
  buildExtraAttachmentMeta,
95
95
  resolveCoalesceMaxAttachments,
96
96
  } from './coalesce-attachments.js'
97
+ import {
98
+ parseForwardOrigin,
99
+ dedupeForwardOrigins,
100
+ buildForwardOriginMeta,
101
+ forwardOriginDateIso,
102
+ type ForwardOriginInfo,
103
+ } from './forward-origin.js'
97
104
  import { StatusReactionController } from '../status-reactions.js'
98
105
  import { DeferredDoneReactions } from '../reaction-defer.js'
99
106
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
@@ -310,7 +317,15 @@ import { recordOperatorEvent } from '../operator-events-history.js'
310
317
  import {
311
318
  formatModelUnavailableCard,
312
319
  resolveModelUnavailableFromOperatorEvent,
320
+ type ModelUnavailableDetection,
313
321
  } from '../model-unavailable.js'
322
+ import {
323
+ build429ClassifiedMetric,
324
+ classify429Detail,
325
+ decideThrottleTier,
326
+ throttleRetryInPlaceMaxMs,
327
+ } from '../throttle-tier.js'
328
+ import { createThrottleTierRunner } from './throttle-tier-wiring.js'
314
329
  import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
315
330
  import { startRestartWatchdog } from './restart-watchdog.js'
316
331
  import { validateStringArray } from './access-validator.js'
@@ -7587,6 +7602,14 @@ type CoalescePayload = {
7587
7602
  attachment?: AttachmentMeta
7588
7603
  // Set only by `merge`: the 2nd..Nth attachments folded into this turn.
7589
7604
  extraAttachments?: CoalesceAttachment[]
7605
+ // Forwarded-message origin of THIS entry (parsed at enqueue time from
7606
+ // message.forward_origin). merged.ctx is the LAST entry's ctx, so a
7607
+ // coalesced burst would otherwise lose the earlier entries' origins —
7608
+ // each entry carries its own.
7609
+ forwardOrigin?: ForwardOriginInfo
7610
+ // Set only by `merge`: the distinct origins of the whole burst, arrival
7611
+ // order, deduped (a single-origin album collapses to one entry).
7612
+ forwardOrigins?: ForwardOriginInfo[]
7590
7613
  }
7591
7614
 
7592
7615
  // Count of attachment-bearing entries currently buffered per coalesce key.
@@ -7619,6 +7642,11 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7619
7642
  (e) => e.downloadImage != null || e.attachment != null,
7620
7643
  coalesceMaxAttachments(),
7621
7644
  )
7645
+ // Distinct forwarded-message origins across the burst, arrival order.
7646
+ // A forwarded album (one origin, N parts) collapses to a single entry
7647
+ // so the attrs are emitted once; a burst forwarded from two different
7648
+ // senders keeps both (numbered forwarded_from_2 siblings downstream).
7649
+ const forwardOrigins = dedupeForwardOrigins(entries.map((e) => e.forwardOrigin))
7622
7650
  return {
7623
7651
  // Drop empty texts (e.g. caption-less album parts) so the join doesn't
7624
7652
  // emit blank lines between attachments.
@@ -7629,6 +7657,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7629
7657
  extraAttachments: extras.length > 0
7630
7658
  ? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
7631
7659
  : undefined,
7660
+ forwardOrigins: forwardOrigins.length > 0 ? forwardOrigins : undefined,
7632
7661
  }
7633
7662
  },
7634
7663
  onFlush: (key, merged) => {
@@ -7639,6 +7668,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7639
7668
  merged.downloadImage,
7640
7669
  merged.attachment,
7641
7670
  merged.extraAttachments,
7671
+ merged.forwardOrigins,
7642
7672
  )
7643
7673
  },
7644
7674
  })
@@ -7659,6 +7689,117 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7659
7689
  function emitGatewayOperatorEvent(event: OperatorEvent): void {
7660
7690
  const { agent, kind } = event
7661
7691
 
7692
+ // ── 429 throttle tier (operator spec: "retry in place under 5 min, else
7693
+ // mark + failover, honest reset messaging") ────────────────────────────
7694
+ // A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
7695
+ // wording that affirms the account's own rate limit ("would exceed your
7696
+ // account's rate limit") — is decided BEFORE the per-kind cooldown gate:
7697
+ // every hit must reach the broker's mark-throttled escalation counter, and
7698
+ // the >threshold failover ACTION must fire, even when the user-facing card
7699
+ // is cooldown-suppressed (a calm 529 card two minutes earlier arms that
7700
+ // cooldown). Server-side transients (529 / "server is temporarily…") fall
7701
+ // through to the existing calm rate-limited card unchanged — an
7702
+ // account-scoped throttle would be the wrong action for a server-wide
7703
+ // condition.
7704
+ //
7705
+ // Classification is three-way (classify429Detail, throttle-tier.ts):
7706
+ // only `account-scoped` may enter the throttle tier. `litellm-local` —
7707
+ // the LiteLLM proxy's OWN tpm/rpm/router limiter tripping before the
7708
+ // request reached Anthropic — takes the calm path with NO broker mark and
7709
+ // NO failover: the condition is proxy-local and says nothing about the
7710
+ // account. Every rate-limited event ALSO emits one
7711
+ // `rate_limit_429_classified` runtime metric (PostHog + JSONL), fired
7712
+ // here — before the cooldown gate — so operators can correlate
7713
+ // account-scoped 429s with fleet TPM even when the card is suppressed.
7714
+ let throttleEscalation: ModelUnavailableDetection | null = null
7715
+ let escalationFired = false
7716
+ const rateLimit429Classification =
7717
+ kind === 'rate-limited' ? classify429Detail(event.detail) : null
7718
+ if (rateLimit429Classification != null && rateLimit429Classification !== 'account-scoped') {
7719
+ // litellm-local / generic-transient: the existing calm rate-limited
7720
+ // path (fall through to renderOperatorEvent below). NO broker
7721
+ // mark-throttled, NO throttle-tier runner, NO failover — for a
7722
+ // proxy-local cap trip those would bench an account that was never
7723
+ // touched.
7724
+ emitRuntimeMetric(
7725
+ build429ClassifiedMetric({
7726
+ agent,
7727
+ detail: event.detail,
7728
+ classification: rateLimit429Classification,
7729
+ action: 'calm',
7730
+ now: Date.now(),
7731
+ }),
7732
+ )
7733
+ if (rateLimit429Classification === 'litellm-local') {
7734
+ process.stderr.write(
7735
+ `telegram gateway: 429 classified litellm-proxy-local agent=${agent} — ` +
7736
+ `calm path, no account attribution, no failover\n`,
7737
+ )
7738
+ }
7739
+ }
7740
+ if (rateLimit429Classification === 'account-scoped') {
7741
+ const throttleDecision = decideThrottleTier({
7742
+ detail: event.detail,
7743
+ now: Date.now(),
7744
+ thresholdMs: throttleRetryInPlaceMaxMs(),
7745
+ })
7746
+ emitRuntimeMetric(
7747
+ build429ClassifiedMetric({
7748
+ agent,
7749
+ detail: event.detail,
7750
+ classification: 'account-scoped',
7751
+ // decideThrottleTier can't return 'none' for account-scoped wording;
7752
+ // map the two live actions onto the metric's vocabulary.
7753
+ action: throttleDecision.action === 'failover' ? 'failover' : 'throttle',
7754
+ now: Date.now(),
7755
+ }),
7756
+ )
7757
+ if (throttleDecision.action === 'throttle') {
7758
+ // Reset is near (≤ threshold) or unparseable (60s default): DO NOT
7759
+ // fail over. Record throttled_until broker-side, post ONE lightweight
7760
+ // notice (per-account + fleet-wide dedup), nudge a retry after the
7761
+ // reset. The runner owns the sequencing (throttle-tier-wiring.ts).
7762
+ process.stderr.write(
7763
+ `telegram gateway: throttle-tier staying-put agent=${agent} ` +
7764
+ `until=${new Date(throttleDecision.throttledUntilMs).toISOString()} ` +
7765
+ `parsedReset=${throttleDecision.resetParsed}\n`,
7766
+ )
7767
+ try {
7768
+ recordOperatorEvent(event)
7769
+ } catch { /* history is best-effort */ }
7770
+ void throttleTierRunner.fire(
7771
+ agent,
7772
+ throttleDecision.throttledUntilMs,
7773
+ throttleDecision.resetParsed,
7774
+ )
7775
+ return
7776
+ }
7777
+ if (throttleDecision.action === 'failover') {
7778
+ // Reset beyond the retry-in-place threshold: the account is benched
7779
+ // until then anyway, so escalate to the standard mark-exhausted +
7780
+ // fleet-failover machinery — honest 'rate_limited' wording, parsed
7781
+ // reset as the mark expiry. The ACTION fires HERE, before the
7782
+ // per-kind card cooldown below can swallow it (the fleetFallbackGate
7783
+ // provides the action-level dedup); only the card stays
7784
+ // cooldown-bounded. The 'rate-limit' trigger tells the dispatcher to
7785
+ // trust this terminal parsed-reset signal over the utilization probe
7786
+ // (a transient 429 negates the usage-limit reading, so utilization
7787
+ // typically looks healthy — the idempotency guard would self-cancel
7788
+ // the swap).
7789
+ const resetAt = new Date(throttleDecision.resetAtMs)
7790
+ throttleEscalation = { kind: 'rate_limited', resetAt, raw: event.detail }
7791
+ if (wouldFireFleetAutoFallback()) {
7792
+ escalationFired = true
7793
+ void fireFleetAutoFallback(
7794
+ agent,
7795
+ resolveExhaustUntil(resetAt.getTime()),
7796
+ resetAt,
7797
+ 'rate-limit',
7798
+ )
7799
+ }
7800
+ }
7801
+ }
7802
+
7662
7803
  if (!shouldEmitOperatorEvent(agent, kind)) {
7663
7804
  process.stderr.write(
7664
7805
  `telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
@@ -7687,7 +7828,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7687
7828
  // by the upstream classifier
7688
7829
  // - detail string contains one of the model-unavailable text patterns
7689
7830
  // (covers raw stderr that slipped past structured classification)
7690
- const modelUnavailable = resolveModelUnavailableFromOperatorEvent(event)
7831
+ // Throttle-tier escalation (long-reset transient 429) takes precedence:
7832
+ // resolveModelUnavailableFromOperatorEvent deliberately returns null for a
7833
+ // bare rate-limited event, but this one is auto-fallback-eligible.
7834
+ const modelUnavailable = throttleEscalation ?? resolveModelUnavailableFromOperatorEvent(event)
7691
7835
  let renderedText: string
7692
7836
  let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
7693
7837
  // #3031 PR 3 — when the card promises an in-flight auto-failover, record
@@ -7709,8 +7853,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7709
7853
  // it just produces a self-cancelling "probed healthy / Stale event?"
7710
7854
  // loop on every 529. Overload is handled by Claude Code's own
7711
7855
  // internal retry, not by switching accounts.
7712
- const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
7713
- const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback()
7856
+ // 'rate_limited' is the throttle-tier escalation (transient 429 whose
7857
+ // reset exceeds the retry-in-place threshold) — same failover mechanics
7858
+ // as a quota wall, honest wording on the card. Its fire already happened
7859
+ // ABOVE the cooldown gate (escalationFired) so the action can't be
7860
+ // swallowed by the per-kind card cooldown; the card here only reports it.
7861
+ const isAutoKind =
7862
+ modelUnavailable.kind === 'quota_exhausted' ||
7863
+ modelUnavailable.kind === 'rate_limited'
7864
+ const willActuallyFire =
7865
+ throttleEscalation != null
7866
+ ? escalationFired
7867
+ : isAutoKind && wouldFireFleetAutoFallback()
7714
7868
  process.stderr.write(
7715
7869
  `telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
7716
7870
  )
@@ -7734,9 +7888,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7734
7888
  // Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
7735
7889
  // weekly wall that surfaced as a 429 got markExhausted's ~5h default and
7736
7890
  // the broker re-mirrored the still-walled account onto the fleet after 5h.
7737
- if (willActuallyFire) {
7891
+ // Throttle-tier escalations (throttleEscalation != null) already fired
7892
+ // above the cooldown gate — don't double-fire here.
7893
+ if (willActuallyFire && throttleEscalation == null) {
7738
7894
  const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
7739
- void fireFleetAutoFallback(agent, untilMs)
7895
+ // Thread the RAW parsed reset (distinct from the floor-applied untilMs)
7896
+ // so the fallback announcement can name it even when the live probe of
7897
+ // the old account fails — honest reset messaging on the enriched card.
7898
+ void fireFleetAutoFallback(agent, untilMs, modelUnavailable.resetAt)
7740
7899
  }
7741
7900
  } else {
7742
7901
  try {
@@ -11374,7 +11533,13 @@ const ipcServer: IpcServer = createIpcServer({
11374
11533
  (msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
11375
11534
  ' — triggering fleet auto-fallback\n',
11376
11535
  )
11377
- void fireFleetAutoFallback(msg.agentName, untilMs)
11536
+ void fireFleetAutoFallback(
11537
+ msg.agentName,
11538
+ untilMs,
11539
+ // Enriched announcement: the sidecar-parsed reset (when present) keeps
11540
+ // the recovery line honest even when the old account's probe fails.
11541
+ msg.resetAt != null ? new Date(msg.resetAt) : undefined,
11542
+ )
11378
11543
  },
11379
11544
 
11380
11545
  // Issue #2971 — read-only wedge-watchdog probe: is there a live pending
@@ -18070,7 +18235,15 @@ async function handleInboundCoalesced(
18070
18235
  ctx.message?.message_thread_id,
18071
18236
  String(from.id),
18072
18237
  )
18073
- const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment })
18238
+ const result = inboundCoalescer.enqueue(key, {
18239
+ text,
18240
+ ctx,
18241
+ downloadImage,
18242
+ attachment,
18243
+ // Parsed HERE (not in merge) because merge only sees the last ctx —
18244
+ // each buffered entry keeps its own forwarded-message origin.
18245
+ forwardOrigin: parseForwardOrigin(ctx.message?.forward_origin),
18246
+ })
18074
18247
  // Coalescing disabled (window <= 0): flush immediately, preserving any
18075
18248
  // media this message carried.
18076
18249
  if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
@@ -18149,6 +18322,11 @@ async function handleInbound(
18149
18322
  // resolved (photos downloaded) and surfaced as numbered meta fields
18150
18323
  // (image_path_2, attachment_file_id_2, …) alongside the primary.
18151
18324
  extraAttachments?: CoalesceAttachment[],
18325
+ // Forwarded-message origins for a coalesced burst (deduped, arrival
18326
+ // order — see the coalescer's merge). Left undefined by the direct /
18327
+ // bypass call sites; handleInbound then parses THIS ctx's
18328
+ // forward_origin itself, so single-message forwards work on every path.
18329
+ coalescedForwardOrigins?: ForwardOriginInfo[],
18152
18330
  ): Promise<void> {
18153
18331
  markIdleActivity() // any inbound resets the idle auto-clear timer + re-arms
18154
18332
  const isTopicMessage = ctx.message?.is_topic_message ?? false
@@ -19367,6 +19545,19 @@ async function handleInbound(
19367
19545
  : undefined
19368
19546
  const replyToTextEscaped = formatReplyToText(replyToTextRaw, REPLY_TO_TEXT_MAX)
19369
19547
 
19548
+ // Forwarded-message origin context. `forward_origin` is stamped by
19549
+ // Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
19550
+ // it via the message body, so it rides the trusted attrs lane and is
19551
+ // NEVER folded into the body text. Coalesced bursts pass their deduped
19552
+ // per-entry origins; direct/bypass paths parse this ctx's own origin.
19553
+ // Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
19554
+ // raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
19555
+ // the channel-meta boundary.
19556
+ const forwardOrigins = coalescedForwardOrigins
19557
+ ?? dedupeForwardOrigins([parseForwardOrigin(ctx.message?.forward_origin)])
19558
+ const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
19559
+ const primaryForwardOrigin = forwardOrigins[0]
19560
+
19370
19561
  if (HISTORY_ENABLED) {
19371
19562
  try {
19372
19563
  recordInbound({
@@ -19380,6 +19571,11 @@ async function handleInbound(
19380
19571
  attachment_kind: attachment?.kind,
19381
19572
  reply_to_message_id: replyToMessageId ?? null,
19382
19573
  reply_to_text: replyToText ?? null,
19574
+ forwarded_from: primaryForwardOrigin?.name ?? null,
19575
+ forwarded_from_type: primaryForwardOrigin?.type ?? null,
19576
+ forwarded_from_id: primaryForwardOrigin?.id != null ? String(primaryForwardOrigin.id) : null,
19577
+ forwarded_date: forwardOriginDateIso(primaryForwardOrigin),
19578
+ forwarded_message_id: primaryForwardOrigin?.messageId ?? null,
19383
19579
  })
19384
19580
  } catch (err) {
19385
19581
  process.stderr.write(`telegram gateway: history recordInbound failed: ${err}\n`)
@@ -19485,6 +19681,11 @@ async function handleInbound(
19485
19681
  // Use the XML-escaped form for the meta — the raw form is in the
19486
19682
  // SQLite buffer for verbatim retrieval via get_recent_messages.
19487
19683
  ...(replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {}),
19684
+ // Forwarded-message origin (server-stamped, attrs-only — see above).
19685
+ // forwarded_from / forwarded_from_type / forwarded_from_id /
19686
+ // forwarded_date, plus numbered _2.. siblings for a multi-origin
19687
+ // burst. Names are XML-escaped inside buildForwardOriginMeta.
19688
+ ...forwardOriginMeta,
19488
19689
  // queued="true" when mid-turn with no steer prefix (new default), or
19489
19690
  // with explicit /queue or /q prefix (legacy alias).
19490
19691
  ...((isQueuedMidTurn || isQueuedPrefix) ? { queued: 'true' } : {}),
@@ -23203,9 +23404,14 @@ function wouldFireFleetAutoFallback(): boolean {
23203
23404
  * so the user sees the outcome inline with the original "Model
23204
23405
  * unavailable" card.
23205
23406
  */
23206
- async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<void> {
23407
+ async function fireFleetAutoFallback(
23408
+ triggerAgent: string,
23409
+ untilMs?: number,
23410
+ parsedResetAt?: Date,
23411
+ trigger?: 'rate-limit',
23412
+ ): Promise<void> {
23207
23413
  return fleetFallbackGate.fire(
23208
- () => doFireFleetAutoFallback(triggerAgent, untilMs),
23414
+ () => doFireFleetAutoFallback(triggerAgent, untilMs, parsedResetAt, trigger),
23209
23415
  (err) => {
23210
23416
  process.stderr.write(
23211
23417
  `telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
@@ -23214,6 +23420,45 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
23214
23420
  )
23215
23421
  }
23216
23422
 
23423
+ // ─── 429 throttle tier — side-effect wiring ─────────────────────────────────
23424
+ // Decision + notice text live in throttle-tier.ts (pure); the SEQUENCING
23425
+ // (broker mark → fleet-deduped notice → jittered retry nudge with the
23426
+ // live-turn safety guards) lives in throttle-tier-wiring.ts so it is
23427
+ // unit-testable with injected deps. This block only binds the real gateway
23428
+ // dependencies.
23429
+ const throttleTierRunner = createThrottleTierRunner({
23430
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? '',
23431
+ getBrokerClient: () =>
23432
+ getAuthBrokerClient(process.env.SWITCHROOM_AGENT_NAME ?? ''),
23433
+ listNoticeChats: () => loadAccess().allowFrom,
23434
+ sendNotice: (chat_id, markdown) => {
23435
+ // Status notice, not the user's answer — silence the ping (same posture
23436
+ // as the fleet-fallback announcement).
23437
+ void swallowingApiCall(
23438
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
23439
+ () => bot.api.sendRichMessage(chat_id, richMessage(markdown), { disable_notification: true }),
23440
+ { chat_id: String(chat_id), verb: 'throttle-tier:notify' },
23441
+ )
23442
+ },
23443
+ resumeDecide: (ts) => fleetFallbackResumeGate.decide(ts),
23444
+ newestActiveTurnStartedAtMs,
23445
+ turnInFlight: () => turnInFlightForGate(),
23446
+ deferRestartToTurnComplete: (agentName, reason) => {
23447
+ // Same lever the schedule_restart IPC uses: the restart drains at the
23448
+ // turn-complete gate (never SIGTERM-now under a live turn). Note the
23449
+ // pending-restart drain cap (PENDING_RESTART_DRAIN_CAP_MS) still bounds
23450
+ // a never-idling session — deliberate, matching scheduled restarts.
23451
+ process.stderr.write(
23452
+ `telegram gateway: [throttle-tier] restart deferred to turn-complete agent=${agentName} reason=${reason}\n`,
23453
+ )
23454
+ pendingRestarts.set(agentName, Date.now())
23455
+ },
23456
+ restartNow: (agentName, reason) => {
23457
+ triggerSelfRestart(agentName, reason)
23458
+ },
23459
+ log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
23460
+ })
23461
+
23217
23462
  /**
23218
23463
  * Broadcast a fleet-fallback FAILURE notice to every authorized chat.
23219
23464
  *
@@ -23275,7 +23520,12 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
23275
23520
  * user-visible announcement was broadcast). False on no-op /
23276
23521
  * error / idempotent-skip — caller uses this to decide whether to
23277
23522
  * arm the post-fire suppression window. */
23278
- async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<boolean> {
23523
+ async function doFireFleetAutoFallback(
23524
+ triggerAgent: string,
23525
+ untilMs?: number,
23526
+ parsedResetAt?: Date,
23527
+ trigger?: 'rate-limit',
23528
+ ): Promise<boolean> {
23279
23529
  try {
23280
23530
  const client = await getAuthBrokerClient(triggerAgent)
23281
23531
  if (!client) {
@@ -23324,6 +23574,16 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
23324
23574
  },
23325
23575
  triggerAgent,
23326
23576
  tz,
23577
+ // Enriched card (429 throttle tier): the RAW reset parsed from the
23578
+ // error prose, so the announcement names when the old account frees
23579
+ // even when its live probe returned nothing.
23580
+ parsedResetAt,
23581
+ // Throttle-tier escalation: a terminal transient 429 with a long
23582
+ // parsed reset NEGATES the usage-limit reading, so the old account's
23583
+ // utilization probe typically classifies healthy — the idempotency
23584
+ // guard would self-cancel the swap ("probed healthy / Stale event?").
23585
+ // Trust the terminal parsed-reset signal over the utilization probe.
23586
+ rateLimitTrigger: trigger === 'rate-limit',
23327
23587
  })
23328
23588
  process.stderr.write(
23329
23589
  `telegram gateway: [fleet-fallback] outcome=${outcome.kind} agent=${triggerAgent}` +