switchroom 0.18.15 → 0.18.17

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 (40) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +432 -10
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +50 -1
  5. package/dist/host-control/main.js +4 -1
  6. package/dist/vault/approvals/kernel-server.js +3 -0
  7. package/dist/vault/broker/server.js +3 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/gateway/gateway.js +386 -259
  11. package/telegram-plugin/draft-stream.ts +78 -3
  12. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  13. package/telegram-plugin/gateway/effort-command.ts +9 -7
  14. package/telegram-plugin/gateway/gateway.ts +265 -220
  15. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  16. package/telegram-plugin/gateway/model-command.ts +96 -18
  17. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  18. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  19. package/telegram-plugin/litellm-local-notice.ts +189 -0
  20. package/telegram-plugin/quota-watch.ts +16 -4
  21. package/telegram-plugin/runtime-metrics.ts +16 -0
  22. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  23. package/telegram-plugin/send-gate.ts +34 -4
  24. package/telegram-plugin/stream-controller.ts +143 -20
  25. package/telegram-plugin/stream-reply-handler.ts +12 -2
  26. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  27. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  28. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  29. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  30. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  31. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  32. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  33. package/telegram-plugin/tests/model-command.test.ts +84 -1
  34. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  35. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  36. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  37. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  39. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  40. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -326,6 +326,8 @@ import {
326
326
  throttleRetryInPlaceMaxMs,
327
327
  } from '../throttle-tier.js'
328
328
  import { createThrottleTierRunner } from './throttle-tier-wiring.js'
329
+ import { parseLitellmNoticeWindowMs } from '../litellm-local-notice.js'
330
+ import { createLitellmLocalNoticeRunner, decideRateLimitedSurface } from './litellm-local-notice-wiring.js'
329
331
  import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
330
332
  import { startRestartWatchdog } from './restart-watchdog.js'
331
333
  import { validateStringArray } from './access-validator.js'
@@ -436,6 +438,8 @@ import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/i
436
438
  import { handleInjectCommand, type InjectDeps } from './inject-handler.js'
437
439
  import {
438
440
  parseModelCommand,
441
+ planModelCommand,
442
+ modelCommandReceiptLine,
439
443
  handleModelCommand,
440
444
  buildModelMenu,
441
445
  handleModelMenuCallback,
@@ -461,18 +465,9 @@ import {
461
465
  readSessionModelFileRaw,
462
466
  restoreSessionModelFileRaw,
463
467
  clearSessionModelFile,
464
- clearSessionModelBootAttempts,
465
468
  readConfiguredDefaultModel,
466
- writeRelaunchModelIntent,
467
- clearRelaunchModelIntent,
468
- intentForRestartReason,
469
- readSessionModelFile,
470
- RELAUNCH_MODEL_INTENT_FILE,
471
- GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
472
- clearStaleGatewayShutdownIntent,
473
469
  writeSessionEffortFile,
474
470
  clearSessionEffortFile,
475
- readSessionEffortFile,
476
471
  } from './session-model-file.js'
477
472
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
478
473
  import { resolveMainModel, SWITCHROOM_DEFAULT_THINKING_EFFORT } from '../../src/agents/scaffold.js'
@@ -1097,16 +1092,10 @@ function triggerSelfRestart(
1097
1092
  )
1098
1093
  return false
1099
1094
  }
1100
- // Session-model stickiness (reference/rfcs/session-model-stickiness.md):
1101
- // boot default is REVERT, so every switchroom-managed bounce must stamp
1102
- // its intent BEFORE the SIGTERM is even scheduled (write-before-kill
1103
- // invariant — pinned by gateway-session-model-relaunch.test.ts). The
1104
- // per-reason table classifies recovery/model-switch bounces as "keep"
1105
- // and the deliberate inline restart button as "revert".
1106
- {
1107
- const smDir = resolveAgentDirFromEnv()
1108
- if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
1109
- }
1095
+ // Session-scoped /model (reference/rfcs/session-model-stickiness.md §0.1):
1096
+ // a `.session-model` carrier is consume-once — start.sh applies it on the
1097
+ // apply-relaunch and deletes it, so no boot needs a keep/revert intent. A
1098
+ // switchroom-managed bounce simply reverts to the configured default.
1110
1099
  process.stderr.write(
1111
1100
  `telegram gateway: restart-via-SIGTERM-PID1 agent=${targetAgent} reason=${reason} (docker)\n`,
1112
1101
  )
@@ -1118,10 +1107,6 @@ function triggerSelfRestart(
1118
1107
  return true
1119
1108
  }
1120
1109
  // Legacy systemd path.
1121
- if (targetAgent === selfAgent) {
1122
- const smDir = resolveAgentDirFromEnv()
1123
- if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
1124
- }
1125
1110
  process.stderr.write(
1126
1111
  `telegram gateway: restart-via-systemctl agent=${targetAgent} reason=${reason}\n`,
1127
1112
  )
@@ -1141,24 +1126,6 @@ function triggerSelfRestart(
1141
1126
  }
1142
1127
  }
1143
1128
 
1144
- // #3018 finding 4: a gateway-only bounce (supervisor relaunch, bare gateway
1145
- // unit restart) leaves the shutdown handler's deploy-survival keep-intent
1146
- // stamp on disk UNCONSUMED — start.sh only runs on a container-level boot.
1147
- // If this gateway boot still sees a gateway-shutdown-stamped intent, the
1148
- // preceding bounce was gateway-only: clear it so a genuine crash inside the
1149
- // 10-min freshness window can't be converted into a "keep" (crash-reverts
1150
- // policy intact). A real container stop/deploy consumes the file in start.sh
1151
- // before any gateway boots, so a legitimate deploy stamp is never touched;
1152
- // triggerSelfRestart / user-slash stamps use un-prefixed reasons.
1153
- {
1154
- const bootSmDir = resolveAgentDirFromEnv()
1155
- if (bootSmDir != null && clearStaleGatewayShutdownIntent(bootSmDir)) {
1156
- process.stderr.write(
1157
- 'telegram gateway: cleared stale gateway-shutdown relaunch-model intent (previous bounce was gateway-only — container never restarted)\n',
1158
- )
1159
- }
1160
- }
1161
-
1162
1129
  // Cached lazily — the claude CLI binary doesn't change inside a running
1163
1130
  // gateway process; on `switchroom update` the gateway restarts, refreshing this.
1164
1131
  let cachedClaudeCliVersion: string | null | undefined = undefined
@@ -1410,6 +1377,11 @@ type Access = {
1410
1377
  parseMode?: 'html' | 'markdownv2' | 'text'
1411
1378
  disableLinkPreview?: boolean
1412
1379
  coalescingGapMs?: number
1380
+ /** Cooldown window (ms) for the litellm-local 429 notice — the debounced
1381
+ * "fleet token limiter engaged" message (litellm-local-notice.ts). Default
1382
+ * 15 min when unset/invalid (parseLitellmNoticeWindowMs). Projected from
1383
+ * channels.telegram.litellm_notice.window_ms by scaffold. */
1384
+ litellmNoticeWindowMs?: number
1413
1385
  /** A2: max media attachments folded into one coalesced turn. Default 10
1414
1386
  * (a full Telegram album / forwarded burst arrives as one turn). Set 1 to
1415
1387
  * restore single-attachment behaviour. Projected from
@@ -1564,6 +1536,7 @@ function readAccessFile(): Access {
1564
1536
  parseMode: parsed.parseMode,
1565
1537
  disableLinkPreview: parsed.disableLinkPreview,
1566
1538
  coalescingGapMs: parsed.coalescingGapMs,
1539
+ litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
1567
1540
  coalesceMaxAttachments: parsed.coalesceMaxAttachments,
1568
1541
  interruptSafeBoundary: parsed.interruptSafeBoundary,
1569
1542
  interruptMaxWaitMs: parsed.interruptMaxWaitMs,
@@ -7713,11 +7686,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7713
7686
  // account-scoped 429s with fleet TPM even when the card is suppressed.
7714
7687
  let throttleEscalation: ModelUnavailableDetection | null = null
7715
7688
  let escalationFired = false
7689
+ // True when decideRateLimitedSurface already consulted (and armed) the
7690
+ // shared per-kind card cooldown for this event — the gate below must not
7691
+ // re-consult it, or the arm from the first consult would self-suppress.
7692
+ let rateLimitedCooldownConsulted = false
7716
7693
  const rateLimit429Classification =
7717
7694
  kind === 'rate-limited' ? classify429Detail(event.detail) : null
7718
7695
  if (rateLimit429Classification != null && rateLimit429Classification !== 'account-scoped') {
7719
- // litellm-local / generic-transient: the existing calm rate-limited
7720
- // path (fall through to renderOperatorEvent below). NO broker
7696
+ // litellm-local / generic-transient: the calm path. NO broker
7721
7697
  // mark-throttled, NO throttle-tier runner, NO failover — for a
7722
7698
  // proxy-local cap trip those would bench an account that was never
7723
7699
  // touched.
@@ -7730,12 +7706,49 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7730
7706
  now: Date.now(),
7731
7707
  }),
7732
7708
  )
7733
- if (rateLimit429Classification === 'litellm-local') {
7709
+ // Surface decision — extracted (decideRateLimitedSurface,
7710
+ // litellm-local-notice-wiring.ts) so the ordering contract with the
7711
+ // shared per-kind card cooldown is pinnable by tests: litellm-local
7712
+ // resolves BEFORE the gate (never arms `${agent}:rate-limited`, never
7713
+ // suppressed by a cooldown a recent 529/generic card armed);
7714
+ // generic-transient consults the gate exactly once HERE.
7715
+ const surface = decideRateLimitedSurface({
7716
+ classification: rateLimit429Classification,
7717
+ agent,
7718
+ shouldEmitCard: (a) => shouldEmitOperatorEvent(a, 'rate-limited'),
7719
+ })
7720
+ if (surface === 'litellm-local-notice') {
7734
7721
  process.stderr.write(
7735
7722
  `telegram gateway: 429 classified litellm-proxy-local agent=${agent} — ` +
7736
7723
  `calm path, no account attribution, no failover\n`,
7737
7724
  )
7725
+ // The dedicated debounced notice REPLACES the generic "🚦 Rate limited"
7726
+ // card for this classification only: one calm message naming the fleet
7727
+ // token limiter (LiteLLM tpm_limit/rpm_limit) instead of a card that
7728
+ // reads like an Anthropic account problem. Classification, quota-ledger,
7729
+ // and failover behavior are untouched (nothing fired above on this
7730
+ // branch). Record into history ONLY when a notice actually posted — a
7731
+ // suppressed low-stakes proxy throttle must not overwrite a more
7732
+ // important most-recent event (e.g. credentials-expired) in the
7733
+ // /status enrichment (operator-events-history keeps the most recent
7734
+ // event per agent).
7735
+ const outcome = litellmLocalNoticeRunner.onRateLimited('litellm-local', agent)
7736
+ if (outcome === 'sent') {
7737
+ try {
7738
+ recordOperatorEvent(event)
7739
+ } catch { /* history is best-effort */ }
7740
+ }
7741
+ return
7742
+ }
7743
+ if (surface === 'cooldown-suppressed') {
7744
+ process.stderr.write(
7745
+ `telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
7746
+ )
7747
+ return
7738
7748
  }
7749
+ // 'generic-card' — the gate passed (and armed) above; fall through to
7750
+ // the existing calm rate-limited card without re-consulting it.
7751
+ rateLimitedCooldownConsulted = true
7739
7752
  }
7740
7753
  if (rateLimit429Classification === 'account-scoped') {
7741
7754
  const throttleDecision = decideThrottleTier({
@@ -7800,7 +7813,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7800
7813
  }
7801
7814
  }
7802
7815
 
7803
- if (!shouldEmitOperatorEvent(agent, kind)) {
7816
+ if (!rateLimitedCooldownConsulted && !shouldEmitOperatorEvent(agent, kind)) {
7804
7817
  process.stderr.write(
7805
7818
  `telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
7806
7819
  )
@@ -10378,16 +10391,6 @@ const ipcServer: IpcServer = createIpcServer({
10378
10391
  // this call is safe wherever it sits relative to the cron early-return
10379
10392
  // above (#3038 review finding 5).
10380
10393
  bridgeDeadWatchdog.noteBridgeRegistered(client.agentName)
10381
- // #3043 item 2: a REAL bridge registering is proof the boot came all the
10382
- // way up healthy — clear start.sh's crashloop boot-attempts counter so only
10383
- // boots that genuinely fail BEFORE the bridge registers accumulate toward
10384
- // the 3-strike override clear. Without this, three quick operator
10385
- // hand-bounces of a healthy agent (each <150s apart) spuriously wipe a
10386
- // working model override. Best-effort; no-op when the file is absent.
10387
- if (client.agentName != null) {
10388
- const smBootDir = resolveAgentDirFromEnv()
10389
- if (smBootDir != null) clearSessionModelBootAttempts(smBootDir)
10390
- }
10391
10394
  client.send({ type: 'status', status: 'agent_connected' })
10392
10395
 
10393
10396
  // Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
@@ -21821,15 +21824,10 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
21821
21824
  })
21822
21825
  }
21823
21826
  stampUserRestartReason(reason)
21824
- // Model-switch restarts are switchroom-managed relaunches: the session
21825
- // override (written by the caller before this dispatch) must survive
21826
- // the bounce, so stamp keep-intent BEFORE dispatch (boot default is
21827
- // revert). hostd shells through `switchroom agent restart`, which
21828
- // deliberately writes no intent of its own.
21829
- {
21830
- const smDir = resolveAgentDirFromEnv()
21831
- if (smDir) writeRelaunchModelIntent(smDir, 'keep', reason)
21832
- }
21827
+ // Model-switch restarts are the APPLY-relaunch for a `.session-model`
21828
+ // carrier the caller wrote immediately above: start.sh consumes it on
21829
+ // the very next boot (this dispatch's boot), then reverts thereafter.
21830
+ // No keep/revert intent is needed — the carrier is consume-once.
21833
21831
  await sweepBeforeSelfRestart()
21834
21832
  const hostdResp = await tryHostdDispatch(name, {
21835
21833
  v: 1,
@@ -21850,14 +21848,11 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
21850
21848
  return
21851
21849
  }
21852
21850
  // hostd is configured but returned an error/denied result. No restart
21853
- // is coming, so the keep-intent stamped above must not linger — a
21854
- // crash within its 10-min freshness window would wrongly KEEP.
21851
+ // is coming, so the carrier the caller wrote must not linger to be
21852
+ // consumed by an unrelated later boot — scheduleModelRelaunch's catch
21853
+ // rolls it back on this throw.
21855
21854
  if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
21856
21855
  clearRestartMarker()
21857
- {
21858
- const smDir = resolveAgentDirFromEnv()
21859
- if (smDir) clearRelaunchModelIntent(smDir)
21860
- }
21861
21856
  throw new Error(
21862
21857
  `hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
21863
21858
  )
@@ -21866,11 +21861,11 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
21866
21861
  /**
21867
21862
  * Switch TO a model that needs a relaunch (sr-* LiteLLM/OpenRouter ids,
21868
21863
  * which claude's native `/model` picker rejects, and the sr-to-claude
21869
- * direction). Write the DURABLE `.session-model` override (start.sh
21870
- * applies it on every keep-relaunch boot and launches `claude --model
21871
- * <token>`), set the in-memory session-model so /status stays honest
21872
- * across the restart window, then run the SAME restart dispatch as
21873
- * scheduleRestart above — which stamps the keep-intent this boot needs.
21864
+ * direction). Write the CONSUME-ONCE `.session-model` carrier (start.sh
21865
+ * applies it on the very next boot — this dispatch's apply-relaunch — and
21866
+ * deletes it, so it reverts on any subsequent restart), set the in-memory
21867
+ * session-model so /status stays honest across the restart window, then
21868
+ * run the SAME restart dispatch as scheduleRestart above.
21874
21869
  */
21875
21870
  scheduleModelRelaunch: async (model: string, reason: string) => {
21876
21871
  const agentDir = resolveAgentDirFromEnv()
@@ -21887,20 +21882,15 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
21887
21882
  try {
21888
21883
  await deps.scheduleRestart(reason)
21889
21884
  } catch (err) {
21890
- // A restart already in flight OWNS the override we just wrote — its
21891
- // boot (stamped keep by the in-flight path's own intent, last-writer-
21892
- // wins) will apply our token, so the switch is queued, not lost: keep
21893
- // the file + override and let the caller tell the operator "~15s".
21894
- // Any OTHER dispatch failure means no restart is coming, so roll BOTH
21895
- // back — a lingering file/override would lie to /status and
21896
- // mis-launch the NEXT relaunch. The keep-intent goes with them
21897
- // (belt-and-braces: scheduleRestart's failure branch clears it too):
21898
- // a fresh keep on disk with no restart coming would wrongly KEEP
21899
- // across a crash inside its 10-min window.
21885
+ // A restart already in flight OWNS the carrier we just wrote — its
21886
+ // boot will consume+apply our token, so the switch is queued, not
21887
+ // lost: keep the file + override and let the caller tell the operator
21888
+ // "~15s". Any OTHER dispatch failure means no restart is coming, so
21889
+ // roll BOTH back — a lingering carrier would lie to /status and be
21890
+ // consumed (mis-applied) by an unrelated later boot.
21900
21891
  if ((err as { code?: string })?.code !== 'restart_in_flight') {
21901
21892
  restoreSessionModelFileRaw(agentDir, prevFileRaw)
21902
21893
  sessionModelSource.setOverride(prevOverride)
21903
- clearRelaunchModelIntent(agentDir)
21904
21894
  }
21905
21895
  throw err
21906
21896
  }
@@ -21921,19 +21911,24 @@ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined
21921
21911
 
21922
21912
  /**
21923
21913
  * Record a POSITIVELY-CONFIRMED typed `/model` switch: set the in-memory
21924
- * override so `/status` reflects the live model and persist the sticky
21925
- * `.session-model` carrier. Shared by the live `bot.command('model')` handler
21926
- * and the deferred (queued mid-turn) apply so both record identically. Returns
21927
- * a persist-warning suffix to append to the reply body (empty when clean).
21914
+ * override so `/status` reflects the live model. Shared by the live
21915
+ * `bot.command('model')` handler and the deferred (queued mid-turn) apply so
21916
+ * both record identically. Returns a warning suffix to append to the reply
21917
+ * body (currently always empty — kept for a stable signature).
21928
21918
  *
21929
- * The `/status` honesty invariant lives here: only `reply.selectedModel`
21930
- * (present only on a confirmed switch) records; an unverified inject records
21931
- * nothing. `/model default` clears the carrier idempotently.
21919
+ * Session-scoped (rev 4): a live Claude `/model` switch writes NO
21920
+ * `.session-model` carrier — it applies in-session and the explicit
21921
+ * `claude --model <configured>` flag reverts it on the next boot, so it lasts
21922
+ * exactly until the next restart with no durable state. (sr-* switches never
21923
+ * reach here — they go through scheduleModelRelaunch, which owns the
21924
+ * consume-once carrier.) The `/status` honesty invariant lives here: only
21925
+ * `reply.selectedModel` records; an unverified inject records nothing.
21926
+ * `/model default` clears any in-memory override and any leftover carrier.
21932
21927
  */
21933
21928
  function recordTypedModelSwitch(
21934
21929
  reply: { text: string; selectedModel?: string },
21935
21930
  requestedModelArg: string | null,
21936
- deps: ModelCommandDeps,
21931
+ _deps: ModelCommandDeps,
21937
21932
  ): string {
21938
21933
  const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null
21939
21934
  if (requested?.toLowerCase() === 'default') {
@@ -21944,28 +21939,14 @@ function recordTypedModelSwitch(
21944
21939
  }
21945
21940
  if (!reply.selectedModel) return ''
21946
21941
  sessionModelSource.setOverride(reply.selectedModel)
21947
- const smDir = resolveAgentDirFromEnv()
21948
- if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
21949
- try {
21950
- writeSessionModelFile(
21951
- smDir,
21952
- requested,
21953
- readConfiguredDefaultModel(smDir) ??
21954
- resolveMainModel(deps.getConfiguredModel() ?? undefined),
21955
- )
21956
- } catch (err) {
21957
- process.stderr.write(
21958
- `telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
21959
- )
21960
- return '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
21961
- }
21962
- }
21963
21942
  return ''
21964
21943
  }
21965
21944
 
21966
21945
  /**
21967
- * Record a model-MENU callback outcome (persist/clear sticky override) and
21968
- * drive an sr-*→Claude graceful restart when the tap crosses that boundary.
21946
+ * Record a model-MENU callback outcome (set the live in-memory override, clear
21947
+ * a leftover carrier on a Default tap) and drive an sr-*→Claude graceful
21948
+ * restart when the tap crosses that boundary — the only menu path that writes a
21949
+ * consume-once `.session-model` carrier (a live Claude tap writes none, rev 4).
21969
21950
  * Extracted from the live `mdl:*` dispatcher so the deferred (queued mid-turn)
21970
21951
  * apply records + restarts identically. Does NOT edit any Telegram message —
21971
21952
  * callers own the card edit. Returns a restart notice when a session restart
@@ -21979,29 +21960,13 @@ function recordModelMenuSideEffects(
21979
21960
  prevSessionModel: string | null,
21980
21961
  ): { restartNotice?: string } {
21981
21962
  // Record a successful session switch so /status reflects what's actually
21982
- // running, and persist the STICKY override
21983
- // (reference/rfcs/session-model-stickiness.md): the canonical token (never
21984
- // the display label) goes to the durable `.session-model`; a confirmed
21985
- // "Default (recommended)" selection clears it instead.
21963
+ // running. Session-scoped (rev 4): a live Claude menu tap writes NO
21964
+ // `.session-model` carrier — it applies in-session (native picker) and
21965
+ // reverts on the next boot. Only the sr→Claude transition below (which
21966
+ // relaunches) writes the consume-once carrier. A confirmed "Default
21967
+ // (recommended)" selection clears any leftover carrier.
21986
21968
  if (outcome.selectedModel) {
21987
21969
  sessionModelSource.setOverride(outcome.selectedModel)
21988
- const smDir = resolveAgentDirFromEnv()
21989
- if (smDir && outcome.selectedModelToken) {
21990
- try {
21991
- writeSessionModelFile(
21992
- smDir,
21993
- outcome.selectedModelToken,
21994
- readConfiguredDefaultModel(smDir) ??
21995
- resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
21996
- )
21997
- } catch (err) {
21998
- outcome.reply.text +=
21999
- '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
22000
- process.stderr.write(
22001
- `telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
22002
- )
22003
- }
22004
- }
22005
21970
  }
22006
21971
  if (outcome.clearedDefault) {
22007
21972
  const smDir = resolveAgentDirFromEnv()
@@ -22013,9 +21978,11 @@ function recordModelMenuSideEffects(
22013
21978
  // torn down — a graceful restart (same mechanism as /restart) is required.
22014
21979
  if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
22015
21980
  const agentName = getMyAgentName()
22016
- // Carry the requested Claude model across the restart via the SAME durable
22017
- // `.session-model` override a Claude → sr-* switch uses — otherwise boot
22018
- // launches the CONFIGURED default and the tapped model is silently dropped.
21981
+ // Carry the requested Claude model across the restart via the SAME
21982
+ // consume-once `.session-model` carrier a Claude → sr-* switch uses —
21983
+ // otherwise this transition's apply-relaunch boots the CONFIGURED default
21984
+ // and the tapped model is silently dropped. Applied on that one boot,
21985
+ // then reverts on the next restart (rev 4).
22019
21986
  const agentDir = resolveAgentDirFromEnv()
22020
21987
  const token = outcome.selectedModelToken
22021
21988
  if (agentDir && token) {
@@ -22178,20 +22145,19 @@ function persistQueuedCommandForRestart(action: ShutdownResolutionAction): strin
22178
22145
  switch (action.persist) {
22179
22146
  case 'model': {
22180
22147
  // #3042 blocker 2a: this token was QUEUED, never confirmed by claude.
22181
- // Under the keep-by-default boot a garbage-but-shape-valid token
22182
- // persisted here would crashloop `claude --model <garbage>` with the
22183
- // gateway dead. Only offline-trustable tokens (static Claude aliases,
22184
- // curated sr-* alias targets) may be persisted unconfirmed; anything
22185
- // else gets the honest "couldn't verify — re-issue" card instead.
22148
+ // The carrier is written immediately before the bounce, so the next
22149
+ // boot IS its apply-relaunch: consume-once means a garbage token can
22150
+ // crash at most one boot before it reverts, but we still gate on
22151
+ // offline-trustable tokens (static Claude aliases, curated sr-* alias
22152
+ // targets) to avoid even that one crash-boot; anything else gets the
22153
+ // honest "couldn't verify — re-issue" card instead.
22186
22154
  if (!isOfflineTrustedModelToken(action.arg)) {
22187
22155
  return `↩️ Couldn’t verify \`${escapeHtmlForTg(action.cmd.targetLabel || action.arg)}\` as a known model without the live session — it was NOT saved. Re-issue \`/model ${escapeHtmlForTg(action.arg)}\` once the agent is back.`
22188
22156
  }
22189
22157
  const configured =
22190
22158
  readConfiguredDefaultModel(agentDir) ?? resolveMainModel(undefined)
22159
+ // Consume-once carrier: applied by the next boot, then reverts.
22191
22160
  writeSessionModelFile(agentDir, expandSrAlias(action.arg), configured)
22192
- // Boot default is keep (#3039), but stamp explicit keep-intent for
22193
- // reason-honesty in the boot notice.
22194
- writeRelaunchModelIntent(agentDir, 'keep', 'queued /model carried across restart')
22195
22161
  break
22196
22162
  }
22197
22163
  case 'clear-model':
@@ -22281,17 +22247,50 @@ bot.command('model', async ctx => {
22281
22247
  const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
22282
22248
  const chatId = String(ctx.chat!.id)
22283
22249
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
22250
+ // #3177 — durable receipt FIRST. A typed /model must NEVER be invisible: even
22251
+ // if every downstream reply is shed/dropped, or the session is in a
22252
+ // phantom-idle window (turn atom cleared while claude is still busy), the
22253
+ // command leaves a greppable log line + a history row before any branch. This
22254
+ // is the fix for the finn 2026-07-12 zero-trace swallow (no log, no reply, no
22255
+ // ack, no deferred apply). `busyNow` folds BOTH busy signals (turn atom AND
22256
+ // the authoritative delivery-machine/approval gate).
22257
+ const busyNow = currentTurn !== null || turnInFlightForGate()
22258
+ process.stderr.write(modelCommandReceiptLine(getMyAgentName(), parsed, busyNow) + '\n')
22259
+ if (HISTORY_ENABLED && ctx.message?.message_id != null) {
22260
+ try {
22261
+ recordInbound({
22262
+ chat_id: chatId,
22263
+ thread_id: threadId ?? null,
22264
+ message_id: ctx.message.message_id,
22265
+ user: ctx.from?.username ?? (ctx.from?.id != null ? String(ctx.from.id) : null),
22266
+ user_id: ctx.from?.id != null ? String(ctx.from.id) : null,
22267
+ ts: ctx.message.date ?? Math.floor(Date.now() / 1000),
22268
+ text,
22269
+ })
22270
+ } catch (err) {
22271
+ process.stderr.write(`telegram gateway: /model recordInbound failed: ${(err as Error)?.message ?? String(err)}\n`)
22272
+ }
22273
+ }
22284
22274
  const deps = buildModelDeps({ chatId, threadId })
22285
- if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
22275
+ // Route on a pure disposition (#3177) that folds BOTH busy signals so a
22276
+ // session busy by EITHER measure ack+queues instead of silently injecting
22277
+ // into a busy pane. Every branch below produces a visible action.
22278
+ const disposition = planModelCommand(parsed, {
22279
+ currentTurnActive: currentTurn !== null,
22280
+ turnInFlight: turnInFlightForGate(),
22281
+ menuEnabled: process.env.SWITCHROOM_MODEL_MENU !== '0',
22282
+ })
22283
+ if (disposition.kind === 'menu') {
22286
22284
  const menu = await buildModelMenu(deps)
22287
22285
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
22288
22286
  return
22289
22287
  }
22290
- // Mid-turn: instead of dead-ending ("Try again in a moment"), ACK + QUEUE +
22291
- // apply-on-idle + confirm (#3017). The typed set path either injects into
22292
- // claude's input box or triggers a carrier restart — both unsafe mid-turn.
22293
- if (parsed.kind === 'set' && deps.isBusy()) {
22294
- const target = expandSrAlias(parsed.model)
22288
+ // Mid-turn (by either busy signal): instead of dead-ending ("Try again in a
22289
+ // moment") or silently injecting into a busy pane, ACK + QUEUE + apply-on-idle
22290
+ // + confirm (#3017/#3177). The typed set path either injects into claude's
22291
+ // input box or triggers a carrier restart — both unsafe while busy.
22292
+ if (disposition.kind === 'queue') {
22293
+ const target = disposition.target
22295
22294
  const sent = await ctx.replyWithRichMessage(
22296
22295
  richMessage(hardenCardBreaks(pendingCmdAckText('model', target, escapeHtmlForTg))),
22297
22296
  threadId != null ? { message_thread_id: threadId } : {},
@@ -22331,39 +22330,38 @@ bot.command('model', async ctx => {
22331
22330
  // is blocklisted for `/effort` since #2471), session-scoped — boot re-pins
22332
22331
  // the configured default via start.sh's `--effort`. Implementation in
22333
22332
  // effort-command.ts so it's unit-testable without booting the bot.
22333
+
22334
+ // The live session-effort override, in memory only (#3186, session-scoped
22335
+ // like /model rev 4). A confirmed live apply records here — NOT to the
22336
+ // `.session-effort` carrier — so it lasts exactly until the next restart
22337
+ // (start.sh's explicit `--effort <configured>` reverts it for free). Seeded
22338
+ // at boot from `.active-session-effort` (the effort sibling of
22339
+ // `.active-session-model`) so a queued-carrier apply-boot still shows the
22340
+ // honest live level on the /effort menu.
22341
+ let sessionEffortOverride: string | null = null
22342
+
22334
22343
  function buildEffortDeps(): EffortCommandDeps {
22335
22344
  return {
22336
- // #3039: single persistence choke point — EVERY positively-confirmed
22337
- // effort apply (typed, menu tap, queued drain) durably records the level
22338
- // to `.session-effort`, which start.sh resolves into `--effort` on every
22339
- // boot. `/effort default` clears it via clearSessionEffort (the handler
22340
- // clears AFTER its restore-apply, so the wrapper's write is undone).
22345
+ // Session-scoped (#3186): a positively-confirmed live apply records the
22346
+ // level IN MEMORY only — no durable carrier. The `.session-effort`
22347
+ // carrier is written solely by persistQueuedCommandForRestart (a queued
22348
+ // mid-turn /effort carried across the bounce) and is consume-once at
22349
+ // boot. `/effort default` clears via clearSessionEffort below.
22341
22350
  applyEffort: async (agent, level) => {
22342
22351
  const result = await applyEffort(agent, level)
22343
- if (result.ok) {
22344
- const agentDir = resolveAgentDirFromEnv()
22345
- if (agentDir) {
22346
- try {
22347
- writeSessionEffortFile(agentDir, level, getConfiguredEffortForPersist())
22348
- } catch (err) {
22349
- process.stderr.write(
22350
- `telegram gateway: session-effort persist failed level=${level}: ${(err as Error)?.message ?? String(err)}\n`,
22351
- )
22352
- }
22353
- }
22354
- }
22352
+ if (result.ok) sessionEffortOverride = level
22355
22353
  return result
22356
22354
  },
22357
22355
  getAgentName: getMyAgentName,
22358
22356
  getConfiguredEffort: () => getConfiguredEffortForPersist(),
22359
22357
  clearSessionEffort: () => {
22358
+ sessionEffortOverride = null
22359
+ // Also drop any leftover queued-command carrier so the next boot can't
22360
+ // consume a stale level the user just cleared.
22360
22361
  const agentDir = resolveAgentDirFromEnv()
22361
22362
  if (agentDir) clearSessionEffortFile(agentDir)
22362
22363
  },
22363
- getSessionEffort: () => {
22364
- const agentDir = resolveAgentDirFromEnv()
22365
- return agentDir ? (readSessionEffortFile(agentDir)?.level ?? null) : null
22366
- },
22364
+ getSessionEffort: () => sessionEffortOverride,
22367
22365
  escapeHtml: escapeHtmlForTg,
22368
22366
  }
22369
22367
  }
@@ -22508,14 +22506,10 @@ bot.command('restart', async ctx => {
22508
22506
  // greeting card shows "Restarted user: /restart from chat" instead
22509
22507
  // of whatever reason the downstream CLI would default to.
22510
22508
  stampUserRestartReason('user: /restart from chat')
22511
- // #3039: /restart is "bounce the session", NOT "clear my model" — the
22512
- // durable override survives every restart and is cleared only by
22513
- // `/model default`. Stamp keep for reason-honesty in the boot notice
22514
- // (absence of intent keeps anyway under the keep-by-default boot).
22515
- {
22516
- const smDir = resolveAgentDirFromEnv()
22517
- if (smDir) writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')
22518
- }
22509
+ // Session-scoped (rev 4): /restart reverts any live /model override to the
22510
+ // configured default. A consume-once `.session-model` carrier (if one was
22511
+ // in flight) was already consumed by its own apply-relaunch, so nothing to
22512
+ // do here — start.sh boots the configured default.
22519
22513
  await sweepBeforeSelfRestart()
22520
22514
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
22521
22515
  v: 1,
@@ -22674,12 +22668,8 @@ async function handleNewCommand(ctx: Context): Promise<void> {
22674
22668
  // Stamp user attribution so the next greeting shows "Restarted user:
22675
22669
  // /new" / "user: /reset" rather than the downstream CLI default.
22676
22670
  stampUserRestartReason(`user: /${kind} from chat`)
22677
- // /new and /reset start a fresh CONVERSATION, not a fresh model choice:
22678
- // the sticky session-model override KEEPS across them (contract row 7).
22679
- // Boot default is revert, so the keep-intent must land before dispatch.
22680
- if (agentDir != null) {
22681
- writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)
22682
- }
22671
+ // Session-scoped (rev 4): /new and /reset are restarts, so they revert any
22672
+ // live /model override to the configured default — no carrier to preserve.
22683
22673
  await sweepBeforeSelfRestart()
22684
22674
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
22685
22675
  v: 1,
@@ -23459,6 +23449,46 @@ const throttleTierRunner = createThrottleTierRunner({
23459
23449
  log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
23460
23450
  })
23461
23451
 
23452
+ // ─── litellm-local 429 notice — side-effect wiring ──────────────────────────
23453
+ // State machine + text + config parsing live in litellm-local-notice.ts
23454
+ // (pure); the sequencing (classification guard → per-agent cooldown →
23455
+ // broadcast + metric) lives in litellm-local-notice-wiring.ts so it is
23456
+ // unit-testable with injected deps. This block only binds the real gateway
23457
+ // dependencies. Deliberately NO broker surface: the litellm-local calm
23458
+ // path's invariant is that account state is never touched.
23459
+ const litellmLocalNoticeRunner = createLitellmLocalNoticeRunner({
23460
+ listNoticeChats: () => loadAccess().allowFrom,
23461
+ sendNotice: (chat_id, markdown) => {
23462
+ // Topic routing — this notice REPLACES the generic operator-event card
23463
+ // for the litellm-local classification, so it must land where that card
23464
+ // would have: supergroup-mode agents route system notifications into the
23465
+ // alerts/admin alias topic ('compact-watchdog' kind, same resolution as
23466
+ // the emitGatewayOperatorEvent broadcast loop), while DM recipients get
23467
+ // a thread-less send (topicForRecipient guards the #2096 "message
23468
+ // thread not found" misrouting class).
23469
+ const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
23470
+ const noticeSupergroup = resolveAgentSupergroupChatId()
23471
+ const noticeThread = topicForRecipient({
23472
+ recipientChatId: chat_id,
23473
+ resolvedTopic: noticeTopic,
23474
+ supergroupChatId: noticeSupergroup,
23475
+ })
23476
+ // Status notice, not the user's answer — silence the ping (same posture
23477
+ // as the throttle-tier / fleet-fallback announcements).
23478
+ void swallowingApiCall(
23479
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
23480
+ () => bot.api.sendRichMessage(chat_id, richMessage(markdown), {
23481
+ disable_notification: true,
23482
+ ...(noticeThread != null ? { message_thread_id: noticeThread } : {}),
23483
+ }),
23484
+ { chat_id: String(chat_id), verb: 'litellm-local-notice:notify' },
23485
+ )
23486
+ },
23487
+ windowMs: () => parseLitellmNoticeWindowMs(loadAccess().litellmNoticeWindowMs),
23488
+ emitMetric: (event) => emitRuntimeMetric(event),
23489
+ log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
23490
+ })
23491
+
23462
23492
  /**
23463
23493
  * Broadcast a fleet-fallback FAILURE notice to every authorized chat.
23464
23494
  *
@@ -25597,7 +25627,7 @@ bot.on('callback_query:data', async ctx => {
25597
25627
  // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
25598
25628
  // Parity with the text `/model sr-*` path — claude's native picker rejects
25599
25629
  // unknown sr-* ids, so an in-place inject can't set them. Carry the token
25600
- // across a graceful restart (the durable `.session-model` override) and
25630
+ // across a graceful restart (the consume-once `.session-model` carrier) and
25601
25631
  // relaunch `claude --model sr-*`. Session-only; reverts to the configured
25602
25632
  // default on the next restart. The sr-* → Claude direction is handled below
25603
25633
  // via the SELECT/alias outcome + isSrToClaudeTransition.
@@ -28006,46 +28036,36 @@ async function shutdown(signal: string): Promise<void> {
28006
28036
  } catch (err) {
28007
28037
  process.stderr.write(`telegram gateway: shutdown.clean_marker_write_failed err=${(err as Error).message}\n`)
28008
28038
  }
28009
- // #3017 — persist a Telegram-set model across a GRACEFUL deploy/restart.
28010
- // Boot default is REVERT, and an EXTERNAL deploy (SIGTERM to PID 1 from
28011
- // `switchroom apply` / `docker compose up`) never routes through
28012
- // triggerSelfRestart, so it stamps no `.relaunch-model-intent` and start.sh
28013
- // drops the user's `/model` choice (the overlord `fable`→`opus` revert on
28014
- // the v0.18.9 roll). A graceful OS-signal shutdown IS a clean, planned
28015
- // bounce — stamp keep-intent for an active `.session-model` override so the
28016
- // chosen model survives and start.sh re-confirms it via `.session-model-alert`
28017
- // on boot. Respect an intent an initiator already stamped (a /restart stamps
28018
- // 'revert' before SIGTERM): only stamp when none exists. A crash routes
28019
- // through the non-OS-signal branch and still reverts (safe side preserved).
28020
- try {
28021
- const smDir = resolveAgentDirFromEnv()
28022
- if (smDir != null) {
28023
- const hasOverride = readSessionModelFile(smDir) != null
28024
- const intentAlreadyStamped = existsSync(join(smDir, RELAUNCH_MODEL_INTENT_FILE))
28025
- if (hasOverride && !intentAlreadyStamped) {
28026
- // The GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX makes this stamp
28027
- // recognisable at the next GATEWAY boot: a gateway-only bounce never
28028
- // runs start.sh, so a leftover stamp with this prefix is cleared at
28029
- // boot (clearStaleGatewayShutdownIntent) instead of lingering to
28030
- // convert a later genuine crash into a "keep" (#3018 finding 4).
28031
- writeRelaunchModelIntent(smDir, 'keep', `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown (deploy/rolling restart) — preserving user-chosen session model`)
28032
- process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamped signal=${signal}\n`)
28033
- }
28034
- }
28035
- } catch (err) {
28036
- process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamp_failed err=${(err as Error).message}\n`)
28037
- }
28039
+ // Session-scoped (rev 4): a graceful deploy/restart REVERTS a live /model
28040
+ // override to the configured default — there is no keep-intent to stamp.
28041
+ // (A live Claude override never had a `.session-model` carrier; an in-flight
28042
+ // sr-* carrier was already consumed by its own apply-relaunch.) A queued —
28043
+ // not-yet-applied — /model IS still persisted just below so it applies as
28044
+ // the agent boots (its apply-relaunch), then reverts on the next restart.
28038
28045
  } else {
28039
28046
  process.stderr.write(`telegram gateway: shutdown.clean_marker_skipped signal=${signal} (crash path — banner will fire on next boot)\n`)
28040
28047
  }
28041
28048
 
28042
28049
  // #3018 finding 3 + #3039: resolve any queued /model|/effort ack cards. The
28043
28050
  // gateway (and with it the in-memory queue) is going away — persist each
28044
- // typed choice to the durable boot carriers (`.session-model` /
28045
- // `.session-effort`) so it still deterministically applies as the agent
28046
- // boots, and edit the ack card to say so. Only an unresolvable menu-tag
28047
- // selection falls back to a re-issue note. Best-effort and time-bounded so
28048
- // a wedged Telegram API can't block shutdown.
28051
+ // typed choice to the consume-once boot carriers (`.session-model` /
28052
+ // `.session-effort`, #3184/#3186) so it still deterministically applies as
28053
+ // the agent boots (that boot consumes the carrier; later restarts revert),
28054
+ // and edit the ack card to say so. Only an unresolvable menu-tag selection
28055
+ // falls back to a re-issue note. Best-effort and time-bounded so a wedged
28056
+ // Telegram API can't block shutdown.
28057
+ //
28058
+ // DELIBERATE (rev 4, #3184 review LOW-3 — applies to BOTH carriers): this
28059
+ // runs on EVERY shutdown path, including crashes (uncaughtException/
28060
+ // unhandledRejection route here), not just the isOsSignal branch above. So
28061
+ // a mid-turn queued /model or /effort + crash can apply on the
28062
+ // crash-recovery boot — technically at odds with a literal "crash reverts"
28063
+ // reading of the session-scoped contract. Intended: it preserves #3178's
28064
+ // "a queued command never silently vanishes" guarantee (the ack card
28065
+ // promised the switch), the model side is gated to offline-trusted tokens
28066
+ // (the effort side is allowlist-gated at write, so a garbage level can't
28067
+ // even cost one crash-boot), and the consume-once carriers bound it to
28068
+ // exactly that one recovery boot — the following restart reverts to config.
28049
28069
  const orphanedCmdActions = pendingCmdShutdownResolutionActions(pendingSessionCommand, escapeHtmlForTg)
28050
28070
  const orphanedCmdEdits = orphanedCmdActions.map(a => ({
28051
28071
  chatId: a.cmd.ackChatId,
@@ -28890,6 +28910,23 @@ void (async () => {
28890
28910
  } catch { /* leave override as-is on a bad read */ }
28891
28911
  }
28892
28912
 
28913
+ // Effort sibling (#3186): start.sh records the EFFECTIVE launched
28914
+ // effort to `.active-session-effort` every boot. Re-hydrate the
28915
+ // in-memory session-effort override so the /effort menu highlight
28916
+ // stays honest after a queued-carrier apply-boot. Only an effort
28917
+ // differing from the configured default counts as an override.
28918
+ const activeEffortPath = join(smAgentDir, '.active-session-effort')
28919
+ if (existsSync(activeEffortPath)) {
28920
+ try {
28921
+ const launchedEffort = readFileSync(activeEffortPath, 'utf8').trim()
28922
+ const configuredEffort = getConfiguredEffortForPersist()
28923
+ sessionEffortOverride =
28924
+ launchedEffort.length > 0 && launchedEffort !== configuredEffort
28925
+ ? launchedEffort
28926
+ : null
28927
+ } catch { /* leave override as-is on a bad read */ }
28928
+ }
28929
+
28893
28930
  const alertPath = join(smAgentDir, '.session-model-alert')
28894
28931
  if (existsSync(alertPath)) {
28895
28932
  let alertText: string | null = null
@@ -29112,6 +29149,14 @@ void (async () => {
29112
29149
  },
29113
29150
  ),
29114
29151
  },
29152
+ // #3084 follow-up: the feed's send/edit adapters transit the send
29153
+ // gate, which SHEDS (resolves undefined) any call made during an
29154
+ // open flood window. Give the feed the SAME on-disk window probe
29155
+ // robustApiCall + the held-card sweep read, so a running/first-
29156
+ // paint tick parks in cooldown instead of re-firing a shed send
29157
+ // every ~6s for the whole ban (the worker-feed shed-contract bug:
29158
+ // 565 `sent.message_id` crashes in one 6h ban).
29159
+ floodWaitRemainingMs: probeFloodWaitRemainingMs,
29115
29160
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
29116
29161
  })
29117
29162
  subagentWatcher = startSubagentWatcher({