switchroom 0.18.12 → 0.18.14

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  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/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -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)."),
@@ -13120,6 +13119,13 @@ var ApprovalLookupRequestSchema = exports_external.object({
13120
13119
  action: exports_external.string().min(1),
13121
13120
  current_approver_set: exports_external.array(exports_external.string())
13122
13121
  });
13122
+ var ApprovalLookupByRequestRequestSchema = exports_external.object({
13123
+ v: exports_external.literal(1),
13124
+ op: exports_external.literal("approval_lookup_by_request"),
13125
+ agent_unit: exports_external.string().min(1),
13126
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
13127
+ current_approver_set: exports_external.array(exports_external.string())
13128
+ });
13123
13129
  var ApprovalConsumeRequestSchema = exports_external.object({
13124
13130
  v: exports_external.literal(1),
13125
13131
  op: exports_external.literal("approval_consume"),
@@ -13174,6 +13180,7 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
13174
13180
  RevokeGrantRequestSchema,
13175
13181
  ApprovalRequestRequestSchema,
13176
13182
  ApprovalLookupRequestSchema,
13183
+ ApprovalLookupByRequestRequestSchema,
13177
13184
  ApprovalConsumeRequestSchema,
13178
13185
  ApprovalRevokeRequestSchema,
13179
13186
  ApprovalListRequestSchema,
@@ -13628,19 +13635,30 @@ class JsonlAuditSink {
13628
13635
  }
13629
13636
 
13630
13637
  // src/scheduler/quota-preflight.ts
13631
- function decideQuotaPreflight(state) {
13638
+ function decideQuotaPreflight(state, opts = {}) {
13639
+ const now = opts.now ?? Date.now();
13632
13640
  const accounts = state.accounts ?? [];
13633
13641
  if (accounts.length === 0) {
13634
13642
  return { defer: false, reason: "no accounts in broker state" };
13635
13643
  }
13636
13644
  const healthy = accounts.filter((a) => !a.exhausted);
13637
- 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);
13638
13652
  return {
13639
- defer: false,
13640
- 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
13641
13656
  };
13642
13657
  }
13643
- 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
+ };
13644
13662
  }
13645
13663
 
13646
13664
  // src/auth/broker/client.ts
@@ -13677,6 +13695,12 @@ var MarkExhaustedRequestSchema = exports_external.object({
13677
13695
  id: exports_external.string().min(1),
13678
13696
  until: exports_external.number().int().positive().optional()
13679
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
+ });
13680
13704
  var RefreshAccountRequestSchema = exports_external.object({
13681
13705
  v: exports_external.literal(PROTOCOL_VERSION),
13682
13706
  op: exports_external.literal("refresh-account"),
@@ -13777,6 +13801,7 @@ var RequestSchema2 = exports_external.discriminatedUnion("op", [
13777
13801
  ListStateRequestSchema,
13778
13802
  SetActiveRequestSchema,
13779
13803
  MarkExhaustedRequestSchema,
13804
+ MarkThrottledRequestSchema,
13780
13805
  RefreshAccountRequestSchema,
13781
13806
  AddAccountRequestSchema,
13782
13807
  RmAccountRequestSchema,
@@ -13796,6 +13821,7 @@ var AccountStateSchema = exports_external.object({
13796
13821
  expiresAt: exports_external.number().optional(),
13797
13822
  exhausted: exports_external.boolean(),
13798
13823
  exhausted_until: exports_external.number().optional(),
13824
+ throttled_until: exports_external.number().optional(),
13799
13825
  threshold_violations: exports_external.number().int().nonnegative().optional(),
13800
13826
  last_refreshed_at: exports_external.number().optional()
13801
13827
  });
@@ -13826,6 +13852,12 @@ var MarkExhaustedDataSchema = exports_external.object({
13826
13852
  rolled: exports_external.array(exports_external.string()),
13827
13853
  rolledTo: exports_external.string().nullable().optional()
13828
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
+ });
13829
13861
  var RefreshAccountDataSchema = exports_external.object({
13830
13862
  account: exports_external.string(),
13831
13863
  expiresAt: exports_external.number().optional()
@@ -14051,6 +14083,15 @@ class AuthBrokerClient {
14051
14083
  const data = await this.send(req);
14052
14084
  return data;
14053
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
+ }
14054
14095
  async claimNotification(key, windowMs) {
14055
14096
  const data = await this.send({
14056
14097
  v: PROTOCOL_VERSION,
@@ -14721,6 +14762,12 @@ var DEFAULT_MAX_QUOTA_DEFER_ATTEMPTS = 3;
14721
14762
  function defaultQuotaDeferBackoffMs(attempt) {
14722
14763
  return [60000, 180000, 300000][attempt] ?? 300000;
14723
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
+ }
14724
14771
  function registerAgentSchedule(opts) {
14725
14772
  const tasks = [];
14726
14773
  const now = opts.now ?? Date.now;
@@ -14761,7 +14808,7 @@ function registerAgentSchedule(opts) {
14761
14808
  handle = scheduleRetry(() => {
14762
14809
  pendingRetries.delete(handle);
14763
14810
  attemptFire(attempt + 1);
14764
- }, backoff(attempt));
14811
+ }, resolveQuotaDeferDelayMs(decision, backoff(attempt), now()));
14765
14812
  pendingRetries.add(handle);
14766
14813
  }
14767
14814
  return;
@@ -15116,10 +15163,10 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
15116
15163
  }
15117
15164
  const cronLib = __require("node-cron");
15118
15165
  const quotaPreflightEnabled = process.env.SWITCHROOM_DISABLE_CRON_QUOTA_PREFLIGHT !== "1";
15119
- const quotaGate = quotaPreflightEnabled ? async () => {
15166
+ const quotaGate = quotaPreflightEnabled ? async (agent) => {
15120
15167
  const client = new AuthBrokerClient;
15121
15168
  try {
15122
- return decideQuotaPreflight(await client.listState());
15169
+ return decideQuotaPreflight(await client.listState(), { agent });
15123
15170
  } finally {
15124
15171
  await client.close().catch(() => {});
15125
15172
  }
@@ -15198,6 +15245,7 @@ if (import.meta.url === `file://${process.argv[1]}` && /(?:^|[/\\])agent-schedul
15198
15245
  export {
15199
15246
  scheduleSignature,
15200
15247
  resolveReloadPollMs,
15248
+ resolveQuotaDeferDelayMs,
15201
15249
  resolveEntryThreadId,
15202
15250
  resolveChannelTarget,
15203
15251
  registerAgentSchedule,
@@ -7010,6 +7010,41 @@ var require_dist = __commonJS((exports) => {
7010
7010
  exports.visitAsync = visit.visitAsync;
7011
7011
  });
7012
7012
 
7013
+ // src/util/atomic.ts
7014
+ import { randomBytes } from "node:crypto";
7015
+ import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
7016
+ function atomicWriteFileSync(destPath, contents, mode = 384) {
7017
+ const tmp = `${destPath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
7018
+ const buf = typeof contents === "string" ? Buffer.from(contents, "utf-8") : contents;
7019
+ let fd = null;
7020
+ try {
7021
+ fd = openSync(tmp, TMP_OPEN_FLAGS, mode);
7022
+ writeSync(fd, buf, 0, buf.length, 0);
7023
+ fsyncSync(fd);
7024
+ closeSync(fd);
7025
+ fd = null;
7026
+ renameSync(tmp, destPath);
7027
+ } catch (err) {
7028
+ if (fd !== null) {
7029
+ try {
7030
+ closeSync(fd);
7031
+ } catch {}
7032
+ }
7033
+ try {
7034
+ rmSync(tmp, { force: true });
7035
+ } catch {}
7036
+ throw err;
7037
+ }
7038
+ }
7039
+ function atomicWriteJsonSync(destPath, value, mode = 384) {
7040
+ atomicWriteFileSync(destPath, JSON.stringify(value, null, 2) + `
7041
+ `, mode);
7042
+ }
7043
+ var TMP_OPEN_FLAGS;
7044
+ var init_atomic = __esm(() => {
7045
+ TMP_OPEN_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
7046
+ });
7047
+
7013
7048
  // node_modules/.bun/handlebars@4.7.9/node_modules/handlebars/dist/cjs/handlebars/utils.js
7014
7049
  var require_utils = __commonJS((exports) => {
7015
7050
  exports.__esModule = true;
@@ -12570,41 +12605,6 @@ var require_lib = __commonJS((exports, module) => {
12570
12605
  }
12571
12606
  });
12572
12607
 
12573
- // src/util/atomic.ts
12574
- import { randomBytes } from "node:crypto";
12575
- import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
12576
- function atomicWriteFileSync(destPath, contents, mode = 384) {
12577
- const tmp = `${destPath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
12578
- const buf = typeof contents === "string" ? Buffer.from(contents, "utf-8") : contents;
12579
- let fd = null;
12580
- try {
12581
- fd = openSync(tmp, TMP_OPEN_FLAGS, mode);
12582
- writeSync(fd, buf, 0, buf.length, 0);
12583
- fsyncSync(fd);
12584
- closeSync(fd);
12585
- fd = null;
12586
- renameSync(tmp, destPath);
12587
- } catch (err) {
12588
- if (fd !== null) {
12589
- try {
12590
- closeSync(fd);
12591
- } catch {}
12592
- }
12593
- try {
12594
- rmSync(tmp, { force: true });
12595
- } catch {}
12596
- throw err;
12597
- }
12598
- }
12599
- function atomicWriteJsonSync(destPath, value, mode = 384) {
12600
- atomicWriteFileSync(destPath, JSON.stringify(value, null, 2) + `
12601
- `, mode);
12602
- }
12603
- var TMP_OPEN_FLAGS;
12604
- var init_atomic = __esm(() => {
12605
- TMP_OPEN_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
12606
- });
12607
-
12608
12608
  // src/auth/broker/index.ts
12609
12609
  import { existsSync as existsSync9, readFileSync as readFileSync8 } from "node:fs";
12610
12610
 
@@ -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)."),
@@ -18112,7 +18111,7 @@ import {
18112
18111
  rmSync as rmSync5,
18113
18112
  statSync as statSync6,
18114
18113
  unlinkSync,
18115
- writeFileSync as writeFileSync3
18114
+ writeFileSync as writeFileSync2
18116
18115
  } from "node:fs";
18117
18116
  import { closeSync as closeSync2, openSync as openSync2, writeSync as writeSync2 } from "node:fs";
18118
18117
  import * as constants2 from "node:constants";
@@ -18124,6 +18123,7 @@ import { createHash } from "node:crypto";
18124
18123
 
18125
18124
  // src/agents/scaffold.ts
18126
18125
  import { join as join4, resolve as resolve5 } from "node:path";
18126
+ init_atomic();
18127
18127
 
18128
18128
  // src/config/timezone.ts
18129
18129
  var CONTAINER_DEFAULT_UTC_ZONES = new Set([
@@ -18390,6 +18390,13 @@ var ApprovalLookupRequestSchema = exports_external.object({
18390
18390
  action: exports_external.string().min(1),
18391
18391
  current_approver_set: exports_external.array(exports_external.string())
18392
18392
  });
18393
+ var ApprovalLookupByRequestRequestSchema = exports_external.object({
18394
+ v: exports_external.literal(1),
18395
+ op: exports_external.literal("approval_lookup_by_request"),
18396
+ agent_unit: exports_external.string().min(1),
18397
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
18398
+ current_approver_set: exports_external.array(exports_external.string())
18399
+ });
18393
18400
  var ApprovalConsumeRequestSchema = exports_external.object({
18394
18401
  v: exports_external.literal(1),
18395
18402
  op: exports_external.literal("approval_consume"),
@@ -18444,6 +18451,7 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
18444
18451
  RevokeGrantRequestSchema,
18445
18452
  ApprovalRequestRequestSchema,
18446
18453
  ApprovalLookupRequestSchema,
18454
+ ApprovalLookupByRequestRequestSchema,
18447
18455
  ApprovalConsumeRequestSchema,
18448
18456
  ApprovalRevokeRequestSchema,
18449
18457
  ApprovalListRequestSchema,
@@ -18643,9 +18651,6 @@ var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
18643
18651
  var OAUTH_BETA = "oauth-2025-04-20";
18644
18652
  var DEFAULT_USER_AGENT = "claude-cli/1.0.0 (external, cli)";
18645
18653
  var DEFAULT_PROBE_MODEL = "claude-haiku-4-5-20251001";
18646
- function isProbeThin(q) {
18647
- return q.fiveHourUtilPresent === false && q.sevenDayUtilPresent === false;
18648
- }
18649
18654
  function parseFloatHeader(headers, name) {
18650
18655
  const v = headers.get(name);
18651
18656
  if (v == null || v.trim().length === 0)
@@ -18742,6 +18747,9 @@ function snapshotFresh(s, now, maxAgeMs = SNAPSHOT_STALE_AGE_MS) {
18742
18747
  function snapshotWalled(s) {
18743
18748
  return s.fiveHourUtilizationPct >= WALL_PCT || s.sevenDayUtilizationPct >= WALL_PCT;
18744
18749
  }
18750
+ function snapshotComplete(s) {
18751
+ return s.fiveHourUtilPresent !== false && s.sevenDayUtilPresent !== false;
18752
+ }
18745
18753
  function overageLiftsWall(snapshot, inAllowList) {
18746
18754
  if (!inAllowList)
18747
18755
  return false;
@@ -18757,16 +18765,19 @@ function snapshotClearlyHealthy(s) {
18757
18765
  }
18758
18766
  function accountEligibility(opts) {
18759
18767
  const { mark, snapshot, now, allowOverage = false } = opts;
18768
+ if (snapshotFresh(snapshot, now) && snapshotWalled(snapshot) && !overageLiftsWall(snapshot, allowOverage)) {
18769
+ return "blocked";
18770
+ }
18760
18771
  if (snapshotFresh(snapshot, now)) {
18761
18772
  const markedAt = mark?.marked_at ?? 0;
18762
18773
  if (snapshot.capturedAt >= markedAt) {
18763
18774
  if (snapshotWalled(snapshot)) {
18764
- if (overageLiftsWall(snapshot, allowOverage)) {
18775
+ if (overageLiftsWall(snapshot, allowOverage))
18765
18776
  return "eligible";
18766
- }
18767
18777
  return "blocked";
18768
18778
  }
18769
- return "eligible";
18779
+ if (snapshotComplete(snapshot))
18780
+ return "eligible";
18770
18781
  }
18771
18782
  }
18772
18783
  if (mark !== undefined && mark.exhausted_until > now)
@@ -18783,7 +18794,7 @@ function snapshotShouldClearMark(snapshot, mark, now) {
18783
18794
  return false;
18784
18795
  if (snapshot.capturedAt < (mark.marked_at ?? 0))
18785
18796
  return false;
18786
- if (isProbeThin(snapshot))
18797
+ if (!snapshotComplete(snapshot))
18787
18798
  return false;
18788
18799
  return snapshotClearlyHealthy(snapshot);
18789
18800
  }
@@ -18792,7 +18803,7 @@ function clampMarkExpiry(opts) {
18792
18803
  const shortCeil = now + shortMs;
18793
18804
  if (proposedUntil <= shortCeil)
18794
18805
  return proposedUntil;
18795
- const liveContradictsWeeklyWall = snapshotFresh(snapshot, now) && snapshot.sevenDayUtilizationPct < WALL_PCT;
18806
+ const liveContradictsWeeklyWall = snapshotFresh(snapshot, now) && snapshot.sevenDayUtilPresent !== false && snapshot.sevenDayUtilizationPct < WALL_PCT;
18796
18807
  return liveContradictsWeeklyWall ? shortCeil : proposedUntil;
18797
18808
  }
18798
18809
  var SOFT_AVOID_FIVE_HOUR_CAP = 98;
@@ -18885,6 +18896,7 @@ function resolveConsumerProbeIntervalMs(env) {
18885
18896
  init_atomic();
18886
18897
 
18887
18898
  // src/auth/account-store.ts
18899
+ init_atomic();
18888
18900
  import {
18889
18901
  chownSync,
18890
18902
  existsSync as existsSync5,
@@ -18893,10 +18905,8 @@ import {
18893
18905
  readdirSync as readdirSync3,
18894
18906
  renameSync as renameSync2,
18895
18907
  rmSync as rmSync2,
18896
- statSync as statSync3,
18897
- writeFileSync as writeFileSync2
18908
+ statSync as statSync3
18898
18909
  } from "node:fs";
18899
- import { randomBytes as randomBytes2 } from "node:crypto";
18900
18910
  import { homedir as homedir4 } from "node:os";
18901
18911
  import { join as join5, resolve as resolve6 } from "node:path";
18902
18912
  var LABEL_MAX = 64;
@@ -18980,8 +18990,8 @@ function enrichClaudeCreds(value) {
18980
18990
  }
18981
18991
  function writeAccountCredentials(label, value, home2 = homedir4()) {
18982
18992
  validateAccountLabel(label);
18983
- mkdirSync2(accountDir(label, home2), { recursive: true });
18984
- atomicWriteJson(accountCredentialsPath(label, home2), enrichClaudeCreds(value));
18993
+ mkdirSync2(accountDir(label, home2), { recursive: true, mode: 448 });
18994
+ atomicWriteJsonSync(accountCredentialsPath(label, home2), enrichClaudeCreds(value));
18985
18995
  }
18986
18996
  function chownAccountFiles(label, uid, home2 = homedir4()) {
18987
18997
  validateAccountLabel(label);
@@ -19007,8 +19017,8 @@ function readAccountMeta(label, home2 = homedir4()) {
19007
19017
  }
19008
19018
  function writeAccountMeta(label, value, home2 = homedir4()) {
19009
19019
  validateAccountLabel(label);
19010
- mkdirSync2(accountDir(label, home2), { recursive: true });
19011
- atomicWriteJson(accountMetaPath(label, home2), value);
19020
+ mkdirSync2(accountDir(label, home2), { recursive: true, mode: 448 });
19021
+ atomicWriteJsonSync(accountMetaPath(label, home2), value);
19012
19022
  }
19013
19023
  function patchAccountMeta(label, patch, home2 = homedir4()) {
19014
19024
  const existing = readAccountMeta(label, home2) ?? { createdAt: Date.now() };
@@ -19016,19 +19026,6 @@ function patchAccountMeta(label, patch, home2 = homedir4()) {
19016
19026
  writeAccountMeta(label, merged, home2);
19017
19027
  return merged;
19018
19028
  }
19019
- function atomicWriteJson(destPath, value, mode = 384) {
19020
- const tmp = `${destPath}.tmp-${process.pid}-${randomBytes2(4).toString("hex")}`;
19021
- try {
19022
- writeFileSync2(tmp, JSON.stringify(value, null, 2) + `
19023
- `, { mode });
19024
- renameSync2(tmp, destPath);
19025
- } catch (err) {
19026
- try {
19027
- rmSync2(tmp, { force: true });
19028
- } catch {}
19029
- throw err;
19030
- }
19031
- }
19032
19029
 
19033
19030
  // src/auth/account-refresh.ts
19034
19031
  var REFRESH_THRESHOLD_MS = 60 * 60 * 1000;
@@ -19767,6 +19764,12 @@ var MarkExhaustedRequestSchema = exports_external.object({
19767
19764
  id: exports_external.string().min(1),
19768
19765
  until: exports_external.number().int().positive().optional()
19769
19766
  });
19767
+ var MarkThrottledRequestSchema = exports_external.object({
19768
+ v: exports_external.literal(PROTOCOL_VERSION),
19769
+ op: exports_external.literal("mark-throttled"),
19770
+ id: exports_external.string().min(1),
19771
+ until: exports_external.number().int().positive()
19772
+ });
19770
19773
  var RefreshAccountRequestSchema = exports_external.object({
19771
19774
  v: exports_external.literal(PROTOCOL_VERSION),
19772
19775
  op: exports_external.literal("refresh-account"),
@@ -19867,6 +19870,7 @@ var RequestSchema2 = exports_external.discriminatedUnion("op", [
19867
19870
  ListStateRequestSchema,
19868
19871
  SetActiveRequestSchema,
19869
19872
  MarkExhaustedRequestSchema,
19873
+ MarkThrottledRequestSchema,
19870
19874
  RefreshAccountRequestSchema,
19871
19875
  AddAccountRequestSchema,
19872
19876
  RmAccountRequestSchema,
@@ -19886,6 +19890,7 @@ var AccountStateSchema = exports_external.object({
19886
19890
  expiresAt: exports_external.number().optional(),
19887
19891
  exhausted: exports_external.boolean(),
19888
19892
  exhausted_until: exports_external.number().optional(),
19893
+ throttled_until: exports_external.number().optional(),
19889
19894
  threshold_violations: exports_external.number().int().nonnegative().optional(),
19890
19895
  last_refreshed_at: exports_external.number().optional()
19891
19896
  });
@@ -19916,6 +19921,12 @@ var MarkExhaustedDataSchema = exports_external.object({
19916
19921
  rolled: exports_external.array(exports_external.string()),
19917
19922
  rolledTo: exports_external.string().nullable().optional()
19918
19923
  });
19924
+ var MarkThrottledDataSchema = exports_external.object({
19925
+ account: exports_external.string(),
19926
+ throttled_until: exports_external.number(),
19927
+ escalated: exports_external.boolean(),
19928
+ rolledTo: exports_external.string().nullable().optional()
19929
+ });
19919
19930
  var RefreshAccountDataSchema = exports_external.object({
19920
19931
  account: exports_external.string(),
19921
19932
  expiresAt: exports_external.number().optional()
@@ -20017,6 +20028,10 @@ function encodeError(id, code, message) {
20017
20028
  var AUTH_BROKER_ROOT = "/run/switchroom/auth-broker";
20018
20029
  var REFRESH_TICK_INTERVAL_MS = 60 * 1000;
20019
20030
  var MARK_EXHAUSTED_DEFAULT_MS = 5 * 60 * 60 * 1000;
20031
+ var MARK_THROTTLED_MAX_MS = 30 * 60 * 1000;
20032
+ var THROTTLE_ESCALATION_HITS = 3;
20033
+ var THROTTLE_ESCALATION_WINDOW_MS = 10 * 60 * 1000;
20034
+ var MARK_THROTTLED_MIN_INTERVAL_MS = 5 * 1000;
20020
20035
  var AUDIT_ROTATE_BYTES = 10 * 1024 * 1024;
20021
20036
  var AUDIT_KEEP = 5;
20022
20037
  var AUDIT_LINE_MAX = 4000;
@@ -20195,7 +20210,7 @@ class AuthBroker {
20195
20210
  if (!this.opts.skipHealthyMarker) {
20196
20211
  try {
20197
20212
  const healthyPath = join8(this.stateDir, "healthy");
20198
- writeFileSync3(healthyPath, String(this.now()) + `
20213
+ writeFileSync2(healthyPath, String(this.now()) + `
20199
20214
  `, { mode: 384 });
20200
20215
  } catch (err) {
20201
20216
  this.logErr(`failed to write healthy marker: ${err.message}`);
@@ -20432,6 +20447,9 @@ class AuthBroker {
20432
20447
  case "mark-exhausted":
20433
20448
  await this.opMarkExhausted(socket, reqId, identity, req.until);
20434
20449
  break;
20450
+ case "mark-throttled":
20451
+ await this.opMarkThrottled(socket, reqId, identity, req.until);
20452
+ break;
20435
20453
  case "refresh-account": {
20436
20454
  const provider = req.provider ?? "anthropic";
20437
20455
  if (!this.providers.has(provider)) {
@@ -20552,9 +20570,15 @@ class AuthBroker {
20552
20570
  isOverageAllowed(account) {
20553
20571
  return (this.config.auth?.allow_overage_accounts ?? []).includes(account);
20554
20572
  }
20573
+ exhaustionMarkOf(account) {
20574
+ const q = this.quota[account];
20575
+ if (!q || q.exhausted_until === undefined)
20576
+ return;
20577
+ return { exhausted_until: q.exhausted_until, marked_at: q.marked_at };
20578
+ }
20555
20579
  isAccountExhausted(account) {
20556
20580
  return isAccountBlocked({
20557
- mark: this.quota[account],
20581
+ mark: this.exhaustionMarkOf(account),
20558
20582
  snapshot: this.lastQuotaCache[account],
20559
20583
  now: this.now(),
20560
20584
  allowOverage: this.isOverageAllowed(account)
@@ -20607,7 +20631,7 @@ class AuthBroker {
20607
20631
  if (!overageLiftsWall(snapshot, true))
20608
20632
  return false;
20609
20633
  return accountEligibility({
20610
- mark: this.quota[account],
20634
+ mark: this.exhaustionMarkOf(account),
20611
20635
  snapshot,
20612
20636
  now,
20613
20637
  allowOverage: true
@@ -20617,7 +20641,7 @@ class AuthBroker {
20617
20641
  const snapshot = this.lastQuotaCache[account];
20618
20642
  const allowOverage = this.isOverageAllowed(account);
20619
20643
  const verdict = accountEligibility({
20620
- mark: this.quota[account],
20644
+ mark: this.exhaustionMarkOf(account),
20621
20645
  snapshot,
20622
20646
  now: this.now(),
20623
20647
  allowOverage
@@ -20735,6 +20759,7 @@ class AuthBroker {
20735
20759
  expiresAt: creds?.claudeAiOauth?.expiresAt,
20736
20760
  exhausted,
20737
20761
  exhausted_until: q?.exhausted_until,
20762
+ throttled_until: q?.throttled_until,
20738
20763
  threshold_violations: this.thresholdViolations[label] ?? 0,
20739
20764
  last_refreshed_at: meta?.lastRefreshedAt,
20740
20765
  last_quota: lq ?? null
@@ -20845,7 +20870,7 @@ class AuthBroker {
20845
20870
  };
20846
20871
  this.lastQuotaCache[label] = snapshot;
20847
20872
  this.persistLastQuotaCache();
20848
- if (snapshotShouldClearMark(snapshot, this.quota[label], this.now())) {
20873
+ if (snapshotShouldClearMark(snapshot, this.exhaustionMarkOf(label), this.now())) {
20849
20874
  delete this.quota[label];
20850
20875
  this.persistQuota();
20851
20876
  process.stdout.write(`auth-broker: live probe shows ${label} healthy (5h=${snapshot.fiveHourUtilizationPct}% 7d=${snapshot.sevenDayUtilizationPct}%) — cleared stale exhaustion mark
@@ -20963,7 +20988,7 @@ class AuthBroker {
20963
20988
  const existing = this.quota[label]?.exhausted_until;
20964
20989
  if (existing !== undefined && existing >= exhaustedUntil)
20965
20990
  continue;
20966
- this.quota[label] = { exhausted_until: exhaustedUntil, marked_at: now };
20991
+ this.quota[label] = { ...this.quota[label], exhausted_until: exhaustedUntil, marked_at: now };
20967
20992
  this.persistQuota();
20968
20993
  this.audit({ op: "mark-exhausted", identity: { kind: "operator" }, account: label, accountKind: "claude", ok: true });
20969
20994
  process.stdout.write(`auth-broker: consumer-quota-sensor marked ${label} exhausted until ${new Date(exhaustedUntil).toISOString()} — consumer(s) fail over
@@ -21004,6 +21029,83 @@ class AuthBroker {
21004
21029
  this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true });
21005
21030
  socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
21006
21031
  }
21032
+ async opMarkThrottled(socket, id, identity, until) {
21033
+ const account = this.callerAccount(identity);
21034
+ if (!account) {
21035
+ this.audit({ op: "mark-throttled", identity, accountKind: "claude", ok: false, error: "no-active-account" });
21036
+ socket.write(encodeError(id, "ACCOUNT_NOT_FOUND", "no active account configured"));
21037
+ return;
21038
+ }
21039
+ const now = this.now();
21040
+ const throttledUntil = Math.min(Math.max(until, now + 1000), now + MARK_THROTTLED_MAX_MS);
21041
+ const entry = this.quota[account] ?? {};
21042
+ const priorHits = entry.throttle_hits ?? [];
21043
+ const lastHit = priorHits.length > 0 ? priorHits[priorHits.length - 1] : undefined;
21044
+ if (lastHit !== undefined && now - lastHit < MARK_THROTTLED_MIN_INTERVAL_MS) {
21045
+ const refreshed = Math.max(entry.throttled_until ?? 0, throttledUntil);
21046
+ this.quota[account] = { ...entry, throttled_until: refreshed };
21047
+ this.persistQuota();
21048
+ this.audit({ op: "mark-throttled", identity, account, accountKind: "claude", ok: true });
21049
+ process.stdout.write(`auth-broker: mark-throttled ${account} deduped (re-mark within ${MARK_THROTTLED_MIN_INTERVAL_MS / 1000}s) — expiry refreshed, no new hit
21050
+ `);
21051
+ socket.write(encodeSuccess(id, {
21052
+ account,
21053
+ throttled_until: refreshed,
21054
+ escalated: false,
21055
+ rolledTo: null
21056
+ }));
21057
+ return;
21058
+ }
21059
+ const hits = priorHits.filter((t) => now - t < THROTTLE_ESCALATION_WINDOW_MS);
21060
+ hits.push(now);
21061
+ const escalate = hits.length >= THROTTLE_ESCALATION_HITS;
21062
+ this.quota[account] = {
21063
+ ...entry,
21064
+ throttled_until: throttledUntil,
21065
+ throttle_hits: escalate ? [] : hits
21066
+ };
21067
+ this.persistQuota();
21068
+ this.audit({ op: "mark-throttled", identity, account, accountKind: "claude", ok: true });
21069
+ process.stdout.write(`auth-broker: mark-throttled ${account} until ${new Date(throttledUntil).toISOString()} ` + `(hit ${hits.length}/${THROTTLE_ESCALATION_HITS} in window)
21070
+ `);
21071
+ let escalated = false;
21072
+ let rolledTo = null;
21073
+ if (escalate) {
21074
+ const probe = await this.probeThrottleEscalation(account);
21075
+ if (probe.exhausted) {
21076
+ escalated = true;
21077
+ process.stdout.write(`auth-broker: throttle-escalation probe corroborates wall on ${account} — mark-exhausted + roll
21078
+ `);
21079
+ this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true, reason: "throttle-escalation" });
21080
+ const roll = await this.markExhaustedAndRoll(account, probe.until ?? undefined, identity);
21081
+ rolledTo = roll.rolledTo;
21082
+ } else {
21083
+ process.stdout.write(`auth-broker: throttle-escalation probe on ${account} did NOT corroborate a wall — staying throttled
21084
+ `);
21085
+ }
21086
+ }
21087
+ socket.write(encodeSuccess(id, {
21088
+ account,
21089
+ throttled_until: throttledUntil,
21090
+ escalated,
21091
+ rolledTo: escalated ? rolledTo : null
21092
+ }));
21093
+ }
21094
+ async probeThrottleEscalation(account) {
21095
+ const creds = readAccountCredentials(account, this.home);
21096
+ const token = creds?.claudeAiOauth?.accessToken;
21097
+ if (!token)
21098
+ return { exhausted: false, until: null };
21099
+ let result;
21100
+ try {
21101
+ result = await this.fetchQuotaImpl({ accessToken: token });
21102
+ } catch (err) {
21103
+ this.logErr(`throttle-escalation probe ${account}: ${err.message}`);
21104
+ return { exhausted: false, until: null };
21105
+ }
21106
+ this.cacheQuotaSnapshot(account, result);
21107
+ return quotaIndicatesExhaustion(result, this.isOverageAllowed(account));
21108
+ }
21007
21109
  async markExhaustedAndRoll(account, until, identity) {
21008
21110
  const now = this.now();
21009
21111
  const exhaustedUntil = clampMarkExpiry({
@@ -21012,7 +21114,7 @@ class AuthBroker {
21012
21114
  shortMs: MARK_EXHAUSTED_DEFAULT_MS,
21013
21115
  snapshot: this.lastQuotaCache[account]
21014
21116
  });
21015
- this.quota[account] = { exhausted_until: exhaustedUntil, marked_at: now };
21117
+ this.quota[account] = { ...this.quota[account], exhausted_until: exhaustedUntil, marked_at: now };
21016
21118
  this.persistQuota();
21017
21119
  const rolledTo = await this.nextHealthyAccountLive(account, this.config.auth?.fallback_order ?? []);
21018
21120
  const rolled = this.fanoutFailoverTo(account, rolledTo);