switchroom 0.18.12 → 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 (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  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/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -34,6 +34,7 @@ import {
34
34
  type AskUserArgs,
35
35
  type AskUserOutcome,
36
36
  } from '../ask-user.js'
37
+ import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
37
38
  import { parseInterruptMarker } from '../interrupt-marker.js'
38
39
  import {
39
40
  ToolFlightTracker,
@@ -93,6 +94,13 @@ import {
93
94
  buildExtraAttachmentMeta,
94
95
  resolveCoalesceMaxAttachments,
95
96
  } from './coalesce-attachments.js'
97
+ import {
98
+ parseForwardOrigin,
99
+ dedupeForwardOrigins,
100
+ buildForwardOriginMeta,
101
+ forwardOriginDateIso,
102
+ type ForwardOriginInfo,
103
+ } from './forward-origin.js'
96
104
  import { StatusReactionController } from '../status-reactions.js'
97
105
  import { DeferredDoneReactions } from '../reaction-defer.js'
98
106
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
@@ -309,7 +317,14 @@ import { recordOperatorEvent } from '../operator-events-history.js'
309
317
  import {
310
318
  formatModelUnavailableCard,
311
319
  resolveModelUnavailableFromOperatorEvent,
320
+ type ModelUnavailableDetection,
312
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'
313
328
  import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
314
329
  import { startRestartWatchdog } from './restart-watchdog.js'
315
330
  import { validateStringArray } from './access-validator.js'
@@ -340,6 +355,7 @@ import {
340
355
  } from '../telegram-button-constraints.js'
341
356
  import {
342
357
  wrapAgentCallbacks,
358
+ redactAgentKeyboard,
343
359
  parseAgentCallback,
344
360
  extractAgentButtonMeta,
345
361
  keyboardIsSingleUse,
@@ -691,8 +707,8 @@ import {
691
707
  startSubagentWatcher,
692
708
  type SubagentWatcherHandle,
693
709
  } from '../subagent-watcher.js'
694
- import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
695
- import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
710
+ import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
711
+ import { makeWorktreeWatchProvider } from '../worktree-watch-cwds.js'
696
712
  import {
697
713
  startBootCard,
698
714
  resolvePersonaName,
@@ -5466,9 +5482,10 @@ const recordFloodWindow = makeFloodWindowRecorder(FLOOD_WINDOWS_PATH)
5466
5482
  // edit floor + no-op-edit skip + PRIORITY SHEDDING + DEGRADED MODE). Wrapped
5467
5483
  // HERE at the robustApiCall layer so every Bot API call routed through the
5468
5484
  // standard retry policy also transits one scheduler (no call site can bypass
5469
- // it). Feature-flagged, default OFF: when SWITCHROOM_TELEGRAM_SEND_GATE !== '1'
5470
- // the gate is a pure passthrough and the retry policy behaves exactly as
5471
- // before. Composes with #3094's pre-call flood gate and #3097's non-essential
5485
+ // it). ON BY DEFAULT (escape hatch): only when SWITCHROOM_TELEGRAM_SEND_GATE is
5486
+ // explicitly set to 0/false/off/no is the gate a pure passthrough and the retry
5487
+ // policy behaves exactly as before. Composes with #3094's pre-call flood gate
5488
+ // and #3097's non-essential
5472
5489
  // drop policy — those decide whether a call happens at all; this paces the
5473
5490
  // calls that do.
5474
5491
  //
@@ -5527,6 +5544,65 @@ const swallowingApiCall = createSwallowingRetryApiCall(
5527
5544
  (line) => process.stderr.write(line),
5528
5545
  )
5529
5546
 
5547
+ /**
5548
+ * The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
5549
+ *
5550
+ * Reactions are cosmetic outbound traffic. Before this seam, ~13 raw
5551
+ * `bot.api.setMessageReaction(...)` / `lockedBot.api.setMessageReaction(...)`
5552
+ * call sites fired reactions OUTSIDE the send gate AND the flood circuit
5553
+ * breaker, so:
5554
+ *
5555
+ * (a) reaction spam was never PACED/SHED by the gate when it's on — a busy
5556
+ * turn's status-reaction churn added straight onto the per-bot flood
5557
+ * budget the REPLIES need; and
5558
+ * (b) a 429 from a reaction was INVISIBLE to the breaker — the flood window
5559
+ * is only learned through the retry module's `onFloodWait` hook, which a
5560
+ * raw `bot.api` call never reaches. Reactions were a residual flood
5561
+ * vector even with the gate ON.
5562
+ *
5563
+ * Routing through `robustApiCall` (= chat-lock → send-gate → retry/breaker,
5564
+ * the SAME path every other outbound call takes) with
5565
+ * `priorityClass: 'cosmetic'` fixes both: the gate paces/sheds reactions under
5566
+ * pressure, and a 429 runs `onFloodWait` so the window is recorded. Callers
5567
+ * keep their own fire-and-forget / `.catch(() => {})` / `await` semantics —
5568
+ * this returns the promise and does NOT swallow.
5569
+ */
5570
+ const gatedSetMessageReaction = (
5571
+ chatId: string,
5572
+ messageId: number,
5573
+ reaction: ReactionTypeEmoji[],
5574
+ ): Promise<unknown> =>
5575
+ robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
5576
+ chat_id: chatId,
5577
+ verb: 'set-message-reaction',
5578
+ priorityClass: 'cosmetic',
5579
+ })
5580
+
5581
+ /** React with a single emoji through the gate (cosmetic). Wraps {@link gatedSetMessageReaction}. */
5582
+ const sendReaction = (
5583
+ chatId: string,
5584
+ messageId: number,
5585
+ emoji: ReactionTypeEmoji['emoji'],
5586
+ ): Promise<unknown> => gatedSetMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
5587
+
5588
+ /**
5589
+ * Bot-API surface handed to `redactAuthCodeMessage` (#488) so its 🔑 reaction
5590
+ * routes through the send gate + flood breaker like every other reaction
5591
+ * (#3155). The OAuth-code DELETE stays raw/best-effort exactly as before — its
5592
+ * own `.then(onOk, onErr)` logging (the "token may still be visible" breadcrumb)
5593
+ * must keep firing, which a swallowing wrapper would suppress.
5594
+ */
5595
+ const redactAuthCodeApi = {
5596
+ deleteMessage: (chatId: string, messageId: number) =>
5597
+ // allow-raw-bot-api: auth-code redact delete stays raw/best-effort (behavior unchanged, #488); #3155 gates only the reaction.
5598
+ bot.api.deleteMessage(chatId, messageId),
5599
+ setMessageReaction: (
5600
+ chatId: string,
5601
+ messageId: number,
5602
+ reaction: Array<{ type: 'emoji'; emoji: string }>,
5603
+ ) => gatedSetMessageReaction(chatId, messageId, reaction as ReactionTypeEmoji[]),
5604
+ }
5605
+
5530
5606
  /**
5531
5607
  * The wrapper for NON-ESSENTIAL sends that must NEVER retry (#3084).
5532
5608
  *
@@ -7525,6 +7601,14 @@ type CoalescePayload = {
7525
7601
  attachment?: AttachmentMeta
7526
7602
  // Set only by `merge`: the 2nd..Nth attachments folded into this turn.
7527
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[]
7528
7612
  }
7529
7613
 
7530
7614
  // Count of attachment-bearing entries currently buffered per coalesce key.
@@ -7557,6 +7641,11 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7557
7641
  (e) => e.downloadImage != null || e.attachment != null,
7558
7642
  coalesceMaxAttachments(),
7559
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))
7560
7649
  return {
7561
7650
  // Drop empty texts (e.g. caption-less album parts) so the join doesn't
7562
7651
  // emit blank lines between attachments.
@@ -7567,6 +7656,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7567
7656
  extraAttachments: extras.length > 0
7568
7657
  ? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
7569
7658
  : undefined,
7659
+ forwardOrigins: forwardOrigins.length > 0 ? forwardOrigins : undefined,
7570
7660
  }
7571
7661
  },
7572
7662
  onFlush: (key, merged) => {
@@ -7577,6 +7667,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7577
7667
  merged.downloadImage,
7578
7668
  merged.attachment,
7579
7669
  merged.extraAttachments,
7670
+ merged.forwardOrigins,
7580
7671
  )
7581
7672
  },
7582
7673
  })
@@ -7597,6 +7688,72 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7597
7688
  function emitGatewayOperatorEvent(event: OperatorEvent): void {
7598
7689
  const { agent, kind } = event
7599
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
+
7600
7757
  if (!shouldEmitOperatorEvent(agent, kind)) {
7601
7758
  process.stderr.write(
7602
7759
  `telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
@@ -7625,7 +7782,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7625
7782
  // by the upstream classifier
7626
7783
  // - detail string contains one of the model-unavailable text patterns
7627
7784
  // (covers raw stderr that slipped past structured classification)
7628
- 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)
7629
7789
  let renderedText: string
7630
7790
  let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
7631
7791
  // #3031 PR 3 — when the card promises an in-flight auto-failover, record
@@ -7647,8 +7807,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7647
7807
  // it just produces a self-cancelling "probed healthy / Stale event?"
7648
7808
  // loop on every 529. Overload is handled by Claude Code's own
7649
7809
  // internal retry, not by switching accounts.
7650
- const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
7651
- 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()
7652
7822
  process.stderr.write(
7653
7823
  `telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
7654
7824
  )
@@ -7672,9 +7842,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7672
7842
  // Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
7673
7843
  // weekly wall that surfaced as a 429 got markExhausted's ~5h default and
7674
7844
  // the broker re-mirrored the still-walled account onto the fleet after 5h.
7675
- 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) {
7676
7848
  const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
7677
- 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)
7678
7853
  }
7679
7854
  } else {
7680
7855
  try {
@@ -11312,7 +11487,13 @@ const ipcServer: IpcServer = createIpcServer({
11312
11487
  (msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
11313
11488
  ' — triggering fleet auto-fallback\n',
11314
11489
  )
11315
- 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
+ )
11316
11497
  },
11317
11498
 
11318
11499
  // Issue #2971 — read-only wedge-watchdog probe: is there a live pending
@@ -11724,10 +11905,21 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
11724
11905
 
11725
11906
  assertAllowedChat(chat_id)
11726
11907
 
11727
- const sent = await rawSendChecklist({
11728
- chat_id,
11908
+ // #2044 outbound secret scrub. Checklist title + task strings are
11909
+ // agent-authored free text sent to Telegram, same class as the reply
11910
+ // `text` path and ask_user — route both through the SAME redactor before
11911
+ // send (via the pure, unit-tested redactChecklistFields helper) so an
11912
+ // echoed token / DATABASE_URL is masked.
11913
+ const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
11729
11914
  title,
11730
11915
  tasks,
11916
+ (t) => redactOutboundText(t, 'send_checklist'),
11917
+ )
11918
+
11919
+ const sent = await rawSendChecklist({
11920
+ chat_id,
11921
+ title: redactedTitle!,
11922
+ tasks: redactedTasks!,
11731
11923
  ...(threadId != null ? { message_thread_id: threadId } : {}),
11732
11924
  ...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
11733
11925
  ...(protectContent ? { protect_content: true } : {}),
@@ -11809,7 +12001,19 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
11809
12001
 
11810
12002
  assertAllowedChat(chat_id)
11811
12003
 
11812
- await rawEditMessageChecklist({ chat_id, message_id, title, tasks })
12004
+ // #2044 outbound secret scrub. update_checklist forwards the same
12005
+ // agent-authored title + task text to Telegram as send_checklist, so it
12006
+ // shares the identical leak class — redact both through the reply-path
12007
+ // redactor (via the shared redactChecklistFields helper) before the edit
12008
+ // lands. title / tasks may be undefined here (partial patch); the helper
12009
+ // passes undefined through untouched.
12010
+ const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
12011
+ title,
12012
+ tasks,
12013
+ (t) => redactOutboundText(t, 'update_checklist'),
12014
+ )
12015
+
12016
+ await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
11813
12017
 
11814
12018
  process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
11815
12019
  return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
@@ -12537,8 +12741,18 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12537
12741
  .join('; ')
12538
12742
  throw new Error(`inline_keyboard validation failed: ${summary}`)
12539
12743
  }
12540
- replyButtonMeta = extractAgentButtonMeta(rawKeyboard)
12541
- replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
12744
+ // #3148 fast-follow: mask any secret an agent put in a visible button
12745
+ // `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
12746
+ // payload BEFORE the keyboard is sent — the same outbound scrub the reply
12747
+ // `text` body uses. `callback_data` (the routing key) is left exact.
12748
+ // Feeding BOTH the meta extraction and the callback wrap from the redacted
12749
+ // copy means the stashed toast, the tap echo (`button_text`), and the
12750
+ // "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
12751
+ const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
12752
+ redactOutboundText(s, 'reply_inline_keyboard'),
12753
+ )
12754
+ replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
12755
+ replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
12542
12756
  }
12543
12757
 
12544
12758
  // on-demand voice: append a single '🔊 Listen' button that synthesizes the
@@ -13509,6 +13723,21 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
13509
13723
  const args = validateAskUserArgs(rawArgs as unknown as AskUserArgs)
13510
13724
  assertAllowedChat(args.chatId)
13511
13725
 
13726
+ // #2044 outbound secret scrub. The question text and each option/button
13727
+ // label are agent-authored free text sent to Telegram, exactly like the
13728
+ // reply `text` path — an agent that echoes a token or DATABASE_URL it just
13729
+ // read into a question or a button label would transmit it unmasked. Route
13730
+ // both through the SAME redactor as reply (via the pure, unit-tested
13731
+ // redactAskUserFields helper), assigning back onto the validated args so
13732
+ // every downstream consumer — the send, the timeout-edit re-render, and
13733
+ // the tap-echo of the chosen label stored in pendingAskUser — sees the
13734
+ // masked value.
13735
+ const scrubbed = redactAskUserFields(args.question, args.options, (t) =>
13736
+ redactOutboundText(t, 'ask_user'),
13737
+ )
13738
+ args.question = scrubbed.question
13739
+ args.options = scrubbed.options
13740
+
13512
13741
  // Resolve thread + reply-to using the same auto-thread heuristic
13513
13742
  // executeReply uses, so an agent that omits message_thread_id still
13514
13743
  // routes into the right forum topic and quotes the user's last
@@ -14609,9 +14838,11 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
14609
14838
  if (!args.message_id) throw new Error('react: message_id is required')
14610
14839
  if (!args.emoji) throw new Error('react: emoji is required')
14611
14840
  assertAllowedChat(String(args.chat_id ?? ''))
14612
- await lockedBot.api.setMessageReaction(String(args.chat_id ?? ''), Number(args.message_id), [
14613
- { type: 'emoji', emoji: args.emoji as ReactionTypeEmoji['emoji'] },
14614
- ])
14841
+ await sendReaction(
14842
+ String(args.chat_id ?? ''),
14843
+ Number(args.message_id),
14844
+ args.emoji as ReactionTypeEmoji['emoji'],
14845
+ )
14615
14846
  return { content: [{ type: 'text', text: 'reacted' }] }
14616
14847
  }
14617
14848
 
@@ -17590,6 +17821,17 @@ function handleSessionEvent(ev: SessionEvent): void {
17590
17821
 
17591
17822
  // ─── PTY partial handler ─────────────────────────────────────────────────
17592
17823
  function handlePtyPartial(text: string): void {
17824
+ // #2044 outbound secret scrub. The PTY-tail partial is the assistant's
17825
+ // reply text extracted from Claude Code's TUI as it renders — an agent
17826
+ // that echoes a secret shows it here (in the live draft-preview stream)
17827
+ // BEFORE the reply tool ever fires, and this path forwards the text
17828
+ // straight to Telegram with no other redaction. Mask at the gateway
17829
+ // boundary through the SAME reply-path redactor so the draft stream, its
17830
+ // dedup key, and lastPtyPreviewByChat all compare redacted-against-
17831
+ // redacted — the identical in-place-mutation invariant the answer stream
17832
+ // relies on. Idempotent: replaying a buffered (already-masked) partial
17833
+ // re-masks to itself.
17834
+ text = redactOutboundText(text, 'pty_preview')
17593
17835
  // #1067: build the PtyHandlerState from a snapshot of currentTurn.
17594
17836
  // The pty-partial-handler module keeps its own state-shape contract
17595
17837
  // (currentSessionChatId / currentSessionThreadId) because it's
@@ -17624,6 +17866,13 @@ function handlePtyPartial(text: string): void {
17624
17866
  }
17625
17867
 
17626
17868
  function handlePtyActivity(text: string): void {
17869
+ // #2044 outbound secret scrub. PTY-activity is agent-derived free text
17870
+ // forwarded to Telegram via handleStreamReply — same leak class as the
17871
+ // draft preview above. Redact through the reply-path redactor before it
17872
+ // streams. (This handler is currently unwired in the gateway — the live
17873
+ // PTY path is handlePtyPartial — but the mask is kept here so the fix is
17874
+ // durable if the activity lane is ever re-armed.)
17875
+ text = redactOutboundText(text, 'pty_activity')
17627
17876
  // #1067: snapshot at entry. handleStreamReply is async and runs in
17628
17877
  // the background via void; the closure already captures `chatId` /
17629
17878
  // `threadId` locals, so the supersession is correctly scoped.
@@ -17940,7 +18189,15 @@ async function handleInboundCoalesced(
17940
18189
  ctx.message?.message_thread_id,
17941
18190
  String(from.id),
17942
18191
  )
17943
- 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
+ })
17944
18201
  // Coalescing disabled (window <= 0): flush immediately, preserving any
17945
18202
  // media this message carried.
17946
18203
  if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
@@ -17976,9 +18233,7 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
17976
18233
  if (activeTurnStartedAt.has(statusKey(chatId, threadId))) return
17977
18234
  const access = loadAccess()
17978
18235
  if (!access.allowFrom.includes(String(from.id))) return
17979
- void bot.api.setMessageReaction(chatId, msgId, [
17980
- { type: 'emoji', emoji: '👀' as ReactionTypeEmoji['emoji'] },
17981
- ]).catch(() => {})
18236
+ void sendReaction(chatId, msgId, '👀' as ReactionTypeEmoji['emoji']).catch(() => {})
17982
18237
  // #2527: log the early-ack fire so operators can see how often the
17983
18238
  // fast pre-coalesce DM path triggers vs. the controller path.
17984
18239
  logStreamingEvent({ kind: 'early_ack_reaction', chatId, messageId: msgId, emoji: '👀' })
@@ -18021,6 +18276,11 @@ async function handleInbound(
18021
18276
  // resolved (photos downloaded) and surfaced as numbered meta fields
18022
18277
  // (image_path_2, attachment_file_id_2, …) alongside the primary.
18023
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[],
18024
18284
  ): Promise<void> {
18025
18285
  markIdleActivity() // any inbound resets the idle auto-clear timer + re-arms
18026
18286
  const isTopicMessage = ctx.message?.is_topic_message ?? false
@@ -18219,9 +18479,7 @@ async function handleInbound(
18219
18479
  )
18220
18480
  if (inFlight) {
18221
18481
  if (msgId != null) {
18222
- void bot.api.setMessageReaction(chat_id, msgId, [
18223
- { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
18224
- ]).catch(() => {})
18482
+ void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
18225
18483
  }
18226
18484
  await executeHaltNow('stop-keyword')
18227
18485
  }
@@ -18266,9 +18524,7 @@ async function handleInbound(
18266
18524
  `in_flight=${toolFlightTracker.inFlightCount()}\n`,
18267
18525
  )
18268
18526
  if (msgId != null) {
18269
- void bot.api.setMessageReaction(chat_id, msgId, [
18270
- { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
18271
- ]).catch(() => {})
18527
+ void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
18272
18528
  }
18273
18529
  if (interrupt.emptyBody) {
18274
18530
  // #3020: empty `!` is a pure halt (no replacement body) — same shared
@@ -18402,9 +18658,7 @@ async function handleInbound(
18402
18658
  })
18403
18659
  if (msgId != null) {
18404
18660
  const emoji = behavior === 'allow' ? '✅' : '❌'
18405
- void bot.api.setMessageReaction(chat_id, msgId, [
18406
- { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
18407
- ]).catch(() => {})
18661
+ void sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
18408
18662
  }
18409
18663
  return
18410
18664
  }
@@ -18460,7 +18714,7 @@ async function handleInbound(
18460
18714
  )
18461
18715
  }
18462
18716
  // Redact the OAuth code paste from chat history (#488).
18463
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18717
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18464
18718
  return
18465
18719
  }
18466
18720
  // Stale — drop the pending entry but let the message fall through
@@ -18492,7 +18746,7 @@ async function handleInbound(
18492
18746
  '_Still finishing the previous paste — one moment._',
18493
18747
  { html: true },
18494
18748
  )
18495
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18749
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18496
18750
  return
18497
18751
  }
18498
18752
  const result = await submitLoopbackRedirect(pendingLoop, text.trim())
@@ -18505,7 +18759,7 @@ async function handleInbound(
18505
18759
  { html: true },
18506
18760
  )
18507
18761
  // Redact the pasted redirect (carries the OAuth code) from history.
18508
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18762
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18509
18763
  return
18510
18764
  }
18511
18765
  if (result.retryable) {
@@ -18518,7 +18772,7 @@ async function handleInbound(
18518
18772
  { html: true },
18519
18773
  )
18520
18774
  // Redact even a rejected paste — it may still carry a live code.
18521
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18775
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18522
18776
  return
18523
18777
  }
18524
18778
  // Non-retryable — the flow is spent. Kill the CLI child before
@@ -18533,7 +18787,7 @@ async function handleInbound(
18533
18787
  `**/auth ${pendingLoop.provider} add failed:** ${escapeHtmlForTg(result.reason)}`,
18534
18788
  { html: true },
18535
18789
  )
18536
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18790
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18537
18791
  return
18538
18792
  }
18539
18793
  // Stale — the intercept window has closed. Kill the child and drop the
@@ -18553,7 +18807,7 @@ async function handleInbound(
18553
18807
  // reference AND a code/error param), so ordinary chatter mentioning
18554
18808
  // localhost flows through untouched. Redact and drop rather than forward.
18555
18809
  if (shouldConsumeLoopbackPaste(text)) {
18556
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18810
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18557
18811
  await switchroomReply(
18558
18812
  ctx,
18559
18813
  '_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
@@ -18588,7 +18842,7 @@ async function handleInbound(
18588
18842
  // Single-use code so a third party can't replay it after exchange,
18589
18843
  // but plaintext OAuth tokens in chat history are still poor
18590
18844
  // hygiene. The helper handles delete + 🔑 reaction silently.
18591
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18845
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18592
18846
  return
18593
18847
  }
18594
18848
  pendingReauthFlows.delete(interceptKey)
@@ -19051,12 +19305,12 @@ async function handleInbound(
19051
19305
  if (isSteering) {
19052
19306
  // Explicit steer: mark with 🤝 on the inbound message; leave the
19053
19307
  // existing StatusReactionController running for the in-flight turn.
19054
- void bot.api.setMessageReaction(chat_id, msgId, [{ type: 'emoji', emoji: '🤝' }]).catch(() => {})
19308
+ void sendReaction(chat_id, msgId, '🤝').catch(() => {})
19055
19309
  } else if (priorTurnInFlight) {
19056
19310
  // Queued mid-turn message (new default): don't touch the existing
19057
19311
  // controller; just ack the inbound message with 👀 so the user
19058
19312
  // knows we received it, without disrupting the in-flight reaction.
19059
- void bot.api.setMessageReaction(chat_id, msgId, [{ type: 'emoji', emoji: '👀' }]).catch(() => {})
19313
+ void sendReaction(chat_id, msgId, '👀').catch(() => {})
19060
19314
  // #203: time-to-ack metric — measure gateway-receive → ack-post delta.
19061
19315
  logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
19062
19316
  } else {
@@ -19099,9 +19353,7 @@ async function handleInbound(
19099
19353
  // msgId here and use it as the reaction-session token in log events.
19100
19354
  const ctrlTurnToken = `${chat_id}:${msgId}`
19101
19355
  const ctrl = new StatusReactionController(async (emoji) => {
19102
- await bot.api.setMessageReaction(chat_id, msgId, [
19103
- { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
19104
- ])
19356
+ await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
19105
19357
  // #203: every status-reaction transition is a user-visible signal.
19106
19358
  signalTracker.noteSignal(key, Date.now())
19107
19359
  }, allowedReactions, {
@@ -19184,9 +19436,7 @@ async function handleInbound(
19184
19436
  }
19185
19437
  }
19186
19438
  } else if (access.ackReaction) {
19187
- void bot.api.setMessageReaction(chat_id, msgId, [
19188
- { type: 'emoji', emoji: access.ackReaction as ReactionTypeEmoji['emoji'] },
19189
- ]).catch(() => {})
19439
+ void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
19190
19440
  // #203: time-to-ack metric for the custom-ack-reaction path.
19191
19441
  logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
19192
19442
  }
@@ -19249,6 +19499,19 @@ async function handleInbound(
19249
19499
  : undefined
19250
19500
  const replyToTextEscaped = formatReplyToText(replyToTextRaw, REPLY_TO_TEXT_MAX)
19251
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
+
19252
19515
  if (HISTORY_ENABLED) {
19253
19516
  try {
19254
19517
  recordInbound({
@@ -19262,6 +19525,11 @@ async function handleInbound(
19262
19525
  attachment_kind: attachment?.kind,
19263
19526
  reply_to_message_id: replyToMessageId ?? null,
19264
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,
19265
19533
  })
19266
19534
  } catch (err) {
19267
19535
  process.stderr.write(`telegram gateway: history recordInbound failed: ${err}\n`)
@@ -19367,6 +19635,11 @@ async function handleInbound(
19367
19635
  // Use the XML-escaped form for the meta — the raw form is in the
19368
19636
  // SQLite buffer for verbatim retrieval via get_recent_messages.
19369
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,
19370
19643
  // queued="true" when mid-turn with no steer prefix (new default), or
19371
19644
  // with explicit /queue or /q prefix (legacy alias).
19372
19645
  ...((isQueuedMidTurn || isQueuedPrefix) ? { queued: 'true' } : {}),
@@ -20529,7 +20802,7 @@ async function sweepBeforeSelfRestart(): Promise<void> {
20529
20802
  try {
20530
20803
  await sweepActiveReactions(
20531
20804
  agentDir,
20532
- (chatId, messageId) => lockedBot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '👍' as ReactionTypeEmoji['emoji'] }]),
20805
+ (chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
20533
20806
  { log: (msg) => process.stderr.write(`telegram gateway: pre-restart reaction sweep — ${msg}\n`) },
20534
20807
  )
20535
20808
  } catch (err) {
@@ -23085,9 +23358,14 @@ function wouldFireFleetAutoFallback(): boolean {
23085
23358
  * so the user sees the outcome inline with the original "Model
23086
23359
  * unavailable" card.
23087
23360
  */
23088
- 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> {
23089
23367
  return fleetFallbackGate.fire(
23090
- () => doFireFleetAutoFallback(triggerAgent, untilMs),
23368
+ () => doFireFleetAutoFallback(triggerAgent, untilMs, parsedResetAt, trigger),
23091
23369
  (err) => {
23092
23370
  process.stderr.write(
23093
23371
  `telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
@@ -23096,6 +23374,45 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
23096
23374
  )
23097
23375
  }
23098
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
+
23099
23416
  /**
23100
23417
  * Broadcast a fleet-fallback FAILURE notice to every authorized chat.
23101
23418
  *
@@ -23157,7 +23474,12 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
23157
23474
  * user-visible announcement was broadcast). False on no-op /
23158
23475
  * error / idempotent-skip — caller uses this to decide whether to
23159
23476
  * arm the post-fire suppression window. */
23160
- 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> {
23161
23483
  try {
23162
23484
  const client = await getAuthBrokerClient(triggerAgent)
23163
23485
  if (!client) {
@@ -23206,6 +23528,16 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
23206
23528
  },
23207
23529
  triggerAgent,
23208
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',
23209
23541
  })
23210
23542
  process.stderr.write(
23211
23543
  `telegram gateway: [fleet-fallback] outcome=${outcome.kind} agent=${triggerAgent}` +
@@ -24905,7 +25237,18 @@ bot.command('usage', async ctx => {
24905
25237
  // /auth snapshot does. switchroomReply routes through the rich path
24906
25238
  // (replyWithRichMessage), which accepts reply_markup. Build a grammy
24907
25239
  // InlineKeyboard so the markup type matches switchroomReply's contract.
24908
- const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
25240
+ // Defense-in-depth (mirrors the operator-private `/auth` treatment,
25241
+ // gateway.ts ~24036): outside a private chat, strip the
25242
+ // `auth:use:<label>` "Switch fleet" rows so the fleet-wide account-
25243
+ // swap button is never even offered in a group/forum. The dispatch-
25244
+ // site allowFrom gate is the load-bearing control; this just avoids
25245
+ // dangling a privileged button in front of non-operators.
25246
+ let kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
25247
+ if (ctx.chat?.type !== 'private') {
25248
+ kbRows = kbRows.filter(
25249
+ (row) => !row.some((b) => b.callbackData?.startsWith('auth:use:')),
25250
+ )
25251
+ }
24909
25252
  const keyboard = new InlineKeyboard()
24910
25253
  kbRows.forEach((row, ri) => {
24911
25254
  if (ri > 0) keyboard.row()
@@ -25033,7 +25376,19 @@ bot.on('callback_query:data', async ctx => {
25033
25376
  // Auth dashboard buttons (`auth:<verb>:<agent>[:<slot>]`). Route
25034
25377
  // through a dedicated handler that maps each action onto the
25035
25378
  // existing CLI invocations plus dashboard refresh.
25379
+ //
25380
+ // Strict allowFrom gate like every other mutating callback family
25381
+ // (`eff:`/`apv:`/`cfg:`/`cn:`/`mdl:`). Its absence was a vulnerability:
25382
+ // `auth:use:<label>` drives `client.setActive(label)`, a fleet-wide
25383
+ // OAuth account swap — on an admin forum/supergroup agent with an empty
25384
+ // group allowFrom, any member could tap it and swap the active account.
25036
25385
  if (data.startsWith('auth:')) {
25386
+ const access = loadAccess()
25387
+ const senderId = String(ctx.from?.id ?? '')
25388
+ if (!access.allowFrom.includes(senderId)) {
25389
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
25390
+ return
25391
+ }
25037
25392
  await handleAuthDashboardCallback(ctx)
25038
25393
  return
25039
25394
  }
@@ -26735,9 +27090,7 @@ async function handleAckOnly(
26735
27090
  const chat_id = String(ctx.chat!.id)
26736
27091
  const msgId = ctx.message?.message_id
26737
27092
  if (msgId != null) {
26738
- void bot.api.setMessageReaction(chat_id, msgId, [
26739
- { type: 'emoji', emoji: (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji'] },
26740
- ]).catch(() => {})
27093
+ void sendReaction(chat_id, msgId, (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji']).catch(() => {})
26741
27094
  }
26742
27095
  const prefix = opts.warn ? 'WARN ' : ''
26743
27096
  process.stderr.write(`telegram gateway: ${prefix}inbound ${kind} ack-only chat_id=${chat_id} from=${ctx.from?.id ?? '?'}\n`)
@@ -26770,9 +27123,7 @@ async function handleRefusal(
26770
27123
  const msgId = ctx.message?.message_id
26771
27124
  const messageThreadId = ctx.message?.message_thread_id
26772
27125
  if (msgId != null) {
26773
- void bot.api.setMessageReaction(chat_id, msgId, [
26774
- { type: 'emoji', emoji: '🚫' as ReactionTypeEmoji['emoji'] },
26775
- ]).catch(() => {})
27126
+ void sendReaction(chat_id, msgId, '🚫' as ReactionTypeEmoji['emoji']).catch(() => {})
26776
27127
  }
26777
27128
  // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND so a
26778
27129
  // deleted topic doesn't crash the refusal handler.
@@ -27831,7 +28182,7 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
27831
28182
  if (startupAgentDir != null) {
27832
28183
  void sweepActiveReactions(
27833
28184
  startupAgentDir,
27834
- (chatId, messageId) => lockedBot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '👍' as ReactionTypeEmoji['emoji'] }]),
28185
+ (chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
27835
28186
  { log: (msg) => process.stderr.write(`telegram gateway: startup reaction sweep — ${msg}\n`) },
27836
28187
  )
27837
28188
  }
@@ -28740,30 +29091,41 @@ void (async () => {
28740
29091
  // Best-effort: a registry read failure (e.g. no worktree dir
28741
29092
  // on an agent that never claims one) must not affect the
28742
29093
  // primary agentCwd watch.
28743
- extraWatchCwdsProvider: () =>
28744
- // Fail-CLOSED ownership filter (unset identity ⇒ nothing;
28745
- // ownerless records excluded; registry throw ⇒ []). Extracted
28746
- // to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
28747
- // Gap-2 ownership predicate is under direct unit test — see
28748
- // telegram-plugin/tests/worktree-watch-cwds.test.ts.
28749
- ownedWorktreeCwds({
28750
- self: process.env.SWITCHROOM_AGENT_NAME,
28751
- listRecords: listWorktreeRecords,
28752
- // Durable, non-env identity fallback (#1116 / #2893): when
28753
- // SWITCHROOM_AGENT_NAME is somehow unset, derive this
28754
- // agent's own identity from its own directory so worktree
28755
- // ownership still resolves (env is only the fast path).
28756
- // `watcherAgentDir` is guaranteed non-null in this branch
28757
- // (the whole watcher is gated on it above). Kill-switch
28758
- // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
28759
- // pre-fix env-only behaviour.
28760
- agentDir:
28761
- process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
28762
- ? undefined
28763
- : watcherAgentDir,
28764
- log: (msg) =>
28765
- process.stderr.write(`telegram gateway: ${msg}\n`),
28766
- }),
29094
+ // On every ~1s rescan tick the provider does BOTH:
29095
+ // 1. advances the heartbeat of every worktree THIS agent owns
29096
+ // — the F1/H3 production driver that keeps `touchHeartbeat`
29097
+ // alive (it had ZERO callers, so every claim read "stale"
29098
+ // 10 min after creation and the reaper's staleness
29099
+ // guarantee collapsed); throttled to ≤1 write / 2 min per
29100
+ // record, and
29101
+ // 2. returns the fail-CLOSED set of owned worktree cwds for the
29102
+ // watcher to also watch (#1116 / Gap-2 ownership predicate).
29103
+ // Extracted to telegram-plugin/worktree-watch-cwds.ts as
29104
+ // `makeWorktreeWatchProvider` so the wiring — specifically that
29105
+ // the provider ACTUALLY drives heartbeats, not just cwds — is
29106
+ // under direct unit test (see the provider behaviour test in
29107
+ // telegram-plugin/tests/worktree-watch-cwds.test.ts). Fully
29108
+ // best-effort: it never throws out of the provider.
29109
+ extraWatchCwdsProvider: makeWorktreeWatchProvider({
29110
+ self: process.env.SWITCHROOM_AGENT_NAME,
29111
+ // Durable, non-env identity fallback (#1116 / #2893): when
29112
+ // SWITCHROOM_AGENT_NAME is somehow unset, derive this agent's
29113
+ // own identity from its own directory so worktree ownership
29114
+ // still resolves (env is only the fast path). `watcherAgentDir`
29115
+ // is guaranteed non-null in this branch (the whole watcher is
29116
+ // gated on it above). Kill-switch
29117
+ // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the pre-fix
29118
+ // env-only behaviour. One `agentDir` governs BOTH the heartbeat
29119
+ // refresh and the cwd derivation.
29120
+ agentDir:
29121
+ process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
29122
+ ? undefined
29123
+ : watcherAgentDir,
29124
+ listRecords: listWorktreeRecords,
29125
+ touchHeartbeat: touchWorktreeHeartbeat,
29126
+ log: (msg) =>
29127
+ process.stderr.write(`telegram gateway: ${msg}\n`),
29128
+ }),
28767
29129
  // Bug 0 fix: previously omitted, leaving the watcher unable to
28768
29130
  // write liveness/stall/turn_end updates to the registry DB.
28769
29131
  // Liveness writes are now persisted across the gateway lifetime.