switchroom 0.14.81 → 0.14.83

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.
@@ -6932,7 +6932,7 @@ var require_public_api = __commonJS((exports) => {
6932
6932
  });
6933
6933
 
6934
6934
  // src/agent-scheduler/index.ts
6935
- import { resolve as resolve4, join } from "node:path";
6935
+ import { resolve as resolve4, join as join2 } from "node:path";
6936
6936
 
6937
6937
  // src/config/loader.ts
6938
6938
  import { readFileSync as readFileSync2, existsSync as existsSync3 } from "node:fs";
@@ -12295,6 +12295,581 @@ class JsonlAuditSink {
12295
12295
  close() {}
12296
12296
  }
12297
12297
 
12298
+ // src/scheduler/quota-preflight.ts
12299
+ function decideQuotaPreflight(state) {
12300
+ const accounts = state.accounts ?? [];
12301
+ if (accounts.length === 0) {
12302
+ return { defer: false, reason: "no accounts in broker state" };
12303
+ }
12304
+ const healthy = accounts.filter((a) => !a.exhausted);
12305
+ if (healthy.length > 0) {
12306
+ return {
12307
+ defer: false,
12308
+ reason: `${healthy.length}/${accounts.length} account(s) healthy`
12309
+ };
12310
+ }
12311
+ return { defer: true, reason: `all ${accounts.length} account(s) exhausted` };
12312
+ }
12313
+
12314
+ // src/auth/broker/client.ts
12315
+ import * as net from "node:net";
12316
+ import { homedir as homedir2 } from "node:os";
12317
+ import { randomUUID } from "node:crypto";
12318
+ import { join } from "node:path";
12319
+
12320
+ // src/auth/broker/protocol.ts
12321
+ var MAX_FRAME_BYTES = 64 * 1024;
12322
+ var PROTOCOL_VERSION = 1;
12323
+ var ProviderNameSchema = exports_external.enum(["anthropic", "google", "microsoft"]);
12324
+ var GetCredentialsRequestSchema = exports_external.object({
12325
+ v: exports_external.literal(PROTOCOL_VERSION),
12326
+ op: exports_external.literal("get-credentials"),
12327
+ id: exports_external.string().min(1),
12328
+ provider: ProviderNameSchema.optional()
12329
+ });
12330
+ var ListStateRequestSchema = exports_external.object({
12331
+ v: exports_external.literal(PROTOCOL_VERSION),
12332
+ op: exports_external.literal("list-state"),
12333
+ id: exports_external.string().min(1)
12334
+ });
12335
+ var SetActiveRequestSchema = exports_external.object({
12336
+ v: exports_external.literal(PROTOCOL_VERSION),
12337
+ op: exports_external.literal("set-active"),
12338
+ id: exports_external.string().min(1),
12339
+ account: exports_external.string().min(1),
12340
+ provider: ProviderNameSchema.optional()
12341
+ });
12342
+ var MarkExhaustedRequestSchema = exports_external.object({
12343
+ v: exports_external.literal(PROTOCOL_VERSION),
12344
+ op: exports_external.literal("mark-exhausted"),
12345
+ id: exports_external.string().min(1),
12346
+ until: exports_external.number().int().positive().optional()
12347
+ });
12348
+ var RefreshAccountRequestSchema = exports_external.object({
12349
+ v: exports_external.literal(PROTOCOL_VERSION),
12350
+ op: exports_external.literal("refresh-account"),
12351
+ id: exports_external.string().min(1),
12352
+ account: exports_external.string().min(1),
12353
+ provider: ProviderNameSchema.optional()
12354
+ });
12355
+ var AnthropicCredentialsSchema = exports_external.object({
12356
+ claudeAiOauth: exports_external.object({
12357
+ accessToken: exports_external.string(),
12358
+ refreshToken: exports_external.string().optional(),
12359
+ expiresAt: exports_external.number().optional(),
12360
+ scopes: exports_external.array(exports_external.string()).optional(),
12361
+ subscriptionType: exports_external.string().optional(),
12362
+ rateLimitTier: exports_external.string().optional()
12363
+ })
12364
+ });
12365
+ var GoogleCredentialsSchema = exports_external.object({
12366
+ googleOauth: exports_external.object({
12367
+ accessToken: exports_external.string(),
12368
+ refreshToken: exports_external.string(),
12369
+ expiresAt: exports_external.number(),
12370
+ scope: exports_external.string(),
12371
+ clientId: exports_external.string(),
12372
+ accountEmail: exports_external.string(),
12373
+ tokenType: exports_external.literal("Bearer")
12374
+ })
12375
+ });
12376
+ var MicrosoftCredentialsSchema = exports_external.object({
12377
+ microsoftOauth: exports_external.object({
12378
+ accessToken: exports_external.string(),
12379
+ refreshToken: exports_external.string(),
12380
+ expiresAt: exports_external.number(),
12381
+ scope: exports_external.string(),
12382
+ clientId: exports_external.string(),
12383
+ accountEmail: exports_external.string(),
12384
+ tokenType: exports_external.literal("Bearer"),
12385
+ tenantId: exports_external.string(),
12386
+ accountType: exports_external.enum(["personal", "work"]),
12387
+ homeAccountId: exports_external.string()
12388
+ })
12389
+ });
12390
+ var ProviderCredentialsSchema = exports_external.union([
12391
+ AnthropicCredentialsSchema,
12392
+ GoogleCredentialsSchema,
12393
+ MicrosoftCredentialsSchema
12394
+ ]);
12395
+ var AddAccountRequestSchema = exports_external.object({
12396
+ v: exports_external.literal(PROTOCOL_VERSION),
12397
+ op: exports_external.literal("add-account"),
12398
+ id: exports_external.string().min(1),
12399
+ label: exports_external.string().min(1),
12400
+ provider: ProviderNameSchema.optional(),
12401
+ credentials: ProviderCredentialsSchema,
12402
+ replace: exports_external.boolean().optional()
12403
+ });
12404
+ var RmAccountRequestSchema = exports_external.object({
12405
+ v: exports_external.literal(PROTOCOL_VERSION),
12406
+ op: exports_external.literal("rm-account"),
12407
+ id: exports_external.string().min(1),
12408
+ label: exports_external.string().min(1),
12409
+ provider: ProviderNameSchema.optional()
12410
+ });
12411
+ var SetOverrideRequestSchema = exports_external.object({
12412
+ v: exports_external.literal(PROTOCOL_VERSION),
12413
+ op: exports_external.literal("set-override"),
12414
+ id: exports_external.string().min(1),
12415
+ agent: exports_external.string().min(1),
12416
+ account: exports_external.string().min(1).nullable()
12417
+ });
12418
+ var ListGoogleAccountsRequestSchema = exports_external.object({
12419
+ v: exports_external.literal(PROTOCOL_VERSION),
12420
+ op: exports_external.literal("list-google-accounts"),
12421
+ id: exports_external.string().min(1)
12422
+ });
12423
+ var ListMicrosoftAccountsRequestSchema = exports_external.object({
12424
+ v: exports_external.literal(PROTOCOL_VERSION),
12425
+ op: exports_external.literal("list-microsoft-accounts"),
12426
+ id: exports_external.string().min(1)
12427
+ });
12428
+ var ProbeQuotaRequestSchema = exports_external.object({
12429
+ v: exports_external.literal(PROTOCOL_VERSION),
12430
+ op: exports_external.literal("probe-quota"),
12431
+ id: exports_external.string().min(1),
12432
+ accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
12433
+ timeoutMs: exports_external.number().int().positive().max(60000).optional()
12434
+ });
12435
+ var RequestSchema = exports_external.discriminatedUnion("op", [
12436
+ GetCredentialsRequestSchema,
12437
+ ListStateRequestSchema,
12438
+ SetActiveRequestSchema,
12439
+ MarkExhaustedRequestSchema,
12440
+ RefreshAccountRequestSchema,
12441
+ AddAccountRequestSchema,
12442
+ RmAccountRequestSchema,
12443
+ SetOverrideRequestSchema,
12444
+ ListGoogleAccountsRequestSchema,
12445
+ ListMicrosoftAccountsRequestSchema,
12446
+ ProbeQuotaRequestSchema
12447
+ ]);
12448
+ var GetCredentialsDataSchema = exports_external.object({
12449
+ account: exports_external.string(),
12450
+ credentials: exports_external.unknown(),
12451
+ expiresAt: exports_external.number().optional()
12452
+ });
12453
+ var AccountStateSchema = exports_external.object({
12454
+ label: exports_external.string(),
12455
+ expiresAt: exports_external.number().optional(),
12456
+ exhausted: exports_external.boolean(),
12457
+ exhausted_until: exports_external.number().optional(),
12458
+ threshold_violations: exports_external.number().int().nonnegative().optional(),
12459
+ last_refreshed_at: exports_external.number().optional()
12460
+ });
12461
+ var AgentStateSchema = exports_external.object({
12462
+ name: exports_external.string(),
12463
+ account: exports_external.string(),
12464
+ override: exports_external.string().nullable()
12465
+ });
12466
+ var ConsumerStateSchema = exports_external.object({
12467
+ name: exports_external.string(),
12468
+ account: exports_external.string(),
12469
+ last_seen_at: exports_external.number().nullable()
12470
+ });
12471
+ var ListStateDataSchema = exports_external.object({
12472
+ active: exports_external.string(),
12473
+ fallback_order: exports_external.array(exports_external.string()),
12474
+ accounts: exports_external.array(AccountStateSchema),
12475
+ agents: exports_external.array(AgentStateSchema),
12476
+ consumers: exports_external.array(ConsumerStateSchema)
12477
+ });
12478
+ var SetActiveDataSchema = exports_external.object({
12479
+ active: exports_external.string(),
12480
+ fanned: exports_external.array(exports_external.string())
12481
+ });
12482
+ var MarkExhaustedDataSchema = exports_external.object({
12483
+ account: exports_external.string(),
12484
+ rolled: exports_external.array(exports_external.string()),
12485
+ rolledTo: exports_external.string().nullable().optional()
12486
+ });
12487
+ var RefreshAccountDataSchema = exports_external.object({
12488
+ account: exports_external.string(),
12489
+ expiresAt: exports_external.number().optional()
12490
+ });
12491
+ var AddAccountDataSchema = exports_external.object({
12492
+ label: exports_external.string(),
12493
+ expiresAt: exports_external.number().optional()
12494
+ });
12495
+ var RmAccountDataSchema = exports_external.object({
12496
+ label: exports_external.string()
12497
+ });
12498
+ var SetOverrideDataSchema = exports_external.object({
12499
+ agent: exports_external.string(),
12500
+ account: exports_external.string().nullable()
12501
+ });
12502
+ var GoogleAccountStateSchema = exports_external.object({
12503
+ account: exports_external.string(),
12504
+ expiresAt: exports_external.number(),
12505
+ scope: exports_external.string(),
12506
+ clientId: exports_external.string()
12507
+ });
12508
+ var ListGoogleAccountsDataSchema = exports_external.object({
12509
+ accounts: exports_external.array(GoogleAccountStateSchema)
12510
+ });
12511
+ var MicrosoftAccountStateSchema = exports_external.object({
12512
+ account: exports_external.string(),
12513
+ expiresAt: exports_external.number(),
12514
+ scope: exports_external.string(),
12515
+ clientId: exports_external.string(),
12516
+ accountType: exports_external.enum(["personal", "work"])
12517
+ });
12518
+ var ListMicrosoftAccountsDataSchema = exports_external.object({
12519
+ accounts: exports_external.array(MicrosoftAccountStateSchema)
12520
+ });
12521
+ var ErrorBodySchema = exports_external.object({
12522
+ code: exports_external.enum([
12523
+ "FORBIDDEN",
12524
+ "INVALID_ARGS",
12525
+ "UNKNOWN_VERB",
12526
+ "VERSION_MISMATCH",
12527
+ "ACCOUNT_NOT_FOUND",
12528
+ "ACCOUNT_ALREADY_EXISTS",
12529
+ "CONFIG_INVALID",
12530
+ "DRIFT_DETECTED",
12531
+ "REFRESH_FAILED",
12532
+ "INTERNAL"
12533
+ ]),
12534
+ message: exports_external.string()
12535
+ });
12536
+ var SuccessResponseSchema = exports_external.object({
12537
+ v: exports_external.literal(PROTOCOL_VERSION),
12538
+ id: exports_external.string(),
12539
+ ok: exports_external.literal(true),
12540
+ data: exports_external.unknown()
12541
+ });
12542
+ var ErrorResponseSchema = exports_external.object({
12543
+ v: exports_external.literal(PROTOCOL_VERSION),
12544
+ id: exports_external.string(),
12545
+ ok: exports_external.literal(false),
12546
+ error: ErrorBodySchema
12547
+ });
12548
+ var ResponseSchema = exports_external.discriminatedUnion("ok", [
12549
+ SuccessResponseSchema,
12550
+ ErrorResponseSchema
12551
+ ]);
12552
+ function encodeRequest(req) {
12553
+ const line = JSON.stringify(RequestSchema.parse(req)) + `
12554
+ `;
12555
+ if (Buffer.byteLength(line, "utf-8") > MAX_FRAME_BYTES) {
12556
+ throw new Error(`auth-broker request exceeds MAX_FRAME_BYTES (${MAX_FRAME_BYTES})`);
12557
+ }
12558
+ return line;
12559
+ }
12560
+ function decodeResponse(line) {
12561
+ const trimmed = line.endsWith(`
12562
+ `) ? line.slice(0, -1) : line;
12563
+ let parsed;
12564
+ try {
12565
+ parsed = JSON.parse(trimmed);
12566
+ } catch {
12567
+ throw new Error("auth-broker response is not valid JSON");
12568
+ }
12569
+ return ResponseSchema.parse(parsed);
12570
+ }
12571
+
12572
+ // src/auth/broker/client.ts
12573
+ var DEFAULT_TIMEOUT_MS = 5000;
12574
+ function reviveDate(v) {
12575
+ if (v == null)
12576
+ return null;
12577
+ if (v instanceof Date)
12578
+ return Number.isNaN(v.getTime()) ? null : v;
12579
+ const d = new Date(v);
12580
+ return Number.isNaN(d.getTime()) ? null : d;
12581
+ }
12582
+ function operatorSocketPath(home2 = homedir2()) {
12583
+ return join(home2, ".switchroom", "state", "auth-broker-operator", "sock");
12584
+ }
12585
+ function resolveAuthBrokerSocketPath(opts) {
12586
+ if (opts?.socket)
12587
+ return opts.socket;
12588
+ const env = process.env.SWITCHROOM_AUTH_BROKER_SOCKET;
12589
+ if (env && env.length > 0)
12590
+ return env;
12591
+ return operatorSocketPath(opts?.home);
12592
+ }
12593
+
12594
+ class AuthBrokerError extends Error {
12595
+ code;
12596
+ constructor(code, message) {
12597
+ super(message);
12598
+ this.code = code;
12599
+ this.name = "AuthBrokerError";
12600
+ }
12601
+ }
12602
+
12603
+ class AuthBrokerUnreachableError extends Error {
12604
+ reason;
12605
+ socketPath;
12606
+ constructor(reason, socketPath) {
12607
+ super(`auth-broker unreachable at ${socketPath}: ${reason}. ` + `The broker may be down; existing credentials remain valid until expiry.`);
12608
+ this.reason = reason;
12609
+ this.socketPath = socketPath;
12610
+ this.name = "AuthBrokerUnreachableError";
12611
+ }
12612
+ }
12613
+
12614
+ class AuthBrokerClient {
12615
+ socketPath;
12616
+ timeoutMs;
12617
+ socket = null;
12618
+ connecting = null;
12619
+ buffer = "";
12620
+ pending = new Map;
12621
+ closed = false;
12622
+ constructor(opts = {}) {
12623
+ this.socketPath = resolveAuthBrokerSocketPath(opts);
12624
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12625
+ }
12626
+ getSocketPath() {
12627
+ return this.socketPath;
12628
+ }
12629
+ async close() {
12630
+ this.closed = true;
12631
+ const sock = this.socket;
12632
+ this.socket = null;
12633
+ this.connecting = null;
12634
+ for (const [, p] of this.pending) {
12635
+ clearTimeout(p.timer);
12636
+ p.reject(new Error("auth-broker client closed"));
12637
+ }
12638
+ this.pending.clear();
12639
+ if (sock) {
12640
+ sock.destroy();
12641
+ }
12642
+ }
12643
+ async getCredentials(provider) {
12644
+ const base = {
12645
+ v: PROTOCOL_VERSION,
12646
+ id: randomUUID(),
12647
+ op: "get-credentials"
12648
+ };
12649
+ const req = provider !== undefined ? { ...base, provider } : base;
12650
+ const data = await this.send(req);
12651
+ return data;
12652
+ }
12653
+ async listState() {
12654
+ const data = await this.send({
12655
+ v: PROTOCOL_VERSION,
12656
+ id: randomUUID(),
12657
+ op: "list-state"
12658
+ });
12659
+ return data;
12660
+ }
12661
+ async listGoogleAccounts() {
12662
+ const data = await this.send({
12663
+ v: PROTOCOL_VERSION,
12664
+ id: randomUUID(),
12665
+ op: "list-google-accounts"
12666
+ });
12667
+ return data;
12668
+ }
12669
+ async listMicrosoftAccounts() {
12670
+ const data = await this.send({
12671
+ v: PROTOCOL_VERSION,
12672
+ id: randomUUID(),
12673
+ op: "list-microsoft-accounts"
12674
+ });
12675
+ return data;
12676
+ }
12677
+ async probeQuota(accounts, timeoutMs) {
12678
+ const data = await this.send({
12679
+ v: PROTOCOL_VERSION,
12680
+ id: randomUUID(),
12681
+ op: "probe-quota",
12682
+ accounts: [...accounts],
12683
+ ...timeoutMs !== undefined ? { timeoutMs } : {}
12684
+ });
12685
+ const parsed = data;
12686
+ for (const entry of parsed.results) {
12687
+ if (entry.result.ok) {
12688
+ entry.result.data.fiveHourResetAt = reviveDate(entry.result.data.fiveHourResetAt);
12689
+ entry.result.data.sevenDayResetAt = reviveDate(entry.result.data.sevenDayResetAt);
12690
+ }
12691
+ }
12692
+ return parsed;
12693
+ }
12694
+ async setActive(account) {
12695
+ const data = await this.send({
12696
+ v: PROTOCOL_VERSION,
12697
+ id: randomUUID(),
12698
+ op: "set-active",
12699
+ account
12700
+ });
12701
+ return data;
12702
+ }
12703
+ async markExhausted(until) {
12704
+ const req = until !== undefined ? { v: PROTOCOL_VERSION, id: randomUUID(), op: "mark-exhausted", until } : { v: PROTOCOL_VERSION, id: randomUUID(), op: "mark-exhausted" };
12705
+ const data = await this.send(req);
12706
+ return data;
12707
+ }
12708
+ async refreshAccount(account) {
12709
+ const data = await this.send({
12710
+ v: PROTOCOL_VERSION,
12711
+ id: randomUUID(),
12712
+ op: "refresh-account",
12713
+ account
12714
+ });
12715
+ return data;
12716
+ }
12717
+ async addAccount(label, credentials, replace, provider) {
12718
+ const base = {
12719
+ v: PROTOCOL_VERSION,
12720
+ id: randomUUID(),
12721
+ op: "add-account",
12722
+ label,
12723
+ credentials
12724
+ };
12725
+ const withReplace = replace ? { ...base, replace: true } : base;
12726
+ const req = provider !== undefined ? { ...withReplace, provider } : withReplace;
12727
+ const data = await this.send(req);
12728
+ return data;
12729
+ }
12730
+ async rmAccount(label, provider) {
12731
+ const base = {
12732
+ v: PROTOCOL_VERSION,
12733
+ id: randomUUID(),
12734
+ op: "rm-account",
12735
+ label
12736
+ };
12737
+ const req = provider !== undefined ? { ...base, provider } : base;
12738
+ const data = await this.send(req);
12739
+ return data;
12740
+ }
12741
+ async setOverride(agent, account) {
12742
+ const data = await this.send({
12743
+ v: PROTOCOL_VERSION,
12744
+ id: randomUUID(),
12745
+ op: "set-override",
12746
+ agent,
12747
+ account
12748
+ });
12749
+ return data;
12750
+ }
12751
+ async ensureConnected() {
12752
+ if (this.closed) {
12753
+ throw new Error("auth-broker client is closed");
12754
+ }
12755
+ if (this.socket && !this.socket.destroyed)
12756
+ return this.socket;
12757
+ if (this.connecting)
12758
+ return this.connecting;
12759
+ this.connecting = new Promise((resolve4, reject) => {
12760
+ const sock = new net.Socket;
12761
+ const onError = (err) => {
12762
+ sock.removeAllListeners();
12763
+ sock.destroy();
12764
+ const code = err.code ?? "ERR";
12765
+ let reason;
12766
+ if (code === "ENOENT")
12767
+ reason = "socket file not found";
12768
+ else if (code === "ECONNREFUSED")
12769
+ reason = "connection refused";
12770
+ else if (code === "EACCES")
12771
+ reason = "access denied";
12772
+ else
12773
+ reason = err.message;
12774
+ reject(new AuthBrokerUnreachableError(reason, this.socketPath));
12775
+ };
12776
+ sock.once("error", onError);
12777
+ sock.once("connect", () => {
12778
+ sock.removeListener("error", onError);
12779
+ sock.on("data", (chunk) => this.onData(chunk));
12780
+ sock.on("error", (err) => this.onSocketError(err));
12781
+ sock.on("close", () => this.onSocketClose());
12782
+ this.socket = sock;
12783
+ resolve4(sock);
12784
+ });
12785
+ sock.connect({ path: this.socketPath });
12786
+ });
12787
+ try {
12788
+ return await this.connecting;
12789
+ } finally {
12790
+ this.connecting = null;
12791
+ }
12792
+ }
12793
+ onData(chunk) {
12794
+ this.buffer += chunk.toString("utf8");
12795
+ let idx;
12796
+ while ((idx = this.buffer.indexOf(`
12797
+ `)) !== -1) {
12798
+ const line = this.buffer.slice(0, idx);
12799
+ this.buffer = this.buffer.slice(idx + 1);
12800
+ if (line.length === 0)
12801
+ continue;
12802
+ let resp;
12803
+ try {
12804
+ resp = decodeResponse(line);
12805
+ } catch (err) {
12806
+ const msg = `unparseable auth-broker response: ${err instanceof Error ? err.message : String(err)}`;
12807
+ this.failAll(new AuthBrokerUnreachableError(msg, this.socketPath));
12808
+ return;
12809
+ }
12810
+ const p = this.pending.get(resp.id);
12811
+ if (!p) {
12812
+ continue;
12813
+ }
12814
+ this.pending.delete(resp.id);
12815
+ clearTimeout(p.timer);
12816
+ p.resolve(resp);
12817
+ }
12818
+ }
12819
+ onSocketError(err) {
12820
+ this.failAll(new AuthBrokerUnreachableError(err.message, this.socketPath));
12821
+ if (this.socket) {
12822
+ this.socket.destroy();
12823
+ this.socket = null;
12824
+ }
12825
+ }
12826
+ onSocketClose() {
12827
+ if (this.pending.size > 0) {
12828
+ this.failAll(new AuthBrokerUnreachableError("connection closed mid-request", this.socketPath));
12829
+ }
12830
+ this.socket = null;
12831
+ }
12832
+ failAll(err) {
12833
+ for (const [, p] of this.pending) {
12834
+ clearTimeout(p.timer);
12835
+ p.reject(err);
12836
+ }
12837
+ this.pending.clear();
12838
+ }
12839
+ async send(req) {
12840
+ const sock = await this.ensureConnected();
12841
+ const id = req.id;
12842
+ const frame = encodeRequest(req);
12843
+ return new Promise((resolve4, reject) => {
12844
+ const timer = setTimeout(() => {
12845
+ this.pending.delete(id);
12846
+ reject(new AuthBrokerUnreachableError(`request ${req.op} timed out after ${this.timeoutMs}ms`, this.socketPath));
12847
+ }, this.timeoutMs);
12848
+ this.pending.set(id, {
12849
+ resolve: (resp) => {
12850
+ if (resp.ok) {
12851
+ resolve4(resp.data);
12852
+ } else {
12853
+ reject(new AuthBrokerError(resp.error.code, resp.error.message));
12854
+ }
12855
+ },
12856
+ reject,
12857
+ timer
12858
+ });
12859
+ sock.write(frame, (err) => {
12860
+ if (err) {
12861
+ const p = this.pending.get(id);
12862
+ if (p) {
12863
+ clearTimeout(p.timer);
12864
+ this.pending.delete(id);
12865
+ }
12866
+ reject(new AuthBrokerUnreachableError(`failed to send ${req.op}: ${err.message}`, this.socketPath));
12867
+ }
12868
+ });
12869
+ });
12870
+ }
12871
+ }
12872
+
12298
12873
  // src/agent-scheduler/ipc-client.ts
12299
12874
  import { createConnection } from "node:net";
12300
12875
  function createInjectIpcClient(options) {
@@ -12734,12 +13309,55 @@ function readRecentFires(jsonlPath) {
12734
13309
  }
12735
13310
 
12736
13311
  // src/agent-scheduler/index.ts
13312
+ var DEFAULT_MAX_QUOTA_DEFER_ATTEMPTS = 3;
13313
+ function defaultQuotaDeferBackoffMs(attempt) {
13314
+ return [60000, 180000, 300000][attempt] ?? 300000;
13315
+ }
12737
13316
  function registerAgentSchedule(opts) {
12738
13317
  const tasks = [];
12739
13318
  const now = opts.now ?? Date.now;
13319
+ const maxAttempts = opts.maxQuotaDeferAttempts ?? DEFAULT_MAX_QUOTA_DEFER_ATTEMPTS;
13320
+ const backoff = opts.quotaDeferBackoffMs ?? defaultQuotaDeferBackoffMs;
13321
+ const scheduleRetry = opts.scheduleRetry ?? ((fn, ms) => {
13322
+ const t = setTimeout(fn, ms);
13323
+ if (typeof t.unref === "function")
13324
+ t.unref();
13325
+ return { cancel: () => clearTimeout(t) };
13326
+ });
12740
13327
  for (const entry of opts.entries) {
12741
- const task = opts.cronLib.schedule(entry.cron, () => {
13328
+ const pendingRetries = new Set;
13329
+ const attemptFire = async (attempt) => {
12742
13330
  const startedAt = now();
13331
+ if (opts.quotaGate) {
13332
+ let decision;
13333
+ try {
13334
+ decision = await opts.quotaGate(entry.agent);
13335
+ } catch {
13336
+ decision = { defer: false, reason: "quota gate error (fail-open)" };
13337
+ }
13338
+ if (decision.defer) {
13339
+ const more = attempt + 1 < maxAttempts;
13340
+ const summary2 = `deferred (quota): ${decision.reason} ` + `[attempt ${attempt + 1}/${maxAttempts}]` + (more ? "" : " — giving up; will re-run on next scheduled occurrence");
13341
+ opts.sink.recordFire({
13342
+ agent: entry.agent,
13343
+ scheduleIndex: entry.scheduleIndex,
13344
+ promptKey: entry.promptKey,
13345
+ exitCode: -2,
13346
+ outputSummary: summary2,
13347
+ startedAt,
13348
+ finishedAt: now()
13349
+ });
13350
+ if (more) {
13351
+ let handle;
13352
+ handle = scheduleRetry(() => {
13353
+ pendingRetries.delete(handle);
13354
+ attemptFire(attempt + 1);
13355
+ }, backoff(attempt));
13356
+ pendingRetries.add(handle);
13357
+ }
13358
+ return;
13359
+ }
13360
+ }
12743
13361
  let delivered = false;
12744
13362
  let summary = "";
12745
13363
  try {
@@ -12760,8 +13378,19 @@ function registerAgentSchedule(opts) {
12760
13378
  startedAt,
12761
13379
  finishedAt
12762
13380
  });
13381
+ };
13382
+ const task = opts.cronLib.schedule(entry.cron, () => attemptFire(0));
13383
+ tasks.push({
13384
+ entry,
13385
+ task: {
13386
+ stop: () => {
13387
+ for (const h of pendingRetries)
13388
+ h.cancel();
13389
+ pendingRetries.clear();
13390
+ task.stop();
13391
+ }
13392
+ }
12763
13393
  });
12764
- tasks.push({ entry, task });
12765
13394
  }
12766
13395
  return tasks;
12767
13396
  }
@@ -12785,7 +13414,7 @@ async function main() {
12785
13414
  }
12786
13415
  const configPath = process.env.SWITCHROOM_CONFIG ?? "/state/config/switchroom.yaml";
12787
13416
  const stateDir = process.env.TELEGRAM_STATE_DIR ?? "/state/agent/telegram";
12788
- const socketPath = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(stateDir, "gateway.sock");
13417
+ const socketPath = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join2(stateDir, "gateway.sock");
12789
13418
  const jsonlPath = process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL ?? "/state/agent/scheduler.jsonl";
12790
13419
  const lockPath = process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK ?? "/state/agent/scheduler.lock";
12791
13420
  const lock = acquireLock(lockPath);
@@ -12913,12 +13542,22 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
12913
13542
  }
12914
13543
  }
12915
13544
  const cronLib = __require("node-cron");
13545
+ const quotaPreflightEnabled = process.env.SWITCHROOM_DISABLE_CRON_QUOTA_PREFLIGHT !== "1";
13546
+ const quotaGate = quotaPreflightEnabled ? async () => {
13547
+ const client = new AuthBrokerClient;
13548
+ try {
13549
+ return decideQuotaPreflight(await client.listState());
13550
+ } finally {
13551
+ await client.close().catch(() => {});
13552
+ }
13553
+ } : undefined;
12916
13554
  const tasks = registerAgentSchedule({
12917
13555
  entries,
12918
13556
  channel,
12919
13557
  sink,
12920
13558
  cronLib,
12921
- dispatcher
13559
+ dispatcher,
13560
+ ...quotaGate ? { quotaGate } : {}
12922
13561
  });
12923
13562
  process.stdout.write(`agent-scheduler: ${agentName} registered ${tasks.length} task(s); ` + `chat=${channel.chatId} thread=${channel.threadId ?? "(none)"} ` + `socket=${socketPath} jsonl=${jsonlPath}
12924
13563
  `);
@@ -14863,6 +14863,39 @@ async function parseSseOrJson(resp) {
14863
14863
  const payload = dataLine ? dataLine.slice("data: ".length) : text;
14864
14864
  return JSON.parse(payload);
14865
14865
  }
14866
+ async function fetchHindsightToolsList(apiUrl, opts) {
14867
+ const fetchImpl = opts?.fetchImpl ?? fetch;
14868
+ const timeoutMs = opts?.timeoutMs ?? 4000;
14869
+ const bankId = opts?.bankId ?? "__doctor_probe__";
14870
+ const controller = new AbortController;
14871
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
14872
+ try {
14873
+ const resp = await fetchImpl(`${apiUrl}`, {
14874
+ method: "POST",
14875
+ headers: {
14876
+ "Content-Type": "application/json",
14877
+ Accept: "application/json, text/event-stream",
14878
+ "X-Bank-Id": bankId
14879
+ },
14880
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
14881
+ signal: controller.signal
14882
+ });
14883
+ clearTimeout(timeout);
14884
+ if (!resp.ok)
14885
+ return { ok: false, reason: `HTTP ${resp.status}` };
14886
+ const parsed = await parseSseOrJson(resp);
14887
+ const raw = parsed.result?.tools;
14888
+ if (!Array.isArray(raw))
14889
+ return { ok: false, reason: "no tools in tools/list response" };
14890
+ const tools = raw.filter((t) => typeof t?.name === "string").map((t) => ({ name: t.name, required: t.inputSchema?.required ?? [] }));
14891
+ return { ok: true, tools };
14892
+ } catch (err) {
14893
+ clearTimeout(timeout);
14894
+ if (err.name === "AbortError")
14895
+ return { ok: false, reason: "Timeout" };
14896
+ return { ok: false, reason: String(err.message ?? err) };
14897
+ }
14898
+ }
14866
14899
  async function probeHindsight(apiUrl, opts) {
14867
14900
  const fetchImpl = opts?.fetchImpl ?? fetch;
14868
14901
  const timeoutMs = opts?.timeoutMs ?? 3000;
@@ -14991,8 +15024,7 @@ async function ensureUserProfileMentalModel(apiUrl, bankId, opts) {
14991
15024
  name: "create_mental_model",
14992
15025
  arguments: {
14993
15026
  name: "user-profile",
14994
- source_query: "What are the key facts, preferences, context, and communication style about the user I talk to? Summarize what matters for making the agent feel like it knows them.",
14995
- types: ["world", "experience"]
15027
+ source_query: "What are the key facts, preferences, context, and communication style about the user I talk to? Summarize what matters for making the agent feel like it knows them."
14996
15028
  }
14997
15029
  }
14998
15030
  }),
@@ -15077,6 +15109,12 @@ async function createBank(apiUrl, bankId, opts) {
15077
15109
  if (!toolResponse.ok) {
15078
15110
  return { ok: false, reason: `Tool call HTTP ${toolResponse.status}` };
15079
15111
  }
15112
+ try {
15113
+ const created = await parseSseOrJson(toolResponse);
15114
+ if (created.result?.isError === true) {
15115
+ return { ok: false, reason: created.result.content?.[0]?.text ?? "create_bank returned isError" };
15116
+ }
15117
+ } catch {}
15080
15118
  return { ok: true };
15081
15119
  } catch (err) {
15082
15120
  if (err.name === "AbortError") {
@@ -15137,8 +15175,8 @@ async function updateBankMissions(apiUrl, bankId, missions, opts) {
15137
15175
  name: "update_bank",
15138
15176
  arguments: {
15139
15177
  bank_id: bankId,
15140
- mission: missions.bank_mission,
15141
- retain_mission: missions.retain_mission
15178
+ ...missions.bank_mission != null ? { mission: missions.bank_mission } : {},
15179
+ ...missions.retain_mission != null ? { config_updates: { retain_mission: missions.retain_mission } } : {}
15142
15180
  }
15143
15181
  }
15144
15182
  }),
@@ -15148,6 +15186,12 @@ async function updateBankMissions(apiUrl, bankId, missions, opts) {
15148
15186
  if (!toolResponse.ok) {
15149
15187
  return { ok: false, reason: `Tool call HTTP ${toolResponse.status}` };
15150
15188
  }
15189
+ try {
15190
+ const updated = await parseSseOrJson(toolResponse);
15191
+ if (updated.result?.isError === true) {
15192
+ return { ok: false, reason: updated.result.content?.[0]?.text ?? "update_bank returned isError" };
15193
+ }
15194
+ } catch {}
15151
15195
  return { ok: true };
15152
15196
  } catch (err) {
15153
15197
  if (err.name === "AbortError") {
@@ -28959,6 +29003,30 @@ var init_manifest = __esm(() => {
28959
29003
  ]);
28960
29004
  });
28961
29005
 
29006
+ // src/memory/hindsight-tools.ts
29007
+ var EXPECTED_HINDSIGHT_TOOLS;
29008
+ var init_hindsight_tools = __esm(() => {
29009
+ EXPECTED_HINDSIGHT_TOOLS = {
29010
+ recall: { required: ["query"] },
29011
+ reflect: { required: ["query"] },
29012
+ retain: { required: ["content"] },
29013
+ sync_retain: { required: ["content"] },
29014
+ delete_document: { required: ["document_id"] },
29015
+ create_directive: { required: ["content", "name"] },
29016
+ list_directives: { required: [] },
29017
+ delete_directive: { required: ["directive_id"] },
29018
+ create_bank: { required: ["bank_id"] },
29019
+ update_bank: { required: [] },
29020
+ list_banks: { required: [] },
29021
+ create_mental_model: { required: ["name", "source_query"] },
29022
+ list_mental_models: { required: [] },
29023
+ update_mental_model: { required: ["mental_model_id"] },
29024
+ refresh_mental_model: { required: ["mental_model_id"] },
29025
+ list_memories: { required: [] },
29026
+ get_memory: { required: ["memory_id"] }
29027
+ };
29028
+ });
29029
+
28962
29030
  // src/cli/doctor-memory.ts
28963
29031
  import { execFileSync as execFileSync17 } from "node:child_process";
28964
29032
  function classifyShmSize(bytes) {
@@ -29030,8 +29098,51 @@ function checkHindsightContainerHealth(opts) {
29030
29098
  } catch {}
29031
29099
  return results;
29032
29100
  }
29101
+ function classifyToolContract(advertised) {
29102
+ const byName = new Map(advertised.map((t) => [t.name, t]));
29103
+ const results = [];
29104
+ for (const [tool, spec] of Object.entries(EXPECTED_HINDSIGHT_TOOLS)) {
29105
+ const real = byName.get(tool);
29106
+ if (real === undefined) {
29107
+ results.push({
29108
+ name: `hindsight contract: ${tool}`,
29109
+ status: "fail",
29110
+ detail: `switchroom calls \`${tool}\` but the server no longer advertises it ` + `(renamed/removed upstream) \u2014 every callsite silently no-ops`,
29111
+ fix: "Upstream hindsight changed its MCP tool contract. Update the callsite " + "+ EXPECTED_HINDSIGHT_TOOLS (src/memory/hindsight-tools.ts) to the new " + "name, refresh tests/fixtures/hindsight-tools-list.snapshot.json, or pin " + "the prior hindsight image."
29112
+ });
29113
+ continue;
29114
+ }
29115
+ const missing = spec.required.filter((arg) => !real.required.includes(arg));
29116
+ const added = real.required.filter((arg) => !spec.required.includes(arg));
29117
+ if (added.length > 0) {
29118
+ results.push({
29119
+ name: `hindsight contract: ${tool}`,
29120
+ status: "fail",
29121
+ detail: `server now requires [${added.join(", ")}] on \`${tool}\` which ` + `switchroom does not track \u2014 calls may silently no-op`,
29122
+ fix: "Reconcile EXPECTED_HINDSIGHT_TOOLS + the callsite args with the new " + "server schema, then refresh the snapshot fixture."
29123
+ });
29124
+ } else if (missing.length > 0) {
29125
+ results.push({
29126
+ name: `hindsight contract: ${tool}`,
29127
+ status: "warn",
29128
+ detail: `switchroom treats [${missing.join(", ")}] as required on \`${tool}\` ` + `but the server no longer does (loosened upstream) \u2014 harmless, but the ` + `fixture is stale`,
29129
+ fix: "Refresh EXPECTED_HINDSIGHT_TOOLS + the snapshot fixture."
29130
+ });
29131
+ }
29132
+ }
29133
+ if (results.length === 0) {
29134
+ const used = Object.keys(EXPECTED_HINDSIGHT_TOOLS).length;
29135
+ results.push({
29136
+ name: "hindsight contract",
29137
+ status: "ok",
29138
+ detail: `${used} used tools present, required args satisfied (${advertised.length} advertised)`
29139
+ });
29140
+ }
29141
+ return results;
29142
+ }
29033
29143
  var MIN_HINDSIGHT_SHM_BYTES;
29034
29144
  var init_doctor_memory = __esm(() => {
29145
+ init_hindsight_tools();
29035
29146
  MIN_HINDSIGHT_SHM_BYTES = 1024 * 1024 * 1024;
29036
29147
  });
29037
29148
 
@@ -32043,6 +32154,10 @@ async function checkHindsight(config) {
32043
32154
  status: "ok",
32044
32155
  detail: `${probe2.serverName} ${probe2.serverVersion} at ${host}:${port}`
32045
32156
  });
32157
+ const toolsList = await fetchHindsightToolsList(url);
32158
+ if (toolsList.ok) {
32159
+ results.push(...classifyToolContract(toolsList.tools));
32160
+ }
32046
32161
  results.push(checkHindsightConsumer(config));
32047
32162
  results.push(...checkHindsightContainerHealth());
32048
32163
  for (const [agentName, agentConfig] of Object.entries(config.agents)) {
@@ -49700,8 +49815,8 @@ var {
49700
49815
  } = import__.default;
49701
49816
 
49702
49817
  // src/build-info.ts
49703
- var VERSION = "0.14.81";
49704
- var COMMIT_SHA = "4ac9cc7d";
49818
+ var VERSION = "0.14.83";
49819
+ var COMMIT_SHA = "057ab099";
49705
49820
 
49706
49821
  // src/cli/agent.ts
49707
49822
  init_source();
@@ -559,16 +559,42 @@
559
559
  }
560
560
  }
561
561
 
562
+ // Guards a second click from starting a second device-code flow (each
563
+ // start makes Microsoft send a sign-in email → the "2 emails" bug).
564
+ let msConnecting = false;
565
+
566
+ // The set of Microsoft account emails currently known to the broker.
567
+ // Used as a resilience baseline: the connect status lives only in the web
568
+ // process's memory, so if that process restarts mid-connect the status
569
+ // reads 'unknown' even when the token WAS stored. Diffing this list tells
570
+ // us the account really connected regardless of the lost status.
571
+ async function fetchMicrosoftAccountEmails() {
572
+ try {
573
+ const r = await fetch(`${API}/api/microsoft-accounts`, { headers: authHeaders() });
574
+ if (!r.ok) return new Set();
575
+ const list = await r.json();
576
+ return new Set((list || []).filter(a => a.brokerKnown).map(a => String(a.account).toLowerCase()));
577
+ } catch { return new Set(); }
578
+ }
579
+
562
580
  // Start an in-browser Microsoft connect: show the device code + link,
563
581
  // then poll until the operator completes sign-in on Microsoft's site.
564
582
  async function connectMicrosoft() {
583
+ if (msConnecting) return; // double-submit guard
584
+ msConnecting = true;
585
+ const btn = document.getElementById('ms-connect-btn');
586
+ if (btn) btn.disabled = true;
587
+ const done = () => { msConnecting = false; const b = document.getElementById('ms-connect-btn'); if (b) b.disabled = false; };
565
588
  const card = document.getElementById('ms-connect-card');
566
589
  const show = (html) => { if (card) card.innerHTML = html; };
567
590
  show('<div class="loading" style="padding:.8rem">Starting…</div>');
591
+ // Snapshot already-connected accounts BEFORE starting, for the
592
+ // restart-resilient terminal check below.
593
+ const before = await fetchMicrosoftAccountEmails();
568
594
  try {
569
595
  const res = await fetch(`${API}/api/connections/microsoft/connect`, { method: 'POST', headers: authHeaders() });
570
596
  const data = await res.json();
571
- if (!res.ok || !data.ok) { show(''); showError(data.error || `HTTP ${res.status}`); return; }
597
+ if (!res.ok || !data.ok) { show(''); showError(data.error || `HTTP ${res.status}`); done(); return; }
572
598
  const url = data.verificationUri, code = data.userCode;
573
599
  show(`<div class="account-card" style="border-color:var(--accent)">
574
600
  <div class="account-card-header"><div class="account-label">Connect a Microsoft account</div></div>
@@ -580,27 +606,58 @@
580
606
  <div id="ms-connect-status" style="color:var(--text-dim);margin-top:.3rem">Waiting for sign-in… (this card expires in ~15 min)</div>
581
607
  </div>`);
582
608
  const statusEl = () => document.getElementById('ms-connect-status');
583
- const started = Date.now();
609
+ const deadline = Date.now() + ((data.expiresInSec || 900) * 1000 + 30000);
610
+ const showConnected = (label) => {
611
+ show(`<div class="loading" style="padding:.8rem;color:var(--green)">✓ Connected ${escapeHtml(label)}. Use the access toggles below to grant an agent.</div>`);
612
+ fetchConnections();
613
+ };
614
+ // On any non-'connected' terminal state ('failed' or 'unknown'),
615
+ // re-check the broker's actual account list before declaring failure:
616
+ // a new account appearing means the connect really succeeded (e.g. the
617
+ // status was lost to a web restart). Only error if nothing new landed.
618
+ // Limitations (acceptable — the in-memory 'connected' state is the
619
+ // primary signal; this is only the lost-status fallback): a concurrent
620
+ // connect of a DIFFERENT account in another tab/CLI could be mistaken
621
+ // for this one; and re-connecting an ALREADY-known account (token
622
+ // refresh) shows no new account so falls through to the error.
623
+ const settleNonConnected = async (reason) => {
624
+ const after = await fetchMicrosoftAccountEmails();
625
+ const fresh = [...after].find(a => !before.has(a));
626
+ if (fresh) { showConnected(fresh); } else { show(''); showError(reason || 'connect failed'); }
627
+ done();
628
+ };
584
629
  const poll = async () => {
585
- const sres = await fetch(`${API}/api/connections/microsoft/connect/${encodeURIComponent(data.requestId)}`, { headers: authHeaders() });
586
- const s = sres.ok ? await sres.json() : { state: 'failed', reason: `HTTP ${sres.status}` };
630
+ let s;
631
+ try {
632
+ const sres = await fetch(`${API}/api/connections/microsoft/connect/${encodeURIComponent(data.requestId)}`, { headers: authHeaders() });
633
+ s = sres.ok ? await sres.json() : { state: 'failed', reason: `HTTP ${sres.status}` };
634
+ } catch {
635
+ // Transient fetch failure — most likely the web process restarting
636
+ // mid-connect (the exact case this flow must survive). Don't die
637
+ // (that would strand msConnecting=true and lock the button): keep
638
+ // polling until the device-code deadline, then settle via the
639
+ // broker re-check (which recovers a token stored before the restart).
640
+ if (Date.now() > deadline) { await settleNonConnected('connection lost'); return; }
641
+ setTimeout(poll, 3000);
642
+ return;
643
+ }
587
644
  if (s.state === 'pending') {
588
- if (Date.now() - started > ((data.expiresInSec || 900) * 1000 + 30000)) { const e = statusEl(); if (e) e.textContent = 'Expired — click Connect to try again.'; return; }
645
+ if (Date.now() > deadline) { const e = statusEl(); if (e) e.textContent = 'Expired — click Connect to try again.'; done(); return; }
589
646
  setTimeout(poll, 3000);
590
647
  return;
591
648
  }
592
649
  if (s.state === 'connected') {
593
- show(`<div class="loading" style="padding:.8rem;color:var(--green)">✓ Connected ${escapeHtml(s.account)} (${escapeHtml(s.accountType)}). Use the access toggles below to grant an agent.</div>`);
594
- fetchConnections();
650
+ showConnected(`${s.account} (${s.accountType})`);
651
+ done();
595
652
  } else {
596
- show('');
597
- showError(s.reason || 'connect failed');
653
+ await settleNonConnected(s.reason);
598
654
  }
599
655
  };
600
656
  setTimeout(poll, 3000);
601
657
  } catch (err) {
602
658
  show('');
603
659
  showError(err.message);
660
+ done();
604
661
  }
605
662
  }
606
663
 
@@ -1165,7 +1222,7 @@
1165
1222
  <div style="margin-bottom:1.5rem">
1166
1223
  <h3 style="margin:0 0 .6rem;font-size:.95rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em">
1167
1224
  Microsoft 365
1168
- <button onclick="connectMicrosoft()" class="usage-pill primary" style="margin-left:.6rem;cursor:pointer;border:none;text-transform:none;font-weight:600">+ Connect a Microsoft account</button>
1225
+ <button id="ms-connect-btn" onclick="connectMicrosoft()" class="usage-pill primary" style="margin-left:.6rem;cursor:pointer;border:none;text-transform:none;font-weight:600">+ Connect a Microsoft account</button>
1169
1226
  </h3>
1170
1227
  <div id="ms-connect-card"></div>
1171
1228
  ${msCards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.81",
3
+ "version": "0.14.83",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52661,6 +52661,53 @@ function evaluateQuotaWatchAccount(args) {
52661
52661
  }
52662
52662
  return { kind: "skip", accountLabel: label, reason: "no-matching-transition" };
52663
52663
  }
52664
+ var FLEET_ALL_EXHAUSTED_KEY = "__fleet_all_exhausted__";
52665
+ function evaluateFleetAllExhausted(args) {
52666
+ const { accounts, prev, now } = args;
52667
+ const allExhausted = accounts.length > 0 && accounts.every((a) => a.exhausted);
52668
+ const wasAlerting = prev.lastNotifiedHealth === "throttling";
52669
+ if (allExhausted && !wasAlerting) {
52670
+ return {
52671
+ kind: "notify",
52672
+ message: buildAllExhaustedMessage(accounts, now),
52673
+ newState: { lastNotifiedHealth: "throttling", lastNotifiedAt: now },
52674
+ transition: "entered"
52675
+ };
52676
+ }
52677
+ if (!allExhausted && wasAlerting) {
52678
+ return {
52679
+ kind: "notify",
52680
+ message: buildFleetRecoveredMessage(accounts),
52681
+ newState: { lastNotifiedHealth: "healthy", lastNotifiedAt: now },
52682
+ transition: "recovered"
52683
+ };
52684
+ }
52685
+ return { kind: "skip", reason: allExhausted ? "still-all-exhausted" : "not-all-exhausted" };
52686
+ }
52687
+ function buildAllExhaustedMessage(accounts, now) {
52688
+ const resets = accounts.map((a) => a.exhausted_until).filter((x) => typeof x === "number" && x > now);
52689
+ const earliest = resets.length > 0 ? Math.min(...resets) : null;
52690
+ const resetLine = earliest ? `Earliest reset: ${formatRelative(new Date(earliest), new Date(now))}.` : `Reset time unknown (no window data).`;
52691
+ return [
52692
+ `\uD83D\uDD34 <b>All accounts exhausted</b>`,
52693
+ ``,
52694
+ `Every Anthropic account (${accounts.length}) is quota-walled \u2014 there is no healthy account to fail over to.`,
52695
+ resetLine,
52696
+ ``,
52697
+ `<i>This is self-healing: agents resume and deferred scheduled jobs run automatically once a window resets. Nothing is lost. Add headroom with <code>/auth add</code> if this recurs.</i>`
52698
+ ].join(`
52699
+ `);
52700
+ }
52701
+ function buildFleetRecoveredMessage(accounts) {
52702
+ const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
52703
+ const which = healthy.length > 0 ? ` (<code>${escapeHtml10(healthy[0])}</code>)` : "";
52704
+ return [
52705
+ `\uD83D\uDFE2 <b>Fleet recovered</b> \u2014 at least one account is healthy again${which}.`,
52706
+ ``,
52707
+ `<i>Agents are back; any deferred scheduled jobs will run on their next occurrence.</i>`
52708
+ ].join(`
52709
+ `);
52710
+ }
52664
52711
  function buildThrottlingMessage(agentName3, snap) {
52665
52712
  const q = snap.quota;
52666
52713
  const fiveStr = fmtPct(q.fiveHourUtilizationPct);
@@ -52810,11 +52857,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52810
52857
  }
52811
52858
 
52812
52859
  // ../src/build-info.ts
52813
- var VERSION = "0.14.81";
52814
- var COMMIT_SHA = "4ac9cc7d";
52815
- var COMMIT_DATE = "2026-06-07T10:43:55+10:00";
52860
+ var VERSION = "0.14.83";
52861
+ var COMMIT_SHA = "057ab099";
52862
+ var COMMIT_DATE = "2026-06-07T12:57:08+10:00";
52816
52863
  var LATEST_PR = null;
52817
- var COMMITS_AHEAD_OF_TAG = 2;
52864
+ var COMMITS_AHEAD_OF_TAG = 4;
52818
52865
 
52819
52866
  // gateway/boot-version.ts
52820
52867
  function formatRelativeAgo(iso) {
@@ -61017,6 +61064,31 @@ async function runQuotaWatch() {
61017
61064
  let watchState = loadQuotaWatchState(stateDir);
61018
61065
  const now = Date.now();
61019
61066
  const access = loadAccess();
61067
+ {
61068
+ const fleetPrev = watchState[FLEET_ALL_EXHAUSTED_KEY] ?? emptyAccountState();
61069
+ const fleetDecision = evaluateFleetAllExhausted({
61070
+ accounts: listStateData.accounts,
61071
+ prev: fleetPrev,
61072
+ now
61073
+ });
61074
+ if (fleetDecision.kind === "notify") {
61075
+ for (const chat_id of access.allowFrom) {
61076
+ await swallowingApiCall(() => bot.api.sendMessage(chat_id, fleetDecision.message, {
61077
+ parse_mode: "HTML",
61078
+ link_preview_options: { is_disabled: true }
61079
+ }), { chat_id, verb: "quota-watch.fleet-all-exhausted" });
61080
+ }
61081
+ watchState = patchQuotaWatchState(watchState, FLEET_ALL_EXHAUSTED_KEY, fleetDecision.newState);
61082
+ try {
61083
+ saveQuotaWatchState(stateDir, watchState);
61084
+ } catch (err) {
61085
+ process.stderr.write(`telegram gateway: quota-watch: fleet-state save failed: ${err}
61086
+ `);
61087
+ }
61088
+ process.stderr.write(`telegram gateway: quota-watch: fleet all-exhausted ${fleetDecision.transition}
61089
+ `);
61090
+ }
61091
+ }
61020
61092
  const pendingTransitions = [];
61021
61093
  const labelToSnapIndex = new Map(snapshots.map((s, i) => [s.label, i]));
61022
61094
  for (const snap of snapshots) {
@@ -412,6 +412,8 @@ import {
412
412
  } from '../credits-watch.js'
413
413
  import {
414
414
  evaluateQuotaWatchAccount,
415
+ evaluateFleetAllExhausted,
416
+ FLEET_ALL_EXHAUSTED_KEY,
415
417
  loadQuotaWatchState,
416
418
  saveQuotaWatchState,
417
419
  patchQuotaWatchState,
@@ -14805,6 +14807,44 @@ async function runQuotaWatch(): Promise<void> {
14805
14807
  const now = Date.now()
14806
14808
  const access = loadAccess()
14807
14809
 
14810
+ // Fleet-wide all-exhausted check FIRST — must run before the per-account
14811
+ // early-return below. When every account is exhausted, the per-account loop
14812
+ // produces only 'blocked' skips → pendingTransitions empty → early return;
14813
+ // so this fleet-level alert (the one the trigger-based all-blocked card
14814
+ // misses during quiet periods / for the consumer+cron paths) would never
14815
+ // fire if placed after. Authoritative source: broker `exhausted` flags.
14816
+ {
14817
+ const fleetPrev = watchState[FLEET_ALL_EXHAUSTED_KEY] ?? emptyAccountState()
14818
+ const fleetDecision = evaluateFleetAllExhausted({
14819
+ accounts: listStateData.accounts,
14820
+ prev: fleetPrev,
14821
+ now,
14822
+ })
14823
+ if (fleetDecision.kind === 'notify') {
14824
+ for (const chat_id of access.allowFrom) {
14825
+ await swallowingApiCall(
14826
+ () =>
14827
+ bot.api.sendMessage(chat_id, fleetDecision.message, {
14828
+ parse_mode: 'HTML',
14829
+ link_preview_options: { is_disabled: true },
14830
+ }),
14831
+ { chat_id, verb: 'quota-watch.fleet-all-exhausted' },
14832
+ )
14833
+ }
14834
+ // Persist immediately — the per-account early-return path below would
14835
+ // otherwise drop this flag change (edge-trigger would re-fire next poll).
14836
+ watchState = patchQuotaWatchState(watchState, FLEET_ALL_EXHAUSTED_KEY, fleetDecision.newState)
14837
+ try {
14838
+ saveQuotaWatchState(stateDir, watchState)
14839
+ } catch (err) {
14840
+ process.stderr.write(`telegram gateway: quota-watch: fleet-state save failed: ${err}\n`)
14841
+ }
14842
+ process.stderr.write(
14843
+ `telegram gateway: quota-watch: fleet all-exhausted ${fleetDecision.transition}\n`,
14844
+ )
14845
+ }
14846
+ }
14847
+
14808
14848
  // First pass: evaluate all accounts against cached state. Collect
14809
14849
  // labels that need a live probe (i.e. accounts with a detected transition
14810
14850
  // that we're about to notify about). We probe those to get fresh
@@ -160,6 +160,99 @@ export function evaluateQuotaWatchAccount(args: {
160
160
  return { kind: "skip", accountLabel: label, reason: "no-matching-transition" };
161
161
  }
162
162
 
163
+ // ─── Fleet-level: all accounts exhausted ───────────────────────────────────────
164
+
165
+ /**
166
+ * Reserved key under which the fleet-wide "all accounts exhausted" alert state
167
+ * is stored in the same quota-watch.json map. Not a valid account label (emails
168
+ * can't contain this), so it never collides with a per-account entry, and the
169
+ * per-account loop (which iterates account snapshots, not state-map keys) never
170
+ * sees it. Encoded as a QuotaWatchAccountState so the existing load validator
171
+ * accepts it: lastNotifiedHealth "throttling" = currently alerting all-exhausted,
172
+ * "healthy"/null = not. Backward-compatible — old files simply lack the key.
173
+ */
174
+ export const FLEET_ALL_EXHAUSTED_KEY = "__fleet_all_exhausted__";
175
+
176
+ export type FleetAllExhaustedDecision =
177
+ | { kind: "notify"; message: string; newState: QuotaWatchAccountState; transition: "entered" | "recovered" }
178
+ | { kind: "skip"; reason: string };
179
+
180
+ /**
181
+ * Fleet-wide all-exhausted alert (edge-triggered).
182
+ *
183
+ * Fires ONCE when every account enters the broker's exhausted state (no healthy
184
+ * account to fail over to — agents go quiet, crons defer, consumers/hindsight
185
+ * silently serve an exhausted account), and ONCE on recovery. This catches the
186
+ * cases the trigger-based interactive all-blocked card misses: a quiet period
187
+ * (no agent happens to 429 into the wall) and the consumer/cron paths.
188
+ *
189
+ * Authoritative source: the broker's per-account `exhausted` flag (set by
190
+ * mark-exhausted via failover + the consumer sensor), NOT probe-derived health
191
+ * — so there is no probe-failure false-alarm. Requires at least one account;
192
+ * an empty fleet never alerts.
193
+ */
194
+ export function evaluateFleetAllExhausted(args: {
195
+ accounts: Array<{ label: string; exhausted: boolean; exhausted_until?: number }>;
196
+ prev: QuotaWatchAccountState;
197
+ now: number;
198
+ }): FleetAllExhaustedDecision {
199
+ const { accounts, prev, now } = args;
200
+ const allExhausted = accounts.length > 0 && accounts.every((a) => a.exhausted);
201
+ // "throttling" doubles as the "currently alerting all-exhausted" marker.
202
+ const wasAlerting = prev.lastNotifiedHealth === "throttling";
203
+
204
+ if (allExhausted && !wasAlerting) {
205
+ return {
206
+ kind: "notify",
207
+ message: buildAllExhaustedMessage(accounts, now),
208
+ newState: { lastNotifiedHealth: "throttling", lastNotifiedAt: now },
209
+ transition: "entered",
210
+ };
211
+ }
212
+ if (!allExhausted && wasAlerting) {
213
+ return {
214
+ kind: "notify",
215
+ message: buildFleetRecoveredMessage(accounts),
216
+ newState: { lastNotifiedHealth: "healthy", lastNotifiedAt: now },
217
+ transition: "recovered",
218
+ };
219
+ }
220
+ return { kind: "skip", reason: allExhausted ? "still-all-exhausted" : "not-all-exhausted" };
221
+ }
222
+
223
+ function buildAllExhaustedMessage(
224
+ accounts: Array<{ label: string; exhausted_until?: number }>,
225
+ now: number,
226
+ ): string {
227
+ const resets = accounts
228
+ .map((a) => a.exhausted_until)
229
+ .filter((x): x is number => typeof x === "number" && x > now);
230
+ const earliest = resets.length > 0 ? Math.min(...resets) : null;
231
+ const resetLine = earliest
232
+ ? `Earliest reset: ${formatRelative(new Date(earliest), new Date(now))}.`
233
+ : `Reset time unknown (no window data).`;
234
+ return [
235
+ `🔴 <b>All accounts exhausted</b>`,
236
+ ``,
237
+ `Every Anthropic account (${accounts.length}) is quota-walled — there is no healthy account to fail over to.`,
238
+ resetLine,
239
+ ``,
240
+ `<i>This is self-healing: agents resume and deferred scheduled jobs run automatically once a window resets. Nothing is lost. Add headroom with <code>/auth add</code> if this recurs.</i>`,
241
+ ].join("\n");
242
+ }
243
+
244
+ function buildFleetRecoveredMessage(
245
+ accounts: Array<{ label: string; exhausted: boolean }>,
246
+ ): string {
247
+ const healthy = accounts.filter((a) => !a.exhausted).map((a) => a.label);
248
+ const which = healthy.length > 0 ? ` (<code>${escapeHtml(healthy[0]!)}</code>)` : "";
249
+ return [
250
+ `🟢 <b>Fleet recovered</b> — at least one account is healthy again${which}.`,
251
+ ``,
252
+ `<i>Agents are back; any deferred scheduled jobs will run on their next occurrence.</i>`,
253
+ ].join("\n");
254
+ }
255
+
163
256
  // ─── Message builders ─────────────────────────────────────────────────────────
164
257
 
165
258
  function buildThrottlingMessage(agentName: string, snap: AccountSnapshot): string {
@@ -12,6 +12,7 @@ import { tmpdir } from "os";
12
12
  import { join } from "path";
13
13
  import {
14
14
  evaluateQuotaWatchAccount,
15
+ evaluateFleetAllExhausted,
15
16
  loadQuotaWatchState,
16
17
  saveQuotaWatchState,
17
18
  patchQuotaWatchState,
@@ -364,3 +365,74 @@ describe("patchQuotaWatchState", () => {
364
365
  expect(current["bob@example.com"]).toBeUndefined();
365
366
  });
366
367
  });
368
+
369
+ describe("evaluateFleetAllExhausted", () => {
370
+ const notAlerting = { lastNotifiedHealth: null, lastNotifiedAt: 0 };
371
+ const alerting = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: 1000 };
372
+
373
+ it("notifies (entered) when every account is exhausted and we weren't alerting", () => {
374
+ const d = evaluateFleetAllExhausted({
375
+ accounts: [
376
+ { label: "a", exhausted: true, exhausted_until: 5_000 },
377
+ { label: "b", exhausted: true, exhausted_until: 9_000 },
378
+ ],
379
+ prev: notAlerting,
380
+ now: 1_000,
381
+ });
382
+ expect(d.kind).toBe("notify");
383
+ if (d.kind === "notify") {
384
+ expect(d.transition).toBe("entered");
385
+ expect(d.newState.lastNotifiedHealth).toBe("throttling");
386
+ expect(d.message).toContain("All accounts exhausted");
387
+ // earliest reset is the 5_000 one
388
+ expect(d.message).toContain("Earliest reset");
389
+ }
390
+ });
391
+
392
+ it("skips (still) when all exhausted and already alerting — no re-spam", () => {
393
+ const d = evaluateFleetAllExhausted({
394
+ accounts: [{ label: "a", exhausted: true }, { label: "b", exhausted: true }],
395
+ prev: alerting,
396
+ now: 2_000,
397
+ });
398
+ expect(d.kind).toBe("skip");
399
+ });
400
+
401
+ it("notifies (recovered) when one account frees after we were alerting", () => {
402
+ const d = evaluateFleetAllExhausted({
403
+ accounts: [{ label: "a", exhausted: false }, { label: "b", exhausted: true }],
404
+ prev: alerting,
405
+ now: 3_000,
406
+ });
407
+ expect(d.kind).toBe("notify");
408
+ if (d.kind === "notify") {
409
+ expect(d.transition).toBe("recovered");
410
+ expect(d.newState.lastNotifiedHealth).toBe("healthy");
411
+ expect(d.message).toContain("Fleet recovered");
412
+ expect(d.message).toContain("a"); // names the healthy account
413
+ }
414
+ });
415
+
416
+ it("skips (not-all) when some account is healthy and we weren't alerting", () => {
417
+ const d = evaluateFleetAllExhausted({
418
+ accounts: [{ label: "a", exhausted: false }, { label: "b", exhausted: true }],
419
+ prev: notAlerting,
420
+ now: 4_000,
421
+ });
422
+ expect(d.kind).toBe("skip");
423
+ });
424
+
425
+ it("never alerts on an empty fleet", () => {
426
+ expect(evaluateFleetAllExhausted({ accounts: [], prev: notAlerting, now: 1 }).kind).toBe("skip");
427
+ });
428
+
429
+ it("shows reset-unknown when no exhausted_until is present", () => {
430
+ const d = evaluateFleetAllExhausted({
431
+ accounts: [{ label: "a", exhausted: true }],
432
+ prev: notAlerting,
433
+ now: 1_000,
434
+ });
435
+ expect(d.kind).toBe("notify");
436
+ if (d.kind === "notify") expect(d.message).toContain("Reset time unknown");
437
+ });
438
+ });