switchroom 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +5 -4
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +125 -3
  4. package/dist/cli/drive-write-pretool.mjs +5436 -0
  5. package/dist/cli/switchroom.js +231 -29
  6. package/dist/host-control/main.js +2 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +1 -1
  10. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  11. package/telegram-plugin/admin-commands/index.ts +2 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  13. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  14. package/telegram-plugin/auto-fallback.ts +28 -301
  15. package/telegram-plugin/dist/gateway/gateway.js +4314 -2143
  16. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  17. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  18. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  19. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  20. package/telegram-plugin/gateway/auth-command.ts +131 -10
  21. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  22. package/telegram-plugin/gateway/boot-card.ts +1 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +6 -9
  24. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  25. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  26. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  27. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  28. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  29. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  30. package/telegram-plugin/gateway/gateway.ts +903 -173
  31. package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
  32. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  33. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  34. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  35. package/telegram-plugin/model-unavailable.ts +28 -12
  36. package/telegram-plugin/silence-poke.ts +153 -1
  37. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  38. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  39. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  40. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  41. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  42. package/telegram-plugin/tests/boot-probes.test.ts +16 -18
  43. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  44. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  45. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  46. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  47. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  48. package/telegram-plugin/turn-flush-safety.ts +55 -1
  49. package/telegram-plugin/uat/SETUP.md +16 -12
  50. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  51. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  52. package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
package/README.md CHANGED
@@ -4,10 +4,11 @@
4
4
 
5
5
  # Switchroom
6
6
 
7
- [![Build status](https://badge.buildkite.com/833b2727d7b26bf72e26a6d7968d99c29ec7f1b68888adfb10.svg?branch=main)](https://buildkite.com/ken-thompson/switchroom)
8
- [![Tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fmekenthompson%2F002f3482b19111d35e57c1903b3733e2%2Fraw%2Fswitchroom-tests.json)](https://buildkite.com/ken-thompson/switchroom)
9
- [![Trigger evals](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fmekenthompson%2F002f3482b19111d35e57c1903b3733e2%2Fraw%2Fswitchroom-trigger-evals.json)](https://buildkite.com/ken-thompson/switchroom)
10
- [![Quality evals](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fmekenthompson%2F002f3482b19111d35e57c1903b3733e2%2Fraw%2Fswitchroom-quality-evals.json)](https://buildkite.com/ken-thompson/switchroom)
7
+ [![Tests](https://github.com/switchroom/switchroom/actions/workflows/ci-tests-core.yml/badge.svg?branch=main)](https://github.com/switchroom/switchroom/actions/workflows/ci-tests-core.yml)
8
+ [![Plugin tests](https://github.com/switchroom/switchroom/actions/workflows/ci-tests-plugin.yml/badge.svg?branch=main)](https://github.com/switchroom/switchroom/actions/workflows/ci-tests-plugin.yml)
9
+ [![Docker e2e](https://github.com/switchroom/switchroom/actions/workflows/docker-e2e.yml/badge.svg?branch=main)](https://github.com/switchroom/switchroom/actions/workflows/docker-e2e.yml)
10
+ [![Trigger evals](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fmekenthompson%2F002f3482b19111d35e57c1903b3733e2%2Fraw%2Fswitchroom-trigger-evals.json)](https://github.com/switchroom/switchroom/actions/workflows/ci-evals.yml)
11
+ [![Quality evals](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Fmekenthompson%2F002f3482b19111d35e57c1903b3733e2%2Fraw%2Fswitchroom-quality-evals.json)](https://github.com/switchroom/switchroom/actions/workflows/ci-evals.yml)
11
12
 
12
13
  **A switchboard for your Pro or Max.** Your Claude subscription, as a fleet of always-on specialist agents you talk to from Telegram. Opinionated UX, done properly.
13
14
 
@@ -11286,7 +11286,7 @@ var QuotaConfigSchema = exports_external.object({
11286
11286
  monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
11287
11287
  });
11288
11288
  var HostControlConfigSchema = exports_external.object({
11289
- enabled: exports_external.boolean().optional().describe("Opt-in to the host-control daemon. Default: false. " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Since Phase 2 (#1175 PR γ) the gateway's /restart, /new, /reset, " + "and /update apply slash-commands automatically dispatch through " + "hostd when enabled — replacing the in-container " + "`spawnSwitchroomDetached` shellout that requires docker access. " + "Set enabled: true on docker-mode installs to make those verbs work " + "(they otherwise fail because the agent container has no docker " + "binary/socket).")
11289
+ enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
11290
11290
  });
11291
11291
  var SwitchroomConfigSchema = exports_external.object({
11292
11292
  switchroom: exports_external.object({
@@ -11312,7 +11312,7 @@ var SwitchroomConfigSchema = exports_external.object({
11312
11312
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
11313
11313
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11314
11314
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11315
- host_control: HostControlConfigSchema.optional().describe("Optional host-control daemon configuration. See RFC C " + "(docs/rfcs/host-control-daemon.md) and the field-level help on " + "`enabled` for the Phase 1 scope."),
11315
+ host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11316
11316
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11317
11317
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11318
11318
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -11286,7 +11286,7 @@ var QuotaConfigSchema = exports_external.object({
11286
11286
  monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
11287
11287
  });
11288
11288
  var HostControlConfigSchema = exports_external.object({
11289
- enabled: exports_external.boolean().optional().describe("Opt-in to the host-control daemon. Default: false. " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Since Phase 2 (#1175 PR γ) the gateway's /restart, /new, /reset, " + "and /update apply slash-commands automatically dispatch through " + "hostd when enabled — replacing the in-container " + "`spawnSwitchroomDetached` shellout that requires docker access. " + "Set enabled: true on docker-mode installs to make those verbs work " + "(they otherwise fail because the agent container has no docker " + "binary/socket).")
11289
+ enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
11290
11290
  });
11291
11291
  var SwitchroomConfigSchema = exports_external.object({
11292
11292
  switchroom: exports_external.object({
@@ -11312,7 +11312,7 @@ var SwitchroomConfigSchema = exports_external.object({
11312
11312
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
11313
11313
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
11314
11314
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
11315
- host_control: HostControlConfigSchema.optional().describe("Optional host-control daemon configuration. See RFC C " + "(docs/rfcs/host-control-daemon.md) and the field-level help on " + "`enabled` for the Phase 1 scope."),
11315
+ host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
11316
11316
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
11317
11317
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
11318
11318
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -11947,6 +11947,93 @@ function allocateAgentUid(name) {
11947
11947
  }
11948
11948
  var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
11949
11949
 
11950
+ // src/auth/quota.ts
11951
+ var OAUTH_BETA = "oauth-2025-04-20";
11952
+ var DEFAULT_USER_AGENT = "claude-cli/1.0.0 (external, cli)";
11953
+ var DEFAULT_PROBE_MODEL = "claude-haiku-4-5-20251001";
11954
+ function parseFloatHeader(headers, name) {
11955
+ const v = headers.get(name);
11956
+ if (v == null || v.trim().length === 0)
11957
+ return null;
11958
+ const n = Number(v);
11959
+ return Number.isFinite(n) ? n : null;
11960
+ }
11961
+ function parseEpochHeader(headers, name) {
11962
+ const v = headers.get(name);
11963
+ if (v == null)
11964
+ return null;
11965
+ const n = Number(v);
11966
+ if (!Number.isFinite(n) || n <= 0)
11967
+ return null;
11968
+ return new Date(n * 1000);
11969
+ }
11970
+ function parseQuotaHeaders(headers) {
11971
+ const fiveHour = parseFloatHeader(headers, "anthropic-ratelimit-unified-5h-utilization");
11972
+ const sevenDay = parseFloatHeader(headers, "anthropic-ratelimit-unified-7d-utilization");
11973
+ if (fiveHour == null && sevenDay == null) {
11974
+ return {
11975
+ ok: false,
11976
+ reason: "no unified rate-limit headers in response (API token, not OAuth?)"
11977
+ };
11978
+ }
11979
+ return {
11980
+ ok: true,
11981
+ data: {
11982
+ fiveHourUtilizationPct: (fiveHour ?? 0) * 100,
11983
+ sevenDayUtilizationPct: (sevenDay ?? 0) * 100,
11984
+ fiveHourResetAt: parseEpochHeader(headers, "anthropic-ratelimit-unified-5h-reset"),
11985
+ sevenDayResetAt: parseEpochHeader(headers, "anthropic-ratelimit-unified-7d-reset"),
11986
+ representativeClaim: headers.get("anthropic-ratelimit-unified-representative-claim"),
11987
+ overageStatus: headers.get("anthropic-ratelimit-unified-overage-status"),
11988
+ overageDisabledReason: headers.get("anthropic-ratelimit-unified-overage-disabled-reason")
11989
+ }
11990
+ };
11991
+ }
11992
+ async function fetchQuota(opts) {
11993
+ const token = opts.accessToken?.trim();
11994
+ if (!token || token.length === 0) {
11995
+ return { ok: false, reason: "fetchQuota requires a non-empty accessToken" };
11996
+ }
11997
+ const controller = new AbortController;
11998
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1e4);
11999
+ const fetchFn = opts.fetchImpl ?? fetch;
12000
+ let resp;
12001
+ try {
12002
+ resp = await fetchFn("https://api.anthropic.com/v1/messages", {
12003
+ method: "POST",
12004
+ headers: {
12005
+ "anthropic-version": "2023-06-01",
12006
+ "anthropic-beta": OAUTH_BETA,
12007
+ authorization: `Bearer ${token}`,
12008
+ "x-app": "cli",
12009
+ "user-agent": DEFAULT_USER_AGENT,
12010
+ "content-type": "application/json"
12011
+ },
12012
+ body: JSON.stringify({
12013
+ model: opts.model ?? DEFAULT_PROBE_MODEL,
12014
+ max_tokens: 1,
12015
+ messages: [{ role: "user", content: "hi" }]
12016
+ }),
12017
+ signal: controller.signal
12018
+ });
12019
+ } catch (err) {
12020
+ const msg = err?.message ?? String(err);
12021
+ clearTimeout(timeout);
12022
+ if (msg.includes("aborted")) {
12023
+ return { ok: false, reason: `quota probe timed out after ${opts.timeoutMs ?? 1e4}ms` };
12024
+ }
12025
+ return { ok: false, reason: `quota probe network error: ${msg}` };
12026
+ }
12027
+ clearTimeout(timeout);
12028
+ const parsed = parseQuotaHeaders(resp.headers);
12029
+ if (parsed.ok)
12030
+ return parsed;
12031
+ if (!resp.ok) {
12032
+ return { ok: false, reason: `HTTP ${resp.status} from Anthropic (${parsed.reason})` };
12033
+ }
12034
+ return parsed;
12035
+ }
12036
+
11950
12037
  // src/util/atomic.ts
11951
12038
  import { randomBytes } from "node:crypto";
11952
12039
  import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
@@ -12634,6 +12721,13 @@ var ListGoogleAccountsRequestSchema = exports_external.object({
12634
12721
  op: exports_external.literal("list-google-accounts"),
12635
12722
  id: exports_external.string().min(1)
12636
12723
  });
12724
+ var ProbeQuotaRequestSchema = exports_external.object({
12725
+ v: exports_external.literal(PROTOCOL_VERSION),
12726
+ op: exports_external.literal("probe-quota"),
12727
+ id: exports_external.string().min(1),
12728
+ accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
12729
+ timeoutMs: exports_external.number().int().positive().max(60000).optional()
12730
+ });
12637
12731
  var RequestSchema = exports_external.discriminatedUnion("op", [
12638
12732
  GetCredentialsRequestSchema,
12639
12733
  ListStateRequestSchema,
@@ -12643,7 +12737,8 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
12643
12737
  AddAccountRequestSchema,
12644
12738
  RmAccountRequestSchema,
12645
12739
  SetOverrideRequestSchema,
12646
- ListGoogleAccountsRequestSchema
12740
+ ListGoogleAccountsRequestSchema,
12741
+ ProbeQuotaRequestSchema
12647
12742
  ]);
12648
12743
  var GetCredentialsDataSchema = exports_external.object({
12649
12744
  account: exports_external.string(),
@@ -13167,6 +13262,9 @@ class AuthBroker {
13167
13262
  case "list-google-accounts":
13168
13263
  await this.opListGoogleAccounts(socket, reqId, identity2);
13169
13264
  break;
13265
+ case "probe-quota":
13266
+ await this.opProbeQuota(socket, reqId, identity2, req.accounts, req.timeoutMs);
13267
+ break;
13170
13268
  }
13171
13269
  } catch (err) {
13172
13270
  socket.write(encodeError(reqId, "INTERNAL", err.message));
@@ -13265,6 +13363,30 @@ class AuthBroker {
13265
13363
  this.audit({ op: "list-google-accounts", identity: identity2, ok: true });
13266
13364
  socket.write(encodeSuccess(id, { accounts }));
13267
13365
  }
13366
+ async opProbeQuota(socket, id, identity2, accounts, timeoutMs) {
13367
+ const results = await Promise.all(accounts.map(async (label) => {
13368
+ const creds = readAccountCredentials(label, this.home);
13369
+ const token = creds?.claudeAiOauth?.accessToken;
13370
+ if (!token) {
13371
+ const result2 = {
13372
+ ok: false,
13373
+ reason: "no credentials for account in broker store"
13374
+ };
13375
+ this.audit({ op: "probe-quota", identity: identity2, account: label, ok: false, error: "missing-credentials" });
13376
+ return { label, result: result2 };
13377
+ }
13378
+ const result = await fetchQuota({ accessToken: token, timeoutMs });
13379
+ this.audit({
13380
+ op: "probe-quota",
13381
+ identity: identity2,
13382
+ account: label,
13383
+ ok: result.ok,
13384
+ error: result.ok ? undefined : result.reason
13385
+ });
13386
+ return { label, result };
13387
+ }));
13388
+ socket.write(encodeSuccess(id, { results }));
13389
+ }
13268
13390
  async opSetActive(socket, id, identity2, account) {
13269
13391
  if (!this.isAdmin(identity2)) {
13270
13392
  this.audit({ op: "set-active", identity: identity2, account, ok: false, error: "FORBIDDEN" });