switchroom 0.15.0 → 0.15.2

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.
@@ -16160,7 +16160,7 @@ function decodeResponse(line) {
16160
16160
  }
16161
16161
  return ResponseSchema.parse(parsed);
16162
16162
  }
16163
- 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;
16163
+ 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;
16164
16164
  var init_protocol = __esm(() => {
16165
16165
  init_zod();
16166
16166
  MAX_FRAME_BYTES = 64 * 1024;
@@ -16276,6 +16276,13 @@ var init_protocol = __esm(() => {
16276
16276
  accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
16277
16277
  timeoutMs: exports_external.number().int().positive().max(60000).optional()
16278
16278
  });
16279
+ ClaimNotificationRequestSchema = exports_external.object({
16280
+ v: exports_external.literal(PROTOCOL_VERSION),
16281
+ op: exports_external.literal("claim-notification"),
16282
+ id: exports_external.string().min(1),
16283
+ key: exports_external.string().min(1).max(512),
16284
+ windowMs: exports_external.number().int().positive().max(86400000)
16285
+ });
16279
16286
  RequestSchema = exports_external.discriminatedUnion("op", [
16280
16287
  GetCredentialsRequestSchema,
16281
16288
  ListStateRequestSchema,
@@ -16287,7 +16294,8 @@ var init_protocol = __esm(() => {
16287
16294
  SetOverrideRequestSchema,
16288
16295
  ListGoogleAccountsRequestSchema,
16289
16296
  ListMicrosoftAccountsRequestSchema,
16290
- ProbeQuotaRequestSchema
16297
+ ProbeQuotaRequestSchema,
16298
+ ClaimNotificationRequestSchema
16291
16299
  ]);
16292
16300
  GetCredentialsDataSchema = exports_external.object({
16293
16301
  account: exports_external.string(),
@@ -16343,6 +16351,9 @@ var init_protocol = __esm(() => {
16343
16351
  agent: exports_external.string(),
16344
16352
  account: exports_external.string().nullable()
16345
16353
  });
16354
+ ClaimNotificationDataSchema = exports_external.object({
16355
+ granted: exports_external.boolean()
16356
+ });
16346
16357
  GoogleAccountStateSchema = exports_external.object({
16347
16358
  account: exports_external.string(),
16348
16359
  expiresAt: exports_external.number(),
@@ -16524,6 +16535,16 @@ class AuthBrokerClient {
16524
16535
  const data = await this.send(req);
16525
16536
  return data;
16526
16537
  }
16538
+ async claimNotification(key, windowMs) {
16539
+ const data = await this.send({
16540
+ v: PROTOCOL_VERSION,
16541
+ id: randomUUID3(),
16542
+ op: "claim-notification",
16543
+ key,
16544
+ windowMs
16545
+ });
16546
+ return data;
16547
+ }
16527
16548
  async refreshAccount(account) {
16528
16549
  const data = await this.send({
16529
16550
  v: PROTOCOL_VERSION,
@@ -24173,6 +24194,7 @@ var init_schema = __esm(() => {
24173
24194
  dangerous_mode: exports_external.boolean().optional().describe("If true, include --dangerously-skip-permissions in start.sh"),
24174
24195
  network_isolation: NetworkIsolationSchema,
24175
24196
  admin: exports_external.boolean().optional().describe("If true, the agent's Telegram gateway intercepts admin slash commands " + "(/agents, /logs, /restart, /delete, /update, /auth, /reconcile, etc.) " + "locally before forwarding to Claude. Commands are handled silently \u2014 " + "Claude never sees them. Requires the agent to use the switchroom-telegram " + "plugin. When false or absent, all messages pass through to Claude unchanged."),
24197
+ root: exports_external.boolean().optional().describe("If true, this is a ROOT-tier debugging agent: a root-privileged " + "container (runs as uid 0, mounts /var/run/docker.sock, the whole " + "~/.switchroom tree, and the host root filesystem at /host) so you " + "can DM it to debug the whole fleet \u2014 read any agent's logs, " + "docker exec into peers, edit host files \u2014 instead of SSHing into " + "the host as root. Implies admin: true (all admin slash commands). " + "Standing root power, audited via the agent's own session transcript " + "and shell history; there is no per-action approval tap. Per-agent " + "only (never set at defaults/profile layers). Grant to exactly one " + "trusted operator-private agent \u2014 it ingests other agents' output, " + "which is attacker-influenced text. See docs/root-agent.md."),
24176
24198
  settings_raw: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Escape hatch: raw object deep-merged into the generated " + "settings.json as the final step. Use for Claude Code settings " + "keys switchroom doesn't wrap directly (e.g. effort, apiKeyHelper). " + "Power-user-only \u2014 prefer the typed fields when they exist."),
24177
24199
  claude_md_raw: exports_external.string().optional().describe("Escape hatch: markdown text appended verbatim to CLAUDE.md on " + "initial scaffold. Not re-applied on reconcile (CLAUDE.md is " + "user-protected). Use for one-off persona tuning that isn't " + "worth a template."),
24178
24200
  cli_args: exports_external.array(exports_external.string()).optional().describe("Escape hatch: extra arguments appended to the `exec claude` " + "invocation in start.sh. Use for Claude Code CLI flags switchroom " + "doesn't expose directly (e.g. --effort high, " + "--exclude-dynamic-system-prompt-sections)."),
@@ -30611,7 +30633,8 @@ function buildSnapshotsFromCachedState(state4) {
30611
30633
  isActive: acc.label === state4.active,
30612
30634
  quota: reviveLastQuota(lq),
30613
30635
  quotaError: lq ? undefined : "no cached quota (no probe since broker start)",
30614
- expiresAtMs: acc.expiresAt
30636
+ expiresAtMs: acc.expiresAt,
30637
+ capturedAtMs: lq?.capturedAt
30615
30638
  };
30616
30639
  });
30617
30640
  }
@@ -40687,17 +40710,31 @@ function renderShowText(state3, now = Date.now(), opts = {}) {
40687
40710
  `);
40688
40711
  }
40689
40712
  function formatAccountsTable(state3, now) {
40690
- const rows = [["ACCOUNT", "STATUS", "EXPIRES", "QUOTA-RESET"]];
40713
+ const rows = [["ACCOUNT", "STATUS", "EXPIRES", "QUOTA 5h\u00b77d", "QUOTA-RESET"]];
40691
40714
  for (const acc of state3.accounts) {
40692
40715
  const isActive = acc.label === state3.active;
40693
40716
  const marker = isActive ? "\u25cf" : acc.exhausted ? "!" : "\u2713";
40694
40717
  const status = isActive ? "active" : acc.exhausted ? "exhausted" : "available";
40695
40718
  const expires = acc.expiresAt != null ? formatRelativeMs(acc.expiresAt - now) : "\u2014";
40696
40719
  const quotaReset = acc.exhausted && acc.exhausted_until != null && acc.exhausted_until > now ? formatRelativeMs(acc.exhausted_until - now) : "\u2014";
40697
- rows.push([`${marker} ${escapeHtml3(acc.label)}`, status, expires, quotaReset]);
40720
+ rows.push([
40721
+ `${marker} ${escapeHtml3(acc.label)}`,
40722
+ status,
40723
+ expires,
40724
+ formatQuotaUtilCell(acc, now),
40725
+ quotaReset
40726
+ ]);
40698
40727
  }
40699
40728
  return alignTable(rows);
40700
40729
  }
40730
+ function formatQuotaUtilCell(acc, now) {
40731
+ const lq = acc.last_quota;
40732
+ if (!lq)
40733
+ return "no data";
40734
+ const age = now - lq.capturedAt;
40735
+ const ageStr = age > 0 ? formatRelativeMs(age) : "0s";
40736
+ return `${Math.round(lq.fiveHourUtilizationPct)}%\u00b7${Math.round(lq.sevenDayUtilizationPct)}% (${ageStr} ago)`;
40737
+ }
40701
40738
  function formatAgentsTable(state3) {
40702
40739
  const rows = [["AGENT", "ACTIVE", "SOURCE"]];
40703
40740
  for (const a of state3.agents) {
@@ -40801,7 +40838,8 @@ function createAuthBrokerClient() {
40801
40838
  rmAccount: (label) => broker.rmAccount(label),
40802
40839
  refreshAccount: (label) => broker.refreshAccount(label),
40803
40840
  setOverride: (agent, account) => broker.setOverride(agent, account),
40804
- probeQuota: (accounts, timeoutMs) => broker.probeQuota(accounts, timeoutMs)
40841
+ probeQuota: (accounts, timeoutMs) => broker.probeQuota(accounts, timeoutMs),
40842
+ claimNotification: (key, windowMs) => broker.claimNotification(key, windowMs)
40805
40843
  };
40806
40844
  return { client: client3, close: () => broker.close() };
40807
40845
  }
@@ -41215,6 +41253,16 @@ class AuthBrokerClient2 {
41215
41253
  const data = await this.send(req);
41216
41254
  return data;
41217
41255
  }
41256
+ async claimNotification(key, windowMs) {
41257
+ const data = await this.send({
41258
+ v: PROTOCOL_VERSION,
41259
+ id: randomUUID4(),
41260
+ op: "claim-notification",
41261
+ key,
41262
+ windowMs
41263
+ });
41264
+ return data;
41265
+ }
41218
41266
  async refreshAccount(account) {
41219
41267
  const data = await this.send({
41220
41268
  v: PROTOCOL_VERSION,
@@ -42085,6 +42133,22 @@ function escHtml2(text) {
42085
42133
  }
42086
42134
 
42087
42135
  // auto-fallback-fleet.ts
42136
+ function renderFallbackFailureNotice(triggerAgent, reason) {
42137
+ return `\u26a0\ufe0f <b>Auto-failover could not run</b> (trigger: <b>${escFailureHtml(triggerAgent)}</b>)
42138
+ ` + `${escFailureHtml(reason)}
42139
+
42140
+ ` + `<i>Switch manually with <code>/auth use &lt;label&gt;</code>, or <code>/auth</code> for fleet status.</i>`;
42141
+ }
42142
+ function escFailureHtml(s) {
42143
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
42144
+ }
42145
+ var FALLBACK_FAILURE_NOTICE_COOLDOWN_MS = 30 * 60000;
42146
+ function evaluateFallbackFailureNotice(prev, now, cooldownMs = FALLBACK_FAILURE_NOTICE_COOLDOWN_MS) {
42147
+ if (now - prev.lastSentAtMs >= cooldownMs) {
42148
+ return { send: true, next: { lastSentAtMs: now } };
42149
+ }
42150
+ return { send: false, next: prev };
42151
+ }
42088
42152
  async function runFleetAutoFallback(deps) {
42089
42153
  const now = deps.now ?? new Date;
42090
42154
  const tz = deps.tz ?? "UTC";
@@ -42979,6 +43043,7 @@ var TELEGRAM_MENU_COMMANDS = [
42979
43043
  { command: "version", description: "Show versions + running agent health" },
42980
43044
  { command: "logs", description: "Show recent agent logs" },
42981
43045
  { command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
43046
+ { command: "model", description: "Show or switch the Claude model" },
42982
43047
  { command: "doctor", description: "Health check (deps, services, MCP)" },
42983
43048
  { command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
42984
43049
  { command: "vault", description: "Manage vault secrets + capability grants" },
@@ -43018,6 +43083,8 @@ function switchroomHelpText(agentName3) {
43018
43083
  `<code>/auth list [agent]</code> \u2014 list account slots and health`,
43019
43084
  `<code>/auth use [agent] &lt;slot&gt;</code> \u2014 switch active slot and restart`,
43020
43085
  `<code>/auth rm [agent] &lt;slot&gt; [--force]</code> \u2014 remove a slot`,
43086
+ `<code>/model</code> \u2014 show the configured Claude model`,
43087
+ `<code>/model &lt;name&gt;</code> \u2014 switch the live session's model (opus \u00b7 sonnet \u00b7 haiku or a full id; until restart)`,
43021
43088
  `<code>/topics</code> \u2014 topic-to-agent mappings`,
43022
43089
  `<code>/permissions [agent]</code> \u2014 show agent permissions`,
43023
43090
  `<code>/grant &lt;tool&gt;</code> \u2014 grant a tool permission`,
@@ -44748,6 +44815,106 @@ Allowed: <code>${deps.escapeHtml(allow)}</code>`, { html: true });
44748
44815
  await deps.reply(ctx, finalText, { html: true, accent });
44749
44816
  }
44750
44817
 
44818
+ // gateway/model-command.ts
44819
+ var MODEL_ALIASES = ["opus", "sonnet", "haiku", "default"];
44820
+ var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/;
44821
+ function isValidModelArg(arg) {
44822
+ return MODEL_ARG_RE.test(arg);
44823
+ }
44824
+ function parseModelCommand(text) {
44825
+ const m = text.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
44826
+ if (!m)
44827
+ return null;
44828
+ const rest = (m[1] ?? "").trim();
44829
+ if (rest.length === 0)
44830
+ return { kind: "show" };
44831
+ const parts = rest.split(/\s+/);
44832
+ if (parts.length > 1) {
44833
+ return { kind: "help", reason: "model takes a single argument" };
44834
+ }
44835
+ const arg = parts[0];
44836
+ if (arg.toLowerCase() === "help")
44837
+ return { kind: "help" };
44838
+ if (!isValidModelArg(arg)) {
44839
+ return { kind: "help", reason: `not a valid model name: ${arg}` };
44840
+ }
44841
+ return { kind: "set", model: arg };
44842
+ }
44843
+ var PERSIST_NOTE = "<i>Session-only \u2014 lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>";
44844
+ function helpText2(deps, reason) {
44845
+ const lines = [];
44846
+ if (reason)
44847
+ lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
44848
+ lines.push("<b>/model</b> \u2014 show or switch the Claude model", "<code>/model</code> \u2014 show the configured model", `<code>/model &lt;name&gt;</code> \u2014 switch the live session (${MODEL_ALIASES.map((a) => `<code>${a}</code>`).join(" \u00b7 ")} or a full model id)`, PERSIST_NOTE);
44849
+ return { text: lines.join(`
44850
+ `), html: true };
44851
+ }
44852
+ async function handleModelCommand(parsed, deps) {
44853
+ if (parsed.kind === "help")
44854
+ return helpText2(deps, parsed.reason);
44855
+ if (parsed.kind === "show") {
44856
+ const configured = deps.getConfiguredModel();
44857
+ const shown = configured && configured.length > 0 ? configured : "default";
44858
+ return {
44859
+ text: [
44860
+ `<b>Model \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
44861
+ `Configured: <code>${deps.escapeHtml(shown)}</code>`,
44862
+ `Switch the live session: ${MODEL_ALIASES.map((a) => `<code>/model ${a}</code>`).join(" \u00b7 ")}`,
44863
+ "or <code>/model &lt;full-model-id&gt;</code>",
44864
+ PERSIST_NOTE
44865
+ ].join(`
44866
+ `),
44867
+ html: true
44868
+ };
44869
+ }
44870
+ if (!isValidModelArg(parsed.model)) {
44871
+ return helpText2(deps, `not a valid model name: ${parsed.model}`);
44872
+ }
44873
+ const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`;
44874
+ let result;
44875
+ try {
44876
+ result = await deps.inject(deps.getAgentName(), `/model ${parsed.model}`);
44877
+ } catch (err) {
44878
+ const msg = err instanceof Error ? err.message : String(err);
44879
+ return {
44880
+ text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`,
44881
+ html: true
44882
+ };
44883
+ }
44884
+ if (result.outcome === "ok") {
44885
+ return {
44886
+ text: [
44887
+ `${verbHtml}`,
44888
+ deps.preBlock(result.output),
44889
+ ...result.truncated ? ["<i>truncated</i>"] : [],
44890
+ PERSIST_NOTE
44891
+ ].join(`
44892
+ `),
44893
+ html: true
44894
+ };
44895
+ }
44896
+ if (result.outcome === "ok_no_output") {
44897
+ return {
44898
+ text: [
44899
+ `${verbHtml} \u2014 sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active model.`,
44900
+ PERSIST_NOTE
44901
+ ].join(`
44902
+ `),
44903
+ html: true
44904
+ };
44905
+ }
44906
+ if (result.errorCode === "session_missing") {
44907
+ return {
44908
+ text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.",
44909
+ html: true
44910
+ };
44911
+ }
44912
+ return {
44913
+ text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
44914
+ html: true
44915
+ };
44916
+ }
44917
+
44751
44918
  // ../src/config/loader.ts
44752
44919
  init_dist();
44753
44920
  init_zod();
@@ -52727,9 +52894,35 @@ function emptyQuotaWatchState() {
52727
52894
  function emptyAccountState() {
52728
52895
  return { lastNotifiedHealth: null, lastNotifiedAt: 0 };
52729
52896
  }
52897
+ var DEFAULT_QUOTA_WATCH_MAX_STALE_MS = 60 * 60000;
52898
+ var DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS = 6 * 60 * 60000;
52899
+ var QUOTA_WATCH_CLAIM_WINDOW_MS = 30 * 60000;
52900
+ function resolveQuotaWatchTuning(env) {
52901
+ const num = (raw, fallback) => {
52902
+ if (raw === undefined || raw === "")
52903
+ return fallback;
52904
+ const n = Number(raw);
52905
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
52906
+ };
52907
+ return {
52908
+ maxStaleMs: num(env.SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS, DEFAULT_QUOTA_WATCH_MAX_STALE_MS),
52909
+ lateRecoveryMs: num(env.SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS, DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS),
52910
+ fleetDedup: env.SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP !== "0",
52911
+ sendOnProbeFail: env.SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL === "1"
52912
+ };
52913
+ }
52914
+ function buildQuotaClaimKey(accountLabel, transition2, chatId) {
52915
+ return `quota-watch:${accountLabel}:${transition2}:${chatId}`;
52916
+ }
52730
52917
  function evaluateQuotaWatchAccount(args) {
52731
52918
  const { agentName: agentName3, snap, prev, now } = args;
52919
+ const bootTick = args.bootTick ?? false;
52920
+ const maxStaleMs = args.tuning?.maxStaleMs ?? 0;
52921
+ const lateRecoveryMs = args.tuning?.lateRecoveryMs ?? 0;
52732
52922
  const label = snap.label;
52923
+ if (maxStaleMs > 0 && snap.capturedAtMs !== undefined && now - snap.capturedAtMs > maxStaleMs) {
52924
+ return { kind: "skip", accountLabel: label, reason: "stale-snapshot" };
52925
+ }
52733
52926
  const currentHealth = classifyHealth(snap);
52734
52927
  if (currentHealth === "unknown" || currentHealth === "blocked") {
52735
52928
  return { kind: "skip", accountLabel: label, reason: `${currentHealth}-not-our-domain` };
@@ -52756,6 +52949,24 @@ function evaluateQuotaWatchAccount(args) {
52756
52949
  lastNotifiedHealth: "healthy",
52757
52950
  lastNotifiedAt: now
52758
52951
  };
52952
+ if (bootTick) {
52953
+ return {
52954
+ kind: "reconcile",
52955
+ accountLabel: label,
52956
+ newAccountState: newState,
52957
+ transition: "recovered-to-healthy",
52958
+ reason: "boot-tick-recovery"
52959
+ };
52960
+ }
52961
+ if (lateRecoveryMs > 0 && now - prev.lastNotifiedAt > lateRecoveryMs) {
52962
+ return {
52963
+ kind: "reconcile",
52964
+ accountLabel: label,
52965
+ newAccountState: newState,
52966
+ transition: "recovered-to-healthy",
52967
+ reason: "late-recovery"
52968
+ };
52969
+ }
52759
52970
  return {
52760
52971
  kind: "notify",
52761
52972
  accountLabel: label,
@@ -52971,10 +53182,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
52971
53182
  }
52972
53183
 
52973
53184
  // ../src/build-info.ts
52974
- var VERSION = "0.15.0";
52975
- var COMMIT_SHA = "5841c1d5";
52976
- var COMMIT_DATE = "2026-06-09T23:17:14Z";
52977
- var LATEST_PR = 2253;
53185
+ var VERSION = "0.15.2";
53186
+ var COMMIT_SHA = "95461524";
53187
+ var COMMIT_DATE = "2026-06-10T02:48:01Z";
53188
+ var LATEST_PR = 2260;
52978
53189
  var COMMITS_AHEAD_OF_TAG = 0;
52979
53190
 
52980
53191
  // gateway/boot-version.ts
@@ -60618,6 +60829,23 @@ bot.command("inject", async (ctx) => {
60618
60829
  formatOutput: formatSwitchroomOutput
60619
60830
  });
60620
60831
  });
60832
+ bot.command("model", async (ctx) => {
60833
+ if (!isAuthorizedSender(ctx))
60834
+ return;
60835
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? "";
60836
+ const parsed = parseModelCommand(text) ?? { kind: "show" };
60837
+ const reply = await handleModelCommand(parsed, {
60838
+ inject: injectSlashCommand,
60839
+ getAgentName: getMyAgentName,
60840
+ getConfiguredModel: () => {
60841
+ const data = switchroomExecJson(["agent", "list"]);
60842
+ return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
60843
+ },
60844
+ escapeHtml: escapeHtmlForTg,
60845
+ preBlock
60846
+ });
60847
+ await switchroomReply(ctx, reply.text, { html: reply.html });
60848
+ });
60621
60849
  bot.command("agentstart", async (ctx) => {
60622
60850
  if (!isAuthorizedSender(ctx))
60623
60851
  return;
@@ -61156,12 +61384,32 @@ async function fireFleetAutoFallback(triggerAgent, untilMs) {
61156
61384
  `);
61157
61385
  });
61158
61386
  }
61387
+ var fallbackFailureNoticeState = { lastSentAtMs: 0 };
61388
+ function broadcastFleetFallbackFailure(triggerAgent, reason) {
61389
+ if (process.env.SWITCHROOM_FLEET_FALLBACK_FAILURE_NOTICE === "0")
61390
+ return;
61391
+ const verdict = evaluateFallbackFailureNotice(fallbackFailureNoticeState, Date.now());
61392
+ if (!verdict.send) {
61393
+ process.stderr.write(`telegram gateway: [fleet-fallback] failure notice suppressed (cooldown) agent=${triggerAgent}: ${reason}
61394
+ `);
61395
+ return;
61396
+ }
61397
+ fallbackFailureNoticeState = verdict.next;
61398
+ const access = loadAccess();
61399
+ if (access.allowFrom.length === 0)
61400
+ return;
61401
+ const html = renderFallbackFailureNotice(triggerAgent, reason);
61402
+ for (const chat_id of access.allowFrom) {
61403
+ swallowingApiCall(() => bot.api.sendMessage(chat_id, html, { parse_mode: "HTML" }), { chat_id, verb: "fleet-fallback:failure-notify" });
61404
+ }
61405
+ }
61159
61406
  async function doFireFleetAutoFallback(triggerAgent, untilMs) {
61160
61407
  try {
61161
61408
  const client3 = await getAuthBrokerClient(triggerAgent);
61162
61409
  if (!client3) {
61163
61410
  process.stderr.write(`telegram gateway: [fleet-fallback] skipped agent=${triggerAgent} reason=no-broker-client
61164
61411
  `);
61412
+ broadcastFleetFallbackFailure(triggerAgent, "auth-broker unreachable (no client).");
61165
61413
  return false;
61166
61414
  }
61167
61415
  const state4 = await client3.listState();
@@ -61194,6 +61442,7 @@ async function doFireFleetAutoFallback(triggerAgent, untilMs) {
61194
61442
  } catch (err) {
61195
61443
  process.stderr.write(`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${err?.message ?? err}
61196
61444
  `);
61445
+ broadcastFleetFallbackFailure(triggerAgent, err?.message ?? String(err));
61197
61446
  return false;
61198
61447
  }
61199
61448
  }
@@ -61230,9 +61479,21 @@ async function runCreditWatch() {
61230
61479
  `);
61231
61480
  }
61232
61481
  }
61233
- async function runQuotaWatch() {
61482
+ async function claimQuotaNotification(brokerClient, key) {
61483
+ try {
61484
+ const res = await brokerClient.claimNotification(key, QUOTA_WATCH_CLAIM_WINDOW_MS);
61485
+ return res.granted;
61486
+ } catch (err) {
61487
+ process.stderr.write(`telegram gateway: quota-watch: claim failed (fail-open): ${err}
61488
+ `);
61489
+ return true;
61490
+ }
61491
+ }
61492
+ async function runQuotaWatch(opts = {}) {
61234
61493
  const agentName3 = getMyAgentName();
61235
61494
  const stateDir = STATE_DIR;
61495
+ const bootTick = opts.bootTick ?? false;
61496
+ const tuning = resolveQuotaWatchTuning(process.env);
61236
61497
  const brokerClient = await getAuthBrokerClient(agentName3);
61237
61498
  if (!brokerClient) {
61238
61499
  process.stderr.write(`telegram gateway: quota-watch: broker client unavailable \u2014 skipping
@@ -61263,6 +61524,14 @@ async function runQuotaWatch() {
61263
61524
  });
61264
61525
  if (fleetDecision.kind === "notify") {
61265
61526
  for (const chat_id of access.allowFrom) {
61527
+ if (tuning.fleetDedup) {
61528
+ const granted = await claimQuotaNotification(brokerClient, buildQuotaClaimKey(FLEET_ALL_EXHAUSTED_KEY, fleetDecision.transition, chat_id));
61529
+ if (!granted) {
61530
+ process.stderr.write(`telegram gateway: quota-watch: fleet-all-exhausted claim denied chat=${chat_id} \u2014 another agent notified
61531
+ `);
61532
+ continue;
61533
+ }
61534
+ }
61266
61535
  await swallowingApiCall(() => bot.api.sendMessage(chat_id, fleetDecision.message, {
61267
61536
  parse_mode: "HTML",
61268
61537
  link_preview_options: { is_disabled: true }
@@ -61281,10 +61550,17 @@ async function runQuotaWatch() {
61281
61550
  }
61282
61551
  const pendingTransitions = [];
61283
61552
  const labelToSnapIndex = new Map(snapshots.map((s, i) => [s.label, i]));
61553
+ let reconciledCount = 0;
61554
+ let mutatedState = watchState;
61284
61555
  for (const snap of snapshots) {
61285
61556
  const prev = watchState[snap.label] ?? emptyAccountState();
61286
- const decision = evaluateQuotaWatchAccount({ agentName: agentName3, snap, prev, now });
61287
- if (decision.kind !== "skip") {
61557
+ const decision = evaluateQuotaWatchAccount({ agentName: agentName3, snap, prev, now, bootTick, tuning });
61558
+ if (decision.kind === "reconcile") {
61559
+ mutatedState = patchQuotaWatchState(mutatedState, decision.accountLabel, decision.newAccountState);
61560
+ reconciledCount++;
61561
+ process.stderr.write(`telegram gateway: quota-watch: reconciled ${decision.transition} for account=${decision.accountLabel} (${decision.reason}) \u2014 no notification
61562
+ `);
61563
+ } else if (decision.kind !== "skip") {
61288
61564
  pendingTransitions.push({
61289
61565
  accountLabel: snap.label,
61290
61566
  snapIndex: labelToSnapIndex.get(snap.label) ?? -1,
@@ -61293,6 +61569,14 @@ async function runQuotaWatch() {
61293
61569
  }
61294
61570
  }
61295
61571
  if (pendingTransitions.length === 0) {
61572
+ if (reconciledCount > 0) {
61573
+ try {
61574
+ saveQuotaWatchState(stateDir, mutatedState);
61575
+ } catch (err) {
61576
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${err}
61577
+ `);
61578
+ }
61579
+ }
61296
61580
  return;
61297
61581
  }
61298
61582
  const crossingLabels = pendingTransitions.map((t) => t.accountLabel);
@@ -61305,8 +61589,20 @@ async function runQuotaWatch() {
61305
61589
  } catch (err) {
61306
61590
  process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}
61307
61591
  `);
61592
+ if (!tuning.sendOnProbeFail) {
61593
+ if (reconciledCount > 0) {
61594
+ try {
61595
+ saveQuotaWatchState(stateDir, mutatedState);
61596
+ } catch (saveErr) {
61597
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}
61598
+ `);
61599
+ }
61600
+ }
61601
+ process.stderr.write(`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds
61602
+ `);
61603
+ return;
61604
+ }
61308
61605
  }
61309
- let mutatedState = watchState;
61310
61606
  const notifications = [];
61311
61607
  for (const { accountLabel, snapIndex, decision } of pendingTransitions) {
61312
61608
  const freshResult = freshProbeMap.get(accountLabel);
@@ -61314,25 +61610,55 @@ async function runQuotaWatch() {
61314
61610
  if (decision.kind !== "notify")
61315
61611
  continue;
61316
61612
  if (freshResult && freshResult.ok && snapIndex >= 0) {
61317
- const enrichedSnap = { ...snapshots[snapIndex], quota: freshResult.data };
61613
+ const enrichedSnap = { ...snapshots[snapIndex], quota: freshResult.data, capturedAtMs: undefined };
61318
61614
  const prev = watchState[accountLabel] ?? emptyAccountState();
61319
- const re = evaluateQuotaWatchAccount({ agentName: agentName3, snap: enrichedSnap, prev, now });
61615
+ const re = evaluateQuotaWatchAccount({ agentName: agentName3, snap: enrichedSnap, prev, now, bootTick, tuning });
61320
61616
  if (re.kind === "notify" && re.transition === decision.transition) {
61321
61617
  enrichedDecision = re;
61618
+ } else if (re.kind === "reconcile") {
61619
+ mutatedState = patchQuotaWatchState(mutatedState, accountLabel, re.newAccountState);
61620
+ reconciledCount++;
61621
+ process.stderr.write(`telegram gateway: quota-watch: reconciled ${re.transition} for account=${accountLabel} (${re.reason}) \u2014 no notification
61622
+ `);
61623
+ continue;
61322
61624
  } else if (re.kind === "skip") {
61323
61625
  continue;
61324
61626
  }
61627
+ } else if (!tuning.sendOnProbeFail) {
61628
+ process.stderr.write(`telegram gateway: quota-watch: probe unavailable for account=${accountLabel} \u2014 deferring notification
61629
+ `);
61630
+ continue;
61325
61631
  }
61326
61632
  if (enrichedDecision.kind !== "notify")
61327
61633
  continue;
61328
- notifications.push({ message: enrichedDecision.message, accountLabel });
61634
+ notifications.push({
61635
+ message: enrichedDecision.message,
61636
+ accountLabel,
61637
+ transition: enrichedDecision.transition
61638
+ });
61329
61639
  mutatedState = patchQuotaWatchState(mutatedState, accountLabel, enrichedDecision.newAccountState);
61330
61640
  }
61331
61641
  if (notifications.length === 0) {
61642
+ if (reconciledCount > 0) {
61643
+ try {
61644
+ saveQuotaWatchState(stateDir, mutatedState);
61645
+ } catch (err) {
61646
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${err}
61647
+ `);
61648
+ }
61649
+ }
61332
61650
  return;
61333
61651
  }
61334
- for (const { message, accountLabel } of notifications) {
61652
+ for (const { message, accountLabel, transition: transition2 } of notifications) {
61335
61653
  for (const chat_id of access.allowFrom) {
61654
+ if (tuning.fleetDedup) {
61655
+ const granted = await claimQuotaNotification(brokerClient, buildQuotaClaimKey(accountLabel, transition2, chat_id));
61656
+ if (!granted) {
61657
+ process.stderr.write(`telegram gateway: quota-watch: claim denied account=${accountLabel} chat=${chat_id} \u2014 another agent notified
61658
+ `);
61659
+ continue;
61660
+ }
61661
+ }
61336
61662
  await swallowingApiCall(() => bot.api.sendMessage(chat_id, message, {
61337
61663
  parse_mode: "HTML",
61338
61664
  link_preview_options: { is_disabled: true }
@@ -61399,7 +61725,7 @@ bot.command("connect", async (ctx) => {
61399
61725
  try {
61400
61726
  const cfg = loadConfig2();
61401
61727
  const me = cfg?.agents?.[getMyAgentName()];
61402
- isAdmin2 = me?.admin === true;
61728
+ isAdmin2 = me?.admin === true || me?.root === true;
61403
61729
  } catch {}
61404
61730
  if (!isAuthAdmin({ isAdmin: isAdmin2 })) {
61405
61731
  await switchroomReply(ctx, `<b>Not authorized.</b> <code>/connect</code> requires this agent to have <code>admin: true</code> in switchroom.yaml.`, { html: true });
@@ -61488,7 +61814,7 @@ bot.command("auth", async (ctx) => {
61488
61814
  try {
61489
61815
  const cfg = loadConfig2();
61490
61816
  const me = cfg?.agents?.[currentAgent];
61491
- isAdmin2 = me?.admin === true;
61817
+ isAdmin2 = me?.admin === true || me?.root === true;
61492
61818
  } catch {}
61493
61819
  const chatId = String(ctx.chat?.id ?? "");
61494
61820
  if (parsed.kind === "add" || parsed.kind === "cancel") {
@@ -64661,7 +64987,7 @@ var didOneTimeSetup = false;
64661
64987
  const QUOTA_WATCH_POLL_MS = Number(process.env.SWITCHROOM_QUOTA_WATCH_POLL_MS ?? 900000);
64662
64988
  if (QUOTA_WATCH_POLL_MS > 0) {
64663
64989
  setTimeout(() => {
64664
- runQuotaWatch().catch((err) => {
64990
+ runQuotaWatch({ bootTick: true }).catch((err) => {
64665
64991
  process.stderr.write(`telegram gateway: quota-watch initial run failed: ${err}
64666
64992
  `);
64667
64993
  });
@@ -34,6 +34,8 @@ export function createAuthBrokerClient(): {
34
34
  broker.setOverride(agent, account),
35
35
  probeQuota: (accounts: readonly string[], timeoutMs?: number) =>
36
36
  broker.probeQuota(accounts, timeoutMs),
37
+ claimNotification: (key: string, windowMs: number) =>
38
+ broker.claimNotification(key, windowMs),
37
39
  }
38
40
  return { client, close: () => broker.close() }
39
41
  }
@@ -238,6 +238,13 @@ export interface AuthBrokerClient {
238
238
  accounts: readonly string[],
239
239
  timeoutMs?: number,
240
240
  ): Promise<{ results: Array<{ label: string; result: import('../quota-check.js').QuotaResult }> }>
241
+ /**
242
+ * Fleet notification-dedup claim (quota-watch). First caller of `key`
243
+ * inside `windowMs` gets `granted: true` and sends; everyone else
244
+ * stays silent. Callers fail OPEN on error (skewed-rollout brokers
245
+ * reject the op at the protocol layer).
246
+ */
247
+ claimNotification(key: string, windowMs: number): Promise<{ granted: boolean }>
241
248
  }
242
249
 
243
250
  export interface AuthCommandContext {
@@ -778,7 +785,7 @@ export function renderShowText(
778
785
  }
779
786
 
780
787
  function formatAccountsTable(state: ListStateData, now: number): string {
781
- const rows: string[][] = [['ACCOUNT', 'STATUS', 'EXPIRES', 'QUOTA-RESET']]
788
+ const rows: string[][] = [['ACCOUNT', 'STATUS', 'EXPIRES', 'QUOTA 5h·7d', 'QUOTA-RESET']]
782
789
  for (const acc of state.accounts) {
783
790
  const isActive = acc.label === state.active
784
791
  const marker = isActive
@@ -792,11 +799,37 @@ function formatAccountsTable(state: ListStateData, now: number): string {
792
799
  acc.exhausted && acc.exhausted_until != null && acc.exhausted_until > now
793
800
  ? formatRelativeMs(acc.exhausted_until - now)
794
801
  : '—'
795
- rows.push([`${marker} ${escapeHtml(acc.label)}`, status, expires, quotaReset])
802
+ rows.push([
803
+ `${marker} ${escapeHtml(acc.label)}`,
804
+ status,
805
+ expires,
806
+ formatQuotaUtilCell(acc, now),
807
+ quotaReset,
808
+ ])
796
809
  }
797
810
  return alignTable(rows)
798
811
  }
799
812
 
813
+ /**
814
+ * Cached-utilization cell for the legacy accounts table. Honesty fix
815
+ * (2026-06-09 incident follow-up): the table previously rendered ONLY
816
+ * the broker exhausted flag, so a 99%-utilized account read
817
+ * "available —" — and an agent parroting the table under-reported. Now
818
+ * shows the broker's cached 5h·7d utilization with its age, and
819
+ * distinguishes "no data" (never probed) from "not exhausted".
820
+ * Cached, not live: the (age) suffix is the staleness disclosure.
821
+ */
822
+ export function formatQuotaUtilCell(
823
+ acc: { last_quota?: { fiveHourUtilizationPct: number; sevenDayUtilizationPct: number; capturedAt: number } | null },
824
+ now: number,
825
+ ): string {
826
+ const lq = acc.last_quota
827
+ if (!lq) return 'no data'
828
+ const age = now - lq.capturedAt
829
+ const ageStr = age > 0 ? formatRelativeMs(age) : '0s'
830
+ return `${Math.round(lq.fiveHourUtilizationPct)}%·${Math.round(lq.sevenDayUtilizationPct)}% (${ageStr} ago)`
831
+ }
832
+
800
833
  function formatAgentsTable(state: ListStateData): string {
801
834
  const rows: string[][] = [['AGENT', 'ACTIVE', 'SOURCE']]
802
835
  for (const a of state.agents) {