switchroom 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +23 -1
- package/dist/auth-broker/index.js +43 -3
- package/dist/cli/drive-write-pretool.mjs +23 -2
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +375 -18
- package/dist/cli/ui/index.html +67 -1
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +18 -0
- package/telegram-plugin/auth-snapshot-format.ts +9 -0
- package/telegram-plugin/auto-fallback-fleet.ts +59 -0
- package/telegram-plugin/dist/gateway/gateway.js +347 -21
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +35 -2
- package/telegram-plugin/gateway/gateway.ts +236 -22
- package/telegram-plugin/gateway/model-command.ts +182 -0
- package/telegram-plugin/quota-watch.ts +141 -3
- package/telegram-plugin/tests/auth-quota-util-cell.test.ts +23 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +71 -0
- package/telegram-plugin/tests/model-command.test.ts +205 -0
- package/telegram-plugin/tests/quota-watch.test.ts +266 -0
- package/telegram-plugin/welcome-text.ts +7 -1
|
@@ -436,3 +436,269 @@ describe("evaluateFleetAllExhausted", () => {
|
|
|
436
436
|
if (d.kind === "notify") expect(d.message).toContain("Reset time unknown");
|
|
437
437
|
});
|
|
438
438
|
});
|
|
439
|
+
|
|
440
|
+
// ── hardening: tuning resolver + claim keys (2026-06-09 incident) ───────────
|
|
441
|
+
|
|
442
|
+
import {
|
|
443
|
+
resolveQuotaWatchTuning,
|
|
444
|
+
buildQuotaClaimKey,
|
|
445
|
+
DEFAULT_QUOTA_WATCH_MAX_STALE_MS,
|
|
446
|
+
DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS,
|
|
447
|
+
QUOTA_WATCH_CLAIM_WINDOW_MS,
|
|
448
|
+
type QuotaWatchDecision,
|
|
449
|
+
} from "../quota-watch.js";
|
|
450
|
+
|
|
451
|
+
describe("resolveQuotaWatchTuning", () => {
|
|
452
|
+
it("defaults: stale gate 60min, late-recovery 6h, dedup on, probe-fail send off", () => {
|
|
453
|
+
const t = resolveQuotaWatchTuning({});
|
|
454
|
+
expect(t.maxStaleMs).toBe(DEFAULT_QUOTA_WATCH_MAX_STALE_MS);
|
|
455
|
+
expect(t.lateRecoveryMs).toBe(DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS);
|
|
456
|
+
expect(t.fleetDedup).toBe(true);
|
|
457
|
+
expect(t.sendOnProbeFail).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("each knob is individually kill-switchable", () => {
|
|
461
|
+
const t = resolveQuotaWatchTuning({
|
|
462
|
+
SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS: "0",
|
|
463
|
+
SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS: "0",
|
|
464
|
+
SWITCHROOM_QUOTA_WATCH_FLEET_DEDUP: "0",
|
|
465
|
+
SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL: "1",
|
|
466
|
+
});
|
|
467
|
+
expect(t.maxStaleMs).toBe(0);
|
|
468
|
+
expect(t.lateRecoveryMs).toBe(0);
|
|
469
|
+
expect(t.fleetDedup).toBe(false);
|
|
470
|
+
expect(t.sendOnProbeFail).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("garbage numeric values fall back to defaults", () => {
|
|
474
|
+
const t = resolveQuotaWatchTuning({
|
|
475
|
+
SWITCHROOM_QUOTA_WATCH_MAX_STALE_MS: "banana",
|
|
476
|
+
SWITCHROOM_QUOTA_WATCH_LATE_RECOVERY_MS: "-5",
|
|
477
|
+
});
|
|
478
|
+
expect(t.maxStaleMs).toBe(DEFAULT_QUOTA_WATCH_MAX_STALE_MS);
|
|
479
|
+
expect(t.lateRecoveryMs).toBe(DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("claim window exceeds one poll cycle (15 min) so all agents' observations of one edge dedup", () => {
|
|
483
|
+
expect(QUOTA_WATCH_CLAIM_WINDOW_MS).toBeGreaterThan(15 * 60_000);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("buildQuotaClaimKey", () => {
|
|
488
|
+
it("is namespaced and distinct per account, transition, and chat", () => {
|
|
489
|
+
const k1 = buildQuotaClaimKey("a@x.com", "entered-throttling", 111);
|
|
490
|
+
const k2 = buildQuotaClaimKey("a@x.com", "entered-throttling", 222);
|
|
491
|
+
const k3 = buildQuotaClaimKey("a@x.com", "recovered-to-healthy", 111);
|
|
492
|
+
const k4 = buildQuotaClaimKey("b@x.com", "entered-throttling", 111);
|
|
493
|
+
expect(new Set([k1, k2, k3, k4]).size).toBe(4);
|
|
494
|
+
expect(k1).toBe("quota-watch:a@x.com:entered-throttling:111");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ── hardening: staleness gate ────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
const TUNING = {
|
|
501
|
+
maxStaleMs: DEFAULT_QUOTA_WATCH_MAX_STALE_MS,
|
|
502
|
+
lateRecoveryMs: DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
describe("evaluateQuotaWatchAccount — staleness gate", () => {
|
|
506
|
+
it("a cached snapshot past maxStaleMs carries no opinion (skip, even on a real edge)", () => {
|
|
507
|
+
const staleHealthy: AccountSnapshot = {
|
|
508
|
+
...HEALTHY_SNAP,
|
|
509
|
+
capturedAtMs: NOW - DEFAULT_QUOTA_WATCH_MAX_STALE_MS - 1,
|
|
510
|
+
};
|
|
511
|
+
const d = evaluateQuotaWatchAccount({
|
|
512
|
+
agentName: "test", snap: staleHealthy, prev: PREV_WAS_THROTTLING, now: NOW, tuning: TUNING,
|
|
513
|
+
});
|
|
514
|
+
expect(d.kind).toBe("skip");
|
|
515
|
+
expect((d as { reason: string }).reason).toBe("stale-snapshot");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("a cached snapshot inside the shelf life classifies normally", () => {
|
|
519
|
+
const freshEnough: AccountSnapshot = {
|
|
520
|
+
...THROTTLING_5H,
|
|
521
|
+
capturedAtMs: NOW - 5 * 60_000,
|
|
522
|
+
};
|
|
523
|
+
const d = evaluateQuotaWatchAccount({
|
|
524
|
+
agentName: "test", snap: freshEnough, prev: PREV_WAS_HEALTHY, now: NOW, tuning: TUNING,
|
|
525
|
+
});
|
|
526
|
+
expect(d.kind).toBe("notify");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("live-probe snapshots (no capturedAtMs) are never stale-gated", () => {
|
|
530
|
+
const d = evaluateQuotaWatchAccount({
|
|
531
|
+
agentName: "test", snap: THROTTLING_5H, prev: PREV_WAS_HEALTHY, now: NOW, tuning: TUNING,
|
|
532
|
+
});
|
|
533
|
+
expect(d.kind).toBe("notify");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("maxStaleMs=0 disables the gate (legacy behaviour)", () => {
|
|
537
|
+
const ancient: AccountSnapshot = { ...THROTTLING_5H, capturedAtMs: NOW - 86_400_000 };
|
|
538
|
+
const d = evaluateQuotaWatchAccount({
|
|
539
|
+
agentName: "test", snap: ancient, prev: PREV_WAS_HEALTHY, now: NOW,
|
|
540
|
+
tuning: { maxStaleMs: 0, lateRecoveryMs: 0 },
|
|
541
|
+
});
|
|
542
|
+
expect(d.kind).toBe("notify");
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// ── hardening: boot-tick + late-recovery reconciliation ─────────────────────
|
|
547
|
+
|
|
548
|
+
describe("evaluateQuotaWatchAccount — recovery reconciliation", () => {
|
|
549
|
+
it("recovery on a boot tick reconciles silently (the fleet-bounce flood case)", () => {
|
|
550
|
+
const d = evaluateQuotaWatchAccount({
|
|
551
|
+
agentName: "test", snap: HEALTHY_SNAP, prev: PREV_WAS_THROTTLING, now: NOW,
|
|
552
|
+
bootTick: true, tuning: TUNING,
|
|
553
|
+
});
|
|
554
|
+
expect(d.kind).toBe("reconcile");
|
|
555
|
+
const r = d as Extract<QuotaWatchDecision, { kind: "reconcile" }>;
|
|
556
|
+
expect(r.reason).toBe("boot-tick-recovery");
|
|
557
|
+
expect(r.transition).toBe("recovered-to-healthy");
|
|
558
|
+
// The latch must clear so the edge never re-fires.
|
|
559
|
+
expect(r.newAccountState.lastNotifiedHealth).toBe("healthy");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("recovery whose 🟡 warning is older than lateRecoveryMs reconciles silently", () => {
|
|
563
|
+
const prev = {
|
|
564
|
+
lastNotifiedHealth: "throttling" as const,
|
|
565
|
+
lastNotifiedAt: NOW - DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS - 1,
|
|
566
|
+
};
|
|
567
|
+
const d = evaluateQuotaWatchAccount({
|
|
568
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW, tuning: TUNING,
|
|
569
|
+
});
|
|
570
|
+
expect(d.kind).toBe("reconcile");
|
|
571
|
+
expect((d as Extract<QuotaWatchDecision, { kind: "reconcile" }>).reason).toBe("late-recovery");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("a prompt recovery on a normal tick still notifies", () => {
|
|
575
|
+
const prev = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: NOW - 30 * 60_000 };
|
|
576
|
+
const d = evaluateQuotaWatchAccount({
|
|
577
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW, tuning: TUNING,
|
|
578
|
+
});
|
|
579
|
+
expect(d.kind).toBe("notify");
|
|
580
|
+
expect((d as Extract<QuotaWatchDecision, { kind: "notify" }>).transition).toBe("recovered-to-healthy");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("a 🟡 warning still notifies on a boot tick (warnings are level-state news)", () => {
|
|
584
|
+
const d = evaluateQuotaWatchAccount({
|
|
585
|
+
agentName: "test", snap: THROTTLING_5H, prev: PREV_WAS_HEALTHY, now: NOW,
|
|
586
|
+
bootTick: true, tuning: TUNING,
|
|
587
|
+
});
|
|
588
|
+
expect(d.kind).toBe("notify");
|
|
589
|
+
expect((d as Extract<QuotaWatchDecision, { kind: "notify" }>).transition).toBe("entered-throttling");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("lateRecoveryMs=0 disables reconciliation (legacy always-send)", () => {
|
|
593
|
+
const prev = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: NOW - 86_400_000 };
|
|
594
|
+
const d = evaluateQuotaWatchAccount({
|
|
595
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW,
|
|
596
|
+
tuning: { maxStaleMs: 0, lateRecoveryMs: 0 },
|
|
597
|
+
});
|
|
598
|
+
expect(d.kind).toBe("notify");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("omitting bootTick/tuning entirely preserves the pre-hardening table", () => {
|
|
602
|
+
const prev = { lastNotifiedHealth: "throttling" as const, lastNotifiedAt: NOW - 86_400_000 };
|
|
603
|
+
const d = evaluateQuotaWatchAccount({
|
|
604
|
+
agentName: "test", snap: HEALTHY_SNAP, prev, now: NOW,
|
|
605
|
+
});
|
|
606
|
+
expect(d.kind).toBe("notify");
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// ── total enumeration: every (state × input) cell of the decision FSM ────────
|
|
611
|
+
//
|
|
612
|
+
// Operator standard (feedback_prove_finite_fsm_not_sample): the decision
|
|
613
|
+
// function is a finite table — prove it by enumerating EVERY cell, not by
|
|
614
|
+
// sampling. Dimensions:
|
|
615
|
+
// prevHealth ∈ {null, healthy, throttling}
|
|
616
|
+
// currentHealth ∈ {healthy, throttling, blocked, unknown}
|
|
617
|
+
// staleness ∈ {live (no capturedAtMs), fresh-cache, stale-cache}
|
|
618
|
+
// bootTick ∈ {false, true}
|
|
619
|
+
// warningAge ∈ {recent (< lateRecoveryMs), late (> lateRecoveryMs)}
|
|
620
|
+
// = 3 × 4 × 3 × 2 × 2 = 144 cells. The expected kind for every cell is
|
|
621
|
+
// computed from the documented table independently and asserted.
|
|
622
|
+
|
|
623
|
+
describe("evaluateQuotaWatchAccount — total enumeration (144 cells)", () => {
|
|
624
|
+
const PREVS = [null, "healthy", "throttling"] as const;
|
|
625
|
+
const CURRENTS = ["healthy", "throttling", "blocked", "unknown"] as const;
|
|
626
|
+
const STALENESS = ["live", "fresh-cache", "stale-cache"] as const;
|
|
627
|
+
const BOOLS = [false, true] as const;
|
|
628
|
+
const AGES = ["recent", "late"] as const;
|
|
629
|
+
|
|
630
|
+
const SNAPS: Record<(typeof CURRENTS)[number], QuotaUtilization | null> = {
|
|
631
|
+
healthy: makeQuota(30, 40),
|
|
632
|
+
throttling: makeQuota(85, 40),
|
|
633
|
+
blocked: makeQuota(99.9, 99.9),
|
|
634
|
+
unknown: null,
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
it("every cell matches the documented table", () => {
|
|
638
|
+
let cells = 0;
|
|
639
|
+
for (const prevHealth of PREVS) {
|
|
640
|
+
for (const currentHealth of CURRENTS) {
|
|
641
|
+
for (const staleness of STALENESS) {
|
|
642
|
+
for (const bootTick of BOOLS) {
|
|
643
|
+
for (const age of AGES) {
|
|
644
|
+
cells++;
|
|
645
|
+
const lastNotifiedAt =
|
|
646
|
+
prevHealth === null
|
|
647
|
+
? 0
|
|
648
|
+
: age === "late"
|
|
649
|
+
? NOW - DEFAULT_QUOTA_WATCH_LATE_RECOVERY_MS - 1
|
|
650
|
+
: NOW - 30 * 60_000;
|
|
651
|
+
const prev = { lastNotifiedHealth: prevHealth, lastNotifiedAt };
|
|
652
|
+
const snap: AccountSnapshot = {
|
|
653
|
+
label: "enum@example.com",
|
|
654
|
+
isActive: false,
|
|
655
|
+
quota: SNAPS[currentHealth],
|
|
656
|
+
capturedAtMs:
|
|
657
|
+
staleness === "live"
|
|
658
|
+
? undefined
|
|
659
|
+
: staleness === "fresh-cache"
|
|
660
|
+
? NOW - 60_000
|
|
661
|
+
: NOW - DEFAULT_QUOTA_WATCH_MAX_STALE_MS - 1,
|
|
662
|
+
};
|
|
663
|
+
const d = evaluateQuotaWatchAccount({
|
|
664
|
+
agentName: "enum", snap, prev, now: NOW, bootTick, tuning: TUNING,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Independent expectation, straight from the documented table.
|
|
668
|
+
let expected: "notify" | "reconcile" | "skip";
|
|
669
|
+
const effPrev = prevHealth ?? "healthy";
|
|
670
|
+
if (staleness === "stale-cache") {
|
|
671
|
+
expected = "skip"; // gate fires before everything else
|
|
672
|
+
} else if (currentHealth === "blocked" || currentHealth === "unknown") {
|
|
673
|
+
expected = "skip"; // credits-watch domain / no data
|
|
674
|
+
} else if (currentHealth === effPrev) {
|
|
675
|
+
expected = "skip"; // steady-state
|
|
676
|
+
} else if (currentHealth === "throttling") {
|
|
677
|
+
expected = "notify"; // warnings always notify
|
|
678
|
+
} else {
|
|
679
|
+
// recovery: throttling → healthy
|
|
680
|
+
expected = bootTick || age === "late" ? "reconcile" : "notify";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
expect(
|
|
684
|
+
d.kind,
|
|
685
|
+
`cell prev=${prevHealth} cur=${currentHealth} stale=${staleness} boot=${bootTick} age=${age}`,
|
|
686
|
+
).toBe(expected);
|
|
687
|
+
|
|
688
|
+
// Terminal-state invariant: any notify/reconcile lands the
|
|
689
|
+
// latch on currentHealth, so re-running the SAME cell with the
|
|
690
|
+
// updated state is always a skip (the FSM cannot loop).
|
|
691
|
+
if (d.kind === "notify" || d.kind === "reconcile") {
|
|
692
|
+
const again = evaluateQuotaWatchAccount({
|
|
693
|
+
agentName: "enum", snap, prev: d.newAccountState, now: NOW, bootTick, tuning: TUNING,
|
|
694
|
+
});
|
|
695
|
+
expect(again.kind, `re-run of cell prev=${prevHealth} cur=${currentHealth}`).toBe("skip");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
expect(cells).toBe(144);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
@@ -254,7 +254,7 @@ export const switchroomHelpCommandNames = [
|
|
|
254
254
|
// Agents
|
|
255
255
|
"agents", "agentstart", "stop", "restart", "logs", "memory",
|
|
256
256
|
// Auth & config — consolidated onto the `/auth` dashboard.
|
|
257
|
-
"auth",
|
|
257
|
+
"auth", "model",
|
|
258
258
|
"topics", "update", "version",
|
|
259
259
|
"permissions", "grant", "dangerous", "vault", "doctor",
|
|
260
260
|
"commands",
|
|
@@ -299,6 +299,10 @@ export const TELEGRAM_MENU_COMMANDS = [
|
|
|
299
299
|
// /memory, /hooks). Requires the tmux supervisor (the default — refused
|
|
300
300
|
// when the agent has experimental.legacy_pty=true).
|
|
301
301
|
{ command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
|
|
302
|
+
// /model — show or switch the Claude model (session-scoped; rides the
|
|
303
|
+
// same inject primitive as `/inject /model` but with a typed argument,
|
|
304
|
+
// so it never opens the undriveable no-arg picker modal).
|
|
305
|
+
{ command: "model", description: "Show or switch the Claude model" },
|
|
302
306
|
{ command: "doctor", description: "Health check (deps, services, MCP)" },
|
|
303
307
|
{ command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
|
|
304
308
|
// Vault — secrets + capability grants. /vault is a top-level command
|
|
@@ -358,6 +362,8 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
358
362
|
`<code>/auth list [agent]</code> — list account slots and health`,
|
|
359
363
|
`<code>/auth use [agent] <slot></code> — switch active slot and restart`,
|
|
360
364
|
`<code>/auth rm [agent] <slot> [--force]</code> — remove a slot`,
|
|
365
|
+
`<code>/model</code> — show the configured Claude model`,
|
|
366
|
+
`<code>/model <name></code> — switch the live session's model (opus · sonnet · haiku or a full id; until restart)`,
|
|
361
367
|
`<code>/topics</code> — topic-to-agent mappings`,
|
|
362
368
|
`<code>/permissions [agent]</code> — show agent permissions`,
|
|
363
369
|
`<code>/grant <tool></code> — grant a tool permission`,
|