switchroom 0.14.83 → 0.14.84

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.
@@ -176,6 +176,71 @@ function sendKeys2(agentName, keys) {
176
176
 
177
177
  // src/agents/wedge-watchdog.ts
178
178
  var WEDGE_FOOTER_SIGNATURE = /(?=[\s\S]*[Ee]sc(?:ape)?[^\n]*cancel)(?=[\s\S]*(?:to select|to navigate|\u2191\/\u2193))/;
179
+ var RATE_LIMIT_MENU_SIGNATURE = /(?=[\s\S]*\/rate-limit-options)(?=[\s\S]*(?:Switch to usage credits|Upgrade your plan))/;
180
+ var MONTHS = {
181
+ jan: 0,
182
+ feb: 1,
183
+ mar: 2,
184
+ apr: 3,
185
+ may: 4,
186
+ jun: 5,
187
+ jul: 6,
188
+ aug: 7,
189
+ sep: 8,
190
+ oct: 9,
191
+ nov: 10,
192
+ dec: 11
193
+ };
194
+ function parseWeeklyReset(text, nowMs = Date.now()) {
195
+ const m = text.match(/resets\s+([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*(?:\(([^)]+)\))?/i);
196
+ if (!m)
197
+ return null;
198
+ const mon = MONTHS[m[1].slice(0, 3).toLowerCase()];
199
+ if (mon === undefined)
200
+ return null;
201
+ const day = Number(m[2]);
202
+ let hour = Number(m[3]);
203
+ const minute = m[4] ? Number(m[4]) : 0;
204
+ const ampm = m[5]?.toLowerCase();
205
+ if (ampm === "pm" && hour < 12)
206
+ hour += 12;
207
+ if (ampm === "am" && hour === 12)
208
+ hour = 0;
209
+ if (!Number.isFinite(day) || !Number.isFinite(hour) || day < 1 || day > 31 || hour > 23) {
210
+ return null;
211
+ }
212
+ const tz = m[6]?.trim();
213
+ const probeYear = new Date(nowMs).getUTCFullYear();
214
+ for (const year of [probeYear, probeYear + 1]) {
215
+ const epoch = wallClockToEpoch(year, mon, day, hour, minute, tz);
216
+ if (epoch != null && epoch > nowMs - 60000)
217
+ return epoch;
218
+ }
219
+ return null;
220
+ }
221
+ function wallClockToEpoch(year, month, day, hour, minute, tz) {
222
+ const asUtc = Date.UTC(year, month, day, hour, minute, 0);
223
+ if (!tz)
224
+ return asUtc;
225
+ try {
226
+ const fmt = new Intl.DateTimeFormat("en-US", {
227
+ timeZone: tz,
228
+ year: "numeric",
229
+ month: "2-digit",
230
+ day: "2-digit",
231
+ hour: "2-digit",
232
+ minute: "2-digit",
233
+ second: "2-digit",
234
+ hour12: false
235
+ });
236
+ const parts = Object.fromEntries(fmt.formatToParts(new Date(asUtc)).filter((p) => p.type !== "literal").map((p) => [p.type, p.value]));
237
+ const shown = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second));
238
+ const offset = shown - asUtc;
239
+ return asUtc - offset;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
179
244
  var DEFAULT_POLL_MS2 = 5000;
180
245
  var DEFAULT_STABILITY_THRESHOLD = 3;
181
246
  var DEFAULT_COOLDOWN_MS = 60000;
@@ -193,6 +258,9 @@ async function runWedgeWatchdog(opts) {
193
258
  const cooldownMs = opts.cooldownMs ?? DEFAULT_COOLDOWN_MS;
194
259
  const deferToPrompts = opts.deferToPrompts ?? PROMPTS2;
195
260
  const signature = opts.wedgeSignature ?? WEDGE_FOOTER_SIGNATURE;
261
+ const rateLimitSignature = opts.rateLimitSignature === null ? null : opts.rateLimitSignature ?? RATE_LIMIT_MENU_SIGNATURE;
262
+ const onRateLimitMenu = opts.onRateLimitMenu;
263
+ const parseReset = opts.parseReset ?? parseWeeklyReset;
196
264
  const maxPolls = opts.maxPolls ?? Number.POSITIVE_INFINITY;
197
265
  const now = opts.now ?? Date.now;
198
266
  const sleep = opts.sleep ?? defaultSleep2;
@@ -202,6 +270,7 @@ async function runWedgeWatchdog(opts) {
202
270
  let lastKey = null;
203
271
  let cooldownUntil = 0;
204
272
  let fires = 0;
273
+ let rateLimitFires = 0;
205
274
  let polls = 0;
206
275
  while (polls < maxPolls) {
207
276
  polls++;
@@ -212,8 +281,38 @@ async function runWedgeWatchdog(opts) {
212
281
  console.error(`[wedge-watchdog] ${opts.agentName}: capture threw: ${err.message}`);
213
282
  text = "";
214
283
  }
215
- const isBlockingModal = !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
216
- if (isBlockingModal) {
284
+ const isRateLimitMenu = !!text && rateLimitSignature !== null && rateLimitSignature.test(text);
285
+ const isBlockingModal = !isRateLimitMenu && !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
286
+ if (isRateLimitMenu) {
287
+ const key = stabilityKey(text);
288
+ if (key === lastKey) {
289
+ stableCount++;
290
+ } else {
291
+ stableCount = 1;
292
+ lastKey = key;
293
+ }
294
+ if (stableCount >= stabilityThreshold && now() >= cooldownUntil) {
295
+ const resetAt = parseReset(text, now());
296
+ console.error(`[wedge-watchdog] ${opts.agentName}: rate-limit (weekly-quota) menu detected ` + `after ${stableCount} stable polls \u2014 signalling failover` + (resetAt != null ? ` (resets ${new Date(resetAt).toISOString()})` : " (reset unparsed)") + ` then parking with Esc`);
297
+ if (onRateLimitMenu) {
298
+ try {
299
+ onRateLimitMenu(opts.agentName, resetAt);
300
+ } catch (err) {
301
+ console.error(`[wedge-watchdog] ${opts.agentName}: onRateLimitMenu threw: ${err.message}`);
302
+ }
303
+ }
304
+ try {
305
+ send(opts.agentName, ["Escape"]);
306
+ } catch (err) {
307
+ console.error(`[wedge-watchdog] ${opts.agentName}: send threw: ${err.message}`);
308
+ }
309
+ fires++;
310
+ rateLimitFires++;
311
+ cooldownUntil = now() + cooldownMs;
312
+ stableCount = 0;
313
+ lastKey = null;
314
+ }
315
+ } else if (isBlockingModal) {
217
316
  const key = stabilityKey(text);
218
317
  if (key === lastKey) {
219
318
  stableCount++;
@@ -239,7 +338,74 @@ async function runWedgeWatchdog(opts) {
239
338
  }
240
339
  await sleep(pollIntervalMs);
241
340
  }
242
- return { fires, polls, reason: "max-polls" };
341
+ return { fires, rateLimitFires, polls, reason: "max-polls" };
342
+ }
343
+
344
+ // src/agents/rate-limit-signal.ts
345
+ import { createConnection } from "node:net";
346
+ import { join } from "node:path";
347
+ function resolveGatewaySocketPath() {
348
+ const stateDir = process.env.TELEGRAM_STATE_DIR ?? "/state/agent/telegram";
349
+ return process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(stateDir, "gateway.sock");
350
+ }
351
+ function signalQuotaWall(agentName, resetAt, opts = {}) {
352
+ const socketPath = opts.socketPath ?? resolveGatewaySocketPath();
353
+ const connect = opts._connect ?? ((p) => createConnection(p));
354
+ const log = opts._log ?? ((m) => console.error(m));
355
+ const connectTimeoutMs = opts.connectTimeoutMs ?? 5000;
356
+ const msg = { type: "quota_wall_detected", agentName };
357
+ if (resetAt != null)
358
+ msg.resetAt = resetAt;
359
+ const line = JSON.stringify(msg) + `
360
+ `;
361
+ return new Promise((resolve) => {
362
+ let settled = false;
363
+ const done = (ok) => {
364
+ if (settled)
365
+ return;
366
+ settled = true;
367
+ resolve(ok);
368
+ };
369
+ let sock;
370
+ try {
371
+ sock = connect(socketPath);
372
+ } catch (err) {
373
+ log(`[rate-limit-signal] ${agentName}: connect threw: ${err.message}`);
374
+ done(false);
375
+ return;
376
+ }
377
+ const timer = setTimeout(() => {
378
+ log(`[rate-limit-signal] ${agentName}: connect timed out`);
379
+ try {
380
+ sock.destroy();
381
+ } catch {}
382
+ done(false);
383
+ }, connectTimeoutMs);
384
+ sock.on("connect", () => {
385
+ try {
386
+ sock.write(line, () => {
387
+ clearTimeout(timer);
388
+ try {
389
+ sock.end();
390
+ } catch {}
391
+ log(`[rate-limit-signal] ${agentName}: quota_wall_detected sent`);
392
+ done(true);
393
+ });
394
+ } catch (err) {
395
+ clearTimeout(timer);
396
+ log(`[rate-limit-signal] ${agentName}: write threw: ${err.message}`);
397
+ try {
398
+ sock.destroy();
399
+ } catch {}
400
+ done(false);
401
+ }
402
+ });
403
+ sock.on("error", (err) => {
404
+ clearTimeout(timer);
405
+ log(`[rate-limit-signal] ${agentName}: socket error: ${err.message}`);
406
+ done(false);
407
+ });
408
+ });
243
409
  }
244
410
 
245
411
  // src/cli/autoaccept-poll.ts
@@ -259,10 +425,17 @@ async function main() {
259
425
  console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog disabled (SWITCHROOM_WEDGE_WATCHDOG=0) \u2014 exiting after boot phase`);
260
426
  process.exit(0);
261
427
  }
428
+ const rateLimitDetect = process.env.SWITCHROOM_RATE_LIMIT_DETECT !== "0";
262
429
  try {
263
- console.error(`[autoaccept-poll] ${agentName}: entering wedge-watchdog (continuous)`);
264
- const res = await runWedgeWatchdog({ agentName });
265
- console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires}`);
430
+ console.error(`[autoaccept-poll] ${agentName}: entering wedge-watchdog (continuous)` + (rateLimitDetect ? " +rate-limit-detect" : " (rate-limit-detect OFF)"));
431
+ const res = await runWedgeWatchdog({
432
+ agentName,
433
+ rateLimitSignature: rateLimitDetect ? undefined : null,
434
+ onRateLimitMenu: rateLimitDetect ? (name, resetAt) => {
435
+ signalQuotaWall(name, resetAt);
436
+ } : undefined
437
+ });
438
+ console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires} rateLimitFires=${res.rateLimitFires}`);
266
439
  } catch (err) {
267
440
  console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog unexpected throw: ${err.message}`);
268
441
  }
@@ -49815,8 +49815,8 @@ var {
49815
49815
  } = import__.default;
49816
49816
 
49817
49817
  // src/build-info.ts
49818
- var VERSION = "0.14.83";
49819
- var COMMIT_SHA = "057ab099";
49818
+ var VERSION = "0.14.84";
49819
+ var COMMIT_SHA = "af97bc41";
49820
49820
 
49821
49821
  // src/cli/agent.ts
49822
49822
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.83",
3
+ "version": "0.14.84",
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": {
@@ -46487,6 +46487,13 @@ function validateClientMessage(msg) {
46487
46487
  const inb = m.inbound;
46488
46488
  return inb.type === "inbound" && typeof inb.chatId === "string" && inb.chatId.length > 0 && typeof inb.text === "string" && typeof inb.messageId === "number" && typeof inb.user === "string" && typeof inb.userId === "number" && typeof inb.ts === "number" && typeof inb.meta === "object" && inb.meta !== null;
46489
46489
  }
46490
+ case "quota_wall_detected": {
46491
+ if (typeof m.agentName !== "string" || !AGENT_NAME_RE3.test(m.agentName))
46492
+ return false;
46493
+ if (m.resetAt !== undefined && (typeof m.resetAt !== "number" || !Number.isFinite(m.resetAt)))
46494
+ return false;
46495
+ return true;
46496
+ }
46490
46497
  case "request_config_approval": {
46491
46498
  if (typeof m.requestId !== "string" || m.requestId.length === 0 || m.requestId.length > 64)
46492
46499
  return false;
@@ -46548,6 +46555,7 @@ function createIpcServer(options) {
46548
46555
  onOperatorEvent,
46549
46556
  onPtyPartial,
46550
46557
  onInjectInbound,
46558
+ onQuotaWallDetected,
46551
46559
  onRequestDriveApproval,
46552
46560
  onRequestMs365Approval,
46553
46561
  onRequestConfigApproval,
@@ -46632,6 +46640,10 @@ function createIpcServer(options) {
46632
46640
  if (onInjectInbound)
46633
46641
  onInjectInbound(client3, msg);
46634
46642
  break;
46643
+ case "quota_wall_detected":
46644
+ if (onQuotaWallDetected)
46645
+ onQuotaWallDetected(client3, msg);
46646
+ break;
46635
46647
  case "request_drive_approval":
46636
46648
  if (onRequestDriveApproval) {
46637
46649
  onRequestDriveApproval(client3, msg).catch((err) => {
@@ -52857,11 +52869,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52857
52869
  }
52858
52870
 
52859
52871
  // ../src/build-info.ts
52860
- var VERSION = "0.14.83";
52861
- var COMMIT_SHA = "057ab099";
52862
- var COMMIT_DATE = "2026-06-07T12:57:08+10:00";
52872
+ var VERSION = "0.14.84";
52873
+ var COMMIT_SHA = "af97bc41";
52874
+ var COMMIT_DATE = "2026-06-07T13:38:19+10:00";
52863
52875
  var LATEST_PR = null;
52864
- var COMMITS_AHEAD_OF_TAG = 4;
52876
+ var COMMITS_AHEAD_OF_TAG = 2;
52865
52877
 
52866
52878
  // gateway/boot-version.ts
52867
52879
  function formatRelativeAgo(iso) {
@@ -56158,6 +56170,13 @@ var ipcServer = createIpcServer({
56158
56170
  pendingInboundBuffer.push(msg.agentName, msg.inbound);
56159
56171
  }
56160
56172
  },
56173
+ onQuotaWallDetected(_client, msg) {
56174
+ const WEEKLY_MS = 604800000;
56175
+ const untilMs = typeof msg.resetAt === "number" && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now() ? msg.resetAt : Date.now() + WEEKLY_MS;
56176
+ process.stderr.write(`telegram gateway: quota_wall_detected agent=${msg.agentName} until=${new Date(untilMs).toISOString()}` + (msg.resetAt == null ? " (reset unparsed \u2192 +7d default)" : "") + ` \u2014 triggering fleet auto-fallback
56177
+ `);
56178
+ fireFleetAutoFallback(msg.agentName, untilMs);
56179
+ },
56161
56180
  log: (msg) => process.stderr.write(`telegram gateway: ipc \u2014 ${msg}
56162
56181
  `)
56163
56182
  });
@@ -60961,13 +60980,13 @@ var fleetFallbackGate = createFleetFallbackGate({
60961
60980
  function wouldFireFleetAutoFallback() {
60962
60981
  return fleetFallbackGate.wouldFire();
60963
60982
  }
60964
- async function fireFleetAutoFallback(triggerAgent) {
60965
- return fleetFallbackGate.fire(() => doFireFleetAutoFallback(triggerAgent), (err) => {
60983
+ async function fireFleetAutoFallback(triggerAgent, untilMs) {
60984
+ return fleetFallbackGate.fire(() => doFireFleetAutoFallback(triggerAgent, untilMs), (err) => {
60966
60985
  process.stderr.write(`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${err?.message ?? err}
60967
60986
  `);
60968
60987
  });
60969
60988
  }
60970
- async function doFireFleetAutoFallback(triggerAgent) {
60989
+ async function doFireFleetAutoFallback(triggerAgent, untilMs) {
60971
60990
  try {
60972
60991
  const client3 = await getAuthBrokerClient(triggerAgent);
60973
60992
  if (!client3) {
@@ -60986,7 +61005,7 @@ async function doFireFleetAutoFallback(triggerAgent) {
60986
61005
  state: state4,
60987
61006
  quotas,
60988
61007
  failover: async () => {
60989
- const r = await client3.markExhausted();
61008
+ const r = await client3.markExhausted(untilMs);
60990
61009
  return { rolledTo: r.rolledTo ?? null, rolled: r.rolled };
60991
61010
  },
60992
61011
  triggerAgent,
@@ -348,6 +348,7 @@ import type {
348
348
  PtyPartialForward,
349
349
  InboundMessage,
350
350
  InjectInboundMessage,
351
+ QuotaWallDetectedMessage,
351
352
  PermissionEvent,
352
353
  } from './ipc-protocol.js'
353
354
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
@@ -6259,6 +6260,30 @@ const ipcServer: IpcServer = createIpcServer({
6259
6260
  }
6260
6261
  },
6261
6262
 
6263
+ // The wedge-watchdog detected claude's /rate-limit-options weekly-quota menu
6264
+ // (a TUI wall that never produced a 429, so the inference-path auto-fallback
6265
+ // never fired). Trigger the SAME fleet auto-fallback the 429 path uses,
6266
+ // threading the parsed weekly reset as markExhausted's `until` — with a
6267
+ // weekly-scale FALLBACK when the sidecar couldn't parse it (resetAt absent):
6268
+ // passing undefined would let markExhausted use its ~5h default, which would
6269
+ // un-exhaust a weekly-walled account and re-wedge it within hours. The
6270
+ // existing chain handles the rest (roll to a fallback subscription account,
6271
+ // or the all-exhausted operator alert when none has quota). Fire-and-forget.
6272
+ onQuotaWallDetected(_client: IpcClient, msg: QuotaWallDetectedMessage) {
6273
+ const WEEKLY_MS = 7 * 24 * 60 * 60 * 1000
6274
+ const untilMs =
6275
+ typeof msg.resetAt === 'number' && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now()
6276
+ ? msg.resetAt
6277
+ : Date.now() + WEEKLY_MS
6278
+ process.stderr.write(
6279
+ `telegram gateway: quota_wall_detected agent=${msg.agentName} ` +
6280
+ `until=${new Date(untilMs).toISOString()}` +
6281
+ (msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
6282
+ ' — triggering fleet auto-fallback\n',
6283
+ )
6284
+ void fireFleetAutoFallback(msg.agentName, untilMs)
6285
+ },
6286
+
6262
6287
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
6263
6288
  })
6264
6289
 
@@ -14605,9 +14630,9 @@ function wouldFireFleetAutoFallback(): boolean {
14605
14630
  * so the user sees the outcome inline with the original "Model
14606
14631
  * unavailable" card.
14607
14632
  */
14608
- async function fireFleetAutoFallback(triggerAgent: string): Promise<void> {
14633
+ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<void> {
14609
14634
  return fleetFallbackGate.fire(
14610
- () => doFireFleetAutoFallback(triggerAgent),
14635
+ () => doFireFleetAutoFallback(triggerAgent, untilMs),
14611
14636
  (err) => {
14612
14637
  process.stderr.write(
14613
14638
  `telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
@@ -14620,7 +14645,7 @@ async function fireFleetAutoFallback(triggerAgent: string): Promise<void> {
14620
14645
  * user-visible announcement was broadcast). False on no-op /
14621
14646
  * error / idempotent-skip — caller uses this to decide whether to
14622
14647
  * arm the post-fire suppression window. */
14623
- async function doFireFleetAutoFallback(triggerAgent: string): Promise<boolean> {
14648
+ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<boolean> {
14624
14649
  try {
14625
14650
  const client = await getAuthBrokerClient(triggerAgent)
14626
14651
  if (!client) {
@@ -14655,7 +14680,11 @@ async function doFireFleetAutoFallback(triggerAgent: string): Promise<boolean> {
14655
14680
  // operator is explicitly choosing, and is admin); only this automatic
14656
14681
  // path moves to the non-admin verb.
14657
14682
  failover: async () => {
14658
- const r = await client.markExhausted()
14683
+ // The 429 inference path passes no `until` (broker ~5h default). The
14684
+ // rate-limit-MENU path (quota_wall_detected) passes the parsed WEEKLY
14685
+ // reset, so the walled account isn't re-probed (and re-wedged) within
14686
+ // the 5h default while it's weekly-capped.
14687
+ const r = await client.markExhausted(untilMs)
14659
14688
  return { rolledTo: r.rolledTo ?? null, rolled: r.rolled }
14660
14689
  },
14661
14690
  triggerAgent,
@@ -393,6 +393,26 @@ export interface RequestConfigFinalizeMessage {
393
393
  detail?: string;
394
394
  }
395
395
 
396
+ /**
397
+ * The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
398
+ * weekly-quota menu (a TUI wall that never produced a 429 the gateway could
399
+ * see). Asks the gateway to trigger the EXISTING account-failover chain
400
+ * (markExhausted → roll to a fallback subscription account, or the
401
+ * all-exhausted operator alert). Fire-and-forget; no reply.
402
+ *
403
+ * Trust model (same as inject_inbound): the socket is per-agent inside the
404
+ * container, but `agentName` is still validated server-side and never trusted
405
+ * to authorize anything beyond triggering the agent's own failover.
406
+ */
407
+ export interface QuotaWallDetectedMessage {
408
+ type: "quota_wall_detected";
409
+ agentName: string;
410
+ /** Parsed weekly-reset epoch-ms. Omitted when the sidecar couldn't parse it;
411
+ * the gateway then uses a weekly-scale default for markExhausted's `until`
412
+ * (NOT the ~5h default, which would un-exhaust a weekly wall and re-wedge). */
413
+ resetAt?: number;
414
+ }
415
+
396
416
  export type ClientToGateway =
397
417
  | RegisterMessage
398
418
  | ToolCallMessage
@@ -407,4 +427,5 @@ export type ClientToGateway =
407
427
  | RequestDriveApprovalMessage
408
428
  | RequestMs365ApprovalMessage
409
429
  | RequestConfigApprovalMessage
410
- | RequestConfigFinalizeMessage;
430
+ | RequestConfigFinalizeMessage
431
+ | QuotaWallDetectedMessage;
@@ -4,6 +4,7 @@ import type {
4
4
  GatewayToClient,
5
5
  HeartbeatMessage,
6
6
  InjectInboundMessage,
7
+ QuotaWallDetectedMessage,
7
8
  OperatorEventForward,
8
9
  PermissionRequestForward,
9
10
  PtyPartialForward,
@@ -44,6 +45,14 @@ export interface IpcServerOptions {
44
45
  * inline scheduler simply ignore inject_inbound messages.
45
46
  */
46
47
  onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
48
+ /**
49
+ * The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
50
+ * weekly-quota menu (no 429 ever reached the gateway). Handler is expected to
51
+ * trigger the existing fleet auto-fallback for `msg.agentName`, threading
52
+ * `msg.resetAt` as the markExhausted `until`. Fire-and-forget; gateways that
53
+ * don't run failover simply ignore it.
54
+ */
55
+ onQuotaWallDetected?: (client: IpcClient, msg: QuotaWallDetectedMessage) => void;
47
56
  /**
48
57
  * RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
49
58
  * to register a kernel approval request + post a diff-preview
@@ -237,6 +246,15 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
237
246
  && typeof inb.meta === "object"
238
247
  && inb.meta !== null;
239
248
  }
249
+ case "quota_wall_detected": {
250
+ // wedge-watchdog detected the /rate-limit-options weekly-quota menu.
251
+ if (typeof m.agentName !== "string"
252
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
253
+ // resetAt optional; when present it must be a finite epoch-ms.
254
+ if (m.resetAt !== undefined
255
+ && (typeof m.resetAt !== "number" || !Number.isFinite(m.resetAt as number))) return false;
256
+ return true;
257
+ }
240
258
  case "request_config_approval": {
241
259
  // #1623 — hostd-initiated config-edit approval card. Wire shape
242
260
  // only; the handler module validates the diff content.
@@ -317,6 +335,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
317
335
  onOperatorEvent,
318
336
  onPtyPartial,
319
337
  onInjectInbound,
338
+ onQuotaWallDetected,
320
339
  onRequestDriveApproval,
321
340
  onRequestMs365Approval,
322
341
  onRequestConfigApproval,
@@ -425,6 +444,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
425
444
  case "inject_inbound":
426
445
  if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
427
446
  break;
447
+ case "quota_wall_detected":
448
+ if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
449
+ break;
428
450
  case "request_drive_approval":
429
451
  if (onRequestDriveApproval) {
430
452
  // Handler is async — fire-and-forget here; the handler
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Validation contract for the `quota_wall_detected` IPC verb — the signal the
3
+ * autoaccept-poll wedge-watchdog sends when it sees claude's /rate-limit-options
4
+ * weekly-quota menu, asking the gateway to trigger account failover.
5
+ *
6
+ * A rogue process on the same UDS must not be able to inject a malformed
7
+ * payload: agentName is required + name-shaped, resetAt (optional) must be a
8
+ * finite number.
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import { validateClientMessage } from "../gateway/ipc-server.js";
12
+
13
+ describe("validateClientMessage — quota_wall_detected", () => {
14
+ it("accepts a well-formed signal (with resetAt)", () => {
15
+ expect(
16
+ validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: 1_780_000_000_000 }),
17
+ ).toBe(true);
18
+ });
19
+
20
+ it("accepts a well-formed signal WITHOUT resetAt (optional)", () => {
21
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn" })).toBe(true);
22
+ });
23
+
24
+ it("rejects a missing / non-string / malformed agentName", () => {
25
+ expect(validateClientMessage({ type: "quota_wall_detected" })).toBe(false);
26
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: 123 })).toBe(false);
27
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "" })).toBe(false);
28
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "../etc" })).toBe(false);
29
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "Finn UPPER" })).toBe(false);
30
+ });
31
+
32
+ it("rejects a non-finite / non-number resetAt", () => {
33
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: "soon" })).toBe(false);
34
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: NaN })).toBe(false);
35
+ expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: Infinity })).toBe(false);
36
+ });
37
+ });