switchroom 0.17.4 → 0.17.6

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.
@@ -11591,7 +11591,7 @@ var SwitchroomConfigSchema = exports_external.object({
11591
11591
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
11592
11592
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
11593
11593
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
11594
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
11594
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
11595
11595
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
11596
11596
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
11597
11597
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -11591,7 +11591,7 @@ var SwitchroomConfigSchema = exports_external.object({
11591
11591
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
11592
11592
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
11593
11593
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
11594
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
11594
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
11595
11595
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
11596
11596
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
11597
11597
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -13910,10 +13910,12 @@ class AuthBroker {
13910
13910
  refreshInFlight = new Set;
13911
13911
  consumerLastSeen = {};
13912
13912
  capChownWarned = false;
13913
+ yamlActive;
13913
13914
  closed = false;
13914
13915
  constructor(config, opts = {}) {
13915
13916
  this.opts = opts;
13916
13917
  this.config = config;
13918
+ this.yamlActive = config.auth?.active;
13917
13919
  this.home = opts.home;
13918
13920
  this.now = opts.now ?? nowMs;
13919
13921
  this.operatorUid = opts.operatorUid;
@@ -14040,6 +14042,8 @@ class AuthBroker {
14040
14042
  this.assertConfigConsistent(config);
14041
14043
  const prev = this.config;
14042
14044
  this.config = config;
14045
+ this.yamlActive = config.auth?.active;
14046
+ this.applyActiveOverride();
14043
14047
  const wanted = new Set;
14044
14048
  for (const name of Object.keys(config.agents ?? {})) {
14045
14049
  wanted.add(this.agentSocketPath(name));
@@ -14331,7 +14335,9 @@ class AuthBroker {
14331
14335
  return auth.active ?? null;
14332
14336
  if (identity2.kind === "consumer") {
14333
14337
  const c = (auth.consumers ?? []).find((x) => x.name === identity2.name);
14334
- return c?.account ?? null;
14338
+ if (!c)
14339
+ return null;
14340
+ return c.account ?? auth.active ?? null;
14335
14341
  }
14336
14342
  const agent = (this.config.agents ?? {})[identity2.name];
14337
14343
  const override = agent?.auth?.override;
@@ -14475,7 +14481,7 @@ class AuthBroker {
14475
14481
  });
14476
14482
  const consumers = (auth.consumers ?? []).map((c) => ({
14477
14483
  name: c.name,
14478
- account: c.account,
14484
+ account: c.account ?? auth.active ?? "",
14479
14485
  last_seen_at: this.consumerLastSeen[c.name] ?? null
14480
14486
  }));
14481
14487
  const active_overage_serving = this.isActiveOverageServing(this.callerAccount(identity2));
@@ -14594,10 +14600,20 @@ class AuthBroker {
14594
14600
  continue;
14595
14601
  }
14596
14602
  this.cacheQuotaSnapshot(label, result);
14603
+ const active = this.config.auth?.active;
14604
+ if (label !== active)
14605
+ continue;
14606
+ const decision = quotaIndicatesExhaustion(result, this.isOverageAllowed(label));
14607
+ if (!decision.exhausted)
14608
+ continue;
14609
+ process.stdout.write(`auth-broker: fleet-quota-probe shows ACTIVE ${label} exhausted — proactive failover
14610
+ `);
14611
+ this.audit({ op: "mark-exhausted", identity: { kind: "operator" }, account: label, accountKind: "claude", ok: true });
14612
+ await this.markExhaustedAndRoll(label, decision.until ?? undefined, { kind: "operator" });
14597
14613
  }
14598
14614
  }
14599
14615
  async consumerQuotaProbeTick() {
14600
- const accounts = Array.from(new Set((this.config.auth?.consumers ?? []).map((c) => c.account)));
14616
+ const accounts = Array.from(new Set((this.config.auth?.consumers ?? []).map((c) => c.account).filter((a) => typeof a === "string" && a.length > 0)));
14601
14617
  for (const label of accounts) {
14602
14618
  const creds = readAccountCredentials(label, this.home);
14603
14619
  const token = creds?.claudeAiOauth?.accessToken;
@@ -14654,6 +14670,7 @@ class AuthBroker {
14654
14670
  auth: { ...this.config.auth ?? {}, active: account }
14655
14671
  };
14656
14672
  this.config = cfg;
14673
+ this.persistActiveOverride(account);
14657
14674
  const fanned = this.fanoutToAffectedAgents(account);
14658
14675
  this.fanoutAllConsumers();
14659
14676
  this.audit({ op: "set-active", identity: identity2, account, accountKind: "claude", ok: true });
@@ -14666,6 +14683,11 @@ class AuthBroker {
14666
14683
  socket.write(encodeError(id, "ACCOUNT_NOT_FOUND", "no active account configured"));
14667
14684
  return;
14668
14685
  }
14686
+ const { rolled, rolledTo } = await this.markExhaustedAndRoll(account, until, identity2);
14687
+ this.audit({ op: "mark-exhausted", identity: identity2, account, accountKind: "claude", ok: true });
14688
+ socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
14689
+ }
14690
+ async markExhaustedAndRoll(account, until, identity2) {
14669
14691
  const now = this.now();
14670
14692
  const exhaustedUntil = clampMarkExpiry({
14671
14693
  proposedUntil: until ?? now + MARK_EXHAUSTED_DEFAULT_MS,
@@ -14678,8 +14700,26 @@ class AuthBroker {
14678
14700
  const rolledTo = await this.nextHealthyAccountLive(account, this.config.auth?.fallback_order ?? []);
14679
14701
  const rolled = this.fanoutFailoverTo(account, rolledTo);
14680
14702
  this.fanoutToAffectedConsumers(account);
14681
- this.audit({ op: "mark-exhausted", identity: identity2, account, accountKind: "claude", ok: true });
14682
- socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
14703
+ if (rolledTo && this.config.auth?.active === account && this.exhaustionLiveCorroborated(account)) {
14704
+ this.config = {
14705
+ ...this.config,
14706
+ auth: { ...this.config.auth ?? {}, active: rolledTo }
14707
+ };
14708
+ this.persistActiveOverride(rolledTo);
14709
+ this.fanoutAllConsumers();
14710
+ this.audit({ op: "auto-promote-active", identity: identity2, account: rolledTo, accountKind: "claude", ok: true });
14711
+ process.stdout.write(`auth-broker: auto-promoted auth.active ${account} → ${rolledTo} ` + `(${account} exhausted until ${new Date(exhaustedUntil).toISOString()}) — persisted
14712
+ `);
14713
+ }
14714
+ return { rolled, rolledTo };
14715
+ }
14716
+ exhaustionLiveCorroborated(account) {
14717
+ const snapshot = this.lastQuotaCache[account];
14718
+ if (!snapshot || !snapshotFresh(snapshot, this.now(), MARK_EXHAUSTED_DEFAULT_MS))
14719
+ return false;
14720
+ if (!snapshotWalled(snapshot))
14721
+ return false;
14722
+ return !overageLiftsWall(snapshot, this.isOverageAllowed(account));
14683
14723
  }
14684
14724
  opClaimNotification(socket, id, identity2, key, windowMs) {
14685
14725
  const now = this.now();
@@ -15187,12 +15227,15 @@ class AuthBroker {
15187
15227
  for (const consumer of this.config.auth?.consumers ?? []) {
15188
15228
  if (!consumer.mirror_dir)
15189
15229
  continue;
15190
- const isPinned = consumer.account === label;
15230
+ const bound = consumer.account ?? this.config.auth?.active;
15231
+ const isPinned = bound === label;
15191
15232
  const effective = this.servingAccountForConsumer(consumer.name);
15192
15233
  const isEffective = effective === label;
15193
15234
  if (!isPinned && !isEffective)
15194
15235
  continue;
15195
- const toMirror = effective ?? consumer.account;
15236
+ const toMirror = effective ?? bound;
15237
+ if (toMirror == null)
15238
+ continue;
15196
15239
  if (this.mirrorAccountToConsumer(toMirror, consumer)) {
15197
15240
  fanned.push(consumer.name);
15198
15241
  }
@@ -15216,7 +15259,7 @@ class AuthBroker {
15216
15259
  const c = (this.config.auth?.consumers ?? []).find((x) => x.name === name);
15217
15260
  if (!c)
15218
15261
  return null;
15219
- return this.accountWithFailover(c.account);
15262
+ return this.accountWithFailover(c.account ?? this.config.auth?.active);
15220
15263
  }
15221
15264
  mirrorAccountToConsumer(label, consumer) {
15222
15265
  const mirrorDir = consumer.mirror_dir;
@@ -15362,6 +15405,42 @@ class AuthBroker {
15362
15405
  this.thresholdViolations = this.readJson("threshold-violations.json") ?? {};
15363
15406
  this.notificationClaims = this.readJson("notification-claims.json") ?? {};
15364
15407
  this.lastQuotaCache = this.readJson("last-quota.json") ?? {};
15408
+ this.applyActiveOverride();
15409
+ }
15410
+ applyActiveOverride() {
15411
+ const ov = this.readJson("active-override.json");
15412
+ if (!ov || typeof ov.active !== "string" || ov.active.length === 0)
15413
+ return;
15414
+ const yamlActive = this.yamlActive ?? null;
15415
+ if ((ov.yaml_active_at_write ?? null) !== yamlActive) {
15416
+ process.stdout.write(`auth-broker: active-override dropped — yaml auth.active changed since the swap ` + `(${ov.yaml_active_at_write ?? "unset"} → ${yamlActive ?? "unset"}); yaml wins
15417
+ `);
15418
+ try {
15419
+ unlinkSync(join4(this.stateDir, "active-override.json"));
15420
+ } catch {}
15421
+ return;
15422
+ }
15423
+ if (!accountExists(ov.active, this.home)) {
15424
+ process.stdout.write(`auth-broker: active-override ignored — account '${ov.active}' not found on disk
15425
+ `);
15426
+ return;
15427
+ }
15428
+ if (ov.active === this.config.auth?.active)
15429
+ return;
15430
+ this.config = {
15431
+ ...this.config,
15432
+ auth: { ...this.config.auth ?? {}, active: ov.active }
15433
+ };
15434
+ process.stdout.write(`auth-broker: active-override applied — auth.active ${yamlActive ?? "unset"} → ${ov.active} ` + `(persisted swap survives restarts)
15435
+ `);
15436
+ }
15437
+ persistActiveOverride(active) {
15438
+ const entry = {
15439
+ active,
15440
+ yaml_active_at_write: this.yamlActive ?? null,
15441
+ updated_at: this.now()
15442
+ };
15443
+ atomicWriteJsonSync(join4(this.stateDir, "active-override.json"), entry, 384);
15365
15444
  }
15366
15445
  readJson(name) {
15367
15446
  const p = join4(this.stateDir, name);
@@ -12339,7 +12339,7 @@ var SwitchroomConfigSchema = exports_external.object({
12339
12339
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
12340
12340
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
12341
12341
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
12342
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
12342
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it \u2014 use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
12343
12343
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
12344
12344
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
12345
12345
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -14177,7 +14177,7 @@ var init_schema = __esm(() => {
14177
14177
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
14178
14178
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
14179
14179
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
14180
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
14180
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it \u2014 use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
14181
14181
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
14182
14182
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
14183
14183
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -43431,7 +43431,7 @@ function checkHindsightConsumer(config, opts) {
43431
43431
  return {
43432
43432
  name: "hindsight consumer",
43433
43433
  status: "warn",
43434
- detail: `auth.consumers[hindsight] -> ${entry.account} (uid ${entry.uid ?? 0}); ` + `couldn't query auth-broker container (not running / docker unavailable)`,
43434
+ detail: `auth.consumers[hindsight] -> ${entry.account ?? "(follows active)"} (uid ${entry.uid ?? 0}); ` + `couldn't query auth-broker container (not running / docker unavailable)`,
43435
43435
  fix: "Check `auth-broker: service health` row above; if the broker is " + "down, `switchroom apply` will bring it back and bind the socket."
43436
43436
  };
43437
43437
  }
@@ -43439,14 +43439,14 @@ function checkHindsightConsumer(config, opts) {
43439
43439
  return {
43440
43440
  name: "hindsight consumer",
43441
43441
  status: "warn",
43442
- detail: `auth.consumers[hindsight] -> ${entry.account} (uid ${entry.uid ?? 0}); ` + `auth-broker is running but socket not bound at /run/switchroom/auth-broker/${entry.name}/sock`,
43442
+ detail: `auth.consumers[hindsight] -> ${entry.account ?? "(follows active)"} (uid ${entry.uid ?? 0}); ` + `auth-broker is running but socket not bound at /run/switchroom/auth-broker/${entry.name}/sock`,
43443
43443
  fix: "Run `switchroom apply` to refresh compose and rebind per-consumer sockets."
43444
43444
  };
43445
43445
  }
43446
43446
  return {
43447
43447
  name: "hindsight consumer",
43448
43448
  status: "ok",
43449
- detail: `auth.consumers[hindsight] -> ${entry.account} (uid ${entry.uid ?? 0})`
43449
+ detail: `auth.consumers[hindsight] -> ${entry.account ?? "(follows active)"} (uid ${entry.uid ?? 0})`
43450
43450
  };
43451
43451
  }
43452
43452
  function probeAuthBrokerSocket(consumerName) {
@@ -63737,8 +63737,8 @@ import { existsSync, readFileSync } from "node:fs";
63737
63737
  import { dirname, join } from "node:path";
63738
63738
 
63739
63739
  // src/build-info.ts
63740
- var VERSION = "0.17.4";
63741
- var COMMIT_SHA = "b842b0c9";
63740
+ var VERSION = "0.17.6";
63741
+ var COMMIT_SHA = "0cc74c23";
63742
63742
 
63743
63743
  // src/cli/resolve-version.ts
63744
63744
  function readPackageVersion() {
@@ -77748,7 +77748,8 @@ async function ensureHindsightConsumer(configPath, account, uid = HINDSIGHT_DEFA
77748
77748
  }
77749
77749
  const entry = new YAMLMap;
77750
77750
  entry.set("name", HINDSIGHT_CONSUMER_NAME);
77751
- entry.set("account", account);
77751
+ if (account !== undefined)
77752
+ entry.set("account", account);
77752
77753
  entry.set("uid", uid);
77753
77754
  consumersNode.add(entry);
77754
77755
  const out = String(doc);
@@ -82886,14 +82887,11 @@ async function stepMemoryBackend(config, nonInteractive, switchroomConfigPath) {
82886
82887
  }
82887
82888
  }
82888
82889
  } catch {}
82889
- const activeAccount = config.auth?.active;
82890
- if (!activeAccount) {
82891
- console.log(source_default.yellow(` No auth.active account set \u2014 skipping consumer registration. ` + `Run \`switchroom auth use <label>\` and re-run setup.`));
82892
- } else {
82890
+ {
82893
82891
  try {
82894
- const result = await ensureHindsightConsumer(switchroomConfigPath, activeAccount);
82892
+ const result = await ensureHindsightConsumer(switchroomConfigPath);
82895
82893
  if (result.added) {
82896
- console.log(source_default.green(` ${STEP_DONE} Registered auth.consumers[${HINDSIGHT_CONSUMER_NAME}] = ${activeAccount}`));
82894
+ console.log(source_default.green(` ${STEP_DONE} Registered auth.consumers[${HINDSIGHT_CONSUMER_NAME}] (follows the fleet active account)`));
82897
82895
  } else {
82898
82896
  console.log(source_default.gray(` auth.consumers[${HINDSIGHT_CONSUMER_NAME}] already present.`));
82899
82897
  }
@@ -16371,7 +16371,7 @@ var SwitchroomConfigSchema = exports_external.object({
16371
16371
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
16372
16372
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
16373
16373
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
16374
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
16374
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
16375
16375
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
16376
16376
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
16377
16377
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -22670,7 +22670,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22670
22670
  import { dirname as dirname4, join as join2 } from "node:path";
22671
22671
 
22672
22672
  // src/build-info.ts
22673
- var VERSION = "0.17.4";
22673
+ var VERSION = "0.17.6";
22674
22674
 
22675
22675
  // src/cli/resolve-version.ts
22676
22676
  function readPackageVersion() {
@@ -4619,7 +4619,7 @@ var init_schema = __esm(() => {
4619
4619
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
4620
4620
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
4621
4621
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
4622
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
4622
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
4623
4623
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
4624
4624
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
4625
4625
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -4619,7 +4619,7 @@ var init_schema = __esm(() => {
4619
4619
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
4620
4620
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
4621
4621
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
4622
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
4622
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
4623
4623
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
4624
4624
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
4625
4625
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.17.4",
4
+ "version": "0.17.6",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -303,12 +303,26 @@ for _stray_claude in \
303
303
  "$HOME/.local/bin/claude" \
304
304
  "$HOME/bin/claude"; do
305
305
  if [ -e "$_stray_claude" ] || [ -L "$_stray_claude" ]; then
306
+ # Skip our own canonical symlink re-seated at end of this block — it points
307
+ # back to the image binary and is not a shadow install. Any other file or
308
+ # symlink at this path (e.g. npm self-install, manual copy) is pruned.
309
+ if [ -L "$_stray_claude" ] && [ "$(readlink "$_stray_claude" 2>/dev/null)" = "/usr/local/bin/claude" ]; then
310
+ continue
311
+ fi
306
312
  echo "start.sh: pruning user-local claude shadow at $_stray_claude (image binary is authoritative)" >&2
307
313
  rm -f "$_stray_claude"
308
314
  fi
309
315
  done
310
316
  rm -rf "$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code" 2>/dev/null || true
311
317
  unset _stray_claude
318
+ # Re-seat the canonical symlink so claude v2.1.x+ self-check ("claude command
319
+ # at $HOME/.local/bin/claude missing or broken") stays silent. The prune loop
320
+ # above skips this canonical pointer (via the readlink guard) and removes only
321
+ # genuine shadow installs; the ln -sf below creates the symlink on first boot
322
+ # and is a no-op on subsequent boots. `mkdir -p` is safe: the dir already exists
323
+ # or is about to be created.
324
+ mkdir -p "$HOME/.local/bin"
325
+ ln -sf /usr/local/bin/claude "$HOME/.local/bin/claude"
312
326
 
313
327
  # ── Root-tier agent: provision the docker CLI ────────────────────────
314
328
  # The root debugging agent (`root: true`) has /var/run/docker.sock
@@ -859,6 +859,14 @@ export interface SnapshotKeyboardOpts {
859
859
  * keyboard agrees with the card body instead of defaulting to a second
860
860
  * `new Date()`. Defaults to wall-clock. */
861
861
  now?: Date;
862
+ /**
863
+ * Demo mode (the `/auth demo` / `/usage demo` suffix). Masks the
864
+ * account-email in each switch-button LABEL (the callback_data keeps the
865
+ * real label — the broker needs it to act) and flips the refresh callback
866
+ * to `auth:refresh:demo` so a ↻ tap re-renders the card still masked
867
+ * instead of leaking the real emails mid-screen-recording.
868
+ */
869
+ demo?: boolean;
862
870
  }
863
871
 
864
872
  /**
@@ -891,14 +899,14 @@ export function buildSnapshotKeyboard(
891
899
  for (const t of switchTargets) {
892
900
  rows.push([
893
901
  {
894
- text: `Switch fleet → ${t.label}`,
902
+ text: `Switch fleet → ${opts.demo ? maskEmail(t.label) : t.label}`,
895
903
  callbackData: `auth:use:${t.label}`,
896
904
  },
897
905
  ]);
898
906
  }
899
907
 
900
908
  rows.push([
901
- { text: '↻ Refresh', callbackData: 'auth:refresh' },
909
+ { text: '↻ Refresh', callbackData: opts.demo ? 'auth:refresh:demo' : 'auth:refresh' },
902
910
  { text: '/usage', insertText: '/usage' },
903
911
  { text: '+ Add', insertText: '/auth add ' },
904
912
  ]);
@@ -919,6 +927,46 @@ function switchPriority(s: AccountSnapshot, now: Date = new Date()): number {
919
927
 
920
928
  // ── snapshot assembly helper ─────────────────────────────────────────
921
929
 
930
+ /**
931
+ * One per-account row of a broker `probe-quota` response. Mirrors the
932
+ * shape `AuthBrokerClient.probeQuota` returns (src/auth/broker/client.ts)
933
+ * without importing across the package boundary.
934
+ */
935
+ export interface ProbeQuotaResultRow {
936
+ label: string;
937
+ result: QuotaResult;
938
+ /** #2495 Change 2 — how this result was sourced. */
939
+ served?: 'live' | 'cache';
940
+ /** Unix ms the served snapshot was captured (set when served==="cache"). */
941
+ capturedAt?: number;
942
+ }
943
+
944
+ /**
945
+ * Zip a broker `probe-quota` response back onto the account list, in input
946
+ * order, and surface cache staleness. Shared by every /auth-surface caller
947
+ * (/auth show, /usage, the ↻ refresh callback) so the "⚠ cached Nm ago"
948
+ * footer logic can't drift between them (#2495 Change 2).
949
+ *
950
+ * Returns `quotas` parallel to `labels` (a missing row degrades to an
951
+ * `ok:false` result, never a hole) and `staleCachedAtMs` — the OLDEST
952
+ * `capturedAt` among cache-served rows, undefined when everything was live.
953
+ */
954
+ export function zipProbeResults(
955
+ labels: readonly string[],
956
+ results: readonly ProbeQuotaResultRow[],
957
+ ): { quotas: QuotaResult[]; staleCachedAtMs?: number } {
958
+ let staleCachedAtMs: number | undefined;
959
+ const quotas = labels.map((label): QuotaResult => {
960
+ const hit = results.find((r) => r.label === label);
961
+ if (!hit) return { ok: false, reason: 'broker returned no result for account' };
962
+ if (hit.served === 'cache' && hit.capturedAt != null) {
963
+ staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
964
+ }
965
+ return hit.result;
966
+ });
967
+ return staleCachedAtMs != null ? { quotas, staleCachedAtMs } : { quotas };
968
+ }
969
+
922
970
  /**
923
971
  * Given the broker's `listState` data + a parallel array of live quota
924
972
  * results (same length, same order), return the AccountSnapshot[] the
@@ -24748,7 +24748,7 @@ var init_schema = __esm(() => {
24748
24748
  name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
24749
24749
  message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
24750
24750
  }).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
24751
- account: exports_external.string().min(1).describe("Pinned account label for this consumer. `get-credentials` returns " + "this account's credentials; `mark-exhausted` from this consumer " + "only affects this account."),
24751
+ account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it \u2014 use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
24752
24752
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
24753
24753
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
24754
24754
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
@@ -30892,6 +30892,7 @@ var init_boot_card = __esm(() => {
30892
30892
  // auth-snapshot-format.ts
30893
30893
  var exports_auth_snapshot_format = {};
30894
30894
  __export(exports_auth_snapshot_format, {
30895
+ zipProbeResults: () => zipProbeResults,
30895
30896
  reviveLastQuota: () => reviveLastQuota,
30896
30897
  renderFallbackAnnouncement: () => renderFallbackAnnouncement2,
30897
30898
  renderAuthSnapshotFormat2: () => renderAuthSnapshotFormat22,
@@ -31283,13 +31284,13 @@ function buildSnapshotKeyboard2(snapshots, opts = {}) {
31283
31284
  for (const t of switchTargets) {
31284
31285
  rows.push([
31285
31286
  {
31286
- text: `Switch fleet \u2192 ${t.label}`,
31287
+ text: `Switch fleet \u2192 ${opts.demo ? maskEmail(t.label) : t.label}`,
31287
31288
  callbackData: `auth:use:${t.label}`
31288
31289
  }
31289
31290
  ]);
31290
31291
  }
31291
31292
  rows.push([
31292
- { text: "\u21bb Refresh", callbackData: "auth:refresh" },
31293
+ { text: "\u21bb Refresh", callbackData: opts.demo ? "auth:refresh:demo" : "auth:refresh" },
31293
31294
  { text: "/usage", insertText: "/usage" },
31294
31295
  { text: "+ Add", insertText: "/auth add " }
31295
31296
  ]);
@@ -31305,6 +31306,19 @@ function switchPriority2(s, now = new Date) {
31305
31306
  return 2;
31306
31307
  return 3;
31307
31308
  }
31309
+ function zipProbeResults(labels, results) {
31310
+ let staleCachedAtMs;
31311
+ const quotas = labels.map((label) => {
31312
+ const hit = results.find((r) => r.label === label);
31313
+ if (!hit)
31314
+ return { ok: false, reason: "broker returned no result for account" };
31315
+ if (hit.served === "cache" && hit.capturedAt != null) {
31316
+ staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
31317
+ }
31318
+ return hit.result;
31319
+ });
31320
+ return staleCachedAtMs != null ? { quotas, staleCachedAtMs } : { quotas };
31321
+ }
31308
31322
  function buildSnapshotsFromState2(state4, quotas) {
31309
31323
  const out = [];
31310
31324
  for (let i = 0;i < state4.accounts.length; i++) {
@@ -42519,13 +42533,13 @@ function buildSnapshotKeyboard(snapshots, opts = {}) {
42519
42533
  for (const t of switchTargets) {
42520
42534
  rows.push([
42521
42535
  {
42522
- text: `Switch fleet \u2192 ${t.label}`,
42536
+ text: `Switch fleet \u2192 ${opts.demo ? maskEmail(t.label) : t.label}`,
42523
42537
  callbackData: `auth:use:${t.label}`
42524
42538
  }
42525
42539
  ]);
42526
42540
  }
42527
42541
  rows.push([
42528
- { text: "\u21bb Refresh", callbackData: "auth:refresh" },
42542
+ { text: "\u21bb Refresh", callbackData: opts.demo ? "auth:refresh:demo" : "auth:refresh" },
42529
42543
  { text: "/usage", insertText: "/usage" },
42530
42544
  { text: "+ Add", insertText: "/auth add " }
42531
42545
  ]);
@@ -42721,7 +42735,7 @@ async function handleAuthCommand(parsed, ctx) {
42721
42735
  let keyboard;
42722
42736
  if (liveQuotas && liveQuotas.length === state3.accounts.length) {
42723
42737
  const snapshots = buildSnapshotsFromState(state3, liveQuotas);
42724
- keyboard = buildSnapshotKeyboard(snapshots, { now: new Date });
42738
+ keyboard = buildSnapshotKeyboard(snapshots, { now: new Date, demo: ctx.demo });
42725
42739
  }
42726
42740
  return {
42727
42741
  text: renderShowText(state3, Date.now(), {
@@ -58590,10 +58604,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
58590
58604
  }
58591
58605
 
58592
58606
  // ../src/build-info.ts
58593
- var VERSION = "0.17.4";
58594
- var COMMIT_SHA = "b842b0c9";
58595
- var COMMIT_DATE = "2026-07-05T11:15:53Z";
58596
- var LATEST_PR = 2839;
58607
+ var VERSION = "0.17.6";
58608
+ var COMMIT_SHA = "0cc74c23";
58609
+ var COMMIT_DATE = "2026-07-05T13:02:44Z";
58610
+ var LATEST_PR = 2854;
58597
58611
  var COMMITS_AHEAD_OF_TAG = 0;
58598
58612
 
58599
58613
  // gateway/boot-version.ts
@@ -60650,9 +60664,10 @@ var FEED_LIVENESS_OPEN_MS = (() => {
60650
60664
  })();
60651
60665
  var POST_ANSWER_LIVENESS_STALE_MS = parsePostAnswerLivenessMs(process.env.SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS) || 30000;
60652
60666
  function turnInFlightForGate() {
60667
+ const hasPendingApproval = pendingPermissions.size > 0;
60653
60668
  if (!isDeliveryCutoverEnabled())
60654
- return claudeBusyKeys.size > 0;
60655
- return probeGateParity(isMachineInTurn(), claudeBusyKeys.size);
60669
+ return claudeBusyKeys.size > 0 || hasPendingApproval;
60670
+ return probeGateParity(isMachineInTurn(), claudeBusyKeys.size) || hasPendingApproval;
60656
60671
  }
60657
60672
  function deliverResumeSyntheticOrBuffer(agent, inbound) {
60658
60673
  const decision = decideInboundDelivery({
@@ -69314,17 +69329,7 @@ Send \`/auth cancel\` to abort.`, { html: true });
69314
69329
  liveQuotas: async (accounts) => {
69315
69330
  try {
69316
69331
  const { results } = await client3.probeQuota(accounts.map((a) => a.label));
69317
- let staleCachedAtMs;
69318
- const quotas = accounts.map((a) => {
69319
- const hit = results.find((r) => r.label === a.label);
69320
- if (!hit)
69321
- return { ok: false, reason: "broker returned no result for account" };
69322
- if (hit.served === "cache" && hit.capturedAt != null) {
69323
- staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
69324
- }
69325
- return hit.result;
69326
- });
69327
- return { quotas, staleCachedAtMs };
69332
+ return zipProbeResults(accounts.map((a) => a.label), results);
69328
69333
  } catch (err) {
69329
69334
  const reason = `broker probe-quota failed: ${err?.message ?? String(err)}`;
69330
69335
  return { quotas: accounts.map(() => ({ ok: false, reason })) };
@@ -70466,7 +70471,8 @@ _Tap /auth to see updated quota for the new active account._`), {}), { chat_id:
70466
70471
  }
70467
70472
  return;
70468
70473
  }
70469
- if (data === "auth:refresh") {
70474
+ if (data === "auth:refresh" || data === "auth:refresh:demo") {
70475
+ const refreshDemo = data === "auth:refresh:demo";
70470
70476
  const refreshMsg = ctx.callbackQuery?.message;
70471
70477
  if (refreshMsg) {
70472
70478
  const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`;
@@ -70493,20 +70499,19 @@ _Tap /auth to see updated quota for the new active account._`), {}), { chat_id:
70493
70499
  return;
70494
70500
  }
70495
70501
  const state4 = await client3.listState();
70496
- const probeResp = state4.accounts.length > 0 ? await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] })) : { results: [] };
70497
- const quotas = state4.accounts.map((a) => {
70498
- const hit = probeResp.results.find((r) => r.label === a.label);
70499
- return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
70500
- });
70502
+ const probeResp = state4.accounts.length > 0 ? await client3.probeQuota(state4.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] })) : { results: [] };
70503
+ const { quotas, staleCachedAtMs } = zipProbeResults(state4.accounts.map((a) => a.label), probeResp.results);
70501
70504
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
70502
70505
  const { renderAuthSnapshotFormat2: renderAuthSnapshotFormat23, buildSnapshotsFromState: buildSnapshotsFromState4, buildSnapshotKeyboard: buildSnapshotKeyboard3 } = await Promise.resolve().then(() => (init_auth_snapshot_format(), exports_auth_snapshot_format));
70503
70506
  const snapshots = buildSnapshotsFromState4(state4, quotas);
70507
+ const renderNow = new Date;
70504
70508
  const text2 = renderAuthSnapshotFormat23(snapshots, {
70505
70509
  tz,
70506
- now: new Date,
70507
- liveProbedAtMs: Date.now()
70510
+ now: renderNow,
70511
+ demo: refreshDemo,
70512
+ ...staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }
70508
70513
  });
70509
- const kbRows = buildSnapshotKeyboard3(snapshots);
70514
+ const kbRows = buildSnapshotKeyboard3(snapshots, { now: renderNow, demo: refreshDemo });
70510
70515
  const inline_keyboard = kbRows.map((row) => row.map((b) => {
70511
70516
  if (b.callbackData)
70512
70517
  return { text: b.text, callback_data: b.callbackData };
@@ -70887,14 +70892,7 @@ bot.command("usage", async (ctx) => {
70887
70892
  const state4 = await client3.listState();
70888
70893
  if (state4.accounts.length > 0) {
70889
70894
  const probeResp = await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] }));
70890
- let staleCachedAtMs;
70891
- const quotas = state4.accounts.map((a) => {
70892
- const hit = probeResp.results.find((r) => r.label === a.label);
70893
- if (hit?.served === "cache" && hit.capturedAt != null) {
70894
- staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
70895
- }
70896
- return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
70897
- });
70895
+ const { quotas, staleCachedAtMs } = zipProbeResults(state4.accounts.map((a) => a.label), probeResp.results);
70898
70896
  const { renderAuthSnapshotFormat2: renderAuthSnapshotFormat23, buildSnapshotsFromState: buildSnapshotsFromState4, buildSnapshotKeyboard: buildSnapshotKeyboard3 } = await Promise.resolve().then(() => (init_auth_snapshot_format(), exports_auth_snapshot_format));
70899
70897
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
70900
70898
  const snapshots = buildSnapshotsFromState4(state4, quotas);
@@ -70904,7 +70902,7 @@ bot.command("usage", async (ctx) => {
70904
70902
  demo,
70905
70903
  ...staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }
70906
70904
  });
70907
- const kbRows = buildSnapshotKeyboard3(snapshots, { now: new Date });
70905
+ const kbRows = buildSnapshotKeyboard3(snapshots, { now: new Date, demo });
70908
70906
  const keyboard = new import_grammy12.InlineKeyboard;
70909
70907
  kbRows.forEach((row, ri) => {
70910
70908
  if (ri > 0)
@@ -403,7 +403,7 @@ export async function handleAuthCommand(
403
403
  let keyboard: AuthCommandReply['keyboard']
404
404
  if (liveQuotas && liveQuotas.length === state.accounts.length) {
405
405
  const snapshots = buildSnapshotsFromState(state, liveQuotas)
406
- keyboard = buildSnapshotKeyboard(snapshots, { now: new Date() })
406
+ keyboard = buildSnapshotKeyboard(snapshots, { now: new Date(), demo: ctx.demo })
407
407
  }
408
408
  return {
409
409
  text: renderShowText(state, Date.now(), {
@@ -574,7 +574,7 @@ import {
574
574
  QUOTA_WATCH_CLAIM_WINDOW_MS,
575
575
  isLiveCorroboration,
576
576
  } from '../quota-watch.js'
577
- import { buildSnapshotsFromState, buildSnapshotsFromCachedState } from '../auth-snapshot-format.js'
577
+ import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
578
578
  import { maskUsername, maskVaultKey } from '../demo-mask.js'
579
579
  import {
580
580
  writeTurnActiveMarker,
@@ -2039,14 +2039,25 @@ const POST_ANSWER_LIVENESS_STALE_MS = parsePostAnswerLivenessMs(
2039
2039
  * message self-blocks. See the snapshot at the inbound handler.
2040
2040
  */
2041
2041
  function turnInFlightForGate(): boolean {
2042
- if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
2042
+ // Include pendingPermissions in the "turn busy" signal (#2841): after the
2043
+ // first interim reply, `releaseTurnBufferGate` clears claudeBusyKeys/machine
2044
+ // even though a permission card is still outstanding and claude is still
2045
+ // blocked mid-turn waiting for the tap verdict. A new inbound that arrives
2046
+ // in this window would see turnInFlightAtReceipt=false and deliver directly,
2047
+ // landing while claude's bridge is suspended in the permission-notification
2048
+ // handler — displacing the approval and orphaning the pending brevo/MCP call.
2049
+ // Keeping the gate closed for as long as there is an outstanding approval
2050
+ // card prevents that race. The gate reopens when the card is tapped or times
2051
+ // out (pendingPermissions.delete in finalizeCallback / TTL sweep).
2052
+ const hasPendingApproval = pendingPermissions.size > 0
2053
+ if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0 || hasPendingApproval
2043
2054
  // Machine is authoritative. Run the log-only drift canary (#2794): the
2044
2055
  // imperative `claudeBusyKeys` shadow is still live in parallel, so a
2045
2056
  // dangerous over-hold divergence (machine holds the gate while the
2046
2057
  // imperative view is idle) is surfaced without changing behaviour. The
2047
2058
  // benign orphan-dangle direction — the wedge the machine self-heals — is
2048
2059
  // NOT flagged. `probeGateParity` returns the machine value unchanged.
2049
- return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
2060
+ return probeGateParity(isMachineInTurn(), claudeBusyKeys.size) || hasPendingApproval
2050
2061
  }
2051
2062
 
2052
2063
  /**
@@ -19273,19 +19284,9 @@ bot.command("auth", async ctx => {
19273
19284
  const { results } = await client.probeQuota(accounts.map((a) => a.label))
19274
19285
  // #2495 Change 2 — the broker tags each result `served:"live"|"cache"`
19275
19286
  // (TTL hit or failed-probe fallback). When ANY account was served from
19276
- // cache, surface the OLDEST snapshot's capturedAt so the card stamps
19277
- // "⚠ cached Nm ago" instead of a false live stamp.
19278
- let staleCachedAtMs: number | undefined
19279
- // Preserve input order (broker also preserves it, but be defensive).
19280
- const quotas = accounts.map((a) => {
19281
- const hit = results.find((r) => r.label === a.label)
19282
- if (!hit) return { ok: false as const, reason: "broker returned no result for account" }
19283
- if (hit.served === 'cache' && hit.capturedAt != null) {
19284
- staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt)
19285
- }
19286
- return hit.result
19287
- })
19288
- return { quotas, staleCachedAtMs }
19287
+ // cache, zipProbeResults surfaces the OLDEST snapshot's capturedAt so
19288
+ // the card stamps "⚠ cached Nm ago" instead of a false live stamp.
19289
+ return zipProbeResults(accounts.map((a) => a.label), results)
19289
19290
  } catch (err) {
19290
19291
  // Surface a uniform per-account failure so the dashboard renders
19291
19292
  // gracefully (label badge stays UNKNOWN) instead of falling back
@@ -21272,13 +21273,18 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
21272
21273
  }
21273
21274
 
21274
21275
  // auth:refresh — re-render the /auth snapshot in-place with a fresh
21275
- // live probe. Replaces the message body; keyboard stays.
21276
- if (data === 'auth:refresh') {
21276
+ // live probe. Replaces the message body; keyboard stays. The `:demo`
21277
+ // variant re-renders with email masking intact (a tap on an
21278
+ // `/auth demo` / `/usage demo` card must not unmask mid-recording).
21279
+ if (data === 'auth:refresh' || data === 'auth:refresh:demo') {
21280
+ const refreshDemo = data === 'auth:refresh:demo'
21277
21281
  // Freshness throttle: each refresh fan-fires N live api.anthropic.com
21278
- // probes (one per account, force=true bypasses the 5-min cache).
21279
- // Without this, a user double-tapping the button burns through
21280
- // their account's RPM budget on duplicate work. Cap at one per
21281
- // AUTH_REFRESH_THROTTLE_MS per (chat, message) pair.
21282
+ // probes (one per account forceLive bypasses the broker's 45s
21283
+ // probe-on-open TTL, because an explicittap is the user asking
21284
+ // for live-now data). Without this, a user double-tapping the
21285
+ // button burns through their account's RPM budget on duplicate
21286
+ // work. Cap at one per AUTH_REFRESH_THROTTLE_MS per (chat, message)
21287
+ // pair.
21282
21288
  const refreshMsg = ctx.callbackQuery?.message
21283
21289
  if (refreshMsg) {
21284
21290
  const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`
@@ -21304,24 +21310,34 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
21304
21310
  }
21305
21311
  const state = await client.listState()
21306
21312
  // Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
21313
+ // forceLive=true: an explicit ↻ tap must bypass the broker's
21314
+ // probe-on-open TTL — pre-fix, a tap inside the TTL window served
21315
+ // the cached snapshot while stamping "Live · refreshed 0s ago".
21307
21316
  const probeResp = state.accounts.length > 0
21308
- ? await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
21317
+ ? await client.probeQuota(state.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] }))
21309
21318
  : { results: [] }
21310
- const quotas = state.accounts.map((a) => {
21311
- const hit = probeResp.results.find((r) => r.label === a.label)
21312
- return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
21313
- })
21319
+ // #2495 Change 2 — even under forceLive a failed upstream probe falls
21320
+ // back to the broker cache (served:"cache"); stamp "⚠ cached Nm ago"
21321
+ // instead of a false live stamp, same as the /auth and /usage paths.
21322
+ const { quotas, staleCachedAtMs } = zipProbeResults(
21323
+ state.accounts.map((a) => a.label),
21324
+ probeResp.results,
21325
+ )
21314
21326
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
21315
21327
  const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
21316
21328
  '../auth-snapshot-format.js'
21317
21329
  )
21318
21330
  const snapshots = buildSnapshotsFromState(state, quotas)
21331
+ // Single clock for card body + keyboard so health classification
21332
+ // can't disagree between the two (#2495 folded nit A).
21333
+ const renderNow = new Date()
21319
21334
  const text = renderAuthSnapshotFormat2(snapshots, {
21320
21335
  tz,
21321
- now: new Date(),
21322
- liveProbedAtMs: Date.now(),
21336
+ now: renderNow,
21337
+ demo: refreshDemo,
21338
+ ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }),
21323
21339
  })
21324
- const kbRows = buildSnapshotKeyboard(snapshots)
21340
+ const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
21325
21341
  const inline_keyboard = kbRows.map((row) =>
21326
21342
  row.map((b) => {
21327
21343
  if (b.callbackData) return { text: b.text, callback_data: b.callbackData }
@@ -21786,14 +21802,10 @@ bot.command('usage', async ctx => {
21786
21802
  // which we surface as a "⚠ cached Nm ago" footer instead of a false
21787
21803
  // live stamp.
21788
21804
  const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
21789
- let staleCachedAtMs: number | undefined
21790
- const quotas = state.accounts.map((a) => {
21791
- const hit = probeResp.results.find((r) => r.label === a.label)
21792
- if (hit?.served === 'cache' && hit.capturedAt != null) {
21793
- staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt)
21794
- }
21795
- return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
21796
- })
21805
+ const { quotas, staleCachedAtMs } = zipProbeResults(
21806
+ state.accounts.map((a) => a.label),
21807
+ probeResp.results,
21808
+ )
21797
21809
  const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
21798
21810
  '../auth-snapshot-format.js'
21799
21811
  )
@@ -21810,7 +21822,7 @@ bot.command('usage', async ctx => {
21810
21822
  // /auth snapshot does. switchroomReply routes through the rich path
21811
21823
  // (replyWithRichMessage), which accepts reply_markup. Build a grammy
21812
21824
  // InlineKeyboard so the markup type matches switchroomReply's contract.
21813
- const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date() })
21825
+ const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
21814
21826
  const keyboard = new InlineKeyboard()
21815
21827
  kbRows.forEach((row, ri) => {
21816
21828
  if (ri > 0) keyboard.row()
@@ -18,6 +18,7 @@ import {
18
18
  renderFallbackAnnouncement,
19
19
  buildSnapshotKeyboard,
20
20
  buildSnapshotsFromState,
21
+ zipProbeResults,
21
22
  buildSnapshotsFromCachedState,
22
23
  reviveLastQuota,
23
24
  THROTTLING_THRESHOLD_PCT,
@@ -720,6 +721,80 @@ describe('buildSnapshotKeyboard', () => {
720
721
  .flat().map((b) => b.text);
721
722
  expect(before).not.toContain('Switch fleet → refilled@x');
722
723
  });
724
+
725
+ it('demo mode masks the switch-button label but keeps the real label in callback_data', () => {
726
+ __resetDemoMaskCachesForTest();
727
+ const snaps: AccountSnapshot[] = [
728
+ snap({ label: 'ken.real@example.com', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
729
+ snap({ label: 'alt.real@example.com', quota: quota({ fiveHourUtilizationPct: 5 }) }),
730
+ ];
731
+ const rows = buildSnapshotKeyboard(snaps, { now: NOW, demo: true });
732
+ const switchBtn = rows.flat().find((b) => b.callbackData?.startsWith('auth:use:'));
733
+ expect(switchBtn).toBeDefined();
734
+ // Label masked — the real email never appears on screen…
735
+ expect(switchBtn!.text).not.toContain('alt.real@example.com');
736
+ // …but the broker still gets the real label to act on.
737
+ expect(switchBtn!.callbackData).toBe('auth:use:alt.real@example.com');
738
+ });
739
+
740
+ it('demo mode flips the refresh callback to auth:refresh:demo so a ↻ tap stays masked', () => {
741
+ const snaps: AccountSnapshot[] = [
742
+ snap({ label: 'a@x', isActive: true, quota: quota({}) }),
743
+ ];
744
+ const demoRows = buildSnapshotKeyboard(snaps, { demo: true }).flat();
745
+ expect(demoRows.find((b) => b.text === '↻ Refresh')?.callbackData).toBe('auth:refresh:demo');
746
+ const plainRows = buildSnapshotKeyboard(snaps).flat();
747
+ expect(plainRows.find((b) => b.text === '↻ Refresh')?.callbackData).toBe('auth:refresh');
748
+ });
749
+ });
750
+
751
+ // ── zipProbeResults ──────────────────────────────────────────────────
752
+
753
+ describe('zipProbeResults', () => {
754
+ const okResult = { ok: true as const, data: quota({ fiveHourUtilizationPct: 5 }) };
755
+
756
+ it('returns quotas parallel to labels with no staleCachedAtMs when everything is live', () => {
757
+ const { quotas, staleCachedAtMs } = zipProbeResults(
758
+ ['a@x', 'b@x'],
759
+ [
760
+ { label: 'a@x', result: okResult, served: 'live' },
761
+ { label: 'b@x', result: okResult, served: 'live' },
762
+ ],
763
+ );
764
+ expect(quotas).toHaveLength(2);
765
+ expect(quotas.every((q) => q.ok)).toBe(true);
766
+ expect(staleCachedAtMs).toBeUndefined();
767
+ });
768
+
769
+ it('surfaces the OLDEST capturedAt among cache-served rows (#2495 Change 2)', () => {
770
+ const { staleCachedAtMs } = zipProbeResults(
771
+ ['a@x', 'b@x', 'c@x'],
772
+ [
773
+ { label: 'a@x', result: okResult, served: 'live' },
774
+ { label: 'b@x', result: okResult, served: 'cache', capturedAt: 5_000 },
775
+ { label: 'c@x', result: okResult, served: 'cache', capturedAt: 2_000 },
776
+ ],
777
+ );
778
+ expect(staleCachedAtMs).toBe(2_000);
779
+ });
780
+
781
+ it('degrades a missing per-label row to ok:false, preserving input order', () => {
782
+ const { quotas } = zipProbeResults(
783
+ ['a@x', 'gone@x'],
784
+ [{ label: 'a@x', result: okResult }],
785
+ );
786
+ expect(quotas[0]!.ok).toBe(true);
787
+ expect(quotas[1]!.ok).toBe(false);
788
+ expect((quotas[1] as { ok: false; reason: string }).reason).toContain('no result');
789
+ });
790
+
791
+ it('ignores served:"cache" rows without capturedAt (no false staleness)', () => {
792
+ const { staleCachedAtMs } = zipProbeResults(
793
+ ['a@x'],
794
+ [{ label: 'a@x', result: okResult, served: 'cache' }],
795
+ );
796
+ expect(staleCachedAtMs).toBeUndefined();
797
+ });
723
798
  });
724
799
 
725
800
  // ── buildSnapshotsFromState ──────────────────────────────────────────
@@ -84,3 +84,29 @@ describe('no-repeat-on-timeout wiring', () => {
84
84
  expect(GATEWAY).toMatch(/permissionTimeoutSignatures\.delete\(sig\)/)
85
85
  })
86
86
  })
87
+
88
+ describe('inbound gate holds while approval card is outstanding (#2841)', () => {
89
+ // turnInFlightForGate() must return true when pendingPermissions is non-empty,
90
+ // even after releaseTurnBufferGate has cleared claudeBusyKeys/machine on the
91
+ // first interim reply. Without this, a new inbound delivered in that window
92
+ // displaces the approval context and orphans the pending MCP call.
93
+ it('turnInFlightForGate includes pendingPermissions.size > 0 on the legacy path', () => {
94
+ // span 1500: the function has a ~800-char comment block before the code lines.
95
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1500)
96
+ // Both paths (legacy claudeBusyKeys + machine-authoritative) must include the check.
97
+ expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0\s*\|\|\s*hasPendingApproval/)
98
+ })
99
+
100
+ it('turnInFlightForGate includes pendingPermissions.size > 0 on the machine path', () => {
101
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1600)
102
+ // probeGateParity(...) || hasPendingApproval — the inner arg contains its own
103
+ // parens (isMachineInTurn()), so match the two tokens independently.
104
+ expect(fn).toContain('probeGateParity(')
105
+ expect(fn).toMatch(/probeGateParity[\s\S]+?\|\|\s*hasPendingApproval/)
106
+ })
107
+
108
+ it('hasPendingApproval reads pendingPermissions.size', () => {
109
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1500)
110
+ expect(fn).toMatch(/pendingPermissions\.size\s*>\s*0/)
111
+ })
112
+ })