switchroom 0.18.13 → 0.18.14

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 (42) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +111 -7
  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 +35 -6
  8. package/dist/host-control/main.js +1 -2
  9. package/dist/vault/approvals/kernel-server.js +0 -1
  10. package/dist/vault/broker/server.js +0 -1
  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 +2 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +620 -67
  20. package/telegram-plugin/dist/server.js +2 -0
  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 +224 -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 +20 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/stream-controller.ts +3 -2
  30. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  31. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  32. package/telegram-plugin/tests/history.test.ts +157 -0
  33. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  34. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  35. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  36. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  37. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  39. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  40. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  41. package/telegram-plugin/throttle-tier.ts +226 -0
  42. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -0,0 +1,226 @@
1
+ /**
2
+ * throttle-tier.ts — the 429 throttle tier (pure decision + notice rendering).
3
+ *
4
+ * Operator-approved behavior spec: "retry in place under 5 min, else mark +
5
+ * failover, honest reset messaging."
6
+ *
7
+ * A terminal TRANSIENT 429 (a `rate_limit_error` whose wording explicitly
8
+ * negates the account-quota reading — see `transientUpstreamSignals` in
9
+ * model-unavailable.ts) used to take the fully calm path: no broker state, no
10
+ * account attribution, a generic "🚦 Rate limited" card. That is right for a
11
+ * few-second burst but wrong for the minutes-long per-account throttles
12
+ * Anthropic emits with a "resets 8:50am (TZ)" hint: the fleet has no memory
13
+ * that the account is throttled (cron fires walk straight into the same 429)
14
+ * and the operator can't tell a throttle from a wall.
15
+ *
16
+ * This module owns the DECISION and the NOTICE TEXT; the gateway wires the
17
+ * side effects (broker `mark-throttled`, the Telegram send, the delayed
18
+ * retry nudge):
19
+ *
20
+ * - reset parseable and ≤ threshold (default 5 min) → `throttle`: stay on
21
+ * the account, record `throttled_until` broker-side, notify once.
22
+ * - reset parseable and > threshold → `failover`: escalate to the existing
23
+ * mark-exhausted + fleet-failover machinery with the parsed reset as the
24
+ * mark expiry.
25
+ * - reset unparseable → `throttle` with a conservative now+60s wait.
26
+ *
27
+ * Wall wording ("You've hit your limit", out_of_credits) never reaches this
28
+ * module — session-tail classifies those `quota-exhausted` and the gateway's
29
+ * existing failover path handles them unchanged.
30
+ *
31
+ * PURE — no IPC, no bot, no clock except the injected `now`.
32
+ */
33
+
34
+ import { escapeMarkdown } from './card-format.js'
35
+ import { formatResetRelative } from './quota-check.js'
36
+ import { parseResetTime } from './model-unavailable.js'
37
+
38
+ // ─── Account-scoped throttle wording ─────────────────────────────────────────
39
+
40
+ /**
41
+ * ACCOUNT-AFFIRMING subset of `transientUpstreamSignals` (model-unavailable.ts).
42
+ * The throttle tier takes ACCOUNT-level actions — broker `mark-throttled`,
43
+ * per-account notice, retry nudge — so it must key on wording that affirms the
44
+ * account's OWN rate limit ("would exceed your account's rate limit",
45
+ * "not your account…"), NOT the server-side phrasings ("Server is temporarily
46
+ * limiting requests (not your usage limit)", 529 overload wording). A
47
+ * server-wide condition recorded as an account throttle would bench the wrong
48
+ * thing and restart-nudge an agent straight back into the same server-side
49
+ * wall. Server-side transients keep the existing calm rate-limited path
50
+ * (Claude Code's internal retry) untouched.
51
+ */
52
+ export const accountScopedThrottleSignals = [
53
+ "would exceed your account's rate limit",
54
+ 'would exceed your account’s rate limit',
55
+ // Covers both "not your account" and "not your account's" (substring).
56
+ 'not your account',
57
+ ]
58
+
59
+ /** True when `text` carries an EXPLICIT account-affirming throttle marker.
60
+ * Never throws on weird input. */
61
+ export function isAccountScopedThrottle(text: string): boolean {
62
+ if (typeof text !== 'string' || text.length === 0) return false
63
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
64
+ const lower = sample.toLowerCase()
65
+ return accountScopedThrottleSignals.some((s) => lower.includes(s))
66
+ }
67
+
68
+ /**
69
+ * Default retry-in-place ceiling: a transient 429 whose reset is within this
70
+ * window waits on the account instead of failing the fleet over. Override
71
+ * with SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS.
72
+ */
73
+ export const THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT = 5 * 60_000
74
+
75
+ /**
76
+ * Wait applied when a transient 429 carries NO parseable reset. Anthropic's
77
+ * burst throttles clear in seconds; 60s is comfortably past the common case
78
+ * without benching the account for long on a misread.
79
+ */
80
+ export const THROTTLE_DEFAULT_WAIT_MS = 60_000
81
+
82
+ /** Resolve the retry-in-place threshold from env (ms), else the default. */
83
+ export function throttleRetryInPlaceMaxMs(
84
+ env: NodeJS.ProcessEnv = process.env,
85
+ ): number {
86
+ const raw = env.SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS
87
+ if (raw == null || raw === '') return THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
88
+ const n = Number(raw)
89
+ return Number.isFinite(n) && n > 0 ? n : THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
90
+ }
91
+
92
+ export type ThrottleTierDecision =
93
+ /** Not a transient per-account 429 — caller falls through to its
94
+ * existing handling (calm card / quota path). */
95
+ | { action: 'none' }
96
+ /** Stay on the account; record throttled_until broker-side; notify once. */
97
+ | { action: 'throttle'; throttledUntilMs: number; resetParsed: boolean }
98
+ /** Reset is beyond the retry-in-place threshold — escalate to the
99
+ * existing mark-exhausted + fleet-failover machinery. */
100
+ | { action: 'failover'; resetAtMs: number }
101
+
102
+ /**
103
+ * THE throttle-tier decision for a terminal `rate-limited` operator event.
104
+ * `detail` is the user-facing error text from the transcript (the source of
105
+ * both the transient-negation wording and the "resets …" prose).
106
+ */
107
+ export function decideThrottleTier(opts: {
108
+ detail: string
109
+ now: number
110
+ /** Retry-in-place ceiling (ms). Callers pass throttleRetryInPlaceMaxMs(). */
111
+ thresholdMs: number
112
+ }): ThrottleTierDecision {
113
+ const { detail, now, thresholdMs } = opts
114
+ // Account-affirming wording ONLY — the wider transient list includes
115
+ // server-side phrasings (529 / "server is temporarily limiting requests")
116
+ // for which an account-scoped throttle would be the wrong action.
117
+ if (!isAccountScopedThrottle(detail)) return { action: 'none' }
118
+ const resetAt = parseResetTime(detail, new Date(now))
119
+ const resetAtMs = resetAt?.getTime()
120
+ if (resetAtMs == null || !Number.isFinite(resetAtMs) || resetAtMs <= now) {
121
+ // No usable reset — wait the conservative default in place.
122
+ return {
123
+ action: 'throttle',
124
+ throttledUntilMs: now + THROTTLE_DEFAULT_WAIT_MS,
125
+ resetParsed: false,
126
+ }
127
+ }
128
+ if (resetAtMs - now <= thresholdMs) {
129
+ return { action: 'throttle', throttledUntilMs: resetAtMs, resetParsed: true }
130
+ }
131
+ return { action: 'failover', resetAtMs }
132
+ }
133
+
134
+ // ─── Per-account notice cooldown ─────────────────────────────────────────────
135
+
136
+ /**
137
+ * Cooldown for the lightweight throttle notice, PER ACCOUNT. A burst of
138
+ * transient 429s (session-tail forwards every terminal error line) must
139
+ * produce ONE notice, not a stream — same shape and rationale as the
140
+ * failure-notice / all-blocked cooldowns in auto-fallback-fleet.ts. Keyed by
141
+ * account (not agent): the throttle is account-scoped, so every agent riding
142
+ * the same throttled account shares one window.
143
+ */
144
+ export const THROTTLE_NOTICE_COOLDOWN_MS = 10 * 60_000
145
+
146
+ export interface ThrottleNoticeState {
147
+ /** account label → unix ms of the last notice sent for it. */
148
+ lastSentAtMsByAccount: Record<string, number>
149
+ }
150
+
151
+ export function evaluateThrottleNotice(
152
+ prev: ThrottleNoticeState,
153
+ account: string,
154
+ now: number,
155
+ cooldownMs: number = THROTTLE_NOTICE_COOLDOWN_MS,
156
+ ): { send: boolean; next: ThrottleNoticeState } {
157
+ const last = prev.lastSentAtMsByAccount[account] ?? 0
158
+ if (now - last >= cooldownMs) {
159
+ return {
160
+ send: true,
161
+ next: {
162
+ lastSentAtMsByAccount: {
163
+ ...prev.lastSentAtMsByAccount,
164
+ [account]: now,
165
+ },
166
+ },
167
+ }
168
+ }
169
+ return { send: false, next: prev }
170
+ }
171
+
172
+ // ─── Notice rendering ────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * The ONE lightweight operator notice for the throttle path. Deliberately not
176
+ * the ⚠️ model-unavailable card: nothing is exhausted and nothing failed
177
+ * over — the point is to say so, name the account, and name the reset.
178
+ */
179
+ export function renderThrottleNotice(opts: {
180
+ /** Account label from the broker's mark-throttled response; null when the
181
+ * broker was unreachable and the account could not be attributed. */
182
+ account: string | null
183
+ agent: string
184
+ throttledUntilMs: number
185
+ /** True when the reset came from parsed "resets …" prose (vs the 60s default). */
186
+ resetParsed: boolean
187
+ now?: Date
188
+ }): string {
189
+ const now = opts.now ?? new Date()
190
+ // "resets in 3m" — same countdown dialect /usage speaks.
191
+ const resetStr = formatResetRelative(new Date(opts.throttledUntilMs), now)
192
+ const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
193
+ const lines = [
194
+ `🚦 **Rate-limited, staying put** — ${acct} hit a transient rate limit on **${escapeMarkdown(opts.agent)}**.`,
195
+ `This is a short throttle, not a quota wall — ${
196
+ opts.resetParsed ? resetStr : `no reset given, retrying in ~60s`
197
+ }.`,
198
+ `_Staying on ${acct}; no failover needed. The turn retries automatically after the reset._`,
199
+ ]
200
+ return lines.join('\n')
201
+ }
202
+
203
+ /**
204
+ * Notice for the broker's ESCALATION outcome: repeated transient 429s on one
205
+ * account were corroborated by a live probe as a genuine wall, so the broker
206
+ * ran the standard mark-exhausted + roll. The gateway that raised the
207
+ * mark-throttled announces it (the reactive-path doctrine — same reason the
208
+ * plain mark-exhausted path announces gateway-side rather than via
209
+ * `last_fleet_roll`), which also covers PINNED (non-fleet-active) accounts.
210
+ */
211
+ export function renderThrottleEscalationNotice(opts: {
212
+ account: string | null
213
+ agent: string
214
+ /** The account the fleet/agents rolled to; null = every fallback blocked. */
215
+ rolledTo: string | null
216
+ }): string {
217
+ const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
218
+ const head =
219
+ `⛔️ **Rate limit was actually a wall** — repeated 429s on ${acct} ` +
220
+ `(trigger: **${escapeMarkdown(opts.agent)}**) were corroborated by a live quota probe.`
221
+ const tail = opts.rolledTo
222
+ ? `Marked exhausted and rolled to \`${escapeMarkdown(opts.rolledTo)}\`.`
223
+ : `Marked exhausted — no fallback account had quota (all blocked). ` +
224
+ `Use \`/auth add <label>\` to attach another subscription.`
225
+ return `${head}\n${tail}`
226
+ }
@@ -61,13 +61,14 @@ import { richRenderEnabled } from "../../render/rich-render.js";
61
61
  const AGENT = "test-harness";
62
62
 
63
63
  // The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
64
- // gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
65
- // collapsible round-trip proof below only runs when BOTH the driver creds AND
66
- // the flag are present the flag must be set in the gateway process under
67
- // test for the renderer to actually shape the outbound message, so gating the
68
- // scenario on the same flag keeps it honest (no false green when the wiring
69
- // isn't live). When the flag is off, this scenario self-skips green exactly
70
- // like the credential-less case.
64
+ // ON BY DEFAULT, with `SWITCHROOM_RICH_RENDER=0` as the escape hatch. The
65
+ // expandable / collapsible round-trip proof below only runs when the driver
66
+ // creds are present AND the renderer hasn't been killed off the same
67
+ // setting must hold in the gateway process under test for the renderer to
68
+ // actually shape the outbound message, so gating the scenario on the same
69
+ // check keeps it honest (no false green when the wiring isn't live). When
70
+ // the kill-switch is set, this scenario self-skips green exactly like the
71
+ // credential-less case.
71
72
  const RICH_RENDER_ON = richRenderEnabled();
72
73
 
73
74
  // The driver session is the load-bearing credential. Absent it, spinUp()