switchroom 0.14.85 → 0.14.86

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.
@@ -13941,24 +13941,24 @@ class AuthBroker {
13941
13941
  }
13942
13942
  servingAccount(identity2) {
13943
13943
  const account = this.callerAccount(identity2);
13944
- if (identity2.kind !== "consumer")
13944
+ if (identity2.kind === "operator")
13945
13945
  return account;
13946
- return this.consumerAccountWithFailover(account);
13946
+ return this.accountWithFailover(account);
13947
13947
  }
13948
13948
  isAccountExhausted(account) {
13949
13949
  const q = this.quota[account];
13950
13950
  return q !== undefined && q.exhausted_until > this.now();
13951
13951
  }
13952
- consumerAccountWithFailover(pinned) {
13953
- if (!pinned || !this.isAccountExhausted(pinned))
13954
- return pinned;
13952
+ accountWithFailover(account) {
13953
+ if (!account || !this.isAccountExhausted(account))
13954
+ return account ?? null;
13955
13955
  for (const cand of this.config.auth?.fallback_order ?? []) {
13956
- if (cand === pinned || this.isAccountExhausted(cand))
13956
+ if (cand === account || this.isAccountExhausted(cand))
13957
13957
  continue;
13958
13958
  if (readAccountCredentials(cand, this.home))
13959
13959
  return cand;
13960
13960
  }
13961
- return pinned;
13961
+ return account;
13962
13962
  }
13963
13963
  async opGetCredentials(socket, id, identity2) {
13964
13964
  const account = this.servingAccount(identity2);
@@ -14600,7 +14600,7 @@ class AuthBroker {
14600
14600
  const auth = this.config.auth ?? {};
14601
14601
  const fanned = [];
14602
14602
  for (const [name, agent] of Object.entries(this.config.agents ?? {})) {
14603
- const effective = agent.auth?.override ?? auth.active;
14603
+ const effective = this.accountWithFailover(agent.auth?.override ?? auth.active);
14604
14604
  if (effective === label) {
14605
14605
  if (this.fanoutForAgent(name))
14606
14606
  fanned.push(name);
@@ -14645,7 +14645,7 @@ class AuthBroker {
14645
14645
  const agent = (this.config.agents ?? {})[name];
14646
14646
  if (!agent)
14647
14647
  return false;
14648
- const effective = agent.auth?.override ?? auth.active;
14648
+ const effective = this.accountWithFailover(agent.auth?.override ?? auth.active);
14649
14649
  if (!effective)
14650
14650
  return false;
14651
14651
  return this.mirrorAccountToAgent(effective, name);
@@ -176,7 +176,7 @@ function sendKeys2(agentName, keys) {
176
176
 
177
177
  // src/agents/wedge-watchdog.ts
178
178
  var WEDGE_FOOTER_SIGNATURE = /(?=[\s\S]*[Ee]sc(?:ape)?[^\n]*cancel)(?=[\s\S]*(?:to select|to navigate|\u2191\/\u2193))/;
179
- var RATE_LIMIT_MENU_SIGNATURE = /(?=[\s\S]*\/rate-limit-options)(?=[\s\S]*(?:Switch to usage credits|Upgrade your plan))/;
179
+ var RATE_LIMIT_MENU_SIGNATURE = /(?=[\s\S]*Stop and wait for)(?=[\s\S]*(?:usage credits|Upgrade your plan|\/rate-limit-options))/;
180
180
  var MONTHS = {
181
181
  jan: 0,
182
182
  feb: 1,
@@ -49815,8 +49815,8 @@ var {
49815
49815
  } = import__.default;
49816
49816
 
49817
49817
  // src/build-info.ts
49818
- var VERSION = "0.14.85";
49819
- var COMMIT_SHA = "292c683f";
49818
+ var VERSION = "0.14.86";
49819
+ var COMMIT_SHA = "8c518120";
49820
49820
 
49821
49821
  // src/cli/agent.ts
49822
49822
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.85",
3
+ "version": "0.14.86",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41381,6 +41381,15 @@ function createFleetFallbackGate(opts) {
41381
41381
  };
41382
41382
  }
41383
41383
 
41384
+ // gateway/exhaust-until.ts
41385
+ var EXHAUST_WEEKLY_FLOOR_MS = 7 * 24 * 60 * 60 * 1000;
41386
+ function resolveExhaustUntil(resetAtMs, now = Date.now()) {
41387
+ if (typeof resetAtMs === "number" && Number.isFinite(resetAtMs) && resetAtMs > now) {
41388
+ return resetAtMs;
41389
+ }
41390
+ return now + EXHAUST_WEEKLY_FLOOR_MS;
41391
+ }
41392
+
41384
41393
  // gateway/auth-add-flow.ts
41385
41394
  import { spawn } from "node:child_process";
41386
41395
  import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync9, rmSync as rmSync3 } from "node:fs";
@@ -52880,11 +52889,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52880
52889
  }
52881
52890
 
52882
52891
  // ../src/build-info.ts
52883
- var VERSION = "0.14.85";
52884
- var COMMIT_SHA = "292c683f";
52885
- var COMMIT_DATE = "2026-06-07T14:50:17+10:00";
52886
- var LATEST_PR = null;
52887
- var COMMITS_AHEAD_OF_TAG = 2;
52892
+ var VERSION = "0.14.86";
52893
+ var COMMIT_SHA = "8c518120";
52894
+ var COMMIT_DATE = "2026-06-07T05:43:21Z";
52895
+ var LATEST_PR = 2223;
52896
+ var COMMITS_AHEAD_OF_TAG = 0;
52888
52897
 
52889
52898
  // gateway/boot-version.ts
52890
52899
  function formatRelativeAgo(iso) {
@@ -55245,7 +55254,8 @@ function emitGatewayOperatorEvent(event) {
55245
55254
  });
55246
55255
  renderedKeyboard = undefined;
55247
55256
  if (willActuallyFire) {
55248
- fireFleetAutoFallback(agent);
55257
+ const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime());
55258
+ fireFleetAutoFallback(agent, untilMs);
55249
55259
  }
55250
55260
  } else {
55251
55261
  try {
@@ -56182,8 +56192,7 @@ var ipcServer = createIpcServer({
56182
56192
  }
56183
56193
  },
56184
56194
  onQuotaWallDetected(_client, msg) {
56185
- const WEEKLY_MS = 604800000;
56186
- const untilMs = typeof msg.resetAt === "number" && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now() ? msg.resetAt : Date.now() + WEEKLY_MS;
56195
+ const untilMs = resolveExhaustUntil(msg.resetAt);
56187
56196
  process.stderr.write(`telegram gateway: quota_wall_detected agent=${msg.agentName} until=${new Date(untilMs).toISOString()}` + (msg.resetAt == null ? " (reset unparsed \u2192 +7d default)" : "") + ` \u2014 triggering fleet auto-fallback
56188
56197
  `);
56189
56198
  fireFleetAutoFallback(msg.agentName, untilMs);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * exhaust-until.ts — the single decision for the `until` (epoch-ms) handed to
3
+ * the broker's mark-exhausted, shared by BOTH quota-wall paths in the gateway:
4
+ * - the /rate-limit-options menu signal (onQuotaWallDetected), and
5
+ * - the inference-path 429 (the model-unavailable operator-event path).
6
+ *
7
+ * Factored out of gateway.ts so the decision is unit-testable without importing
8
+ * the gateway module (heavy import-time side effects). Pure: no IPC, no clock
9
+ * except the injectable `now`.
10
+ *
11
+ * The ONE invariant both paths depend on: NEVER return undefined. A weekly wall
12
+ * reaches the gateway as a reset hint the parser can't always read:
13
+ * - Menu path: the sidecar's parseWeeklyReset handles "resets Jun 9, 5am (tz)"
14
+ * → a finite future epoch flows straight through.
15
+ * - 429 path: model-unavailable.ts's parseResetTime handles the ROLLING
16
+ * wordings ("resets in 2h", "retry after 60s") → a finite ~hours epoch. But
17
+ * the WEEKLY wording reaches it as "resets <Mon> <D>, <H>am", which
18
+ * parseResetTime CANNOT parse (`new Date('Jun 9, 5am 2026')` → Invalid Date)
19
+ * → resetAtMs is undefined here EXACTLY when the wall is weekly.
20
+ *
21
+ * undefined (or a past / NaN reset) is the dangerous case: markExhausted's ~5h
22
+ * default would un-exhaust a weekly-walled account after 5h and let the broker
23
+ * re-mirror it onto the fleet (the #2218 rollback). So undefined → the +7d
24
+ * floor. A too-long `until` only delays the broker's re-probe of the account;
25
+ * it never serves an exhausted account, so erring long is the compliance-safe
26
+ * direction (vision.md pillar 3).
27
+ */
28
+
29
+ /** Weekly-quota window — the safety floor for an exhaustion `until`. */
30
+ export const EXHAUST_WEEKLY_FLOOR_MS = 7 * 24 * 60 * 60 * 1000
31
+
32
+ /**
33
+ * Resolve the exhaustion `until` (epoch-ms) from a best-effort reset timestamp.
34
+ * Returns `resetAtMs` when it is a finite epoch in the future; otherwise the
35
+ * +7d floor anchored at `now`. NEVER returns undefined.
36
+ */
37
+ export function resolveExhaustUntil(
38
+ resetAtMs: number | undefined,
39
+ now: number = Date.now(),
40
+ ): number {
41
+ if (typeof resetAtMs === 'number' && Number.isFinite(resetAtMs) && resetAtMs > now) {
42
+ return resetAtMs
43
+ }
44
+ return now + EXHAUST_WEEKLY_FLOOR_MS
45
+ }
@@ -125,6 +125,7 @@ import {
125
125
  } from './microsoft-connect-flow.js'
126
126
  import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
127
127
  import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
128
+ import { resolveExhaustUntil } from './exhaust-until.js'
128
129
  import {
129
130
  pendingAuthAddFlows,
130
131
  startAccountAuthSession,
@@ -4400,8 +4401,17 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
4400
4401
  // separately with the causal-shape headline ("5-hour limit on
4401
4402
  // ken" instead of generic "quota exhausted") — see
4402
4403
  // auth-snapshot-format.ts → renderFallbackAnnouncement.
4404
+ //
4405
+ // Thread the parsed reset as markExhausted's `until` via the shared
4406
+ // resolveExhaustUntil floor. parseResetTime gives a finite epoch for
4407
+ // ROLLING wordings ("resets in 2h", "retry after 60s"); a WEEKLY wall
4408
+ // ("resets Jun 9, 5am") is UNPARSEABLE here → undefined → +7d floor.
4409
+ // Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
4410
+ // weekly wall that surfaced as a 429 got markExhausted's ~5h default and
4411
+ // the broker re-mirrored the still-walled account onto the fleet after 5h.
4403
4412
  if (willActuallyFire) {
4404
- void fireFleetAutoFallback(agent)
4413
+ const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
4414
+ void fireFleetAutoFallback(agent, untilMs)
4405
4415
  }
4406
4416
  } else {
4407
4417
  try {
@@ -6271,11 +6281,7 @@ const ipcServer: IpcServer = createIpcServer({
6271
6281
  // existing chain handles the rest (roll to a fallback subscription account,
6272
6282
  // or the all-exhausted operator alert when none has quota). Fire-and-forget.
6273
6283
  onQuotaWallDetected(_client: IpcClient, msg: QuotaWallDetectedMessage) {
6274
- const WEEKLY_MS = 7 * 24 * 60 * 60 * 1000
6275
- const untilMs =
6276
- typeof msg.resetAt === 'number' && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now()
6277
- ? msg.resetAt
6278
- : Date.now() + WEEKLY_MS
6284
+ const untilMs = resolveExhaustUntil(msg.resetAt)
6279
6285
  process.stderr.write(
6280
6286
  `telegram gateway: quota_wall_detected agent=${msg.agentName} ` +
6281
6287
  `until=${new Date(untilMs).toISOString()}` +
@@ -14681,10 +14687,12 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
14681
14687
  // operator is explicitly choosing, and is admin); only this automatic
14682
14688
  // path moves to the non-admin verb.
14683
14689
  failover: async () => {
14684
- // The 429 inference path passes no `until` (broker ~5h default). The
14685
- // rate-limit-MENU path (quota_wall_detected) passes the parsed WEEKLY
14686
- // reset, so the walled account isn't re-probed (and re-wedged) within
14687
- // the 5h default while it's weekly-capped.
14690
+ // BOTH paths now pass a resolved `untilMs` via resolveExhaustUntil
14691
+ // (never undefined): the rate-limit-MENU path threads the parsed weekly
14692
+ // reset, and the 429 inference path threads parseResetTime's value or
14693
+ // the +7d floor when it's a weekly wall it can't parse. So a
14694
+ // weekly-capped account is never re-probed (and re-wedged) within the
14695
+ // broker's ~5h default.
14688
14696
  const r = await client.markExhausted(untilMs)
14689
14697
  return { rolledTo: r.rolledTo ?? null, rolled: r.rolled }
14690
14698
  },
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Unit tests for the shared exhaustion-`until` decision (exhaust-until.ts) and
3
+ * the 429-path composition it sits in.
4
+ *
5
+ * The load-bearing invariant (vision.md pillar 3 + the #2218 weekly-wall
6
+ * rollback): the value handed to markExhausted is NEVER undefined, so a weekly
7
+ * wall can never fall back to the broker's ~5h default and un-exhaust early.
8
+ *
9
+ * The 429-path composition test runs the REAL detectModelUnavailable
10
+ * (model-unavailable.ts) into the REAL resolveExhaustUntil — proving the gateway
11
+ * 429 branch (`resolveExhaustUntil(modelUnavailable.resetAt?.getTime())`) threads
12
+ * a finite `until` for both the rolling and the unparseable-weekly wordings.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import { resolveExhaustUntil, EXHAUST_WEEKLY_FLOOR_MS } from '../gateway/exhaust-until.js'
17
+ import { detectModelUnavailable } from '../model-unavailable.js'
18
+
19
+ const NOW = Date.UTC(2026, 5, 7, 0, 0, 0) // 2026-06-07
20
+
21
+ describe('resolveExhaustUntil', () => {
22
+ it('passes through a finite reset in the future', () => {
23
+ const reset = NOW + 2 * 60 * 60 * 1000 // +2h (a rolling window)
24
+ expect(resolveExhaustUntil(reset, NOW)).toBe(reset)
25
+ })
26
+
27
+ it('undefined → +7d floor (the WEEKLY-wall case), never undefined', () => {
28
+ const until = resolveExhaustUntil(undefined, NOW)
29
+ expect(until).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
30
+ expect(until).toBeGreaterThan(NOW + 5 * 60 * 60 * 1000) // past the ~5h default
31
+ })
32
+
33
+ it('a past / now / NaN / non-finite reset → +7d floor (treated as unparsed)', () => {
34
+ expect(resolveExhaustUntil(NOW - 1, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
35
+ expect(resolveExhaustUntil(NOW, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
36
+ expect(resolveExhaustUntil(Number.NaN, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
37
+ expect(resolveExhaustUntil(Number.POSITIVE_INFINITY, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
38
+ })
39
+
40
+ it('the floor is comfortably longer than the broker ~5h mark-exhausted default', () => {
41
+ expect(EXHAUST_WEEKLY_FLOOR_MS).toBeGreaterThan(5 * 60 * 60 * 1000)
42
+ expect(EXHAUST_WEEKLY_FLOOR_MS).toBe(7 * 24 * 60 * 60 * 1000)
43
+ })
44
+ })
45
+
46
+ describe('429-path composition: detectModelUnavailable → resolveExhaustUntil', () => {
47
+ // This mirrors gateway.ts's 429 branch:
48
+ // const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
49
+ // NB: parseResetTime anchors RELATIVE wordings ("in 2h", "retry after Ns") at
50
+ // the real Date.now() — not injectable through detectModelUnavailable — so the
51
+ // rolling-window assertions check the SHAPE (finite, future, well below the
52
+ // +7d floor, no longer than the stated window), never an exact epoch.
53
+ function untilFor(stderr: string): { u: number; before: number } {
54
+ const before = Date.now()
55
+ const d = detectModelUnavailable(stderr)
56
+ expect(d).not.toBeNull()
57
+ return { u: resolveExhaustUntil(d?.resetAt?.getTime(), before), before }
58
+ }
59
+
60
+ it('ROLLING wording ("resets in 2h 15m") → a finite ~2h until, NOT the +7d floor', () => {
61
+ const { u, before } = untilFor('usage limit reached · resets in 2h 15m')
62
+ expect(Number.isFinite(u)).toBe(true)
63
+ expect(u).toBeGreaterThan(before)
64
+ const window = (2 * 60 + 15) * 60 * 1000
65
+ expect(u).toBeLessThanOrEqual(before + window + 2000) // ~2h15m, allow detect/resolve skew
66
+ expect(u).toBeLessThan(before + EXHAUST_WEEKLY_FLOOR_MS / 2) // unmistakably NOT the floor
67
+ })
68
+
69
+ it('ROLLING wording ("retry after 60 seconds") → a finite ~60s until, NOT the +7d floor', () => {
70
+ const { u, before } = untilFor('rate_limit_error: retry after 60 seconds — usage limit')
71
+ expect(u).toBeGreaterThan(before)
72
+ expect(u).toBeLessThanOrEqual(before + 60 * 1000 + 2000)
73
+ expect(u).toBeLessThan(before + EXHAUST_WEEKLY_FLOOR_MS / 2)
74
+ })
75
+
76
+ it("WEEKLY wording (\"resets Jun 9, 5am (tz)\") is UNPARSEABLE on the 429 path → +7d floor, NOT undefined/5h", () => {
77
+ // parseResetTime can't read the calendar weekly wording — Invalid Date →
78
+ // resetAt undefined. resolveExhaustUntil MUST floor it to +7d, otherwise
79
+ // markExhausted's ~5h default re-wedges the weekly-walled account.
80
+ const d = detectModelUnavailable("You've hit your limit · resets Jun 9, 5am (Australia/Melbourne)")
81
+ expect(d?.kind).toBe('quota_exhausted')
82
+ expect(d?.resetAt).toBeUndefined() // pins the parser-blind-spot premise
83
+ const u = resolveExhaustUntil(d?.resetAt?.getTime(), NOW)
84
+ expect(u).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
85
+ })
86
+ })