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
@@ -24574,6 +24574,8 @@ var init_bridge = __esm(async () => {
24574
24574
  "",
24575
24575
  'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24576
24576
  "",
24577
+ `If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
24578
+ "",
24577
24579
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24578
24580
  "",
24579
24581
  "If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
@@ -28,6 +28,7 @@ export function createAuthBrokerClient(): {
28
28
  listState: () => broker.listState(),
29
29
  setActive: (label: string) => broker.setActive(label),
30
30
  markExhausted: (until?: number) => broker.markExhausted(until),
31
+ markThrottled: (until: number) => broker.markThrottled(until),
31
32
  rmAccount: (label: string) => broker.rmAccount(label),
32
33
  refreshAccount: (label: string) => broker.refreshAccount(label),
33
34
  setOverride: (agent: string, account: string | null) =>
@@ -301,6 +301,20 @@ export interface AuthBrokerClient {
301
301
  * identity — so auto-fallback works from any agent.
302
302
  */
303
303
  markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
304
+ /**
305
+ * 429 throttle tier (broker `mark-throttled`). Records a transient
306
+ * per-account rate limit on the CALLER's own account — `throttled_until`
307
+ * in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
308
+ * eligibility. `escalated` is true when the broker's escalation guard
309
+ * (repeated hits corroborated by a live probe) converted it into the
310
+ * standard mark-exhausted + roll; `rolledTo` names the roll target then.
311
+ */
312
+ markThrottled(until: number): Promise<{
313
+ account: string
314
+ throttled_until: number
315
+ escalated: boolean
316
+ rolledTo?: string | null
317
+ }>
304
318
  rmAccount(label: string): Promise<{ label: string }>
305
319
  refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
306
320
  setOverride(
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Pure helpers for forwarded-message origin metadata — kept out of
3
+ * `gateway.ts` (same pattern as `coalesce-attachments.ts`) so the parsing,
4
+ * escaping, and numbered-sibling logic can be unit-tested without the
5
+ * gateway's `loadAccess()` / IPC machinery.
6
+ *
7
+ * When a user forwards a message to the bot, Telegram stamps
8
+ * `message.forward_origin` SERVER-side (Bot API 7.0+). Unlike the message
9
+ * body — which the forwarding user fully controls — the origin record
10
+ * cannot be forged by typing, so it is the trusted lane for "who
11
+ * originally sent this". The gateway surfaces it to the agent as
12
+ * `forwarded_*` channel-meta ATTRIBUTES only; origin info is never
13
+ * injected into the body text, keeping the attacker-influenceable body
14
+ * and the server-stamped provenance on separate lanes.
15
+ *
16
+ * Caveat surfaced via `forwarded_from_type="hidden_user"`: a sender who
17
+ * enabled forward privacy yields only a self-reported display name with
18
+ * NO verifiable id — agents can see the name but must not treat it as an
19
+ * authenticated identity.
20
+ */
21
+
22
+ import type { MessageOrigin } from 'grammy/types'
23
+ import { escapeXmlAttribute } from '../steering.js'
24
+
25
+ /**
26
+ * Cap on the human-readable origin name/title. Names and titles are
27
+ * attacker-controlled (a hostile account can set a 4KB display name);
28
+ * mirror the REPLY_TO_TEXT_MAX truncate-then-escape pattern with a cap
29
+ * sized for names rather than message previews.
30
+ */
31
+ export const FORWARDED_FROM_NAME_MAX = 100
32
+
33
+ export type ForwardOriginType = 'user' | 'hidden_user' | 'chat' | 'channel'
34
+
35
+ /**
36
+ * Normalized origin record. `name` is RAW (truncated but unescaped) so it
37
+ * can be persisted verbatim to the SQLite history buffer — the XML
38
+ * escaping happens at the channel-meta boundary in
39
+ * `buildForwardOriginMeta`, same split as `replyToText` /
40
+ * `replyToTextEscaped` in the gateway inbound handler.
41
+ */
42
+ export interface ForwardOriginInfo {
43
+ /** Human-readable name/title of the original sender (raw, truncated). */
44
+ name: string
45
+ type: ForwardOriginType
46
+ /** Numeric id when the origin shape exposes one (user id / chat id). */
47
+ id?: number
48
+ /** Unix seconds of the original message (`forward_origin.date`). */
49
+ date?: number
50
+ /** Channel origins only: the message id inside the origin channel. */
51
+ messageId?: number
52
+ }
53
+
54
+ function truncateName(name: string): string {
55
+ return name.length > FORWARDED_FROM_NAME_MAX
56
+ ? name.slice(0, FORWARDED_FROM_NAME_MAX - 1) + '…'
57
+ : name
58
+ }
59
+
60
+ /** Join first/last name plus a trailing `(@username)` when present. */
61
+ function personName(parts: {
62
+ first_name?: string
63
+ last_name?: string
64
+ username?: string
65
+ }): string {
66
+ const name = [parts.first_name, parts.last_name]
67
+ .filter((p): p is string => typeof p === 'string' && p.length > 0)
68
+ .join(' ')
69
+ const handle = parts.username ? `(@${parts.username})` : ''
70
+ return [name, handle].filter((p) => p.length > 0).join(' ')
71
+ }
72
+
73
+ /** Chat/channel title plus a trailing `(@username)` when present. */
74
+ function chatTitle(chat: {
75
+ title?: string
76
+ username?: string
77
+ first_name?: string
78
+ last_name?: string
79
+ }): string {
80
+ // Defensive: `Chat` is a union — groups/channels carry `title`, a
81
+ // private chat carries first/last name instead. Fall through so a
82
+ // malformed/unexpected shape still yields whatever name exists.
83
+ if (chat.title && chat.title.length > 0) {
84
+ const handle = chat.username ? `(@${chat.username})` : ''
85
+ return [chat.title, handle].filter((p) => p.length > 0).join(' ')
86
+ }
87
+ return personName(chat)
88
+ }
89
+
90
+ /**
91
+ * Parse Telegram's `message.forward_origin` into a normalized record.
92
+ * Returns `undefined` for non-forwarded messages, unknown origin types,
93
+ * and records so malformed that no human-readable name can be recovered
94
+ * (the attrs are context, not a gate — degrading to "no origin metadata"
95
+ * is safe; inventing a name is not).
96
+ *
97
+ * Accepts `unknown`-ish input defensively: the origin arrives from the
98
+ * Telegram wire and future Bot API versions may add origin types this
99
+ * build doesn't know.
100
+ */
101
+ export function parseForwardOrigin(
102
+ origin: MessageOrigin | undefined,
103
+ ): ForwardOriginInfo | undefined {
104
+ if (origin == null || typeof origin !== 'object') return undefined
105
+ const date = typeof origin.date === 'number' ? origin.date : undefined
106
+ switch (origin.type) {
107
+ case 'user': {
108
+ const u = origin.sender_user
109
+ if (u == null || typeof u !== 'object') return undefined
110
+ const name = personName(u)
111
+ if (name.length === 0) {
112
+ // No printable name at all — fall back to the (unforgeable) id.
113
+ if (typeof u.id !== 'number') return undefined
114
+ return { name: String(u.id), type: 'user', id: u.id, date }
115
+ }
116
+ return {
117
+ name: truncateName(name),
118
+ type: 'user',
119
+ ...(typeof u.id === 'number' ? { id: u.id } : {}),
120
+ date,
121
+ }
122
+ }
123
+ case 'hidden_user': {
124
+ // Forward-privacy senders: `sender_user_name` is a SELF-REPORTED
125
+ // display name with no verifiable id. The `hidden_user` type marker
126
+ // is the agent's signal to treat the name as unauthenticated.
127
+ const name = typeof origin.sender_user_name === 'string'
128
+ ? origin.sender_user_name
129
+ : ''
130
+ if (name.length === 0) return undefined
131
+ return { name: truncateName(name), type: 'hidden_user', date }
132
+ }
133
+ case 'chat': {
134
+ const c = origin.sender_chat
135
+ if (c == null || typeof c !== 'object') return undefined
136
+ const name = chatTitle(c)
137
+ if (name.length === 0) {
138
+ if (typeof c.id !== 'number') return undefined
139
+ return { name: String(c.id), type: 'chat', id: c.id, date }
140
+ }
141
+ return {
142
+ name: truncateName(name),
143
+ type: 'chat',
144
+ ...(typeof c.id === 'number' ? { id: c.id } : {}),
145
+ date,
146
+ }
147
+ }
148
+ case 'channel': {
149
+ const c = origin.chat
150
+ if (c == null || typeof c !== 'object') return undefined
151
+ const name = chatTitle(c)
152
+ const messageId = typeof origin.message_id === 'number' ? origin.message_id : undefined
153
+ if (name.length === 0) {
154
+ if (typeof c.id !== 'number') return undefined
155
+ return { name: String(c.id), type: 'channel', id: c.id, date, messageId }
156
+ }
157
+ return {
158
+ name: truncateName(name),
159
+ type: 'channel',
160
+ ...(typeof c.id === 'number' ? { id: c.id } : {}),
161
+ date,
162
+ messageId,
163
+ }
164
+ }
165
+ default:
166
+ // Unknown future origin type — no metadata beats wrong metadata.
167
+ return undefined
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Identity key for burst dedup: same-typed origins with the same id are
173
+ * one origin; id-less shapes (hidden_user) fall back to the name.
174
+ */
175
+ export function forwardOriginKey(o: ForwardOriginInfo): string {
176
+ return o.id != null ? `${o.type}:${o.id}` : `${o.type}:${o.name}`
177
+ }
178
+
179
+ /**
180
+ * Collapse the per-message origins of a coalesced burst into the DISTINCT
181
+ * origins in arrival order. A 10-part album forwarded from one channel
182
+ * yields ONE origin (attrs emitted once); a burst mixing forwards from two
183
+ * different senders yields two (the second gets `forwarded_from_2` etc.).
184
+ * Non-forwarded entries (`undefined`) are skipped. First occurrence wins,
185
+ * so the emitted `forwarded_date` is the first message's origin date.
186
+ */
187
+ export function dedupeForwardOrigins(
188
+ origins: Array<ForwardOriginInfo | undefined>,
189
+ ): ForwardOriginInfo[] {
190
+ const seen = new Set<string>()
191
+ const out: ForwardOriginInfo[] = []
192
+ for (const o of origins) {
193
+ if (o == null) continue
194
+ const key = forwardOriginKey(o)
195
+ if (seen.has(key)) continue
196
+ seen.add(key)
197
+ out.push(o)
198
+ }
199
+ return out
200
+ }
201
+
202
+ /**
203
+ * Build the `forwarded_*` channel-meta fields. Fixed per-origin attribute
204
+ * order (documented here, tested in forward-origin.test.ts):
205
+ * forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date
206
+ * The primary field is the human-readable NAME; the numeric id is
207
+ * supplementary and follows it. The first origin gets the bare keys;
208
+ * subsequent distinct origins get `_2`, `_3`, … suffixes — the same
209
+ * numbered-sibling convention as `image_path_2` / `attachment_file_id_2`.
210
+ *
211
+ * Names/titles are attacker-controlled, so every value that can carry
212
+ * user text goes through `escapeXmlAttribute` (the same escaper
213
+ * `formatReplyToText` uses) before landing in the channel tag.
214
+ */
215
+ export function buildForwardOriginMeta(
216
+ origins: ForwardOriginInfo[],
217
+ ): Record<string, string> {
218
+ const out: Record<string, string> = {}
219
+ origins.forEach((o, i) => {
220
+ const suffix = i === 0 ? '' : `_${i + 1}`
221
+ out[`forwarded_from${suffix}`] = escapeXmlAttribute(truncateName(o.name))
222
+ out[`forwarded_from_type${suffix}`] = o.type
223
+ if (o.id != null) out[`forwarded_from_id${suffix}`] = String(o.id)
224
+ if (o.date != null) {
225
+ out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString()
226
+ }
227
+ })
228
+ return out
229
+ }
230
+
231
+ /** ISO form of an origin date for the SQLite history record. */
232
+ export function forwardOriginDateIso(o: ForwardOriginInfo | undefined): string | null {
233
+ if (o?.date == null) return null
234
+ return new Date(o.date * 1000).toISOString()
235
+ }
@@ -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,14 @@ 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
+ decideThrottleTier,
324
+ isAccountScopedThrottle,
325
+ throttleRetryInPlaceMaxMs,
326
+ } from '../throttle-tier.js'
327
+ import { createThrottleTierRunner } from './throttle-tier-wiring.js'
314
328
  import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
315
329
  import { startRestartWatchdog } from './restart-watchdog.js'
316
330
  import { validateStringArray } from './access-validator.js'
@@ -7587,6 +7601,14 @@ type CoalescePayload = {
7587
7601
  attachment?: AttachmentMeta
7588
7602
  // Set only by `merge`: the 2nd..Nth attachments folded into this turn.
7589
7603
  extraAttachments?: CoalesceAttachment[]
7604
+ // Forwarded-message origin of THIS entry (parsed at enqueue time from
7605
+ // message.forward_origin). merged.ctx is the LAST entry's ctx, so a
7606
+ // coalesced burst would otherwise lose the earlier entries' origins —
7607
+ // each entry carries its own.
7608
+ forwardOrigin?: ForwardOriginInfo
7609
+ // Set only by `merge`: the distinct origins of the whole burst, arrival
7610
+ // order, deduped (a single-origin album collapses to one entry).
7611
+ forwardOrigins?: ForwardOriginInfo[]
7590
7612
  }
7591
7613
 
7592
7614
  // Count of attachment-bearing entries currently buffered per coalesce key.
@@ -7619,6 +7641,11 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7619
7641
  (e) => e.downloadImage != null || e.attachment != null,
7620
7642
  coalesceMaxAttachments(),
7621
7643
  )
7644
+ // Distinct forwarded-message origins across the burst, arrival order.
7645
+ // A forwarded album (one origin, N parts) collapses to a single entry
7646
+ // so the attrs are emitted once; a burst forwarded from two different
7647
+ // senders keeps both (numbered forwarded_from_2 siblings downstream).
7648
+ const forwardOrigins = dedupeForwardOrigins(entries.map((e) => e.forwardOrigin))
7622
7649
  return {
7623
7650
  // Drop empty texts (e.g. caption-less album parts) so the join doesn't
7624
7651
  // emit blank lines between attachments.
@@ -7629,6 +7656,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7629
7656
  extraAttachments: extras.length > 0
7630
7657
  ? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
7631
7658
  : undefined,
7659
+ forwardOrigins: forwardOrigins.length > 0 ? forwardOrigins : undefined,
7632
7660
  }
7633
7661
  },
7634
7662
  onFlush: (key, merged) => {
@@ -7639,6 +7667,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7639
7667
  merged.downloadImage,
7640
7668
  merged.attachment,
7641
7669
  merged.extraAttachments,
7670
+ merged.forwardOrigins,
7642
7671
  )
7643
7672
  },
7644
7673
  })
@@ -7659,6 +7688,72 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7659
7688
  function emitGatewayOperatorEvent(event: OperatorEvent): void {
7660
7689
  const { agent, kind } = event
7661
7690
 
7691
+ // ── 429 throttle tier (operator spec: "retry in place under 5 min, else
7692
+ // mark + failover, honest reset messaging") ────────────────────────────
7693
+ // A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
7694
+ // wording that affirms the account's own rate limit ("would exceed your
7695
+ // account's rate limit") — is decided BEFORE the per-kind cooldown gate:
7696
+ // every hit must reach the broker's mark-throttled escalation counter, and
7697
+ // the >threshold failover ACTION must fire, even when the user-facing card
7698
+ // is cooldown-suppressed (a calm 529 card two minutes earlier arms that
7699
+ // cooldown). Server-side transients (529 / "server is temporarily…") fall
7700
+ // through to the existing calm rate-limited card unchanged — an
7701
+ // account-scoped throttle would be the wrong action for a server-wide
7702
+ // condition.
7703
+ let throttleEscalation: ModelUnavailableDetection | null = null
7704
+ let escalationFired = false
7705
+ if (kind === 'rate-limited' && isAccountScopedThrottle(event.detail)) {
7706
+ const throttleDecision = decideThrottleTier({
7707
+ detail: event.detail,
7708
+ now: Date.now(),
7709
+ thresholdMs: throttleRetryInPlaceMaxMs(),
7710
+ })
7711
+ if (throttleDecision.action === 'throttle') {
7712
+ // Reset is near (≤ threshold) or unparseable (60s default): DO NOT
7713
+ // fail over. Record throttled_until broker-side, post ONE lightweight
7714
+ // notice (per-account + fleet-wide dedup), nudge a retry after the
7715
+ // reset. The runner owns the sequencing (throttle-tier-wiring.ts).
7716
+ process.stderr.write(
7717
+ `telegram gateway: throttle-tier staying-put agent=${agent} ` +
7718
+ `until=${new Date(throttleDecision.throttledUntilMs).toISOString()} ` +
7719
+ `parsedReset=${throttleDecision.resetParsed}\n`,
7720
+ )
7721
+ try {
7722
+ recordOperatorEvent(event)
7723
+ } catch { /* history is best-effort */ }
7724
+ void throttleTierRunner.fire(
7725
+ agent,
7726
+ throttleDecision.throttledUntilMs,
7727
+ throttleDecision.resetParsed,
7728
+ )
7729
+ return
7730
+ }
7731
+ if (throttleDecision.action === 'failover') {
7732
+ // Reset beyond the retry-in-place threshold: the account is benched
7733
+ // until then anyway, so escalate to the standard mark-exhausted +
7734
+ // fleet-failover machinery — honest 'rate_limited' wording, parsed
7735
+ // reset as the mark expiry. The ACTION fires HERE, before the
7736
+ // per-kind card cooldown below can swallow it (the fleetFallbackGate
7737
+ // provides the action-level dedup); only the card stays
7738
+ // cooldown-bounded. The 'rate-limit' trigger tells the dispatcher to
7739
+ // trust this terminal parsed-reset signal over the utilization probe
7740
+ // (a transient 429 negates the usage-limit reading, so utilization
7741
+ // typically looks healthy — the idempotency guard would self-cancel
7742
+ // the swap).
7743
+ const resetAt = new Date(throttleDecision.resetAtMs)
7744
+ throttleEscalation = { kind: 'rate_limited', resetAt, raw: event.detail }
7745
+ if (wouldFireFleetAutoFallback()) {
7746
+ escalationFired = true
7747
+ void fireFleetAutoFallback(
7748
+ agent,
7749
+ resolveExhaustUntil(resetAt.getTime()),
7750
+ resetAt,
7751
+ 'rate-limit',
7752
+ )
7753
+ }
7754
+ }
7755
+ }
7756
+
7662
7757
  if (!shouldEmitOperatorEvent(agent, kind)) {
7663
7758
  process.stderr.write(
7664
7759
  `telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
@@ -7687,7 +7782,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7687
7782
  // by the upstream classifier
7688
7783
  // - detail string contains one of the model-unavailable text patterns
7689
7784
  // (covers raw stderr that slipped past structured classification)
7690
- const modelUnavailable = resolveModelUnavailableFromOperatorEvent(event)
7785
+ // Throttle-tier escalation (long-reset transient 429) takes precedence:
7786
+ // resolveModelUnavailableFromOperatorEvent deliberately returns null for a
7787
+ // bare rate-limited event, but this one is auto-fallback-eligible.
7788
+ const modelUnavailable = throttleEscalation ?? resolveModelUnavailableFromOperatorEvent(event)
7691
7789
  let renderedText: string
7692
7790
  let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
7693
7791
  // #3031 PR 3 — when the card promises an in-flight auto-failover, record
@@ -7709,8 +7807,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7709
7807
  // it just produces a self-cancelling "probed healthy / Stale event?"
7710
7808
  // loop on every 529. Overload is handled by Claude Code's own
7711
7809
  // internal retry, not by switching accounts.
7712
- const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
7713
- const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback()
7810
+ // 'rate_limited' is the throttle-tier escalation (transient 429 whose
7811
+ // reset exceeds the retry-in-place threshold) — same failover mechanics
7812
+ // as a quota wall, honest wording on the card. Its fire already happened
7813
+ // ABOVE the cooldown gate (escalationFired) so the action can't be
7814
+ // swallowed by the per-kind card cooldown; the card here only reports it.
7815
+ const isAutoKind =
7816
+ modelUnavailable.kind === 'quota_exhausted' ||
7817
+ modelUnavailable.kind === 'rate_limited'
7818
+ const willActuallyFire =
7819
+ throttleEscalation != null
7820
+ ? escalationFired
7821
+ : isAutoKind && wouldFireFleetAutoFallback()
7714
7822
  process.stderr.write(
7715
7823
  `telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
7716
7824
  )
@@ -7734,9 +7842,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7734
7842
  // Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
7735
7843
  // weekly wall that surfaced as a 429 got markExhausted's ~5h default and
7736
7844
  // the broker re-mirrored the still-walled account onto the fleet after 5h.
7737
- if (willActuallyFire) {
7845
+ // Throttle-tier escalations (throttleEscalation != null) already fired
7846
+ // above the cooldown gate — don't double-fire here.
7847
+ if (willActuallyFire && throttleEscalation == null) {
7738
7848
  const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
7739
- void fireFleetAutoFallback(agent, untilMs)
7849
+ // Thread the RAW parsed reset (distinct from the floor-applied untilMs)
7850
+ // so the fallback announcement can name it even when the live probe of
7851
+ // the old account fails — honest reset messaging on the enriched card.
7852
+ void fireFleetAutoFallback(agent, untilMs, modelUnavailable.resetAt)
7740
7853
  }
7741
7854
  } else {
7742
7855
  try {
@@ -11374,7 +11487,13 @@ const ipcServer: IpcServer = createIpcServer({
11374
11487
  (msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
11375
11488
  ' — triggering fleet auto-fallback\n',
11376
11489
  )
11377
- void fireFleetAutoFallback(msg.agentName, untilMs)
11490
+ void fireFleetAutoFallback(
11491
+ msg.agentName,
11492
+ untilMs,
11493
+ // Enriched announcement: the sidecar-parsed reset (when present) keeps
11494
+ // the recovery line honest even when the old account's probe fails.
11495
+ msg.resetAt != null ? new Date(msg.resetAt) : undefined,
11496
+ )
11378
11497
  },
11379
11498
 
11380
11499
  // Issue #2971 — read-only wedge-watchdog probe: is there a live pending
@@ -18070,7 +18189,15 @@ async function handleInboundCoalesced(
18070
18189
  ctx.message?.message_thread_id,
18071
18190
  String(from.id),
18072
18191
  )
18073
- const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment })
18192
+ const result = inboundCoalescer.enqueue(key, {
18193
+ text,
18194
+ ctx,
18195
+ downloadImage,
18196
+ attachment,
18197
+ // Parsed HERE (not in merge) because merge only sees the last ctx —
18198
+ // each buffered entry keeps its own forwarded-message origin.
18199
+ forwardOrigin: parseForwardOrigin(ctx.message?.forward_origin),
18200
+ })
18074
18201
  // Coalescing disabled (window <= 0): flush immediately, preserving any
18075
18202
  // media this message carried.
18076
18203
  if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
@@ -18149,6 +18276,11 @@ async function handleInbound(
18149
18276
  // resolved (photos downloaded) and surfaced as numbered meta fields
18150
18277
  // (image_path_2, attachment_file_id_2, …) alongside the primary.
18151
18278
  extraAttachments?: CoalesceAttachment[],
18279
+ // Forwarded-message origins for a coalesced burst (deduped, arrival
18280
+ // order — see the coalescer's merge). Left undefined by the direct /
18281
+ // bypass call sites; handleInbound then parses THIS ctx's
18282
+ // forward_origin itself, so single-message forwards work on every path.
18283
+ coalescedForwardOrigins?: ForwardOriginInfo[],
18152
18284
  ): Promise<void> {
18153
18285
  markIdleActivity() // any inbound resets the idle auto-clear timer + re-arms
18154
18286
  const isTopicMessage = ctx.message?.is_topic_message ?? false
@@ -19367,6 +19499,19 @@ async function handleInbound(
19367
19499
  : undefined
19368
19500
  const replyToTextEscaped = formatReplyToText(replyToTextRaw, REPLY_TO_TEXT_MAX)
19369
19501
 
19502
+ // Forwarded-message origin context. `forward_origin` is stamped by
19503
+ // Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
19504
+ // it via the message body, so it rides the trusted attrs lane and is
19505
+ // NEVER folded into the body text. Coalesced bursts pass their deduped
19506
+ // per-entry origins; direct/bypass paths parse this ctx's own origin.
19507
+ // Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
19508
+ // raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
19509
+ // the channel-meta boundary.
19510
+ const forwardOrigins = coalescedForwardOrigins
19511
+ ?? dedupeForwardOrigins([parseForwardOrigin(ctx.message?.forward_origin)])
19512
+ const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
19513
+ const primaryForwardOrigin = forwardOrigins[0]
19514
+
19370
19515
  if (HISTORY_ENABLED) {
19371
19516
  try {
19372
19517
  recordInbound({
@@ -19380,6 +19525,11 @@ async function handleInbound(
19380
19525
  attachment_kind: attachment?.kind,
19381
19526
  reply_to_message_id: replyToMessageId ?? null,
19382
19527
  reply_to_text: replyToText ?? null,
19528
+ forwarded_from: primaryForwardOrigin?.name ?? null,
19529
+ forwarded_from_type: primaryForwardOrigin?.type ?? null,
19530
+ forwarded_from_id: primaryForwardOrigin?.id != null ? String(primaryForwardOrigin.id) : null,
19531
+ forwarded_date: forwardOriginDateIso(primaryForwardOrigin),
19532
+ forwarded_message_id: primaryForwardOrigin?.messageId ?? null,
19383
19533
  })
19384
19534
  } catch (err) {
19385
19535
  process.stderr.write(`telegram gateway: history recordInbound failed: ${err}\n`)
@@ -19485,6 +19635,11 @@ async function handleInbound(
19485
19635
  // Use the XML-escaped form for the meta — the raw form is in the
19486
19636
  // SQLite buffer for verbatim retrieval via get_recent_messages.
19487
19637
  ...(replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {}),
19638
+ // Forwarded-message origin (server-stamped, attrs-only — see above).
19639
+ // forwarded_from / forwarded_from_type / forwarded_from_id /
19640
+ // forwarded_date, plus numbered _2.. siblings for a multi-origin
19641
+ // burst. Names are XML-escaped inside buildForwardOriginMeta.
19642
+ ...forwardOriginMeta,
19488
19643
  // queued="true" when mid-turn with no steer prefix (new default), or
19489
19644
  // with explicit /queue or /q prefix (legacy alias).
19490
19645
  ...((isQueuedMidTurn || isQueuedPrefix) ? { queued: 'true' } : {}),
@@ -23203,9 +23358,14 @@ function wouldFireFleetAutoFallback(): boolean {
23203
23358
  * so the user sees the outcome inline with the original "Model
23204
23359
  * unavailable" card.
23205
23360
  */
23206
- async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<void> {
23361
+ async function fireFleetAutoFallback(
23362
+ triggerAgent: string,
23363
+ untilMs?: number,
23364
+ parsedResetAt?: Date,
23365
+ trigger?: 'rate-limit',
23366
+ ): Promise<void> {
23207
23367
  return fleetFallbackGate.fire(
23208
- () => doFireFleetAutoFallback(triggerAgent, untilMs),
23368
+ () => doFireFleetAutoFallback(triggerAgent, untilMs, parsedResetAt, trigger),
23209
23369
  (err) => {
23210
23370
  process.stderr.write(
23211
23371
  `telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
@@ -23214,6 +23374,45 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
23214
23374
  )
23215
23375
  }
23216
23376
 
23377
+ // ─── 429 throttle tier — side-effect wiring ─────────────────────────────────
23378
+ // Decision + notice text live in throttle-tier.ts (pure); the SEQUENCING
23379
+ // (broker mark → fleet-deduped notice → jittered retry nudge with the
23380
+ // live-turn safety guards) lives in throttle-tier-wiring.ts so it is
23381
+ // unit-testable with injected deps. This block only binds the real gateway
23382
+ // dependencies.
23383
+ const throttleTierRunner = createThrottleTierRunner({
23384
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? '',
23385
+ getBrokerClient: () =>
23386
+ getAuthBrokerClient(process.env.SWITCHROOM_AGENT_NAME ?? ''),
23387
+ listNoticeChats: () => loadAccess().allowFrom,
23388
+ sendNotice: (chat_id, markdown) => {
23389
+ // Status notice, not the user's answer — silence the ping (same posture
23390
+ // as the fleet-fallback announcement).
23391
+ void swallowingApiCall(
23392
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
23393
+ () => bot.api.sendRichMessage(chat_id, richMessage(markdown), { disable_notification: true }),
23394
+ { chat_id: String(chat_id), verb: 'throttle-tier:notify' },
23395
+ )
23396
+ },
23397
+ resumeDecide: (ts) => fleetFallbackResumeGate.decide(ts),
23398
+ newestActiveTurnStartedAtMs,
23399
+ turnInFlight: () => turnInFlightForGate(),
23400
+ deferRestartToTurnComplete: (agentName, reason) => {
23401
+ // Same lever the schedule_restart IPC uses: the restart drains at the
23402
+ // turn-complete gate (never SIGTERM-now under a live turn). Note the
23403
+ // pending-restart drain cap (PENDING_RESTART_DRAIN_CAP_MS) still bounds
23404
+ // a never-idling session — deliberate, matching scheduled restarts.
23405
+ process.stderr.write(
23406
+ `telegram gateway: [throttle-tier] restart deferred to turn-complete agent=${agentName} reason=${reason}\n`,
23407
+ )
23408
+ pendingRestarts.set(agentName, Date.now())
23409
+ },
23410
+ restartNow: (agentName, reason) => {
23411
+ triggerSelfRestart(agentName, reason)
23412
+ },
23413
+ log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
23414
+ })
23415
+
23217
23416
  /**
23218
23417
  * Broadcast a fleet-fallback FAILURE notice to every authorized chat.
23219
23418
  *
@@ -23275,7 +23474,12 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
23275
23474
  * user-visible announcement was broadcast). False on no-op /
23276
23475
  * error / idempotent-skip — caller uses this to decide whether to
23277
23476
  * arm the post-fire suppression window. */
23278
- async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<boolean> {
23477
+ async function doFireFleetAutoFallback(
23478
+ triggerAgent: string,
23479
+ untilMs?: number,
23480
+ parsedResetAt?: Date,
23481
+ trigger?: 'rate-limit',
23482
+ ): Promise<boolean> {
23279
23483
  try {
23280
23484
  const client = await getAuthBrokerClient(triggerAgent)
23281
23485
  if (!client) {
@@ -23324,6 +23528,16 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
23324
23528
  },
23325
23529
  triggerAgent,
23326
23530
  tz,
23531
+ // Enriched card (429 throttle tier): the RAW reset parsed from the
23532
+ // error prose, so the announcement names when the old account frees
23533
+ // even when its live probe returned nothing.
23534
+ parsedResetAt,
23535
+ // Throttle-tier escalation: a terminal transient 429 with a long
23536
+ // parsed reset NEGATES the usage-limit reading, so the old account's
23537
+ // utilization probe typically classifies healthy — the idempotency
23538
+ // guard would self-cancel the swap ("probed healthy / Stale event?").
23539
+ // Trust the terminal parsed-reset signal over the utilization probe.
23540
+ rateLimitTrigger: trigger === 'rate-limit',
23327
23541
  })
23328
23542
  process.stderr.write(
23329
23543
  `telegram gateway: [fleet-fallback] outcome=${outcome.kind} agent=${triggerAgent}` +