SLOPSHOPPER

aimux

Every aimux subscription's 5-hour and weekly usage above the prompt, a warning before this one runs out, and one Enter to move on

newbandcommandtoastprocesstimer
★ 29v?MITupdated 2026-10-05Digital-Threads/aimux/mod
A shopper browsing a rack in a slop shop
README

aimux

npm version npm downloads license node GitHub stars

Run multiple Claude Code / Codex / Gemini subscriptions side by side: one shared brain — skills, agents, memory, settings — separate logins, and every subscription's live 5h/7d limits in a single view.

aimux status — every subscription's 5-hour and weekly usage and when it resets, in one table

Problem

You have multiple Claude Code subscriptions (personal, work, client) each in separate ~/.claude-* directories. You maintain symlinks manually, duplicate settings, and juggle bash functions to switch between them.

Solution

aimux treats your AI CLI configs like tmux treats terminals: one shared brain, multiple isolated sessions.

  • Shared layer: agents, skills, commands, rules, memory, plugins, settings — symlinked from a single source of truth
  • Private layer: credentials, rate limits, session state — isolated per profile
  • Zero duplication: add a skill once, available everywhere

Install

npm install -g @digital-threads/aimux

Getting Started

You have ~/.claude + extra directories (~/.claude-work, etc.)

npm install -g aimux
aimux init              # auto-detects all ~/.claude* dirs
aimux status            # verify profiles, auth, symlinks
aimux run w             # launch work profile (prefix matching)

You have only ~/.claude (one subscription)

npm install -g aimux
aimux init              # creates config with main profile
aimux profile add work  # add a new profile
aimux auth login work   # OAuth for the new account
aimux profile update w -m 'opus[1m]'
aimux run w

You want to connect a 3rd-party / self-hosted API endpoint

aimux profile add myapi --api
# Configure API endpoint (leave blank to use default):
#   Base URL:                          https://api.your-provider.com/v1
#   Auth token:                        [hidden]
#   Default model [claude-sonnet-5-5]:
#   Opus model    [claude-opus-5-5]:
#   Sonnet model  [claude-sonnet-5-5]:
#   Haiku model   [claude-haiku-4-5]:
# ✓ Credentials saved to ~/.aimux/profiles/myapi/.env (chmod 600)
aimux run myapi

See Per-profile environment variables for the declarative alternative (.env file / env: block) used by power users and CI.

Fresh machine (nothing installed)

# Install Claude CLI first, then:
claude auth login       # creates ~/.claude
npm install -g @digital-threads/aimux
aimux init
aimux profile add work
aimux auth login work

Day-to-day usage

aimux run               # interactive picker (↑↓ + Enter)
aimux run w             # prefix match → work
aimux run o -m claude-sonnet-5-5  # one-time model override
aimux run w --resume    # flags pass through to Claude CLI
aimux run --auto        # launch whichever subscription has the most headroom left
aimux status            # dashboard
aimux usage             # token usage by profile for the last 7 days
aimux usage --all       # all known transcript usage

# Set a default model per profile. Prefer a family alias: `opus[1m]` always means the
# newest Opus, so a new release is picked up without touching aimux. A full id such
# as `claude-opus-5-5` pins that exact version until you change it. Quote names with
# brackets, or the shell will try to expand them.
aimux profile update w -m 'opus[1m]'
aimux profile update w -m claude-opus-5-5   # pin one version
aimux profile update w --unset-model        # let the CLI choose its own default

# Set a fallback model, tried automatically when the primary is overloaded/unavailable
aimux profile update w --fallback-model claude-sonnet-5-5
aimux profile update w --unset-fallback-model   # remove it

Switch your shell to a profile (aimux use)

aimux run launches a one-off session. If you'd rather activate a profile so plain claude / codex use it — like nvm use or pyenv shell — enable the shell integration once:

# add to ~/.zshrc or ~/.bashrc (fish: ~/.config/fish/config.fish)
eval "$(aimux shell-init)"

Then:

aimux use work     # activate 'work' in this shell (persistent until you switch)
claude             # runs under 'work', on its model — no `aimux run` needed
codex              # same; the CLI adapter sets CODEX_HOME for you
aimux use api      # switch profiles — stale ANTHROPIC_*/tokens are cleaned up
aimux use          # no name → interactive picker

Each shell is independent, so different terminals can hold different active profiles at once. The switch only exports env vars into the current shell — it never changes global state. aimux run still works for one-off launches, and it always runs the profile you name, whichever one the shell has active.

Several subscriptions side by side (aimux split)

aimux split              # one tmux pane per logged-in claude subscription, in this directory
aimux split work client  # just these two

Each pane runs a full aimux run <profile>, labelled with its profile on the pane border; a pane whose run fails stays open with its error. Closing the window ends the panes, as in any terminal — the conversations are saved, and aimux run <profile> --resume picks one up again. Inside tmux it opens a new window instead of nesting tmux, and that window is yours to keep or close. Needs tmux (sudo apt install tmux, or brew install tmux).

When a subscription runs out mid-session

Transcripts are shared, so a session can carry on under another subscription — only the login changes. When a claude session started with aimux run stops because its window is spent, aimux offers to continue it where there is the most room left:

⚠ work hit its 5-hour limit (resets 23:10).
Continue this session on personal (5h 10%, 7d 16%)? [Y/n]

Press Enter and the same session resumes there, with the flags you started it with. aimux reads the hit from the session's own transcript, so an ordinary exit costs nothing; the other subscriptions are only checked when a limit was actually reached. It works inside aimux split panes too.

Every subscription's usage inside Claude Code

A claude session started with aimux run — or in an aimux split pane — shows every subscription in a band above the prompt, the one it runs on first:

Inside Claude Code: every subscription's usage above the prompt, this session's first, with a warning before it runs out

  • The band. This session's own subscription updates with every reply; a window close to its limit turns red and says when it resets. The others are refreshed at most every five minutes, and only while you work: an idle session sends nothing, and all open sessions share one reading.
  • A warning at 90%, naming the subscription with more room left.
  • One Enter to move. Once the window is spent, /exit is already in the prompt: press Enter and aimux carries the conversation over to that subscription without asking again.
  • /aimux prints the whole table, each window with its reset time.

It is a Claude Code mod (Claude Code 2.1.287 or newer), loaded only into the sessions aimux starts; AIMUX_NO_MOD=1 turns it off. The same figures are available to scripts as aimux status --json.

Commands

CommandDescription
aimux initAuto-detect Claude dirs, create config, migrate profiles
aimux init --source <path>Initialize with explicit source directory
aimux statusTUI dashboard — profiles, auth, live 5h/7d limit usage and reset times (claude + codex), auto-mode rules when any are set, symlink health. --watch [s] keeps it on screen and re-reads the limits; --json prints them for scripts; --max-age <s> reuses a reading that recent
aimux status --no-limitsSame dashboard without the rate-limit probe (offline / faster)
aimux usageShow token usage by profile (Claude transcripts + codex rollouts), including sessions started outside aimux
aimux usage --profile work --since 24hShow usage for one profile over a recent window
aimux run [profile]Launch AI CLI with correct env and model
aimux runInteractive picker — history pre-selects last used profile
aimux run wPrefix matching — launches work if unambiguous
aimux run work -m claude-sonnet-5-5Launch with model override
aimux run --autoProbe every subscription's live limits and launch the one with the most headroom (stays within the same CLI)
aimux split [profiles...]Open several subscriptions side by side, one tmux pane each (default: every logged-in claude subscription)
aimux use [profile]Switch the current shell to a profile (persistent) — plain claude/codex then use it. Requires eval "$(aimux shell-init)" in your rc
aimux shell-initPrint the shell function that enables aimux use (add to ~/.zshrc/~/.bashrc/fish config)
aimux agentsMulti-profile agent view — see and manage claude background sessions across all profiles in one TUI
aimux profile add <name>Create new profile with symlinks
aimux profile add <name> --apiCreate a 3rd-party API profile (interactive endpoint + token prompt)
aimux profile add <name> --cli codexCreate a profile for another AI CLI (e.g. Codex)
aimux handoff <sessionId> --to <profile>Continue a session under another profile/CLI via summary handoff
aimux profile update <name>Update model/cli settings
aimux profile update <name> --fallback-model <model>Set a fallback model, used when the primary is overloaded/unavailable
aimux profile update <name> --unset-modelRemove the default model, so the CLI uses its own (newest) default
aimux profile update <name> --unset-fallback-modelRemove the fallback model
aimux profile update <name> -e KEY=VALUESet an env var in the profile .env file
aimux profile update <name> --unset-env KEYRemove an env var from the profile .env file
aimux profile listList all profiles (same table as aimux status; --no-limits skips the probe)
aimux profile remove <name>Remove profile and clean up
aimux profile clone <src> <name>Clone profile with private files
aimux rebuild [profile]Sync symlinks and surface local shared-file conflicts
aimux doctorHealth check — broken symlinks, missing shared entries, conflicts
aimux auth login <profile>Launch OAuth flow for a profile
aimux auth statusShow auth file status per profile
aimux setup-shellAuto-install shell completions (bash/zsh/fish)
aimux migrate isolateOne-time migration: convert per-profile jobs/, daemon/, projects/ symlinks into real private dirs so each profile gets its own supervisor and sessions. Safe — no data is deleted. Add --dry-run to preview.

All profile commands support prefix matching: aimux run w → work, aimux profile update o → own.

How It Works

~/.claude/          ← source of truth (your main profile)
  agents/
  skills/
  commands/
  memory/
  settings.json
  .credentials.json  ← private, stays here

~/.aimux/
  config.yaml        ← aimux config
  profiles/
    work/
      agents/ → ~/.claude/agents      ← symlink (shared)
      skills/ → ~/.claude/skills      ← symlink (shared)
      memory/ → ~/.claude/memory      ← symlink (shared)
      plugins/                        ← real dir (shared content, per-profile metadata)
        marketplaces/ → ~/.claude/plugins/marketplaces   ← symlink (shared)
        cache/        → ~/.claude/plugins/cache           ← symlink (shared)
        known_marketplaces.json       ← real file (paths point inside this profile)
        installed_plugins.json        ← real file (paths point inside this profile)
      .credentials.json               ← real file (private)
      .claude.json                    ← real file (private)
    own/
      ...same pattern...

When you run aimux run work, it sets CLAUDE_CONFIG_DIR=~/.aimux/profiles/work and launches the CLI. Claude sees a complete config directory — shared content via symlinks, private auth locally.

Plugins are shared too, but Claude validates that a marketplace's installLocation lives inside the active config directory. So each profile gets a real plugins/ directory: the heavy content (marketplaces/, cache/) is symlinked to the shared ~/.claude/plugins, while known_marketplaces.json and installed_plugins.json are real, path-rewritten copies. ~/.claude stays the source of truth — install or update plugins from your main profile (or with CLAUDE_CONFIG_DIR=~/.claude claude plugin …) and every profile picks them up on its next run. A plugin installed from inside a profile is merged back into the shared source automatically.

Multiple AI CLIs (Codex, Gemini)

aimux isn't claude-only. A profile's cli field selects which AI CLI it runs, so you can keep claude, Codex, and Gemini subscriptions side by side — same shared brain, isolated auth — and even hand a live conversation from one to the other when a limit hits.

aimux profile add codework --cli codex   # a Codex profile
aimux profile add gem --cli gemini       # a Gemini profile (~/.gemini)
aimux auth login codework                # runs `codex login` under an isolated CODEX_HOME
aimux run codework                        # launches Codex with the right model flag
aimux agents                              # claude + codex sessions in one view (CLI-badged)
  • Isolation per CLI. Each CLI gets its own config-dir env (CLAUDE_CONFIG_DIR / CODEX_HOME / GEMINI_CLI_HOME), so subscriptions never collide.
  • Per-CLI source-of-truth. shared_sources maps each CLI to its source (claude → ~/.claude, codex → ~/.codex); the legacy shared_source stays as the claude alias.
  • Per-CLI sharing. claude shares everything except private; Codex shares a knowledge allowlist (skills, rules, memories) and keeps auth.json / config.toml / sessions/ private; Gemini shares GEMINI.md / skills / commands / extensions / memories and keeps auth + settings.json + history private.
  • Gemini specifics. Gemini has no direct config-dir override, so aimux points GEMINI_CLI_HOME at the profile's parent and the profile dir IS gemini's .gemini. Gemini resumes by per-project index (-r latest), not by session id, so cross-Gemini native resume isn't wired into the session list yet.
  • claude is untouched. Multi-CLI is strictly opt-in — without a non-claude profile nothing changes.

Cross-CLI handoff (limit failover)

Hit a subscription limit mid-session? Continue it under another CLI:

aimux handoff <sessionId> --to codework

The two CLIs' transcripts are mutually unreadable, so this is a summary handoff, not a native resume: aimux reads the source transcript, summarizes it with the target profile (self-contained — the target CLI does the summarizing), then launches the target seeded with that summary. Same-CLI continuation (claude↔claude, codex↔codex) still uses native resume (aimux run <profile> --resume <id>). Note: the conversation context carries over, not the model — the target continues with its own model.

Other models via a provider preset

Many providers expose an Anthropic-compatible endpoint, so they run on the claude CLI itself — just a different base URL + token. A provider profile is therefore a claude profile: it shares the full claude brain (skills, plugins, settings, memory, transcripts) and you can even --resume a claude session under it natively (same CLI, same model swap).

One command, prompts only for the token:

aimux profile add ds --provider deepseek   # fills base URL + model mapping
aimux run ds

Built-in presets: deepseek, kimi, glm, qwen, minimax, mimo. Base URLs are verified; model names drift — update with aimux profile update <name> -e ANTHROPIC_MODEL=….

For anything else (local models via Ollama/LM Studio, a proxy, Bedrock/Vertex), use the generic aimux profile add <name> --api and point ANTHROPIC_BASE_URL at it (see below). Costs shown by aimux usage are list-price estimates (Claude, codex/gpt-5, and the Anthropic-compatible providers) and will drift as prices change.

Per-profile environment variables

Some Claude Code modes (3rd-party proxies, self-hosted gateways, Bedrock, Vertex) are activated by environment variables rather than OAuth. aimux injects per-profile env into the spawned claude process (and into aimux auth login <profile>) from two sources, merged in this order:

  1. <profile>/.env — a dotenv file inside the profile directory. Best for secrets. Written with chmod 600 when aimux creates it; aimux run warns if it becomes group/other-readable.
  2. env: block under the profile in config.yaml — best for non-secret toggles you want versioned. Overrides .env on key conflict.

The fastest way to set up an API profile is the interactive prompt:

aimux profile add myapi --api      # prompts for Base URL, hidden token, models
aimux profile update myapi -e ANTHROPIC_MODEL=claude-opus-5-5   # edit later

…which writes something like:

# ~/.aimux/profiles/myapi/.env — do not commit
ANTHROPIC_BASE_URL=https://api.your-provider.com/v1
ANTHROPIC_AUTH_TOKEN=sk-your-token...
ANTHROPIC_MODEL=claude-sonnet-5-5
ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-5-5
ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4-5

The .env parser supports KEY=value, export KEY=value, comments, and single/double-quoted values (with \n/\t escapes inside double quotes). It does not do ${VAR} interpolation or multi-line values — it's a secrets loader, not a full dotenv-expand. .env is always private (never symlinked to the shared source).

Config

# ~/.aimux/config.yaml
version: 1
shared_source: /home/user/.claude

profiles:
  main:
    cli: claude
    path: /home/user/.claude
    is_source: true
  work:
    cli: claude
    model: opus[1m]            # follows the newest Opus
    path: /home/user/.aimux/profiles/work
  myapi:
    cli: claude
    model: claude-sonnet-5-5
    path: /home/user/.aimux/profiles/myapi   # secrets live in this dir's .env
    # Optional non-secret env injected into the spawned CLI (overrides .env).
    # env:
    #   ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-5-5

private:
  - .credentials.json
  - .env                  # API credentials — never symlinked, never committed
  - .claude.json
  - policy-limits.json
  - mcp-needs-auth-cache.json
  - remote-settings.json
  - settings.local.json
  - stats-cache.json
  - statsig
  - telemetry
  - state                 # MCP protocol verdicts, per install
  - session-env           # per-session environment
  - backups               # copies of the private .claude.json
  - remote                # Remote Control daemon + its binaries
  - security              # Agent SDK venv and logs
  - shell-snapshots
  - cache                 # fetched model catalog, per account

The defaults above are always applied — listing extra entries adds to them. Claude Code keeps growing new runtime directories, so if aimux doctor reports a conflict on one, add it here and run aimux rebuild.

Requirements

  • Node.js 22+
  • Claude Code CLI installed

License

MIT

Source 2 files
hooks/register.tsx 382 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code';
3
4import type { Probe, Usage, View } from '../types';
5
6/**
7 * aimux inside Claude Code: every subscription's 5-hour and weekly usage in a band
8 * above the prompt, a warning before the one this session runs on is spent, and —
9 * once it is — `/exit` in the prompt, so one Enter moves the conversation to the
10 * freest one. `/aimux` prints the whole table with reset times.
11 *
12 * This session's own windows arrive with every response (`session.measure`). The
13 * other subscriptions are asked of aimux (`aimux status --json`), at the start and
14 * after a turn once the figures are five minutes old — an idle session asks nothing,
15 * and aimux's on-disk cache lets every open session share one probe.
16 *
17 * Moving is aimux's job, after claude exits: it carries the conversation over when it
18 * finds the hit in the transcript. The mod only tells it the person already agreed —
19 * by running the `/exit` it offered — so aimux does not ask a second time.
20 *
21 * A band of its own rather than `$.ui.status`: the engine draws that line as one of
22 * its pinned warnings, yellow with a ⚠, which reads as something being wrong.
23 *
24 * Inert in a session aimux did not launch: nothing names the profile or aimux.
25 */
26
27const REFRESH_MS = 5 * 60_000;
28/** Where the warning comes, and where a window counts as spent after a refused turn. */
29const WARN_AT = 90;
30const SPENT_AT = 95;
31/** Where the band paints a window red and says when it resets. */
32const NEAR = 80;
33const WINDOW_NAME: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly' };
34
35const pct = (p: number | null) => (p === null ? '–' : String(p));
36const windowName = (w: SessionRateLimit) => WINDOW_NAME[w.kind] ?? w.kind;
37
38/** A window's reset as epoch milliseconds, from the ISO time the engine reports. */
39const parseTime = (iso: string | undefined) => (iso ? Date.parse(iso) : undefined);
40
41/** The band's colors for a window's use, as `aimux status` paints them. */
42const levelColor = (p: number) => (p >= NEAR ? 'red' : p >= 60 ? 'yellow' : 'green');
43
44/**
45 * The freest other claude subscription: its tightest window lowest, and lower than
46 * this one's (`own`) — moving is only worth it to somewhere with more room left.
47 */
48function freest(others: Record<string, Probe>, current: string, own: number): [string, Usage] | undefined {
49  let best: [string, Usage, number] | undefined;
50
51  for (const [name, probe] of Object.entries(others)) {
52    if (name === current || probe.cli !== 'claude' || !probe.status) continue;
53
54    const windows = [probe.status.fiveHourPct, probe.status.weeklyPct].filter((p): p is number => p !== null);
55    if (windows.length === 0) continue;
56
57    const tightest = Math.max(...windows);
58    if (tightest < Math.min(own, 100) && (!best || tightest < best[2])) best = [name, probe.status, tightest];
59  }
60
61  return best && [best[0], best[1]];
62}
63
64/** ` (resets in 2h 10m)`, or nothing when the reset time is unknown or past. Counted
65 *  from `now` rather than printed as a clock time: the mod's sandbox has no time zone. */
66export function resetsIn(resetsAt: number | undefined, now: number): string {
67  if (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= now) return '';
68
69  const minutes = Math.ceil((resetsAt - now) / 60_000);
70  const days = Math.floor(minutes / 1440);
71  const hours = Math.floor((minutes % 1440) / 60);
72  const text = days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
73
74  return ` (resets in ${text})`;
75}
76
77/** One subscription: its windows, each named as claude's own status line names them;
78 *  one it does not have (codex's 5-hour one) is left out. */
79export type Cell = { name: string; isExpired: boolean; windows: { label: string; pct: number; resetsAt?: number }[] };
80
81/**
82 * The session's own subscription first, wherever it sits in the profile list, then
83 * the rest. Its own login is never in doubt: the session running is proof enough, and
84 * an older reading that said otherwise predates it.
85 */
86export function cells(view: View): Cell[] {
87  const cell = (name: string, usage: Usage | null | undefined, isExpired: boolean): Cell => ({
88    name,
89    isExpired,
90    windows: [
91      ...(usage?.fiveHourPct != null ? [{ label: '5h', pct: usage.fiveHourPct, resetsAt: usage.fiveHourResetsAt }] : []),
92      ...(usage?.weeklyPct != null ? [{ label: '7d', pct: usage.weeklyPct, resetsAt: usage.weeklyResetsAt }] : []),
93    ],
94  });
95
96  const rest = Object.entries(view.others)
97    .filter(([name]) => name !== view.current)
98    .map(([name, probe]) => cell(name, probe.status, probe.error === 'auth'));
99
100  return [cell(view.current, view.live ?? view.others[view.current]?.status, false), ...rest];
101}
102
103/** What `/aimux` prints: every subscription on its own line, each window with its reset. */
104export function table(view: View): string {
105  const all = cells(view);
106  const width = Math.max(...all.map((c) => c.name.length));
107
108  const lines = all.map((c, i) => {
109    const name = `${i === 0 ? '▸' : ' '} ${c.name.padEnd(width)}`;
110    if (c.isExpired) return `${name}  login expired — run: aimux run ${c.name}`;
111    if (c.windows.length === 0) return `${name}  no figures right now`;
112
113    const windows = c.windows.map((w) => `${w.label} ${w.pct}%${resetsIn(w.resetsAt, view.now)}`).join(' · ');
114    return `${name}  ${windows}${i === 0 ? '  ← this session' : ''}`;
115  });
116
117  return ['How much of each subscription is used', ...lines].join('\n');
118}
119
120export function warning(current: string, window: SessionRateLimit, others: Record<string, Probe>, own: number, now: number): string {
121  const head = `${current} has used ${Math.round(window.percentUsed)}% of its ${windowName(window)} window${resetsIn(parseTime(window.resetsAt), now)}.`;
122  if (Object.keys(others).length === 0) return `${head} aimux could not read the other subscriptions.`;
123
124  const next = freest(others, current, own);
125  if (!next) return `${head} No other claude subscription has more room right now.`;
126
127  const [name, usage] = next;
128  return `${head} Freest now: ${name} (5h ${pct(usage.fiveHourPct)}%, 7d ${pct(usage.weeklyPct)}%). `
129    + 'When this one runs out, aimux will offer to move this conversation there.';
130}
131
132// What the band draws: host state, so a write redraws it.
133const view = atom({ plugin: 'aimux', key: 'view' } as const, null);
134
135// The session's own figures. Module state: a hot reload starts it over, which only
136// means one more probe.
137// ponytail: an `aimux split` of N panes starts N sessions at once, each probing every
138// subscription before the shared reading exists — N×M one-token requests, once. A lock
139// around the probe would make it one round; not worth it yet.
140let current: string | undefined;
141let aimux: string[] | undefined;
142let handoff: string | undefined;
143let others: Record<string, Probe> = {};
144let live: Usage | undefined;
145let fetchedAt: number | undefined;
146let reading: Promise<boolean> | undefined;
147let offered: string | undefined;
148const warned = new Set<string>();
149
150async function snapshot($: EngineInterface): Promise<View | undefined> {
151  return current ? { current, others, live: live ?? null, now: await $.clock.now() } : undefined;
152}
153
154async function show($: EngineInterface) {
155  const next = await snapshot($);
156  if (next) await update($, view, () => next);
157}
158
159/** One question to aimux; whether it answered. */
160async function readOthers($: EngineInterface, command: string[]): Promise<boolean> {
161  try {
162    const { exitCode, stdout } = await $.process.run(command, { timeoutMs: 60_000 });
163    if (exitCode !== 0) return false;
164
165    others = JSON.parse(stdout).profiles;
166    return true;
167  } catch {
168    // aimux unreachable this time: keep the figures we have
169    return false;
170  } finally {
171    // A failed attempt waits its turn too: this runs after every response, and a
172    // broken aimux must not be started again each time.
173    fetchedAt = await $.clock.now();
174  }
175}
176
177/**
178 * Ask aimux for every subscription's figures, no older than `maxAgeSeconds`; whether
179 * it answered. One question at a time: a second caller waits for the one under way.
180 */
181function fetchOthers($: EngineInterface, maxAgeSeconds: number): Promise<boolean> {
182  if (!aimux) return Promise.resolve(false);
183
184  reading ??= readOthers($, [...aimux, 'status', '--json', '--max-age', String(maxAgeSeconds)])
185    .finally(() => { reading = undefined; });
186
187  return reading;
188}
189
190async function refresh($: EngineInterface) {
191  const now = await $.clock.now();
192  if (fetchedAt !== undefined && now - fetchedAt < REFRESH_MS) return;
193
194  await fetchOthers($, 240);
195  await show($);
196}
197
198/** `/aimux`: the whole table, read fresh — or saying so when it could not be. */
199async function report($: EngineInterface): Promise<string> {
200  // A background reading under way may be an older one than asked for here: let it
201  // land, then ask again.
202  await reading;
203  const isFresh = await fetchOthers($, 30);
204  await show($);
205
206  const now = await snapshot($);
207  if (!now) return 'aimux did not start this session, so it has nothing to show here.';
208
209  return isFresh ? table(now) : `${table(now)}\n(aimux could not be reached just now — these are the last figures it gave)`;
210}
211
212/** The window is spent: name where to go, and put `/exit` where one Enter runs it. */
213async function offerMove($: EngineInterface, window: SessionRateLimit, now: number) {
214  if (!current) return;
215
216  const head = `${current} is out of its ${windowName(window)} window${resetsIn(parseTime(window.resetsAt), now)}.`;
217  const next = freest(others, current, 100);
218  if (!next) {
219    $.ui.toast(`${head} No other claude subscription has room right now.`, { timeoutMs: 30_000 });
220    return;
221  }
222
223  const [name, usage] = next;
224  offered = name;
225
226  // Never over a draft: the person's own words stay, and they type /exit themselves.
227  const box = await $.prompt.read();
228  const filled = box.text.trim() === '' && (await $.prompt.fill({ text: '/exit' })).isFilled;
229  const how = filled ? 'Press Enter' : 'Run /exit';
230
231  $.ui.toast(
232    `${head} ${how} to carry this conversation over to ${name} (5h ${pct(usage.fiveHourPct)}%, 7d ${pct(usage.weeklyPct)}%).`,
233    { timeoutMs: 30_000 },
234  );
235}
236
237/**
238 * A spent window, by whichever sign came first — its use reaching 100%, or a turn the
239 * API refused over the limit. One offer per window, and not before aimux has answered
240 * once: the offer is about where to go next.
241 */
242async function spent($: EngineInterface, window: SessionRateLimit, now: number) {
243  const key = `${window.kind}:${window.resetsAt ?? ''}:out`;
244  if (fetchedAt === undefined || warned.has(key)) return;
245
246  warned.add(key);
247  await offerMove($, window, now);
248}
249
250/** A turn the API refused over a rate limit: spent, if a window is in fact nearly full. */
251async function refusedOverLimit($: EngineInterface) {
252  const { rateLimits } = await $.session.usage();
253  const tightest = rateLimits
254    .filter((w) => w.kind in WINDOW_NAME)
255    .sort((a, b) => b.percentUsed - a.percentUsed)[0];
256
257  // A refusal with room left in every window is the API being busy, not a spent
258  // subscription: nothing to move for.
259  if (tightest && tightest.percentUsed >= SPENT_AT) await spent($, tightest, await $.clock.now());
260}
261
262/** The person ran /exit after the offer: tell aimux, so it moves without asking again. */
263async function agreeToMove($: EngineInterface) {
264  if (offered && handoff) await $.fs.write(handoff, offered);
265}
266
267export const register: Register = (on) => {
268  on('session.start', async ($, e, next) => {
269    current = await $.env.get('AIMUX_RUN_PROFILE');
270    const self = await $.env.get('AIMUX_SELF');
271    aimux = current && self ? JSON.parse(self) : undefined;
272    handoff = await $.env.get('AIMUX_HANDOFF');
273
274    if (aimux) {
275      await $.command.register({ name: 'aimux', description: 'How much of every subscription is used, and when each resets' });
276
277      // On a timer, not awaited: the first prompt must not wait on a network probe.
278      $.clock.after(0, () => void refresh($));
279    }
280
281    return next(e);
282  });
283
284  on('session.measure', async ($, e, next) => {
285    if (!current) return next(e);
286
287    const now = await $.clock.now();
288    const five = e.rateLimits.find((w) => w.kind === 'five_hour');
289    const week = e.rateLimits.find((w) => w.kind === 'seven_day');
290    if (five || week) {
291      live = {
292        fiveHourPct: five ? Math.round(five.percentUsed) : null,
293        weeklyPct: week ? Math.round(week.percentUsed) : null,
294        fiveHourResetsAt: parseTime(five?.resetsAt),
295        weeklyResetsAt: parseTime(week?.resetsAt),
296      };
297    }
298
299    const own = Math.max(...[five, week].map((w) => w?.percentUsed ?? 0));
300    for (const window of [five, week]) {
301      if (!window) continue;
302
303      // One notice per window and stage: a new reset time is a new window, and one
304      // that eased back under the line may warn again.
305      const at = `${window.kind}:${window.resetsAt ?? ''}`;
306      if (window.percentUsed < WARN_AT) {
307        warned.delete(`${at}:near`);
308        warned.delete(`${at}:out`);
309        continue;
310      }
311
312      if (window.percentUsed >= 100) {
313        await spent($, window, now);
314        continue;
315      }
316
317      // Not before aimux has answered once: the notice names where to go next.
318      if (fetchedAt === undefined || warned.has(`${at}:near`)) continue;
319
320      warned.add(`${at}:near`);
321      $.ui.toast(warning(current, window, others, own, now), { timeoutMs: 15_000 });
322    }
323
324    await show($);
325    $.clock.after(0, () => void refresh($));
326    return next(e);
327  });
328
329  on('classic.StopFailure', async ($, e, next) => {
330    if (current && e.error === 'rate_limit') await refusedOverLimit($);
331    return next(e);
332  });
333
334  on('command.run', { command: 'exit' }, async ($, e, next) => {
335    await agreeToMove($);
336    return next(e);
337  });
338
339  on('command.run', { command: 'aimux' }, async ($) => ({ text: await report($) }));
340
341  // `aimux  ▸ dt (this session) 5h:27% 7d:43%  │  main 5h:13% 7d:12% · cx 7d:36%`
342  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
343    const drawn = await read($, view);
344    if (drawn === null || e.props.hasSurvey) return next(e);
345
346    const { Box, Text } = $.ui.resolve(e);
347    const [own, ...rest] = cells(drawn);
348    if (!own) return next(e);
349
350    const usage = (cell: Cell) => {
351      if (cell.isExpired) return [<Text color="yellow">login expired</Text>];
352      if (cell.windows.length === 0) return [<Text dimColor>–</Text>];
353
354      // A window close to its limit also says when it frees up.
355      return cell.windows.flatMap((w, i) => [
356        <Text dimColor>{`${i > 0 ? ' ' : ''}${w.label}:`}</Text>,
357        <Text color={levelColor(w.pct)}>{`${w.pct}%`}</Text>,
358        <Text dimColor>{w.pct >= NEAR ? resetsIn(w.resetsAt, drawn.now) : ''}</Text>,
359      ]);
360    };
361
362    // One Text of colored spans, not a row of boxes: a row squeezes each box on a
363    // narrow terminal and wraps them one by one; a single text wraps as a line does.
364    return (
365      <Box>
366        <Text>
367          <Text dimColor>aimux  </Text>
368          <Text bold>{`▸ ${own.name} `}</Text>
369          <Text dimColor>(this session) </Text>
370          {usage(own)}
371          {rest.length > 0 ? <Text dimColor>{'  │  '}</Text> : null}
372          {rest.flatMap((cell, i) => [
373            <Text dimColor>{`${i > 0 ? ' · ' : ''}`}</Text>,
374            <Text>{`${cell.name} `}</Text>,
375            ...usage(cell),
376          ])}
377        </Text>
378      </Box>
379    );
380  });
381};
382
types/index.d.ts 19 lines
1export type Usage = {
2  fiveHourPct: number | null;
3  weeklyPct: number | null;
4  /** When each window resets, in epoch milliseconds, where known. */
5  fiveHourResetsAt?: number;
6  weeklyResetsAt?: number;
7};
8export type Probe = { cli: string; status: Usage | null; error?: string };
9
10/** What the band above the prompt draws: this session's subscription and the rest,
11 *  as of `now` (which the reset times are counted from). */
12export type View = { current: string; others: Record<string, Probe>; live: Usage | null; now: number };
13
14declare module 'claude-code' {
15  interface PluginState {
16    aimux: { view: View | null };
17  }
18}
19