switchroom 0.18.13 → 0.18.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -11187,7 +11187,6 @@ var TelegramChannelSchema = exports_external.object({
11187
11187
  enabled: exports_external.boolean().default(true).describe("Master switch for the per-agent Telegram gateway sidecar. " + "When false, start.sh skips the gateway supervise loop and the " + "agent boots without bot-token requirements (smoke-test + " + "offline-dev use case)."),
11188
11188
  plugin: exports_external.enum(["switchroom", "official"]).optional().describe("Which Telegram MCP plugin to load. Default is 'switchroom' — the " + "enhanced fork with streaming edits, reactions, history, and " + "access control. Set to 'official' for the upstream marketplace " + "plugin (basic send/receive only)."),
11189
11189
  format: exports_external.enum(["html", "markdownv2", "text"]).optional().describe("Default reply format passed to the plugin"),
11190
- rate_limit_ms: exports_external.number().optional().describe("Minimum delay between outgoing messages in ms"),
11191
11190
  stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
11192
11191
  stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive in-place stream edits " + "during a turn. Lower = more responsive stream, higher = fewer API " + "calls. Floored at 250 by draft-stream itself. Default 400 ms for DMs " + "and 1000 ms for groups/forums (respects Telegram's ~1 edit/sec/message " + "practical ceiling). Override per-agent if a particular agent needs " + "snappier or quieter streaming."),
11193
11192
  clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
@@ -13636,19 +13635,30 @@ class JsonlAuditSink {
13636
13635
  }
13637
13636
 
13638
13637
  // src/scheduler/quota-preflight.ts
13639
- function decideQuotaPreflight(state) {
13638
+ function decideQuotaPreflight(state, opts = {}) {
13639
+ const now = opts.now ?? Date.now();
13640
13640
  const accounts = state.accounts ?? [];
13641
13641
  if (accounts.length === 0) {
13642
13642
  return { defer: false, reason: "no accounts in broker state" };
13643
13643
  }
13644
13644
  const healthy = accounts.filter((a) => !a.exhausted);
13645
- if (healthy.length > 0) {
13645
+ if (healthy.length === 0) {
13646
+ return { defer: true, reason: `all ${accounts.length} account(s) exhausted` };
13647
+ }
13648
+ const effectiveLabel = (opts.agent !== undefined ? (state.agents ?? []).find((a) => a.name === opts.agent)?.account : undefined) ?? state.active;
13649
+ const effective = accounts.find((a) => a.label === effectiveLabel);
13650
+ if (effective !== undefined && !effective.exhausted && effective.throttled_until !== undefined && effective.throttled_until > now) {
13651
+ const inS = Math.ceil((effective.throttled_until - now) / 1000);
13646
13652
  return {
13647
- defer: false,
13648
- reason: `${healthy.length}/${accounts.length} account(s) healthy`
13653
+ defer: true,
13654
+ reason: `account '${effective.label}' rate-throttled (clears in ${inS}s)`,
13655
+ retryAtMs: effective.throttled_until
13649
13656
  };
13650
13657
  }
13651
- return { defer: true, reason: `all ${accounts.length} account(s) exhausted` };
13658
+ return {
13659
+ defer: false,
13660
+ reason: `${healthy.length}/${accounts.length} account(s) healthy`
13661
+ };
13652
13662
  }
13653
13663
 
13654
13664
  // src/auth/broker/client.ts
@@ -13685,6 +13695,12 @@ var MarkExhaustedRequestSchema = exports_external.object({
13685
13695
  id: exports_external.string().min(1),
13686
13696
  until: exports_external.number().int().positive().optional()
13687
13697
  });
13698
+ var MarkThrottledRequestSchema = exports_external.object({
13699
+ v: exports_external.literal(PROTOCOL_VERSION),
13700
+ op: exports_external.literal("mark-throttled"),
13701
+ id: exports_external.string().min(1),
13702
+ until: exports_external.number().int().positive()
13703
+ });
13688
13704
  var RefreshAccountRequestSchema = exports_external.object({
13689
13705
  v: exports_external.literal(PROTOCOL_VERSION),
13690
13706
  op: exports_external.literal("refresh-account"),
@@ -13785,6 +13801,7 @@ var RequestSchema2 = exports_external.discriminatedUnion("op", [
13785
13801
  ListStateRequestSchema,
13786
13802
  SetActiveRequestSchema,
13787
13803
  MarkExhaustedRequestSchema,
13804
+ MarkThrottledRequestSchema,
13788
13805
  RefreshAccountRequestSchema,
13789
13806
  AddAccountRequestSchema,
13790
13807
  RmAccountRequestSchema,
@@ -13804,6 +13821,7 @@ var AccountStateSchema = exports_external.object({
13804
13821
  expiresAt: exports_external.number().optional(),
13805
13822
  exhausted: exports_external.boolean(),
13806
13823
  exhausted_until: exports_external.number().optional(),
13824
+ throttled_until: exports_external.number().optional(),
13807
13825
  threshold_violations: exports_external.number().int().nonnegative().optional(),
13808
13826
  last_refreshed_at: exports_external.number().optional()
13809
13827
  });
@@ -13834,6 +13852,12 @@ var MarkExhaustedDataSchema = exports_external.object({
13834
13852
  rolled: exports_external.array(exports_external.string()),
13835
13853
  rolledTo: exports_external.string().nullable().optional()
13836
13854
  });
13855
+ var MarkThrottledDataSchema = exports_external.object({
13856
+ account: exports_external.string(),
13857
+ throttled_until: exports_external.number(),
13858
+ escalated: exports_external.boolean(),
13859
+ rolledTo: exports_external.string().nullable().optional()
13860
+ });
13837
13861
  var RefreshAccountDataSchema = exports_external.object({
13838
13862
  account: exports_external.string(),
13839
13863
  expiresAt: exports_external.number().optional()
@@ -14059,6 +14083,15 @@ class AuthBrokerClient {
14059
14083
  const data = await this.send(req);
14060
14084
  return data;
14061
14085
  }
14086
+ async markThrottled(until) {
14087
+ const data = await this.send({
14088
+ v: PROTOCOL_VERSION,
14089
+ id: randomUUID(),
14090
+ op: "mark-throttled",
14091
+ until
14092
+ });
14093
+ return data;
14094
+ }
14062
14095
  async claimNotification(key, windowMs) {
14063
14096
  const data = await this.send({
14064
14097
  v: PROTOCOL_VERSION,
@@ -14729,6 +14762,12 @@ var DEFAULT_MAX_QUOTA_DEFER_ATTEMPTS = 3;
14729
14762
  function defaultQuotaDeferBackoffMs(attempt) {
14730
14763
  return [60000, 180000, 300000][attempt] ?? 300000;
14731
14764
  }
14765
+ function resolveQuotaDeferDelayMs(decision, fallbackMs, nowMs) {
14766
+ if (decision.retryAtMs === undefined)
14767
+ return fallbackMs;
14768
+ const target = decision.retryAtMs - nowMs + 2000;
14769
+ return Math.min(Math.max(target, 5000), 10 * 60000);
14770
+ }
14732
14771
  function registerAgentSchedule(opts) {
14733
14772
  const tasks = [];
14734
14773
  const now = opts.now ?? Date.now;
@@ -14769,7 +14808,7 @@ function registerAgentSchedule(opts) {
14769
14808
  handle = scheduleRetry(() => {
14770
14809
  pendingRetries.delete(handle);
14771
14810
  attemptFire(attempt + 1);
14772
- }, backoff(attempt));
14811
+ }, resolveQuotaDeferDelayMs(decision, backoff(attempt), now()));
14773
14812
  pendingRetries.add(handle);
14774
14813
  }
14775
14814
  return;
@@ -15124,10 +15163,10 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
15124
15163
  }
15125
15164
  const cronLib = __require("node-cron");
15126
15165
  const quotaPreflightEnabled = process.env.SWITCHROOM_DISABLE_CRON_QUOTA_PREFLIGHT !== "1";
15127
- const quotaGate = quotaPreflightEnabled ? async () => {
15166
+ const quotaGate = quotaPreflightEnabled ? async (agent) => {
15128
15167
  const client = new AuthBrokerClient;
15129
15168
  try {
15130
- return decideQuotaPreflight(await client.listState());
15169
+ return decideQuotaPreflight(await client.listState(), { agent });
15131
15170
  } finally {
15132
15171
  await client.close().catch(() => {});
15133
15172
  }
@@ -15206,6 +15245,7 @@ if (import.meta.url === `file://${process.argv[1]}` && /(?:^|[/\\])agent-schedul
15206
15245
  export {
15207
15246
  scheduleSignature,
15208
15247
  resolveReloadPollMs,
15248
+ resolveQuotaDeferDelayMs,
15209
15249
  resolveEntryThreadId,
15210
15250
  resolveChannelTarget,
15211
15251
  registerAgentSchedule,
@@ -16816,7 +16816,6 @@ var TelegramChannelSchema = exports_external.object({
16816
16816
  enabled: exports_external.boolean().default(true).describe("Master switch for the per-agent Telegram gateway sidecar. " + "When false, start.sh skips the gateway supervise loop and the " + "agent boots without bot-token requirements (smoke-test + " + "offline-dev use case)."),
16817
16817
  plugin: exports_external.enum(["switchroom", "official"]).optional().describe("Which Telegram MCP plugin to load. Default is 'switchroom' — the " + "enhanced fork with streaming edits, reactions, history, and " + "access control. Set to 'official' for the upstream marketplace " + "plugin (basic send/receive only)."),
16818
16818
  format: exports_external.enum(["html", "markdownv2", "text"]).optional().describe("Default reply format passed to the plugin"),
16819
- rate_limit_ms: exports_external.number().optional().describe("Minimum delay between outgoing messages in ms"),
16820
16819
  stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
16821
16820
  stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive in-place stream edits " + "during a turn. Lower = more responsive stream, higher = fewer API " + "calls. Floored at 250 by draft-stream itself. Default 400 ms for DMs " + "and 1000 ms for groups/forums (respects Telegram's ~1 edit/sec/message " + "practical ceiling). Override per-agent if a particular agent needs " + "snappier or quieter streaming."),
16822
16821
  clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
@@ -18119,13 +18118,51 @@ import * as constants2 from "node:constants";
18119
18118
  import { createHash as createHash2 } from "node:crypto";
18120
18119
  import { dirname as dirname3, join as join8, resolve as resolve9 } from "node:path";
18121
18120
 
18122
- // src/agents/compose.ts
18123
- import { createHash } from "node:crypto";
18124
-
18125
18121
  // src/agents/scaffold.ts
18126
18122
  import { join as join4, resolve as resolve5 } from "node:path";
18127
18123
  init_atomic();
18128
18124
 
18125
+ // src/agents/agent-uid.ts
18126
+ import { createHash } from "node:crypto";
18127
+
18128
+ // src/broker-common/peercred-path.ts
18129
+ function matchSocketName(socketPath, patterns, maxNameLen) {
18130
+ if (typeof socketPath !== "string" || socketPath.length === 0)
18131
+ return null;
18132
+ let name = null;
18133
+ for (const re of patterns) {
18134
+ const m = socketPath.match(re);
18135
+ if (m && typeof m[1] === "string") {
18136
+ name = m[1];
18137
+ break;
18138
+ }
18139
+ }
18140
+ if (name === null || name.length === 0)
18141
+ return null;
18142
+ if (maxNameLen !== undefined && name.length > maxNameLen)
18143
+ return null;
18144
+ return name;
18145
+ }
18146
+
18147
+ // src/vault/broker/peercred.ts
18148
+ var RESERVED_AGENT_NAMES = new Set(["operator", "hostd"]);
18149
+ function isReservedAgentName(name) {
18150
+ return RESERVED_AGENT_NAMES.has(name);
18151
+ }
18152
+
18153
+ // src/agents/agent-uid.ts
18154
+ var AGENT_UID_MIN = 10001;
18155
+ var AGENT_UID_MAX = 10999;
18156
+ function allocateAgentUid(name) {
18157
+ if (isReservedAgentName(name)) {
18158
+ throw new Error(`agent name '${name}' is reserved by switchroom for another identity kind ` + `(see vault/broker/peercred.ts:RESERVED_AGENT_NAMES). Pick a different name.`);
18159
+ }
18160
+ const hash = createHash("sha256").update(name).digest();
18161
+ const u32 = hash.readUInt32BE(0);
18162
+ const range = AGENT_UID_MAX - AGENT_UID_MIN + 1;
18163
+ return AGENT_UID_MIN + u32 % range;
18164
+ }
18165
+
18129
18166
  // src/config/timezone.ts
18130
18167
  var CONTAINER_DEFAULT_UTC_ZONES = new Set([
18131
18168
  "UTC",
@@ -18269,31 +18306,6 @@ var SCRYPT_MAXMEM = 128 * 1024 * 1024;
18269
18306
  import { homedir as homedir3 } from "node:os";
18270
18307
  import { join as join3 } from "node:path";
18271
18308
 
18272
- // src/broker-common/peercred-path.ts
18273
- function matchSocketName(socketPath, patterns, maxNameLen) {
18274
- if (typeof socketPath !== "string" || socketPath.length === 0)
18275
- return null;
18276
- let name = null;
18277
- for (const re of patterns) {
18278
- const m = socketPath.match(re);
18279
- if (m && typeof m[1] === "string") {
18280
- name = m[1];
18281
- break;
18282
- }
18283
- }
18284
- if (name === null || name.length === 0)
18285
- return null;
18286
- if (maxNameLen !== undefined && name.length > maxNameLen)
18287
- return null;
18288
- return name;
18289
- }
18290
-
18291
- // src/vault/broker/peercred.ts
18292
- var RESERVED_AGENT_NAMES = new Set(["operator", "hostd"]);
18293
- function isReservedAgentName(name) {
18294
- return RESERVED_AGENT_NAMES.has(name);
18295
- }
18296
-
18297
18309
  // src/vault/broker/protocol.ts
18298
18310
  var MAX_FRAME_BYTES = 64 * 1024;
18299
18311
  var AgentNameSchema = exports_external.string().min(1).max(64, "agent name max 64 chars").regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, "agent name must be kebab-case ASCII (alnum + _- only, first char alnum)").refine((s) => !isReservedAgentName(s), {
@@ -18635,17 +18647,6 @@ var DOCKER_TELEGRAM_PLUGIN_PATH = "/opt/switchroom/telegram-plugin";
18635
18647
  var DOCKER_HOOKS_PATH = `${DOCKER_TELEGRAM_PLUGIN_PATH}/hooks`;
18636
18648
 
18637
18649
  // src/agents/compose.ts
18638
- var AGENT_UID_MIN = 10001;
18639
- var AGENT_UID_MAX = 10999;
18640
- function allocateAgentUid(name) {
18641
- if (isReservedAgentName(name)) {
18642
- throw new Error(`agent name '${name}' is reserved by switchroom for another identity kind ` + `(see vault/broker/peercred.ts:RESERVED_AGENT_NAMES). Pick a different name.`);
18643
- }
18644
- const hash = createHash("sha256").update(name).digest();
18645
- const u32 = hash.readUInt32BE(0);
18646
- const range = AGENT_UID_MAX - AGENT_UID_MIN + 1;
18647
- return AGENT_UID_MIN + u32 % range;
18648
- }
18649
18650
  var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
18650
18651
 
18651
18652
  // src/auth/quota.ts
@@ -19765,6 +19766,12 @@ var MarkExhaustedRequestSchema = exports_external.object({
19765
19766
  id: exports_external.string().min(1),
19766
19767
  until: exports_external.number().int().positive().optional()
19767
19768
  });
19769
+ var MarkThrottledRequestSchema = exports_external.object({
19770
+ v: exports_external.literal(PROTOCOL_VERSION),
19771
+ op: exports_external.literal("mark-throttled"),
19772
+ id: exports_external.string().min(1),
19773
+ until: exports_external.number().int().positive()
19774
+ });
19768
19775
  var RefreshAccountRequestSchema = exports_external.object({
19769
19776
  v: exports_external.literal(PROTOCOL_VERSION),
19770
19777
  op: exports_external.literal("refresh-account"),
@@ -19865,6 +19872,7 @@ var RequestSchema2 = exports_external.discriminatedUnion("op", [
19865
19872
  ListStateRequestSchema,
19866
19873
  SetActiveRequestSchema,
19867
19874
  MarkExhaustedRequestSchema,
19875
+ MarkThrottledRequestSchema,
19868
19876
  RefreshAccountRequestSchema,
19869
19877
  AddAccountRequestSchema,
19870
19878
  RmAccountRequestSchema,
@@ -19884,6 +19892,7 @@ var AccountStateSchema = exports_external.object({
19884
19892
  expiresAt: exports_external.number().optional(),
19885
19893
  exhausted: exports_external.boolean(),
19886
19894
  exhausted_until: exports_external.number().optional(),
19895
+ throttled_until: exports_external.number().optional(),
19887
19896
  threshold_violations: exports_external.number().int().nonnegative().optional(),
19888
19897
  last_refreshed_at: exports_external.number().optional()
19889
19898
  });
@@ -19914,6 +19923,12 @@ var MarkExhaustedDataSchema = exports_external.object({
19914
19923
  rolled: exports_external.array(exports_external.string()),
19915
19924
  rolledTo: exports_external.string().nullable().optional()
19916
19925
  });
19926
+ var MarkThrottledDataSchema = exports_external.object({
19927
+ account: exports_external.string(),
19928
+ throttled_until: exports_external.number(),
19929
+ escalated: exports_external.boolean(),
19930
+ rolledTo: exports_external.string().nullable().optional()
19931
+ });
19917
19932
  var RefreshAccountDataSchema = exports_external.object({
19918
19933
  account: exports_external.string(),
19919
19934
  expiresAt: exports_external.number().optional()
@@ -20015,6 +20030,10 @@ function encodeError(id, code, message) {
20015
20030
  var AUTH_BROKER_ROOT = "/run/switchroom/auth-broker";
20016
20031
  var REFRESH_TICK_INTERVAL_MS = 60 * 1000;
20017
20032
  var MARK_EXHAUSTED_DEFAULT_MS = 5 * 60 * 60 * 1000;
20033
+ var MARK_THROTTLED_MAX_MS = 30 * 60 * 1000;
20034
+ var THROTTLE_ESCALATION_HITS = 3;
20035
+ var THROTTLE_ESCALATION_WINDOW_MS = 10 * 60 * 1000;
20036
+ var MARK_THROTTLED_MIN_INTERVAL_MS = 5 * 1000;
20018
20037
  var AUDIT_ROTATE_BYTES = 10 * 1024 * 1024;
20019
20038
  var AUDIT_KEEP = 5;
20020
20039
  var AUDIT_LINE_MAX = 4000;
@@ -20430,6 +20449,9 @@ class AuthBroker {
20430
20449
  case "mark-exhausted":
20431
20450
  await this.opMarkExhausted(socket, reqId, identity, req.until);
20432
20451
  break;
20452
+ case "mark-throttled":
20453
+ await this.opMarkThrottled(socket, reqId, identity, req.until);
20454
+ break;
20433
20455
  case "refresh-account": {
20434
20456
  const provider = req.provider ?? "anthropic";
20435
20457
  if (!this.providers.has(provider)) {
@@ -20550,9 +20572,15 @@ class AuthBroker {
20550
20572
  isOverageAllowed(account) {
20551
20573
  return (this.config.auth?.allow_overage_accounts ?? []).includes(account);
20552
20574
  }
20575
+ exhaustionMarkOf(account) {
20576
+ const q = this.quota[account];
20577
+ if (!q || q.exhausted_until === undefined)
20578
+ return;
20579
+ return { exhausted_until: q.exhausted_until, marked_at: q.marked_at };
20580
+ }
20553
20581
  isAccountExhausted(account) {
20554
20582
  return isAccountBlocked({
20555
- mark: this.quota[account],
20583
+ mark: this.exhaustionMarkOf(account),
20556
20584
  snapshot: this.lastQuotaCache[account],
20557
20585
  now: this.now(),
20558
20586
  allowOverage: this.isOverageAllowed(account)
@@ -20605,7 +20633,7 @@ class AuthBroker {
20605
20633
  if (!overageLiftsWall(snapshot, true))
20606
20634
  return false;
20607
20635
  return accountEligibility({
20608
- mark: this.quota[account],
20636
+ mark: this.exhaustionMarkOf(account),
20609
20637
  snapshot,
20610
20638
  now,
20611
20639
  allowOverage: true
@@ -20615,7 +20643,7 @@ class AuthBroker {
20615
20643
  const snapshot = this.lastQuotaCache[account];
20616
20644
  const allowOverage = this.isOverageAllowed(account);
20617
20645
  const verdict = accountEligibility({
20618
- mark: this.quota[account],
20646
+ mark: this.exhaustionMarkOf(account),
20619
20647
  snapshot,
20620
20648
  now: this.now(),
20621
20649
  allowOverage
@@ -20733,6 +20761,7 @@ class AuthBroker {
20733
20761
  expiresAt: creds?.claudeAiOauth?.expiresAt,
20734
20762
  exhausted,
20735
20763
  exhausted_until: q?.exhausted_until,
20764
+ throttled_until: q?.throttled_until,
20736
20765
  threshold_violations: this.thresholdViolations[label] ?? 0,
20737
20766
  last_refreshed_at: meta?.lastRefreshedAt,
20738
20767
  last_quota: lq ?? null
@@ -20843,7 +20872,7 @@ class AuthBroker {
20843
20872
  };
20844
20873
  this.lastQuotaCache[label] = snapshot;
20845
20874
  this.persistLastQuotaCache();
20846
- if (snapshotShouldClearMark(snapshot, this.quota[label], this.now())) {
20875
+ if (snapshotShouldClearMark(snapshot, this.exhaustionMarkOf(label), this.now())) {
20847
20876
  delete this.quota[label];
20848
20877
  this.persistQuota();
20849
20878
  process.stdout.write(`auth-broker: live probe shows ${label} healthy (5h=${snapshot.fiveHourUtilizationPct}% 7d=${snapshot.sevenDayUtilizationPct}%) — cleared stale exhaustion mark
@@ -20961,7 +20990,7 @@ class AuthBroker {
20961
20990
  const existing = this.quota[label]?.exhausted_until;
20962
20991
  if (existing !== undefined && existing >= exhaustedUntil)
20963
20992
  continue;
20964
- this.quota[label] = { exhausted_until: exhaustedUntil, marked_at: now };
20993
+ this.quota[label] = { ...this.quota[label], exhausted_until: exhaustedUntil, marked_at: now };
20965
20994
  this.persistQuota();
20966
20995
  this.audit({ op: "mark-exhausted", identity: { kind: "operator" }, account: label, accountKind: "claude", ok: true });
20967
20996
  process.stdout.write(`auth-broker: consumer-quota-sensor marked ${label} exhausted until ${new Date(exhaustedUntil).toISOString()} — consumer(s) fail over
@@ -21002,6 +21031,83 @@ class AuthBroker {
21002
21031
  this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true });
21003
21032
  socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
21004
21033
  }
21034
+ async opMarkThrottled(socket, id, identity, until) {
21035
+ const account = this.callerAccount(identity);
21036
+ if (!account) {
21037
+ this.audit({ op: "mark-throttled", identity, accountKind: "claude", ok: false, error: "no-active-account" });
21038
+ socket.write(encodeError(id, "ACCOUNT_NOT_FOUND", "no active account configured"));
21039
+ return;
21040
+ }
21041
+ const now = this.now();
21042
+ const throttledUntil = Math.min(Math.max(until, now + 1000), now + MARK_THROTTLED_MAX_MS);
21043
+ const entry = this.quota[account] ?? {};
21044
+ const priorHits = entry.throttle_hits ?? [];
21045
+ const lastHit = priorHits.length > 0 ? priorHits[priorHits.length - 1] : undefined;
21046
+ if (lastHit !== undefined && now - lastHit < MARK_THROTTLED_MIN_INTERVAL_MS) {
21047
+ const refreshed = Math.max(entry.throttled_until ?? 0, throttledUntil);
21048
+ this.quota[account] = { ...entry, throttled_until: refreshed };
21049
+ this.persistQuota();
21050
+ this.audit({ op: "mark-throttled", identity, account, accountKind: "claude", ok: true });
21051
+ process.stdout.write(`auth-broker: mark-throttled ${account} deduped (re-mark within ${MARK_THROTTLED_MIN_INTERVAL_MS / 1000}s) — expiry refreshed, no new hit
21052
+ `);
21053
+ socket.write(encodeSuccess(id, {
21054
+ account,
21055
+ throttled_until: refreshed,
21056
+ escalated: false,
21057
+ rolledTo: null
21058
+ }));
21059
+ return;
21060
+ }
21061
+ const hits = priorHits.filter((t) => now - t < THROTTLE_ESCALATION_WINDOW_MS);
21062
+ hits.push(now);
21063
+ const escalate = hits.length >= THROTTLE_ESCALATION_HITS;
21064
+ this.quota[account] = {
21065
+ ...entry,
21066
+ throttled_until: throttledUntil,
21067
+ throttle_hits: escalate ? [] : hits
21068
+ };
21069
+ this.persistQuota();
21070
+ this.audit({ op: "mark-throttled", identity, account, accountKind: "claude", ok: true });
21071
+ process.stdout.write(`auth-broker: mark-throttled ${account} until ${new Date(throttledUntil).toISOString()} ` + `(hit ${hits.length}/${THROTTLE_ESCALATION_HITS} in window)
21072
+ `);
21073
+ let escalated = false;
21074
+ let rolledTo = null;
21075
+ if (escalate) {
21076
+ const probe = await this.probeThrottleEscalation(account);
21077
+ if (probe.exhausted) {
21078
+ escalated = true;
21079
+ process.stdout.write(`auth-broker: throttle-escalation probe corroborates wall on ${account} — mark-exhausted + roll
21080
+ `);
21081
+ this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true, reason: "throttle-escalation" });
21082
+ const roll = await this.markExhaustedAndRoll(account, probe.until ?? undefined, identity);
21083
+ rolledTo = roll.rolledTo;
21084
+ } else {
21085
+ process.stdout.write(`auth-broker: throttle-escalation probe on ${account} did NOT corroborate a wall — staying throttled
21086
+ `);
21087
+ }
21088
+ }
21089
+ socket.write(encodeSuccess(id, {
21090
+ account,
21091
+ throttled_until: throttledUntil,
21092
+ escalated,
21093
+ rolledTo: escalated ? rolledTo : null
21094
+ }));
21095
+ }
21096
+ async probeThrottleEscalation(account) {
21097
+ const creds = readAccountCredentials(account, this.home);
21098
+ const token = creds?.claudeAiOauth?.accessToken;
21099
+ if (!token)
21100
+ return { exhausted: false, until: null };
21101
+ let result;
21102
+ try {
21103
+ result = await this.fetchQuotaImpl({ accessToken: token });
21104
+ } catch (err) {
21105
+ this.logErr(`throttle-escalation probe ${account}: ${err.message}`);
21106
+ return { exhausted: false, until: null };
21107
+ }
21108
+ this.cacheQuotaSnapshot(account, result);
21109
+ return quotaIndicatesExhaustion(result, this.isOverageAllowed(account));
21110
+ }
21005
21111
  async markExhaustedAndRoll(account, until, identity) {
21006
21112
  const now = this.now();
21007
21113
  const exhaustedUntil = clampMarkExpiry({
@@ -21010,7 +21116,7 @@ class AuthBroker {
21010
21116
  shortMs: MARK_EXHAUSTED_DEFAULT_MS,
21011
21117
  snapshot: this.lastQuotaCache[account]
21012
21118
  });
21013
- this.quota[account] = { exhausted_until: exhaustedUntil, marked_at: now };
21119
+ this.quota[account] = { ...this.quota[account], exhausted_until: exhaustedUntil, marked_at: now };
21014
21120
  this.persistQuota();
21015
21121
  const rolledTo = await this.nextHealthyAccountLive(account, this.config.auth?.fallback_order ?? []);
21016
21122
  const rolled = this.fanoutFailoverTo(account, rolledTo);
@@ -4741,6 +4741,12 @@ var MarkExhaustedRequestSchema = exports_external.object({
4741
4741
  id: exports_external.string().min(1),
4742
4742
  until: exports_external.number().int().positive().optional()
4743
4743
  });
4744
+ var MarkThrottledRequestSchema = exports_external.object({
4745
+ v: exports_external.literal(PROTOCOL_VERSION),
4746
+ op: exports_external.literal("mark-throttled"),
4747
+ id: exports_external.string().min(1),
4748
+ until: exports_external.number().int().positive()
4749
+ });
4744
4750
  var RefreshAccountRequestSchema = exports_external.object({
4745
4751
  v: exports_external.literal(PROTOCOL_VERSION),
4746
4752
  op: exports_external.literal("refresh-account"),
@@ -4841,6 +4847,7 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
4841
4847
  ListStateRequestSchema,
4842
4848
  SetActiveRequestSchema,
4843
4849
  MarkExhaustedRequestSchema,
4850
+ MarkThrottledRequestSchema,
4844
4851
  RefreshAccountRequestSchema,
4845
4852
  AddAccountRequestSchema,
4846
4853
  RmAccountRequestSchema,
@@ -4860,6 +4867,7 @@ var AccountStateSchema = exports_external.object({
4860
4867
  expiresAt: exports_external.number().optional(),
4861
4868
  exhausted: exports_external.boolean(),
4862
4869
  exhausted_until: exports_external.number().optional(),
4870
+ throttled_until: exports_external.number().optional(),
4863
4871
  threshold_violations: exports_external.number().int().nonnegative().optional(),
4864
4872
  last_refreshed_at: exports_external.number().optional()
4865
4873
  });
@@ -4890,6 +4898,12 @@ var MarkExhaustedDataSchema = exports_external.object({
4890
4898
  rolled: exports_external.array(exports_external.string()),
4891
4899
  rolledTo: exports_external.string().nullable().optional()
4892
4900
  });
4901
+ var MarkThrottledDataSchema = exports_external.object({
4902
+ account: exports_external.string(),
4903
+ throttled_until: exports_external.number(),
4904
+ escalated: exports_external.boolean(),
4905
+ rolledTo: exports_external.string().nullable().optional()
4906
+ });
4893
4907
  var RefreshAccountDataSchema = exports_external.object({
4894
4908
  account: exports_external.string(),
4895
4909
  expiresAt: exports_external.number().optional()
@@ -5115,6 +5129,15 @@ class AuthBrokerClient {
5115
5129
  const data = await this.send(req);
5116
5130
  return data;
5117
5131
  }
5132
+ async markThrottled(until) {
5133
+ const data = await this.send({
5134
+ v: PROTOCOL_VERSION,
5135
+ id: randomUUID2(),
5136
+ op: "mark-throttled",
5137
+ until
5138
+ });
5139
+ return data;
5140
+ }
5118
5141
  async claimNotification(key, windowMs) {
5119
5142
  const data = await this.send({
5120
5143
  v: PROTOCOL_VERSION,
@@ -4000,7 +4000,7 @@ function decodeResponse(line) {
4000
4000
  }
4001
4001
  return ResponseSchema.parse(parsed);
4002
4002
  }
4003
- var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, ClaimNotificationRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, ClaimNotificationDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
4003
+ var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, MarkThrottledRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, ClaimNotificationRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, MarkThrottledDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, ClaimNotificationDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
4004
4004
  var init_protocol = __esm(() => {
4005
4005
  init_zod();
4006
4006
  MAX_FRAME_BYTES = 64 * 1024;
@@ -4029,6 +4029,12 @@ var init_protocol = __esm(() => {
4029
4029
  id: exports_external.string().min(1),
4030
4030
  until: exports_external.number().int().positive().optional()
4031
4031
  });
4032
+ MarkThrottledRequestSchema = exports_external.object({
4033
+ v: exports_external.literal(PROTOCOL_VERSION),
4034
+ op: exports_external.literal("mark-throttled"),
4035
+ id: exports_external.string().min(1),
4036
+ until: exports_external.number().int().positive()
4037
+ });
4032
4038
  RefreshAccountRequestSchema = exports_external.object({
4033
4039
  v: exports_external.literal(PROTOCOL_VERSION),
4034
4040
  op: exports_external.literal("refresh-account"),
@@ -4129,6 +4135,7 @@ var init_protocol = __esm(() => {
4129
4135
  ListStateRequestSchema,
4130
4136
  SetActiveRequestSchema,
4131
4137
  MarkExhaustedRequestSchema,
4138
+ MarkThrottledRequestSchema,
4132
4139
  RefreshAccountRequestSchema,
4133
4140
  AddAccountRequestSchema,
4134
4141
  RmAccountRequestSchema,
@@ -4148,6 +4155,7 @@ var init_protocol = __esm(() => {
4148
4155
  expiresAt: exports_external.number().optional(),
4149
4156
  exhausted: exports_external.boolean(),
4150
4157
  exhausted_until: exports_external.number().optional(),
4158
+ throttled_until: exports_external.number().optional(),
4151
4159
  threshold_violations: exports_external.number().int().nonnegative().optional(),
4152
4160
  last_refreshed_at: exports_external.number().optional()
4153
4161
  });
@@ -4178,6 +4186,12 @@ var init_protocol = __esm(() => {
4178
4186
  rolled: exports_external.array(exports_external.string()),
4179
4187
  rolledTo: exports_external.string().nullable().optional()
4180
4188
  });
4189
+ MarkThrottledDataSchema = exports_external.object({
4190
+ account: exports_external.string(),
4191
+ throttled_until: exports_external.number(),
4192
+ escalated: exports_external.boolean(),
4193
+ rolledTo: exports_external.string().nullable().optional()
4194
+ });
4181
4195
  RefreshAccountDataSchema = exports_external.object({
4182
4196
  account: exports_external.string(),
4183
4197
  expiresAt: exports_external.number().optional()
@@ -4376,6 +4390,15 @@ class AuthBrokerClient {
4376
4390
  const data = await this.send(req);
4377
4391
  return data;
4378
4392
  }
4393
+ async markThrottled(until) {
4394
+ const data = await this.send({
4395
+ v: PROTOCOL_VERSION,
4396
+ id: randomUUID(),
4397
+ op: "mark-throttled",
4398
+ until
4399
+ });
4400
+ return data;
4401
+ }
4379
4402
  async claimNotification(key, windowMs) {
4380
4403
  const data = await this.send({
4381
4404
  v: PROTOCOL_VERSION,