Switch the Claude subscription account when a rate-limit window is nearly full, and back once it resets

Personal application configs and agent settings, managed with Dotter.
Install Homebrew, clone this repository, then install Dotter:
brew install dotter
Create .dotter/local.toml (ignored by Git) to select a profile:
packages = ["air-m5"]
Available profiles:
air-m5pro-m2Profile contents are defined in .dotter/global.toml.
develop installs rustup, uv, and pnpm, configures Fish paths, and installs Rust stable, the default stable Python supplied by uv, and Node.js LTS.
containers installs Docker, Compose, Buildx, and Colima. Start the container engine with colima-up (4 CPUs, 8 GiB, Rosetta); stop it with colima stop.
Preview config changes, then install the selected groups and link their configs:
dotter deploy --dry-run
make deploy
make deploy can overwrite existing target files (--force).
.dotter/global.toml: profiles, groups, and config destinations..dotter/Brewfile-*: programs to install for each group.dotfiles/: application configs.claude/, codex/: agent settings and extensions.bin/: personal CLI tools; scripts/: repository utilities.Opened issues:
network.enabled = false: commands have no network access. Commands matched by prefix_rule(..., decision="allow") are the exception: they bypass the sandbox and have network access (probably filesystem too, since this is a sandbox bypass).
network.enabled = true: network access is enabled for all commands.
There is also an option to restrict traffic by domains. It requires all three: network.enabled = true, network.mode = "limited", and features.network_proxy = true. Then traffic is filtered by network.domains. Current caveat: even prefix_rule(... allow) commands are routed through the proxy, so an allowlisted command fails if its domain is not allowed there.
Note: network.mode = "limited" is silently ineffective without features.network_proxy = true.
Denied reads under [permissions.<profile>.filesystem.":workspace_roots"] disable the unsandboxed part of rules.allow:
[permissions.dev_workspace.filesystem.":workspace_roots"]
"**/*.env" = "deny"
With any filesystem deny, Codex does not run allowlisted commands outside the sandbox. The rule still matches, and the approval prompt can be skipped, but execution stays sandboxed because denied reads only work inside the sandbox.
Practical result: rules.allow stops being useful as a sandbox bypass. Commands that need denied files, such as .env, fail even when the command is allowlisted.
hooks/register.ts 56 lines1import type { EngineInterface, Register, SessionRateLimit } from "claude-code"
2
3// Readings only wake a verified check. Keep work pending until Python acknowledges it.
4let last = "[]"
5let pending = false
6let running = false
7
8function key(limits: readonly SessionRateLimit[]) {
9 return JSON.stringify(limits.map(limit => [limit.kind, limit.percentUsed, limit.resetsAt]))
10}
11
12async function check($: EngineInterface, limits: readonly SessionRateLimit[], changed = false) {
13 const reading = key(limits)
14 if (changed || reading !== last) pending = true
15
16 last = reading
17 if (!pending || running) return
18
19 running = true
20 pending = false
21 try {
22 const script = `${$.plugin.root}/account-switch.py`
23 const run = await $.process.run(["uv", "run", "--script", script, "measure"], {
24 stdin: JSON.stringify(limits),
25 timeoutMs: 60_000,
26 })
27 if (run.exitCode !== 0) pending = true
28
29 const text = (run.stdout || run.stderr).trim()
30 if (text) $.ui.toast(text)
31 } catch {
32 pending = true
33 $.ui.toast("Account usage check could not run; will retry")
34 } finally {
35 running = false
36 }
37}
38
39export const register: Register = on => {
40 on("session.measure", async ($, e, next) => {
41 const result = await next(e)
42 if (e.changed.includes("rateLimits")) await check($, e.rateLimits, true)
43
44 return result
45 })
46
47 // Retry skipped checks even if a blocked session produces no more responses.
48 on("session.start", async ($, e, next) => {
49 const result = await next(e)
50 $.clock.every(60_000, async () => check($, (await $.session.usage()).rateLimits))
51 await check($, (await $.session.usage()).rateLimits)
52
53 return result
54 })
55}
56