switchroom 0.14.93 → 0.15.1

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.
@@ -13283,6 +13283,13 @@ var ProbeQuotaRequestSchema = exports_external.object({
13283
13283
  accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
13284
13284
  timeoutMs: exports_external.number().int().positive().max(60000).optional()
13285
13285
  });
13286
+ var ClaimNotificationRequestSchema = exports_external.object({
13287
+ v: exports_external.literal(PROTOCOL_VERSION),
13288
+ op: exports_external.literal("claim-notification"),
13289
+ id: exports_external.string().min(1),
13290
+ key: exports_external.string().min(1).max(512),
13291
+ windowMs: exports_external.number().int().positive().max(86400000)
13292
+ });
13286
13293
  var RequestSchema2 = exports_external.discriminatedUnion("op", [
13287
13294
  GetCredentialsRequestSchema,
13288
13295
  ListStateRequestSchema,
@@ -13294,7 +13301,8 @@ var RequestSchema2 = exports_external.discriminatedUnion("op", [
13294
13301
  SetOverrideRequestSchema,
13295
13302
  ListGoogleAccountsRequestSchema,
13296
13303
  ListMicrosoftAccountsRequestSchema,
13297
- ProbeQuotaRequestSchema
13304
+ ProbeQuotaRequestSchema,
13305
+ ClaimNotificationRequestSchema
13298
13306
  ]);
13299
13307
  var GetCredentialsDataSchema = exports_external.object({
13300
13308
  account: exports_external.string(),
@@ -13350,6 +13358,9 @@ var SetOverrideDataSchema = exports_external.object({
13350
13358
  agent: exports_external.string(),
13351
13359
  account: exports_external.string().nullable()
13352
13360
  });
13361
+ var ClaimNotificationDataSchema = exports_external.object({
13362
+ granted: exports_external.boolean()
13363
+ });
13353
13364
  var GoogleAccountStateSchema = exports_external.object({
13354
13365
  account: exports_external.string(),
13355
13366
  expiresAt: exports_external.number(),
@@ -13556,6 +13567,16 @@ class AuthBrokerClient {
13556
13567
  const data = await this.send(req);
13557
13568
  return data;
13558
13569
  }
13570
+ async claimNotification(key, windowMs) {
13571
+ const data = await this.send({
13572
+ v: PROTOCOL_VERSION,
13573
+ id: randomUUID(),
13574
+ op: "claim-notification",
13575
+ key,
13576
+ windowMs
13577
+ });
13578
+ return data;
13579
+ }
13559
13580
  async refreshAccount(account) {
13560
13581
  const data = await this.send({
13561
13582
  v: PROTOCOL_VERSION,
@@ -13367,6 +13367,13 @@ var ProbeQuotaRequestSchema = exports_external.object({
13367
13367
  accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
13368
13368
  timeoutMs: exports_external.number().int().positive().max(60000).optional()
13369
13369
  });
13370
+ var ClaimNotificationRequestSchema = exports_external.object({
13371
+ v: exports_external.literal(PROTOCOL_VERSION),
13372
+ op: exports_external.literal("claim-notification"),
13373
+ id: exports_external.string().min(1),
13374
+ key: exports_external.string().min(1).max(512),
13375
+ windowMs: exports_external.number().int().positive().max(86400000)
13376
+ });
13370
13377
  var RequestSchema = exports_external.discriminatedUnion("op", [
13371
13378
  GetCredentialsRequestSchema,
13372
13379
  ListStateRequestSchema,
@@ -13378,7 +13385,8 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
13378
13385
  SetOverrideRequestSchema,
13379
13386
  ListGoogleAccountsRequestSchema,
13380
13387
  ListMicrosoftAccountsRequestSchema,
13381
- ProbeQuotaRequestSchema
13388
+ ProbeQuotaRequestSchema,
13389
+ ClaimNotificationRequestSchema
13382
13390
  ]);
13383
13391
  var GetCredentialsDataSchema = exports_external.object({
13384
13392
  account: exports_external.string(),
@@ -13434,6 +13442,9 @@ var SetOverrideDataSchema = exports_external.object({
13434
13442
  agent: exports_external.string(),
13435
13443
  account: exports_external.string().nullable()
13436
13444
  });
13445
+ var ClaimNotificationDataSchema = exports_external.object({
13446
+ granted: exports_external.boolean()
13447
+ });
13437
13448
  var GoogleAccountStateSchema = exports_external.object({
13438
13449
  account: exports_external.string(),
13439
13450
  expiresAt: exports_external.number(),
@@ -13520,6 +13531,7 @@ var MARK_EXHAUSTED_DEFAULT_MS = 5 * 60 * 60 * 1000;
13520
13531
  var AUDIT_ROTATE_BYTES = 10 * 1024 * 1024;
13521
13532
  var AUDIT_KEEP = 5;
13522
13533
  var AUDIT_LINE_MAX = 4000;
13534
+ var NOTIFICATION_CLAIM_MAX_AGE_MS = 86400000;
13523
13535
  function sha256Hex(content) {
13524
13536
  return createHash2("sha256").update(content).digest("hex");
13525
13537
  }
@@ -13560,6 +13572,7 @@ class AuthBroker {
13560
13572
  listeners = new Map;
13561
13573
  refreshTimer = null;
13562
13574
  consumerProbeTimer = null;
13575
+ fleetProbeTimer = null;
13563
13576
  fetchQuotaImpl;
13564
13577
  stateDir;
13565
13578
  socketRoot;
@@ -13572,6 +13585,7 @@ class AuthBroker {
13572
13585
  lastQuotaCache = {};
13573
13586
  shaIndex = {};
13574
13587
  thresholdViolations = {};
13588
+ notificationClaims = {};
13575
13589
  lastWrittenExpiresAt = new Map;
13576
13590
  refreshInFlight = new Set;
13577
13591
  consumerLastSeen = {};
@@ -13641,6 +13655,17 @@ class AuthBroker {
13641
13655
  }, probeMs);
13642
13656
  this.consumerProbeTimer.unref();
13643
13657
  }
13658
+ if (probeMs > 0 && process.env.SWITCHROOM_DISABLE_FLEET_QUOTA_PROBE !== "1") {
13659
+ this.fleetQuotaProbeTick().catch((err) => {
13660
+ this.logErr(`fleet-quota-probe (boot) threw: ${err.message}`);
13661
+ });
13662
+ this.fleetProbeTimer = setInterval(() => {
13663
+ this.fleetQuotaProbeTick().catch((err) => {
13664
+ this.logErr(`fleet-quota-probe threw: ${err.message}`);
13665
+ });
13666
+ }, probeMs);
13667
+ this.fleetProbeTimer.unref();
13668
+ }
13644
13669
  }
13645
13670
  const fanned = this.fanoutAll();
13646
13671
  if (fanned.length > 0) {
@@ -13671,6 +13696,10 @@ class AuthBroker {
13671
13696
  clearInterval(this.consumerProbeTimer);
13672
13697
  this.consumerProbeTimer = null;
13673
13698
  }
13699
+ if (this.fleetProbeTimer) {
13700
+ clearInterval(this.fleetProbeTimer);
13701
+ this.fleetProbeTimer = null;
13702
+ }
13674
13703
  for (const [sock, lis] of this.listeners) {
13675
13704
  try {
13676
13705
  lis.server.close();
@@ -13955,6 +13984,9 @@ class AuthBroker {
13955
13984
  case "probe-quota":
13956
13985
  await this.opProbeQuota(socket, reqId, identity2, req.accounts, req.timeoutMs);
13957
13986
  break;
13987
+ case "claim-notification":
13988
+ this.opClaimNotification(socket, reqId, identity2, req.key, req.windowMs);
13989
+ break;
13958
13990
  }
13959
13991
  } catch (err) {
13960
13992
  socket.write(encodeError(reqId, "INTERNAL", err.message));
@@ -14096,22 +14128,42 @@ class AuthBroker {
14096
14128
  ok: result.ok,
14097
14129
  error: result.ok ? undefined : result.reason
14098
14130
  });
14099
- if (result.ok) {
14100
- this.lastQuotaCache[label] = {
14101
- fiveHourUtilizationPct: result.data.fiveHourUtilizationPct,
14102
- sevenDayUtilizationPct: result.data.sevenDayUtilizationPct,
14103
- fiveHourResetAt: result.data.fiveHourResetAt?.toISOString() ?? null,
14104
- sevenDayResetAt: result.data.sevenDayResetAt?.toISOString() ?? null,
14105
- representativeClaim: result.data.representativeClaim,
14106
- overageStatus: result.data.overageStatus,
14107
- overageDisabledReason: result.data.overageDisabledReason,
14108
- capturedAt: this.now()
14109
- };
14110
- }
14131
+ if (result.ok)
14132
+ this.cacheQuotaSnapshot(label, result);
14111
14133
  return { label, result };
14112
14134
  }));
14113
14135
  socket.write(encodeSuccess(id, { results }));
14114
14136
  }
14137
+ cacheQuotaSnapshot(label, result) {
14138
+ if (!result.ok)
14139
+ return;
14140
+ this.lastQuotaCache[label] = {
14141
+ fiveHourUtilizationPct: result.data.fiveHourUtilizationPct,
14142
+ sevenDayUtilizationPct: result.data.sevenDayUtilizationPct,
14143
+ fiveHourResetAt: result.data.fiveHourResetAt?.toISOString() ?? null,
14144
+ sevenDayResetAt: result.data.sevenDayResetAt?.toISOString() ?? null,
14145
+ representativeClaim: result.data.representativeClaim,
14146
+ overageStatus: result.data.overageStatus,
14147
+ overageDisabledReason: result.data.overageDisabledReason,
14148
+ capturedAt: this.now()
14149
+ };
14150
+ }
14151
+ async fleetQuotaProbeTick() {
14152
+ for (const label of listAccounts(this.home)) {
14153
+ const creds = readAccountCredentials(label, this.home);
14154
+ const token = creds?.claudeAiOauth?.accessToken;
14155
+ if (!token)
14156
+ continue;
14157
+ let result;
14158
+ try {
14159
+ result = await this.fetchQuotaImpl({ accessToken: token });
14160
+ } catch (err) {
14161
+ this.logErr(`fleet-quota-probe ${label}: ${err.message}`);
14162
+ continue;
14163
+ }
14164
+ this.cacheQuotaSnapshot(label, result);
14165
+ }
14166
+ }
14115
14167
  async consumerQuotaProbeTick() {
14116
14168
  const accounts = Array.from(new Set((this.config.auth?.consumers ?? []).map((c) => c.account)));
14117
14169
  for (const label of accounts) {
@@ -14175,6 +14227,21 @@ class AuthBroker {
14175
14227
  this.audit({ op: "mark-exhausted", identity: identity2, account, ok: true });
14176
14228
  socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
14177
14229
  }
14230
+ opClaimNotification(socket, id, identity2, key, windowMs) {
14231
+ const now = this.now();
14232
+ const prev = this.notificationClaims[key];
14233
+ const granted = prev === undefined || now - prev >= windowMs;
14234
+ if (granted) {
14235
+ this.notificationClaims[key] = now;
14236
+ for (const [k, ts] of Object.entries(this.notificationClaims)) {
14237
+ if (now - ts > NOTIFICATION_CLAIM_MAX_AGE_MS)
14238
+ delete this.notificationClaims[k];
14239
+ }
14240
+ this.persistNotificationClaims();
14241
+ this.audit({ op: "claim-notification", identity: identity2, account: key, ok: true });
14242
+ }
14243
+ socket.write(encodeSuccess(id, { granted }));
14244
+ }
14178
14245
  async opRefreshAccount(socket, id, identity2, account) {
14179
14246
  if (!this.isAdmin(identity2)) {
14180
14247
  this.audit({ op: "refresh-account", identity: identity2, account, ok: false, error: "FORBIDDEN" });
@@ -14777,6 +14844,7 @@ class AuthBroker {
14777
14844
  this.quota = this.readJson("quota.json") ?? {};
14778
14845
  this.shaIndex = this.readJson("sha-index.json") ?? {};
14779
14846
  this.thresholdViolations = this.readJson("threshold-violations.json") ?? {};
14847
+ this.notificationClaims = this.readJson("notification-claims.json") ?? {};
14780
14848
  }
14781
14849
  readJson(name) {
14782
14850
  const p = join4(this.stateDir, name);
@@ -14791,6 +14859,9 @@ class AuthBroker {
14791
14859
  persistQuota() {
14792
14860
  atomicWriteJsonSync(join4(this.stateDir, "quota.json"), this.quota, 384);
14793
14861
  }
14862
+ persistNotificationClaims() {
14863
+ atomicWriteJsonSync(join4(this.stateDir, "notification-claims.json"), this.notificationClaims, 384);
14864
+ }
14794
14865
  persistShaIndex() {
14795
14866
  atomicWriteJsonSync(join4(this.stateDir, "sha-index.json"), this.shaIndex, 384);
14796
14867
  }
@@ -4000,7 +4000,7 @@ function decodeResponse(line) {
4000
4000
  }
4001
4001
  return ResponseSchema.parse(parsed);
4002
4002
  }
4003
- var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
4003
+ var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, ClaimNotificationRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, ClaimNotificationDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
4004
4004
  var init_protocol = __esm(() => {
4005
4005
  init_zod();
4006
4006
  MAX_FRAME_BYTES = 64 * 1024;
@@ -4116,6 +4116,13 @@ var init_protocol = __esm(() => {
4116
4116
  accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
4117
4117
  timeoutMs: exports_external.number().int().positive().max(60000).optional()
4118
4118
  });
4119
+ ClaimNotificationRequestSchema = exports_external.object({
4120
+ v: exports_external.literal(PROTOCOL_VERSION),
4121
+ op: exports_external.literal("claim-notification"),
4122
+ id: exports_external.string().min(1),
4123
+ key: exports_external.string().min(1).max(512),
4124
+ windowMs: exports_external.number().int().positive().max(86400000)
4125
+ });
4119
4126
  RequestSchema = exports_external.discriminatedUnion("op", [
4120
4127
  GetCredentialsRequestSchema,
4121
4128
  ListStateRequestSchema,
@@ -4127,7 +4134,8 @@ var init_protocol = __esm(() => {
4127
4134
  SetOverrideRequestSchema,
4128
4135
  ListGoogleAccountsRequestSchema,
4129
4136
  ListMicrosoftAccountsRequestSchema,
4130
- ProbeQuotaRequestSchema
4137
+ ProbeQuotaRequestSchema,
4138
+ ClaimNotificationRequestSchema
4131
4139
  ]);
4132
4140
  GetCredentialsDataSchema = exports_external.object({
4133
4141
  account: exports_external.string(),
@@ -4183,6 +4191,9 @@ var init_protocol = __esm(() => {
4183
4191
  agent: exports_external.string(),
4184
4192
  account: exports_external.string().nullable()
4185
4193
  });
4194
+ ClaimNotificationDataSchema = exports_external.object({
4195
+ granted: exports_external.boolean()
4196
+ });
4186
4197
  GoogleAccountStateSchema = exports_external.object({
4187
4198
  account: exports_external.string(),
4188
4199
  expiresAt: exports_external.number(),
@@ -4364,6 +4375,16 @@ class AuthBrokerClient {
4364
4375
  const data = await this.send(req);
4365
4376
  return data;
4366
4377
  }
4378
+ async claimNotification(key, windowMs) {
4379
+ const data = await this.send({
4380
+ v: PROTOCOL_VERSION,
4381
+ id: randomUUID(),
4382
+ op: "claim-notification",
4383
+ key,
4384
+ windowMs
4385
+ });
4386
+ return data;
4387
+ }
4367
4388
  async refreshAccount(account) {
4368
4389
  const data = await this.send({
4369
4390
  v: PROTOCOL_VERSION,
@@ -25561,7 +25561,7 @@ function decodeResponse2(line) {
25561
25561
  }
25562
25562
  return ResponseSchema2.parse(parsed);
25563
25563
  }
25564
- var MAX_FRAME_BYTES2, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema2, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema2, ResponseSchema2;
25564
+ var MAX_FRAME_BYTES2, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, ClaimNotificationRequestSchema, RequestSchema2, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, ClaimNotificationDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema2, ResponseSchema2;
25565
25565
  var init_protocol2 = __esm(() => {
25566
25566
  init_zod();
25567
25567
  MAX_FRAME_BYTES2 = 64 * 1024;
@@ -25677,6 +25677,13 @@ var init_protocol2 = __esm(() => {
25677
25677
  accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
25678
25678
  timeoutMs: exports_external.number().int().positive().max(60000).optional()
25679
25679
  });
25680
+ ClaimNotificationRequestSchema = exports_external.object({
25681
+ v: exports_external.literal(PROTOCOL_VERSION),
25682
+ op: exports_external.literal("claim-notification"),
25683
+ id: exports_external.string().min(1),
25684
+ key: exports_external.string().min(1).max(512),
25685
+ windowMs: exports_external.number().int().positive().max(86400000)
25686
+ });
25680
25687
  RequestSchema2 = exports_external.discriminatedUnion("op", [
25681
25688
  GetCredentialsRequestSchema,
25682
25689
  ListStateRequestSchema,
@@ -25688,7 +25695,8 @@ var init_protocol2 = __esm(() => {
25688
25695
  SetOverrideRequestSchema,
25689
25696
  ListGoogleAccountsRequestSchema,
25690
25697
  ListMicrosoftAccountsRequestSchema,
25691
- ProbeQuotaRequestSchema
25698
+ ProbeQuotaRequestSchema,
25699
+ ClaimNotificationRequestSchema
25692
25700
  ]);
25693
25701
  GetCredentialsDataSchema = exports_external.object({
25694
25702
  account: exports_external.string(),
@@ -25744,6 +25752,9 @@ var init_protocol2 = __esm(() => {
25744
25752
  agent: exports_external.string(),
25745
25753
  account: exports_external.string().nullable()
25746
25754
  });
25755
+ ClaimNotificationDataSchema = exports_external.object({
25756
+ granted: exports_external.boolean()
25757
+ });
25747
25758
  GoogleAccountStateSchema = exports_external.object({
25748
25759
  account: exports_external.string(),
25749
25760
  expiresAt: exports_external.number(),
@@ -25925,6 +25936,16 @@ class AuthBrokerClient {
25925
25936
  const data = await this.send(req);
25926
25937
  return data;
25927
25938
  }
25939
+ async claimNotification(key, windowMs) {
25940
+ const data = await this.send({
25941
+ v: PROTOCOL_VERSION,
25942
+ id: randomUUID(),
25943
+ op: "claim-notification",
25944
+ key,
25945
+ windowMs
25946
+ });
25947
+ return data;
25948
+ }
25928
25949
  async refreshAccount(account) {
25929
25950
  const data = await this.send({
25930
25951
  v: PROTOCOL_VERSION,
@@ -49937,8 +49958,8 @@ var {
49937
49958
  } = import__.default;
49938
49959
 
49939
49960
  // src/build-info.ts
49940
- var VERSION = "0.14.93";
49941
- var COMMIT_SHA = "87b62902";
49961
+ var VERSION = "0.15.1";
49962
+ var COMMIT_SHA = "a93177c8";
49942
49963
 
49943
49964
  // src/cli/agent.ts
49944
49965
  init_source();
@@ -59358,15 +59379,27 @@ function formatQuotaReset(state) {
59358
59379
  const mins = Math.floor(remainingMs % 3600000 / 60000);
59359
59380
  return `${hours}h ${mins}m`;
59360
59381
  }
59382
+ function formatQuotaUtilCell(a, now = Date.now()) {
59383
+ const lq = a.last_quota;
59384
+ if (!lq)
59385
+ return "no data";
59386
+ const ageMs = Math.max(0, now - lq.capturedAt);
59387
+ const mins = Math.floor(ageMs / 60000);
59388
+ const ageStr = mins < 1 ? "just now" : mins < 60 ? `${mins}m ago` : mins < 1440 ? `${Math.floor(mins / 60)}h ago` : `${Math.floor(mins / 1440)}d ago`;
59389
+ const five = Math.round(lq.fiveHourUtilizationPct);
59390
+ const seven = Math.round(lq.sevenDayUtilizationPct);
59391
+ return `${five}%\u00b7${seven}% (${ageStr})`;
59392
+ }
59361
59393
  function printAccountsTable(state) {
59362
- console.log(source_default.bold(" ACCOUNT STATUS EXPIRES QUOTA-RESET"));
59394
+ console.log(source_default.bold(" ACCOUNT STATUS EXPIRES QUOTA 5h\u00b77d QUOTA-RESET"));
59363
59395
  for (const a of state.accounts) {
59364
59396
  const marker = a.label === state.active ? source_default.green("\u25cf") : a.exhausted ? source_default.red("!") : source_default.gray("\u2713");
59365
59397
  const status = a.label === state.active ? source_default.green("active ") : a.exhausted ? source_default.red("exhausted") : "available";
59366
59398
  const label = a.label.padEnd(32);
59367
59399
  const exp = formatExpiry2(a.expiresAt).padEnd(10);
59400
+ const util3 = formatQuotaUtilCell(a).padEnd(20);
59368
59401
  const quota = formatQuotaReset(a);
59369
- console.log(` ${marker} ${label} ${status} ${exp} ${quota}`);
59402
+ console.log(` ${marker} ${label} ${status} ${exp} ${util3} ${quota}`);
59370
59403
  }
59371
59404
  }
59372
59405
  function printAgentsTable(state) {
@@ -440,15 +440,20 @@
440
440
  return h;
441
441
  }
442
442
 
443
+ // Tolerate a single transient failure on the 10s auto-refresh: one blip
444
+ // (e.g. the web container restarting) shouldn't flash a scary error over a
445
+ // healthy dashboard. Only surface the error after two in a row.
446
+ let agentFetchFails = 0;
443
447
  async function fetchAgents() {
444
448
  try {
445
449
  const res = await fetch(`${API}/api/agents`, { headers: authHeaders() });
446
450
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
447
451
  agents = await res.json();
448
452
  render();
453
+ agentFetchFails = 0;
449
454
  clearError();
450
455
  } catch (err) {
451
- showError(`Failed to fetch agents: ${err.message}`);
456
+ if (++agentFetchFails >= 2) showError(`Failed to fetch agents: ${err.message}`);
452
457
  }
453
458
  }
454
459
 
@@ -494,12 +499,18 @@
494
499
  }
495
500
 
496
501
  async function fetchConnections() {
502
+ // Each fetch falls back independently (.catch → default). A single
503
+ // network blip — e.g. one endpoint momentarily unreachable — must NOT
504
+ // reject the whole batch and blank the connected accounts; the others
505
+ // still render. (Previously a bare Promise.all meant any one failure
506
+ // wiped the tab, so a connected Google/Microsoft account "vanished".)
507
+ const safe = (p, fallback) => p.then(r => r.ok ? r.json() : fallback).catch(() => fallback);
497
508
  try {
498
509
  const [google, microsoft, notion, agents] = await Promise.all([
499
- fetch(`${API}/api/google-accounts`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
500
- fetch(`${API}/api/microsoft-accounts`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
501
- fetch(`${API}/api/notion-workspace`, { headers: authHeaders() }).then(r => r.ok ? r.json() : { configured: false, databases: [] }),
502
- fetch(`${API}/api/agents`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
510
+ safe(fetch(`${API}/api/google-accounts`, { headers: authHeaders() }), []),
511
+ safe(fetch(`${API}/api/microsoft-accounts`, { headers: authHeaders() }), []),
512
+ safe(fetch(`${API}/api/notion-workspace`, { headers: authHeaders() }), { configured: false, databases: [] }),
513
+ safe(fetch(`${API}/api/agents`, { headers: authHeaders() }), []),
503
514
  ]);
504
515
  const agentNames = (agents || []).map(a => a.name).sort();
505
516
  renderConnections({ google, microsoft, notion, agentNames });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.93",
3
+ "version": "0.15.1",
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": {
@@ -43,6 +43,11 @@ export interface AccountSnapshot {
43
43
  /** Mirrors the broker's `expiresAt` so the table can show token-life
44
44
  * for accounts whose creds are about to expire. */
45
45
  expiresAtMs?: number;
46
+ /** Unix ms when `quota` was captured. Set for CACHED snapshots
47
+ * (`buildSnapshotsFromCachedState`) so consumers can refuse to treat
48
+ * stale data as current; undefined for live-probe snapshots (fresh
49
+ * by construction). */
50
+ capturedAtMs?: number;
46
51
  }
47
52
 
48
53
  // ── health classification ────────────────────────────────────────────
@@ -653,6 +658,10 @@ export function buildSnapshotsFromCachedState(
653
658
  quota: reviveLastQuota(lq),
654
659
  quotaError: lq ? undefined : 'no cached quota (no probe since broker start)',
655
660
  expiresAtMs: acc.expiresAt,
661
+ // Surface the cache age so quota-watch can refuse to classify off
662
+ // stale data (the 2026-06-09 incident: a recovery latched days
663
+ // earlier only surfaced — and notified — at the next fleet bounce).
664
+ capturedAtMs: lq?.capturedAt,
656
665
  };
657
666
  });
658
667
  }
@@ -42,6 +42,65 @@ import {
42
42
  buildSnapshotsFromState,
43
43
  } from './auth-snapshot-format.js';
44
44
 
45
+ /**
46
+ * Failure notice for when the fallback dispatcher itself errors (broker
47
+ * unreachable, listState/markExhausted throw). The model-unavailable
48
+ * card renders "Auto-failover in progress — see the announcement below"
49
+ * BEFORE the outcome is known; every error path must therefore still
50
+ * produce an announcement or the card's promise is broken (the
51
+ * 2026-06-06→07 incident: 12 cards promised an announcement while every
52
+ * dispatch errored "set-active requires admin" — log-only, nothing
53
+ * arrived). Pure builder so the shape is unit-testable.
54
+ */
55
+ export function renderFallbackFailureNotice(triggerAgent: string, reason: string): string {
56
+ return (
57
+ `⚠️ <b>Auto-failover could not run</b> (trigger: <b>${escFailureHtml(triggerAgent)}</b>)\n` +
58
+ `${escFailureHtml(reason)}\n\n` +
59
+ `<i>Switch manually with <code>/auth use &lt;label&gt;</code>, or <code>/auth</code> for fleet status.</i>`
60
+ );
61
+ }
62
+
63
+ function escFailureHtml(s: string): string {
64
+ return s
65
+ .replace(/&/g, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;')
69
+ .replace(/'/g, '&#39;');
70
+ }
71
+
72
+ /**
73
+ * Cooldown for the failure notice. The fleetFallbackGate's dedup window
74
+ * deliberately arms ONLY on a successful swap (fleet-fallback-gate.ts:
75
+ * "No-ops … DO NOT arm the suppression window") — so it bounds nothing
76
+ * on the error path, and the card-less `quota_wall_detected` trigger
77
+ * re-signals every ~60s for the duration of a weekly wall. Without a
78
+ * notice-level bound, a persistent broker outage during a wall would
79
+ * stream ~60 failure notices/hour to every chat for days.
80
+ *
81
+ * Plain time cooldown, per gateway, in-memory. Deliberately NOT keyed
82
+ * by reason: broker error strings vary per attempt (timeout ms values
83
+ * etc.), so a new-reason bypass would re-open the spam hole. Worst
84
+ * case is one notice per gateway per cooldown window.
85
+ */
86
+ export const FALLBACK_FAILURE_NOTICE_COOLDOWN_MS = 30 * 60_000;
87
+
88
+ export interface FallbackFailureNoticeState {
89
+ /** Unix ms of the last failure notice this gateway sent. 0 = never. */
90
+ lastSentAtMs: number;
91
+ }
92
+
93
+ export function evaluateFallbackFailureNotice(
94
+ prev: FallbackFailureNoticeState,
95
+ now: number,
96
+ cooldownMs: number = FALLBACK_FAILURE_NOTICE_COOLDOWN_MS,
97
+ ): { send: boolean; next: FallbackFailureNoticeState } {
98
+ if (now - prev.lastSentAtMs >= cooldownMs) {
99
+ return { send: true, next: { lastSentAtMs: now } };
100
+ }
101
+ return { send: false, next: prev };
102
+ }
103
+
45
104
  export type FleetFallbackOutcome =
46
105
  | {
47
106
  kind: 'switched';