AIS pool and identity usage pane

Switch between multiple claude / codex / grok / kimi / zai / ali / pi / opencode identities — each with its own isolated config, auth, history, and plugins — without ever exporting an env var by hand. zai and ali are a bit different from the other six: neither is a real CLI; both are fake proxy names this project invents for the REAL crush binary (a multi-provider terminal coding agent by Charm), zai pointed at the ZAI/Z.ai provider so it runs real GLM models (see "ZAI" below), ali pointed at Alibaba Cloud Model Studio's Token plan (see "ALI" below), each kept in its own separate ~/.zai / ~/.ali set of profiles.
If you use Claude Code, Codex, Grok, Kimi Code, Pi, or Crush pointed at Z.ai's GLM models across multiple accounts — personal, a client, an employer — the native tools have exactly one logged-in identity at a time. Switching means logging out/in or juggling CLAUDE_CONFIG_DIR/CODEX_HOME/GROK_HOME/ KIMI_CODE_HOME by hand, every time, in every shell. This project installs a thin wrapper in front of each real CLI that picks the right identity automatically, mostly based on which directory you're standing in.
Eight compiled executables, claude, codex, grok, kimi, zai, ali, pi, and opencode, install to ~/.local/bin (ahead of the real Homebrew/nvm binaries on PATH; see the grok/kimi PATH caveat below, those tools' own installers can end up ahead of ~/.local/bin instead; zai/ali have no such risk, since there's no real zai/ali binary on PATH to lose a race against; their real binary is crush, resolved the same way every other wrapper resolves its own real binary). Each invocation resolves an identity through the same four-step chain, hands off to the real binary, then starts any configured SSH exchange in a detached background worker with everything else forwarded untouched:
flowchart TD
A["you run: claude / codex / grok / kimi / zai / ali / pi / opencode ..."] --> B{"--identity=<name>\npassed explicitly?"}
B -- yes --> R[resolved]
B -- no --> C{"identity env var\nalready set?"}
C -- yes --> R
C -- no --> D{"cwd matches an\nidentity's directories\npattern?"}
D -- yes --> R
D -- no --> E{"interactive TTY?"}
E -- no --> F["fail fast, loud error\n(automation never hangs)"]
E -- yes --> G["picker: pick existing\nor create new\n(60s timeout)"]
G --> R
R --> H["set the identity env var(s)\n→ Bun.spawn the REAL binary,\nfull stdio + signal passthrough"]
--identity=<name> — explicit, always wins.CLAUDE_CONFIG_DIR / CODEX_HOME / GROK_HOME / KIMI_CODE_HOME / CRUSH_GLOBAL_CONFIG / PI_CODING_AGENT_DIR / OPENCODE_CONFIG_DIR inherited from a parent process (e.g. a nested subagent session honoring its parent's identity).directories list in identities.json. Resolved silently, no prompt.@clack/prompts picker over existing identities plus "create new," bounded by a 60-second timeout. Skipped entirely in a non-interactive context (piped input, claude -p, codex exec, grok -p/grok agent, kimi -p/kimi acp, zai run, pi -p, no TTY) — automation gets a fast, loud error instead of a hang.zai's real binary is crush (github.com/charmbracelet/crush) — a real, actively-maintained multi-provider terminal coding agent, configured with a crush.json pointing its "zai" provider entry at Z.ai's API instead of some other backend (see "ZAI" below). Unlike an earlier design that piggybacked on the real claude binary, crush is a genuinely GLM-aware client: it shows the actual model name (e.g. zai/glm-4.6), not a translated Claude alias — the whole reason this project doesn't just point Claude Code at Z.ai's Anthropic-compatible endpoint instead (that endpoint transparently remaps whatever model alias Claude Code sends, with no way for Claude Code's own UI to reflect what actually answered).
On Linux the wrapper execves the real tool over itself (libc via bun:ffi), so the tool owns the shim's PID and terminal and a wrapper crash can never orphan it. Sync and Herdr tab-title work run in a detached __session-sidecar that watches the tool's PID. Where execve is unavailable (macOS, no FFI) the wrapper falls back to Bun.spawn with full fd inheritance and explicit signal relay (SIGINT/SIGTERM/SIGHUP/SIGQUIT/SIGTSTP), mirroring the child's exit code, the model nvm/asdf/direnv shims use.
AIS owns one machine-local memory file at ~/.ais/memory/GLOBAL.md. It is shared across every identity and every managed agent. It is deliberately outside the AIS source repository, every vendor profile, the SSH profile-sync roots, and the git-managed profile backup, so its contents are never pushed or copied as identity state.
At launch, the wrapper projects that one file through each tool's supported native instruction channel: Claude's appended system-prompt file, Codex's additional developer instructions, Grok's rules, Kimi's global AGENTS.md discovery, Pi's appended system prompt, OpenCode's runtime instructions configuration, and Crush's global context paths for both zai and ali. Those integrations are disposable views. The AIS file remains the only writable authority.
If ~/.ais/memory resolves into a git-base base checkout (a git toplevel shaped <root>/.worktrees/<org>/.base/<repo>) and an executable git-base is on PATH, ais memory add, edit and init never write the file in place. They compute the new content, then run git-base land <org>/<repo> -m "docs(memory): ..." --file <relpath>=<tempfile>, which commits in an ephemeral worktree, pushes and fast-forwards the base. The command prints the pushed commit SHA, or unchanged. If git-base land fails, ais memory exits non-zero with its stderr and does not fall back to an in-place write. Without a base (or without git-base) behaviour is unchanged.
Overrides: AIS_MEMORY_LAND=0 forces in-place writes; AIS_GIT_BASE_BIN sets the git-base executable path.
ais memory show
ais memory path
ais memory edit
ais memory add "- Durable fact"
printf '%s\n' '## Durable context' '- A multiline fact' | ais memory add --stdin
Codex state-DB backfills also self-heal before launch. Codex can leave backfill_state.status='running' behind after its worker exits, then reject every replacement process for the remainder of its 15-minute lease. The wrapper checks state_5.sqlite and its SQLite sidecars for a real open owner; when none exists, it expires only that orphaned lease and preserves the last watermark so Codex resumes immediately. A live worker is never disturbed, and an ownership check that cannot be completed leaves the database unchanged.
Define per-user MCP servers once in ~/.codex/config.toml. Every AIS Codex launch reads that file again and projects only missing defaults from its mcp_servers table through runtime configuration arguments. Codex merges those defaults with its native configuration. New identities inherit them on their first launch; edits apply to existing identities on their next launch. AIS does not copy shared entries into identity files. Its shared operational runtime allowlist is model_auto_compact_token_limit, tool_output_token_limit, and shared fields within agents and features. These shared values override identity duplicates, including settings written back by Codex, while identity-only fields remain. Shared user preference keys, model, model_reasoning_effort, and service_tier, are never sent as runtime configuration arguments: any such argument outranks config.toml, and Codex refuses the /model picker's save while one exists ("a higher-priority configuration layer overrides the saved value"). Instead, AIS writes the shared default into an identity's config.toml once, while the key is still unset, so once you pick a model or reasoning effort inside Codex it wins every later launch. To seed identities afresh from the shared value, remove the key from the identity's config.toml. An explicit non-OpenAI model_provider skips shared model, service_tier and agents.default_subagent_model; other shared agent values still apply, and local provider-specific model, tier and subagent choices remain untouched. Provider configuration, authentication, projects, hooks, trusted hashes, marketplaces, plugins, and browser options are not inherited.
Shared permission defaults are limited to approval_policy, approvals_reviewer, sandbox_mode, default_permissions, and the legacy sandbox_workspace_write table.
For Full Access, set these shared per-user values once in ~/.codex/config.toml:
approval_policy = "never"
sandbox_mode = "danger-full-access"
Shared policy, reviewer and compatible sandbox settings override identity duplicates. Explicit invocation overrides still win. A local named permission profile, or a shared named profile conflicting with local legacy sandbox fields, suppresses shared sandbox settings so the two representations are not mixed. A shared default_permissions takes precedence over shared legacy sandbox settings. Custom named permission definitions are not inherited.
For MCP servers, explicit fields in an identity's own config.toml override shared fields, including nested environment and tool settings. Local values and unrelated local servers are never forwarded into launch arguments. Arrays replace shared arrays. Set enabled = false under an identity's server table to opt out, or keep only identity-specific tool approvals there and inherit the shared transport. An explicit identity url excludes shared stdio fields, and an explicit command excludes shared HTTP fields, while common settings remain defaults. Invocation -c/--config overrides follow the projection and take precedence. For app-server and exec (including e), AIS places the complete root config override list inside the subcommand before user subcommand overrides. This preserves shared config and memory when the native child parser has its own -c. Launching with the global Codex home itself adds no projection. Other tools retain their existing configuration behaviour.
The projection applies to CLI launches, including codex app through the CLI. The direct --desktop path bypasses CLI argument projection, as it does for global memory. Invalid shared or identity TOML stops projection with a diagnostic that omits configuration contents.
Each identity's entire config directory is self-contained and swapped in as CLAUDE_CONFIG_DIR / CODEX_HOME / GROK_HOME / KIMI_CODE_HOME / CRUSH_GLOBAL_CONFIG (see "How it works" above):
~/.claude/ ← container, not itself an identity
├── identities.json ← registry: name, label, directories, configDir
└── identities/
└── <name>/ ← one fully isolated config per identity
├── settings.json
├── auth …
└── projects, history, plugins, …
~/.codex/ ← same pattern for Codex
├── identities.json
└── identities/<name>/…
~/.grok/ ← same pattern for Grok
├── identities.json
└── identities/<name>/…
~/.kimi-code/ ← same pattern for Kimi Code
├── identities.json
└── identities/<name>/…
~/.zai/ ← this wrapper's OWN container, separate
│ from ~/.claude's registry on purpose
└── identities/<name>/… ← each dir IS a Crush config dir
(crush.json, session state, …), with
crush.json's "zai" provider entry
holding that identity's Z.ai API key
~/.ali/ ← the SECOND crush-backed proxy's OWN
│ container, same shape as ~/.zai's,
│ kept separate from it too
└── identities/<name>/… ← crush.json's "alibaba" provider
entry, holding the identity's
Alibaba Model Studio API key
// identities.json entry
{
"name": "<name>",
"label": "<label>",
"description": "optional",
"configDir": "~/.claude/identities/<name>",
"directories": ["<absolute-directory>/*"]
}
directories pattern grammar is intentionally stricter than a glob dialect: a plain path matches only that exact directory; a path ending in /* matches it and everything nested beneath it, recursively. Nothing else is supported. When two identities' patterns both match, the longest (most specific) pattern wins, and an exact match always outranks a recursive one anchored at the same directory — a genuine tie falls through to the interactive prompt.
src/
├── identities/ the account-resolution engine — no process/OS concerns
│ ├── types.ts Identity / IdentitiesFile / ToolConfig / ResolveOptions
│ ├── store.ts load/validate/atomic-persist identities.json
│ ├── match.ts directory-pattern grammar + specificity-scored matching
│ ├── chrome-profile.ts resolveChromeMcpTarget(): override > active identity
│ ├── chrome-mcp.ts machine-local per-identity Chrome config + self-heal + CDP tab-open
│ ├── resolve.ts resolveIdentity(): flag > env > dir-match > prompt/error
│ ├── prompt.ts @clack/prompts picker + create-new-identity flow
│ ├── zai-auth.ts writeZaiAuthFile(): non-interactive Z.ai auth for zai identities
│ └── errors.ts typed IdentityResolutionError subclasses
├── shared/ process/OS mechanics — no identity-resolution logic
│ ├── resolve-binary.ts find the REAL claude/codex/grok/kimi/crush/open binary,
│ │ recursion guard — takes a realBinaryName, not a toolName
│ │ (zai's realBinaryName is "crush", not "zai")
│ ├── exec.ts Bun.spawn passthrough, signal relay, exit-code parity
│ ├── execve.ts libc execve via bun:ffi (Linux), falls back to exec.ts
│ ├── session-sidecar.ts detached sidecar: sync watcher, Herdr titles, post-exit sync
│ ├── cli-args.ts strip --identity=/--desktop, detect non-interactive intent
│ ├── codex-backfill.ts recover orphaned Codex state-DB backfill leases
│ ├── launch-desktop.ts best-effort --desktop launch
│ └── run-wrapper.ts wires the above together; shared by all five entrypoints;
│ also mirrors ToolConfig.extraEnvVarNames (zai's own
│ CRUSH_GLOBAL_DATA, alongside its CRUSH_GLOBAL_CONFIG envVarName)
├── sync/ SSH/rsync profile sync: config validation, portable
│ registries, newest-file exchange, lock + watcher
├── cli/ the `ais` management CLI — depends on identities/*, never the reverse
│ ├── dispatch.ts subcommand routing + error->exit-code handling
│ ├── args.ts minimal argv parser
│ ├── version.ts help.ts update.ts
│ ├── sync/ `ais sync list|add|remove|now|dedupe`
│ ├── identities/ actions.ts (pure mutations) + resolve-tool.ts (--tool resolution)
│ │ + dispatch/list/show/create/chrome-overrides.ts (CLI wrappers)
│ ├── limits/ `ais limits`: live/cached provider quota per identity per PROVIDER —
│ │ provider-first sections (Anthropic/OpenAI/Kimi/Z.ai/...), never tool
│ │ names; multi-provider clients (pi/opencode) contribute rows to the
│ │ same provider sections their native tools answer for. Per-tool
│ │ fetchers (claude/codex/grok/kimi — kimi's is a live
│ │ api.kimi.com usages fetch + OAuth token refresh; zai's is a live
│ │ api.z.ai quota fetch using the key from that identity's own
│ │ crush.json) + pi/opencode adapters reusing the same live reads
│ │ against credentials stored in their own auth files + report/watch
│ ├── resume/ `ais resume`: per-cwd resumable-session listing + relaunch —
│ │ per-tool readers (kimi's reads session_index.jsonl + per-session
│ │ state.json; zai's reads Crush's own project-local `.crush/crush.db`
│ │ SQLite file — found via that identity's own projects.json, unlike
│ │ the other four which store sessions under the identity's own
│ │ configDir) + picker + launch
│ ├── zai-usage.ts real local token/message/cost totals for zai, summed straight out
│ of Crush's own project-local crush.db (bun:sqlite) — Z.ai's API has
│ no such data, but Crush tracks it locally like every other tool
│ └── usage/ `ais usage`: per-identity/provider token usage & cost via tokscale
│ tokscale.ts (env-var mapping + --json types) + run.ts (collect
│ targets, spawn tokscale) + report.ts (table rendering) — zai uses
│ zai-usage.ts instead of tokscale entirely — see "Usage reporting" below
├── claude.ts entrypoint: ToolConfig for claude
├── codex.ts entrypoint: ToolConfig for codex
├── grok.ts entrypoint: ToolConfig for grok
├── kimi.ts entrypoint: ToolConfig for kimi
├── zai.ts entrypoint: ToolConfig for zai — execs the real `crush` binary,
│ pointed at Z.ai via that identity's own crush.json
├── open.ts entrypoint: shadows `open`, routes links into the active identity's Chrome (Claude MCP)
└── ais.ts entrypoint: the `ais` management CLI
scripts/
├── backup.ts mirrors every real config dir into a persistent git repo at
│ ~/.ais/backups and commits whatever changed
├── migrate.ts one-time, interactive, moves existing dirs into identities/<name>/
├── build.ts bun build --compile for all seven entrypoints
└── install.ts backup + copy compiled binaries to ~/.local/bin
This project's own data (backups, SSH sync's local cache/staging, the ais upgrade-managed npm prefix, sync config) all lives under one consolidated ~/.ais root — see "SSH profile and usage sync" and "Commands" below. ~/.local/bin (the shim binaries themselves, including ais) is the one deliberate exception: those need to be on PATH with zero shell-rc edits, so they stay outside ~/.ais.
identities/* and shared/* never import from each other in the wrong direction — shared/run-wrapper.ts depends on identities/*, never the reverse — so the resolution engine stays pure and unit-testable without any process/TTY/filesystem mocking. cli/* follows the same rule: it depends on identities/*, never the reverse, and adds no identity-resolution logic of its own.
bun install
bun test # sanity check before touching anything real
bun run migrate # ONE-TIME, destructive — see warning below
bun run build
bun run install:shims
# open a new terminal (or `hash -r`)
bun run migraterestructures your live~/.claudeand~/.codexdirectories into theidentities/<name>/layout above. It takes a full backup first (a git commit in the persistent repo at~/.ais/backups) and refuses to run if aclaude/codexprocess is currently active — close every running session first, and run it from a plain terminal, not from inside an active Claude Code/Codex session (its own config dir would be one of the directories being moved). It does not cover~/.grok,~/.kimi-code, or~/.zai— that was a one-time migration for pre-existing~/.claude/~/.codexcontent only.~/.grok/identities.json,~/.kimi-code/identities.json, and~/.zai/identities.jsonall just start empty; create your first grok/kimi/zai identity viaais identities create --tool=grok/--tool=kimi/--tool=zaior the interactive picker. Forzaispecifically, pass--api-key=<your Z.ai key>(or answer the prompt for it) at creation time — see "ZAI" below.
claude --identity=<name> # explicit
claude # auto-detect from cwd, or prompt
codex --identity=<name> app . # launch the Codex desktop app
claude --identity=<name> --desktop # best-effort: launch Claude.app directly
grok --identity=<name> "fix the bug"
kimi --identity=<name> -p "fix the bug"
zai --identity=<name> run "fix the bug"
pi --identity=<name> -p "fix the bug"
grok's and kimi's own installers can put their real binary ahead of this wrapper on
PATH. Unlike Homebrew'sclaudeornvm'scodex, the official Grok CLI installer appendsexport PATH="$HOME/.grok/bin:$PATH"to your shell rc, and Kimi Code's installer does the same withexport PATH="$HOME/.kimi-code/bin:$PATH"(confirmed at~/.zshrc:166-167 on this machine) — either line can end up ahead of your~/.local/binexport, depending on where it lands relative to it. Check withwhich grok/which kimiafterbun run install:shims: if either doesn't print~/.local/bin/<tool>, move that tool's PATH line in your shell rc (e.g.~/.zshrc) to before the line exporting~/.local/bin, then open a new terminal. SeeAGENTS.mdfor the full caveat.zaihas no equivalent risk — there's no realzaibinary anywhere to lose a PATH race against; the real binary it execs iscrush, resolved the same way every other wrapper resolves its own real binary.
zai doesn't proxy a CLI called "zai" — there isn't one, and (despite an earlier assumption in this project's own history) neither is Z.ai's own "ZCode" product a CLI: it's a desktop-only Electron app with no terminal interface at all. This project tried two other designs before landing on the current one — see AGENTS.md's "zai case study" (now on take 3) for the full history — but the short version:
Z.ai's GLM Coding Plan ships an Anthropic-compatible API endpoint, which means the real Claude Code CLI can work as a drop-in client with zero code changes. It's also the wrong choice: that endpoint transparently remaps whatever model alias Claude Code sends (e.g. "opus") to a GLM model server-side, but Claude Code's own UI has no visibility into that remap — it keeps showing its own Anthropic alias regardless of what actually answered. zai instead proxies Crush, a real, actively-maintained multi-provider terminal coding agent by Charm (also officially documented by Z.ai as a supported integration) — it's genuinely aware it's talking to Z.ai, so it shows the real model name (zai/glm-4.6, not a translated alias).
zai --identity=<name> ... resolves an identity exactly like the other four wrappers, then execs the real crush binary with CRUSH_GLOBAL_CONFIG pointed at that zai identity's own directory (holding crush.json) and CRUSH_GLOBAL_DATA pointed at a data/ subdirectory beneath it (Crush's own session/model-cache state) — the two must resolve to different directories, not the same one: pointing both at the literal same path was tried first and confirmed to make Crush double-register every provider/model.
Getting the key into place is a solved,
hooks/register.tsx 488 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { LimitsEntry, SwapStatus, SwapUsage } from '../types'
5import {
6 bandRows,
7 entriesFor,
8 humanise,
9 memberWindow,
10 others,
11 parseJson,
12 poolProviders,
13 providerBlocks,
14 snapshotPath,
15 pct,
16 resolveCurrent,
17 shares,
18 shortError,
19 switchNotice,
20 tone,
21 totals,
22} from './model'
23import { createContinuationLimiter, createRecoveryLifecycle, createTurnRecoveryState, recoveryDelay, runRateLimitRecoveryHook, runRecoveryCycle, runSessionEndHook } from './recovery'
24import type { RecoveryStatus } from './recovery'
25import type { Section } from './model'
26
27const PANE = 'ais-usage'
28
29const status = atom({ plugin: 'ais-usage', key: 'status' } as const, null)
30const usage = atom({ plugin: 'ais-usage', key: 'usage' } as const, null)
31const limits = atom({ plugin: 'ais-usage', key: 'limits' } as const, null)
32const expanded = atom({ plugin: 'ais-usage', key: 'expanded' } as const, [])
33// The one expanded section; opening one closes the other.
34const open = atom({ plugin: 'ais-usage', key: 'open' } as const, null as Section | null)
35const updatedAt = atom({ plugin: 'ais-usage', key: 'updatedAt' } as const, 0)
36const loading = atom({ plugin: 'ais-usage', key: 'loading' } as const, 0)
37const errors = atom({ plugin: 'ais-usage', key: 'errors' } as const, [])
38
39// Last pool member seen at a prompt; '' until the first prompt.
40const lastMember = atom({ plugin: 'ais-usage', key: 'lastMember' } as const, '')
41
42/**
43 * Shares toggles and data with `ais statusline`, which draws the expanded blocks.
44 * One file per session (identity + session id), so sessions on one identity keep their own toggles.
45 * shortcut: $.fs has no rename or delete, so the write is not atomic (the reader ignores a torn file) and dead sessions'
46 * files stay until the reader's staleness check ignores them; upgrade when $.fs can delete.
47 */
48const writeSnapshot = async ($: any) => {
49 try {
50 const home = (await $.env.get('HOME')) ?? ''
51 // Read per write: /clear starts a new session id without a new session.start. No id: the identity file.
52 const id = await $.session.id().catch(() => undefined)
53 const path = snapshotPath(home, await $.env.get('CLAUDE_CONFIG_DIR'), id || undefined)
54 const lim = await read($, limits)
55 const st = await read($, status)
56 const r = resolveCurrent(lim, await $.env.get('CLAUDE_CONFIG_DIR'), st)
57 const raw = await read($, open)
58 const o = raw === 'others' && !providerBlocks(lim, r).length ? null : raw
59 await $.fs.write(
60 path,
61 JSON.stringify({
62 open: o,
63 othersOpen: o === 'others',
64 membersOpen: o === 'members',
65 limits: lim,
66 status: st,
67 updatedAt: Date.now(),
68 }),
69 )
70 } catch {
71 // The status line is optional; a failed write must never break the mod.
72 }
73}
74
75/** Runs `ais` (on PATH, else ~/.local/bin/ais). */
76const runAis = async ($: any, args: string[], timeoutMs: number) => {
77 try {
78 return await $.process.run(['ais', ...args], { timeoutMs })
79 } catch {
80 return await $.process.run([`${(await $.env.get('HOME')) ?? ''}/.local/bin/ais`, ...args], { timeoutMs })
81 }
82}
83
84const ANSI = /\u001b\[[0-9;]*m/g
85const USAGE = 'Usage: /ais-swap [next | <account>]'
86
87const pad2 = (n: number) => String(n).padStart(2, '0')
88
89let isLimitsBusy = false
90let recoveryTimer: { cancel: () => void } | undefined
91let recoverySubmitMember: string | undefined
92const recoveryLifecycle = createRecoveryLifecycle()
93const continuationLimiter = createContinuationLimiter()
94const turnRecovery = createTurnRecoveryState()
95let observedPoolActive: string | undefined
96let mainTurnId: string | undefined
97
98const cancelRecovery = () => {
99 recoveryLifecycle.cancel()
100 recoveryTimer?.cancel()
101 recoveryTimer = undefined
102 if (recoverySubmitMember) continuationLimiter.cancelled(recoverySubmitMember)
103 recoverySubmitMember = undefined
104}
105
106const poolStatus = async ($: any, configDir: string): Promise<RecoveryStatus | undefined> => {
107 try {
108 const r = await runAis($, ['claude-swap', 'status', '--json', `--config-dir=${configDir}`], 20000)
109 const value = r.exitCode === 0 ? parseJson<RecoveryStatus>(r.stdout) : undefined
110 if (value?.active) observedPoolActive = value.active
111 return value
112 } catch {
113 return undefined
114 }
115}
116
117const queueContinuation = ($: any) => {
118 // Prompt submission from inside a working hook is rejected. The timer
119 // runs after the classic hook chain has returned and the turn is idle.
120 void $.prompt.submit({ text: 'Continue the interrupted request now that an allowed pool member is available.' }).catch(() => undefined)
121}
122
123const scheduleContinuation = ($: any, active: string | undefined) => {
124 if (!active) return
125 if (recoverySubmitMember === active && recoveryTimer) return
126 cancelRecovery()
127 const delay = continuationLimiter.reserve(active, Date.now())
128 if (delay === undefined) return
129 const generation = recoveryLifecycle.capture()
130 recoverySubmitMember = active
131 recoveryTimer = $.clock.after(delay, async () => {
132 recoveryTimer = undefined
133 recoverySubmitMember = undefined
134 continuationLimiter.fired(active)
135 if (recoveryLifecycle.isCurrent(generation)) queueContinuation($)
136 })
137}
138
139const scheduleRecovery = ($: any, configDir: string, before: RecoveryStatus, failureAtMs: number, assumeLimited: boolean, retryAtMs?: number, activeRetryAtMs?: number, immediate = false, submitOnly = false, trustLimitEvidence = false) => {
140 if (submitOnly) return scheduleContinuation($, before.active)
141 cancelRecovery()
142 const generation = recoveryLifecycle.capture()
143 recoveryTimer = $.clock.after(recoveryDelay(retryAtMs, Date.now(), immediate ? 0 : 60_000), async () => {
144 recoveryTimer = undefined
145 const isCurrent = () => recoveryLifecycle.isCurrent(generation)
146 void runRecoveryCycle({
147 before,
148 failureAtMs,
149 activeRetryAtMs,
150 trustLimitEvidence,
151 isCurrent,
152 readStatus: () => poolStatus($, configDir),
153 swap: async () => {
154 try {
155 const r = await runAis($, [
156 'claude-swap', 'next', '--if-limited', ...(assumeLimited ? ['--assume-limited'] : []), '--reason=auto', '--json',
157 ...(trustLimitEvidence ? ['--trust-limit-evidence'] : []),
158 `--failure-at-ms=${failureAtMs}`,
159 `--config-dir=${configDir}`, `--expected-active=${before.active}`,
160 ], 60000)
161 return r.exitCode === 0 ? parseJson<{ action?: string; retryAtMs?: number; activeRetryAtMs?: number }>(r.stdout) : undefined
162 } catch {
163 return undefined
164 }
165 },
166 submit: active => { if (isCurrent()) scheduleContinuation($, active) },
167 retry: (nextRetryAtMs, nextActiveRetryAtMs, nextTrustLimitEvidence) => {
168 if (isCurrent()) scheduleRecovery($, configDir, before, failureAtMs, assumeLimited, nextRetryAtMs, nextActiveRetryAtMs, false, false, nextTrustLimitEvidence)
169 },
170 }).catch(() => undefined)
171 })
172}
173
174export const register: Register = on => {
175 // Set by session.start; the command and the Refresh button call it.
176 let refreshHandle: (() => Promise<void>) | undefined
177
178 on('session.start', async ($, e, next) => {
179 cancelRecovery()
180 // Clears a status left by an older loaded version after /reload-plugins.
181 $.ui.status(undefined)
182
183 const run = (args: string[], timeoutMs: number) => runAis($, args, timeoutMs)
184
185 const fetchJson = async <T,>(src: string, args: string[], timeoutMs: number) => {
186 await update($, loading, n => n + 1)
187 let value: T | undefined
188 let err = ''
189 try {
190 const r = await run(args, timeoutMs)
191 value = parseJson<T>(r.stdout)
192 if (value === undefined) err = `exit ${r.exitCode}: ${shortError(r.stderr || r.stdout, 80)}`
193 } catch (x) {
194 err = shortError(String((x as Error)?.message ?? x), 80)
195 }
196 await update($, errors, list => [
197 ...list.filter(s => !s.startsWith(`${src}:`)),
198 ...(err ? [`${src}: ${err}`] : []),
199 ])
200 await update($, loading, n => n - 1)
201 if (value !== undefined) await update($, updatedAt, () => Date.now())
202 return value
203 }
204
205 const refreshStatus = async () => {
206 const v = await fetchJson<SwapStatus>('status', ['claude-swap', 'status', '--json', '--no-usage'], 20000)
207 if (v) {
208 await update($, status, () => v)
209 if (v.active) observedPoolActive = v.active
210 await writeSnapshot($)
211 }
212 }
213 const refreshUsage = async () => {
214 const v = await fetchJson<SwapUsage>('usage', ['claude-swap', 'usage', '--json'], 20000)
215 if (v) await update($, usage, () => v)
216 }
217 const refreshLimits = async () => {
218 if (isLimitsBusy) return
219 isLimitsBusy = true
220 try {
221 const v = await fetchJson<LimitsEntry[]>('limits', ['limits', '--json'], 60000)
222 if (Array.isArray(v)) {
223 await update($, limits, () => v)
224 await writeSnapshot($)
225 }
226 } finally {
227 isLimitsBusy = false
228 }
229 }
230 const refreshAll = async () => {
231 await Promise.all([refreshLimits(), refreshStatus(), refreshUsage()])
232 }
233
234 await $.command.register({
235 name: 'ais-usage',
236 description: 'Show AIS pool and identity usage',
237 })
238 await $.command.register({
239 name: 'ais-swap',
240 description: 'Switch the active account of the AIS swap pool',
241 argumentHint: '[next | <account>]',
242 })
243 void refreshAll()
244 $.clock.every(60_000, () => void refreshStatus())
245 $.clock.every(600_000, () => void refreshUsage())
246 $.clock.every(300_000, () => void refreshLimits())
247 refreshHandle = refreshAll
248
249 return next(e)
250 })
251
252 on('classic.StopFailure', async ($, e, next) => {
253 let configDir: string | undefined
254 const generation = recoveryLifecycle.capture()
255 const failureAtMs = turnRecovery.stopFailure()
256 return runRateLimitRecoveryHook(e, () => next(e), async () => {
257 try {
258 configDir = await $.env.get('CLAUDE_CONFIG_DIR')
259 return configDir ? await poolStatus($, configDir) : undefined
260 } catch {
261 return undefined
262 }
263 }, (before, failureAtMs, assumeLimited, submitOnly, trustLimitEvidence) => {
264 if (configDir && recoveryLifecycle.isCurrent(generation)) scheduleRecovery($, configDir, before, failureAtMs, assumeLimited, undefined, undefined, true, submitOnly, trustLimitEvidence)
265 }, Date.now, failureAtMs)
266 })
267
268 on('turn.start', ($, e, next) => {
269 mainTurnId = e.turnId
270 turnRecovery.start(Date.now())
271 return next(e)
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 const result = await next(e)
276 if (!e.agentId && e.turnId === mainTurnId) {
277 const st = await read($, status)
278 turnRecovery.complete(e.reason, observedPoolActive ?? st?.active, continuationLimiter)
279 mainTurnId = undefined
280 }
281 return result
282 })
283
284 on('session.end', (_$, e, next) => runSessionEndHook(recoveryLifecycle, e, next, () => {
285 recoveryTimer?.cancel()
286 recoveryTimer = undefined
287 if (recoverySubmitMember) continuationLimiter.cancelled(recoverySubmitMember)
288 recoverySubmitMember = undefined
289 }))
290
291 on('prompt.submit', async ($, e, next) => {
292 cancelRecovery()
293 const st = await read($, status)
294 const notice = switchNotice(await read($, lastMember), st)
295 if (st) await update($, lastMember, () => st.active)
296 if (!notice) return next(e)
297 $.ui.toast(notice)
298 return next({ ...e, context: [...(e.context ?? []), notice] })
299 })
300
301 on('command.run', { command: 'ais-usage' }, async $ => {
302 await $.ui.open({ id: PANE, title: 'AIS usage' })
303 void refreshHandle?.()
304
305 return { text: 'AIS usage pane opened.' }
306 })
307
308 on('command.run', { command: 'ais-swap' }, async ($, e) => {
309 const arg = e.args.trim()
310 const words = arg.split(/\s+/).filter(Boolean)
311 if (words.length > 1) return { text: USAGE }
312 const word = words[0] ?? ''
313 const swap = word === '' || word === 'status' ? ['status'] : word === 'next' ? ['next', '--reason=manual'] : ['to', word]
314 const r = await runAis($, ['claude-swap', ...swap], 60000)
315 const out = (r.exitCode === 0 ? r.stdout : r.stderr || r.stdout).replace(ANSI, '').trim()
316 if (r.exitCode !== 0) return { text: out || `ais claude-swap ${swap[0]} failed (exit ${r.exitCode})` }
317 if (swap[0] === 'status') return { text: out }
318 await refreshHandle?.()
319 const active = (await read($, status))?.active ?? (word === 'next' ? '' : word)
320 return {
321 text: `${out}\nA running session picks the new login up on its next message.`,
322 ...(active ? { context: [`The AIS swap pool's active account changed to ${active}.`] } : {}),
323 }
324 })
325
326 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
327 const st = await read($, status)
328 const lim = await read($, limits)
329 const r = resolveCurrent(lim, await $.env.get('CLAUDE_CONFIG_DIR'), st)
330 if (!lim || !bandRows(r, st, lim).length) return next(e)
331
332 const { Box, Text, Button } = $.ui.resolve(e)
333
334 const hasOthers = providerBlocks(lim, r).length > 0
335 const o = await read($, open)
336 // A section that cannot show anything counts as closed.
337 const isOthers = o === 'others' && hasOthers
338 const isMembers = o === 'members' && r.kind === 'pool'
339 const toggle = (s: Section) => async () => {
340 await update($, open, v => (v === s ? null : s))
341 await writeSnapshot($)
342 }
343
344 return (
345 <Box flexDirection="column" alignSelf="flex-start">
346 <Box flexDirection="row">
347 {hasOthers && (
348 <Button key="others" plain onPress={toggle('others')}>
349 {isOthers ? '▾' : '▸'} Other providers
350 </Button>
351 )}
352 {hasOthers && r.kind === 'pool' && <Text>{' '}</Text>}
353 {r.kind === 'pool' && (
354 <Button key="members" plain onPress={toggle('members')}>
355 {isMembers ? '▾' : '▸'} Pool members
356 </Button>
357 )}
358 </Box>
359 <Text dimColor>{e.props.hint}</Text>
360 </Box>
361 )
362 })
363
364 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
365 const { Box, Text, Button } = $.ui.resolve(e)
366 const st = await read($, status)
367 const us = await read($, usage)
368 const lim = await read($, limits)
369 const open = await read($, expanded)
370 const errs = await read($, errors)
371 const busy = (await read($, loading)) > 0
372 const at = await read($, updatedAt)
373 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
374 const r = resolveCurrent(lim, dir, st)
375
376 const P = ({ v }: { v: number | undefined }) => <Text color={tone(v ?? 0)}>{pct(v)}</Text>
377
378 const rows = (entries: LimitsEntry[], withName = false) =>
379 entries.map(l =>
380 l.windows.length ? (
381 <Text key={`${l.toolName}-${l.identity.name}`}>
382 {(withName ? `${l.identity.name === r.current ? '*' : ' '} ${l.identity.name}` : l.toolName).padEnd(withName ? 20 : 8)}
383 {l.windows.map(w => (
384 <Text>
385 {' '}
386 {w.label} <Text color={tone(w.usedPercent)}>{pct(w.usedPercent)}</Text>
387 {w.resetsAt ? <Text dimColor> (resets {w.resetsAt})</Text> : null}
388 {' '}
389 </Text>
390 ))}
391 </Text>
392 ) : (
393 <Text key={`${l.toolName}-${l.identity.name}`} dimColor>
394 {(withName ? `${l.identity.name === r.current ? '*' : ' '} ${l.identity.name}` : l.toolName).padEnd(withName ? 20 : 8)} {l.status} {shortError(l.error)}
395 </Text>
396 ),
397 )
398
399 const share = shares(us)
400 const msgs = new Map((us?.members ?? []).map(m => [m.member, m.messages]))
401 const tot = totals(us)
402
403 const sections = (
404 <Box flexDirection="column">
405 {r.kind === 'unknown' && (
406 <Text color="yellow">unknown identity ({r.configDir || 'CLAUDE_CONFIG_DIR unset'})</Text>
407 )}
408 {r.kind === 'pool' && st && (
409 <Box flexDirection="column">
410 <Text bold>
411 Pool {r.pool} active: {st.active} auto: {st.auto ? 'on' : 'off'} @
412 {st.thresholdPercent}%
413 </Text>
414 {st.accounts.map(a => (
415 <Text key={a.name} dimColor={!a.allowed}>
416 {a.active ? '*' : ' '} {a.name.padEnd(18)} 5h{' '}
417 <P v={memberWindow(st, lim, a.name, 'fiveHour')?.pct} /> wk <P v={memberWindow(st, lim, a.name, 'sevenDay')?.pct} />
418 {' '}30d share {pct(share[a.name])} ({humanise(msgs.get(a.name) ?? 0)} msgs)
419 </Text>
420 ))}
421 <Text dimColor>
422 30d total: {humanise(tot.messages)} messages, {humanise(tot.output)} output tokens
423 </Text>
424 </Box>
425 )}
426 {r.kind === 'pool' ? (
427 <Box flexDirection="column">
428 <Text bold>Same pool, other providers</Text>
429 {poolProviders(lim, r).map(g => (
430 <Box key={`g-${g.tool}`} flexDirection="column">
431 <Text bold>{g.tool}</Text>
432 {rows(g.rows, true)}
433 </Box>
434 ))}
435 </Box>
436 ) : (
437 r.current && (
438 <Box flexDirection="column">
439 <Text bold>This identity: {r.current}</Text>
440 {rows(entriesFor(lim, r.current))}
441 </Box>
442 )
443 )}
444 <Text bold>Other identities</Text>
445 {others(lim, [r.pool, r.current, ...(r.members ?? [])]).map(o => {
446 const isOpen = open.includes(o.name)
447 return (
448 <Box key={`o-${o.name}`} flexDirection="column">
449 <Button
450 key={`b-${o.name}`}
451 onPress={() =>
452 update($, expanded, list =>
453 list.includes(o.name) ? list.filter(n => n !== o.name) : [...list, o.name],
454 )
455 }
456 >
457 {isOpen ? '▾' : '▸'} {o.name}{' '}
458 <Text dimColor>
459 ({o.entries.length} tools, max <Text color={tone(o.max)}>{pct(o.max)}</Text>)
460 </Text>
461 </Button>
462 {isOpen && rows(o.entries)}
463 </Box>
464 )
465 })}
466 {errs.map(s => (
467 <Text dimColor>{shortError(s, 100)}</Text>
468 ))}
469 <Box>
470 <Text dimColor>
471 {busy && !lim ? 'loading...' : at ? `updated ${clock(at)}` : ''}{' '}
472 </Text>
473 <Button key="refresh" onPress={() => void refreshHandle?.()}>
474 Refresh
475 </Button>
476 </Box>
477 </Box>
478 )
479
480 return sections
481 })
482}
483
484const clock = (ms: number) => {
485 const d = new Date(ms)
486 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
487}
488hooks/model.ts 447 lines1import type { ExtraUsage, LimitsEntry, SwapAccount, SwapStatus, SwapUsage, Win } from '../types'
2
3export type Resolved = {
4 kind: 'pool' | 'identity' | 'unknown'
5 pool?: string
6 members?: string[]
7 current?: string
8 configDir: string
9}
10
11export const parseJson = <T>(text: string): T | undefined => {
12 try {
13 return JSON.parse(text) as T
14 } catch {
15 return undefined
16 }
17}
18
19const norm = (dir: string) => dir.replace(/\/+$/, '')
20
21export const resolveCurrent = (
22 limits: LimitsEntry[] | null,
23 configDir: string | undefined,
24 status: SwapStatus | null,
25): Resolved => {
26 const dir = norm(configDir ?? '')
27 const hit = (limits ?? []).find(
28 l => l.toolName === 'claude' && norm(l.identity.configDir) === dir,
29 )
30 if (!hit) return { kind: 'unknown', configDir: dir }
31 const sp = hit.identity.swapPool
32 if (sp) {
33 return {
34 kind: 'pool',
35 pool: hit.identity.name,
36 members: Array.isArray(sp.accounts) ? sp.accounts : [],
37 current: status?.active ?? sp.active,
38 configDir: dir,
39 }
40 }
41 return { kind: 'identity', current: hit.identity.name, configDir: dir }
42}
43
44export const humanise = (n: number): string => {
45 const a = Math.abs(n)
46 const f = (v: number, s: string) => `${v >= 100 ? Math.round(v) : Math.round(v * 10) / 10}${s}`
47 if (a >= 1e9) return f(n / 1e9, 'B')
48 if (a >= 1e6) return f(n / 1e6, 'M')
49 if (a >= 1e3) return f(n / 1e3, 'k')
50 return String(Math.round(n))
51}
52
53/** Share of output tokens per member, in percent (0 when the total is 0). */
54export const shares = (usage: SwapUsage | null): Record<string, number> => {
55 const out: Record<string, number> = {}
56 const total = (usage?.members ?? []).reduce((s, m) => s + m.output, 0)
57 for (const m of usage?.members ?? []) out[m.member] = total ? (m.output / total) * 100 : 0
58 return out
59}
60
61export const totals = (usage: SwapUsage | null) => ({
62 messages: (usage?.members ?? []).reduce((s, m) => s + m.messages, 0),
63 output: (usage?.members ?? []).reduce((s, m) => s + m.output, 0),
64})
65
66export const entriesFor = (limits: LimitsEntry[] | null, name: string): LimitsEntry[] =>
67 (limits ?? []).filter(l => l.identity.name === name && !l.identity.swapPool)
68
69export const maxPercent = (entries: LimitsEntry[]): number =>
70 Math.max(0, ...entries.flatMap(e => e.windows.map(w => w.usedPercent)))
71
72/** Identities other than the excluded names, skipping pools, sorted by name. */
73export const others = (limits: LimitsEntry[] | null, exclude: (string | undefined)[]) => {
74 const names = new Set<string>()
75 const pools = new Set<string>()
76 for (const l of limits ?? []) {
77 if (l.identity.swapPool) pools.add(l.identity.name)
78 else names.add(l.identity.name)
79 }
80 return [...names]
81 .filter(n => !pools.has(n) && !exclude.includes(n))
82 .sort()
83 .map(name => {
84 const entries = entriesFor(limits, name)
85 return { name, entries, max: maxPercent(entries) }
86 })
87}
88
89export type Tone = 'red' | 'yellow' | 'green'
90export const tone = (p: number): Tone => (p >= 95 ? 'red' : p >= 75 ? 'yellow' : 'green')
91
92export const pct = (p: number | undefined) => `${Math.round(p ?? 0)}%`
93
94export const shortError = (s: string | undefined, max = 60) => {
95 const t = (s ?? '').replace(/\s+/g, ' ').trim()
96 return t.length > max ? `${t.slice(0, max - 3)}...` : t
97}
98
99const pick = (ws: Win[], cat: string) => ws.find(w => w.category === cat)
100
101const CAT = { fiveHour: 'session', sevenDay: 'week' } as const
102
103/** One member's window: from status when it carries usage, else from its own `claude` limits entry (live preferred, cached accepted). */
104export const memberWindow = (
105 status: SwapStatus | null,
106 limits: LimitsEntry[] | null,
107 name: string,
108 key: 'fiveHour' | 'sevenDay',
109): { pct: number; resetsAt?: string | number } | undefined => {
110 const u = status?.accounts.find(a => a.name === name)?.usage?.[key]
111 if (u) return { pct: u.utilization, resetsAt: u.resetsAt }
112 const es = entriesFor(limits, name).filter(l => l.toolName === 'claude')
113 const w = [...es.filter(l => l.status === 'live'), ...es].map(l => pick(l.windows, CAT[key])).find(Boolean)
114 return w ? { pct: w.usedPercent, resetsAt: w.resetsAtMs ?? w.resetsAt } : undefined
115}
116
117/** One member's Claude extra usage: from status when it carries it, else from its own `claude` limits entry. */
118export const memberExtra = (status: SwapStatus | null, limits: LimitsEntry[] | null, name: string): ExtraUsage | undefined =>
119 status?.accounts.find(a => a.name === name)?.usage?.extraUsage ??
120 entriesFor(limits, name).find(l => l.toolName === 'claude' && l.extraUsage)?.extraUsage
121
122export type ProviderGroup = { tool: string; rows: LimitsEntry[] }
123
124/** Pool case: per non-claude tool, the identities that are members of the pool. */
125export const poolProviders = (limits: LimitsEntry[] | null, r: Resolved): ProviderGroup[] => {
126 const tools = [...new Set((limits ?? []).map(l => l.toolName))].filter(t => t !== 'claude').sort()
127 const out: ProviderGroup[] = []
128 for (const tool of tools) {
129 const own = (limits ?? []).find(l => l.toolName === tool && l.identity.swapPool)
130 const names = Array.isArray(own?.identity.swapPool?.accounts)
131 ? own!.identity.swapPool!.accounts!
132 : (r.members ?? [])
133 const rows = names.flatMap(n =>
134 (limits ?? []).filter(l => l.toolName === tool && l.identity.name === n && !l.identity.swapPool),
135 )
136 if (rows.length) out.push({ tool, rows })
137 }
138 return out
139}
140
141const clamp01 = (p: number) => Math.min(100, Math.max(0, Number.isFinite(p) ? p : 0))
142const cells = (p: number, width: number) => Math.round((clamp01(p) / 100) * width)
143
144/** Single-colour bar body (no brackets) as [filled, empty] full-block runs. */
145export const bar = (p: number, width: number): [string, string] => {
146 const n = cells(p, width)
147 return ['\u2588'.repeat(n), '\u2588'.repeat(Math.max(0, width - n))]
148}
149
150export type Window5 = 'fiveHour' | 'sevenDay'
151
152/** Mean utilization of one window across allowed members that report it. */
153export const poolWindowPct = (
154 accounts: SwapAccount[],
155 key: Window5,
156): { pct: number | undefined; reporting: number; total: number } => {
157 const allowed = accounts.filter(a => a.allowed)
158 const vals = allowed.flatMap(a => {
159 const u = a.usage?.[key]?.utilization
160 return typeof u === 'number' ? [u] : []
161 })
162 return {
163 pct: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : undefined,
164 reporting: vals.length,
165 total: allowed.length,
166 }
167}
168
169export type BarSegment = { kind: 'active' | 'pool' | 'empty'; len: number }
170
171/** Overlay bar: cells up to the lower value take its colour, up to the higher value the higher one's, then empty. Equal values draw as active. */
172export const twoToneBar = (activePct: number, poolPct: number | undefined, width: number): BarSegment[] => {
173 const a = cells(activePct, width)
174 const p = poolPct === undefined ? 0 : cells(poolPct, width)
175 const [lo, hi] = a <= p ? (['active', 'pool'] as const) : (['pool', 'active'] as const)
176 const segs: BarSegment[] = [
177 { kind: lo, len: Math.min(a, p) },
178 { kind: hi, len: Math.abs(a - p) },
179 { kind: 'empty', len: width - Math.max(a, p) },
180 ]
181 return segs.filter(s => s.len > 0)
182}
183
184const pad2 = (n: number) => String(n).padStart(2, '0')
185/** Window rows a band can show; codex plans report `week` or `month`. */
186export type RowName = 'session' | 'week' | 'month' | 'extra'
187const ROW_NAMES: readonly RowName[] = ['session', 'week', 'month']
188const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
189
190/** Reset moment as `HH:MM` (session) or `Mon D` (week); raw text when unparseable. */
191export const resetLabel = (at: string | number | undefined, kind: RowName): string => {
192 if (at === undefined || at === '') return ''
193 const d = new Date(at)
194 if (Number.isNaN(d.getTime())) return String(at)
195 return kind === 'session' ? `${pad2(d.getHours())}:${pad2(d.getMinutes())}` : `${MONTHS[d.getMonth()]} ${d.getDate()}`
196}
197
198export type BandRow = {
199 name: RowName
200 active: number
201 resets: string
202 /** Pool case only. */
203 pool?: { pct: number | undefined; reporting: number; total: number }
204 /** The current member's extra usage is not enabled: the label reads `off`. */
205 off?: boolean
206}
207
208/** Rows for the band above the prompt, or [] when there is nothing to show. */
209export const bandRows = (r: Resolved, status: SwapStatus | null, limits: LimitsEntry[] | null): BandRow[] => {
210 if (!r.current) return []
211 const out: BandRow[] = []
212 if (r.kind === 'pool') {
213 const accounts = status?.accounts ?? []
214 for (const [name, key] of [['session', 'fiveHour'], ['week', 'sevenDay']] as const) {
215 const w = memberWindow(status, limits, r.current, key)
216 if (!w) continue
217 // Members without usage in status (it is fetched without usage to spare the endpoint) report from limits.
218 const synth = accounts.map(a => {
219 const m = memberWindow(status, limits, a.name, key)
220 return { ...a, usage: m ? { [key]: { utilization: m.pct } } : undefined } as SwapAccount
221 })
222 out.push({ name, active: w.pct, resets: resetLabel(w.resetsAt, name), pool: poolWindowPct(synth, key) })
223 }
224 const extras = (r.members ?? []).map(n => ({ n, x: memberExtra(status, limits, n) }))
225 if (extras.some(m => m.x?.enabled)) {
226 const on = extras.filter(m => m.x?.enabled && (accounts.find(a => a.name === m.n)?.allowed ?? true))
227 const vals = on.flatMap(m => (typeof m.x?.usedPercent === 'number' ? [m.x.usedPercent] : []))
228 const cur = memberExtra(status, limits, r.current)
229 out.push({
230 name: 'extra',
231 active: cur?.enabled ? (cur.usedPercent ?? 0) : 0,
232 resets: '',
233 off: !cur?.enabled,
234 pool: { pct: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : undefined, reporting: vals.length, total: on.length },
235 })
236 }
237 } else {
238 const e = entriesFor(limits, r.current).find(l => l.toolName === 'claude')
239 for (const name of ['session', 'week'] as const) {
240 const w = e && pick(e.windows, name)
241 if (w) out.push({ name, active: w.usedPercent, resets: resetLabel(w.resetsAtMs ?? w.resetsAt, name) })
242 }
243 if (e?.extraUsage?.enabled) out.push({ name: 'extra', active: e.extraUsage.usedPercent ?? 0, resets: '' })
244 }
245 return out
246}
247
248/** Plain texts of one row: pool text, current text, label, and the non-bar tail (resets, reporting). */
249export function rowText(r: Resolved, row: BandRow) {
250 const pool = row.pool
251 const left = pool?.pct === undefined ? undefined : Math.max(0, 100 - Math.round(pool.pct))
252 const poolText = pool ? (pool.pct === undefined ? 'pool n/a' : `pool ${pct(pool.pct)} used, ${left}% left`) : ''
253 const own = row.off ? 'off' : pct(row.active)
254 const curText = pool ? `${r.current} ${own}` : own
255 const labelLen = pool ? poolText.length + 5 + curText.length : curText.length
256 const resets = row.resets ? ` resets ${row.resets}` : ''
257 const reporting = pool && pool.reporting < pool.total ? ` (${pool.reporting}/${pool.total} reporting)` : ''
258 return { pool, poolText, curText, labelLen, resets, reporting }
259}
260
261export const NAME_COL = 8
262const MARGIN = 3 // safety so a row never wraps
263
264/** Bar width shared by every row: the columns left after the longest row's non-bar text. */
265export function barWidth(r: Resolved, rows: BandRow[], cols: number): number {
266 const pad = labelPad(r, rows)
267 const fixed = Math.max(
268 ...rows.map(row => {
269 const t = rowText(r, row)
270 return NAME_COL + 2 + 1 + pad + t.resets.length + t.reporting.length
271 }),
272 )
273 const withResets = cols - MARGIN - fixed
274 if (withResets >= 10) return withResets
275 const withoutResets = Math.max(...rows.map(row => NAME_COL + 3 + pad + rowText(r, row).reporting.length))
276 return Math.max(10, cols - MARGIN - withoutResets)
277}
278
279/** Longest label among `rows`; shorter ones are right-padded to it. */
280export const labelPad = (r: Resolved, rows: BandRow[]): number =>
281 Math.max(...rows.map(row => rowText(r, row).labelLen))
282
283export type Seg = { text: string; color?: Tone | 'blue' | 'cyan' | 'gray'; dim?: boolean; bold?: boolean }
284
285/** One band row as coloured segments; `width` is the bar width, `pad` the longest label. */
286export function rowSegments(r: Resolved, row: BandRow, width: number, pad = 0, cols?: number): Seg[] {
287 const t = rowText(r, row)
288 const resetsOwnLine = !!t.resets && cols !== undefined &&
289 NAME_COL + 3 + Math.max(pad, t.labelLen) + width + t.resets.length + t.reporting.length > cols - MARGIN
290 const pool = t.pool
291 const body: Seg[] = pool
292 ? twoToneBar(row.active, pool.pct, width).map(s => ({
293 text: '\u2588'.repeat(s.len),
294 color: ({ active: 'cyan', pool: 'blue', empty: 'gray' } as const)[s.kind],
295 }))
296 : [
297 { text: bar(row.active, width)[0], color: tone(row.active) },
298 { text: bar(row.active, width)[1], color: 'gray' },
299 ]
300 const label: Seg[] = pool
301 ? [
302 { text: t.poolText, color: tone(pool.pct ?? 0) },
303 { text: ' | ' },
304 { text: t.curText, color: tone(row.active) },
305 ]
306 : [{ text: t.curText, color: tone(row.active) }]
307 return [
308 { text: row.name.padEnd(NAME_COL) + '[' },
309 ...body,
310 { text: '] ' },
311 ...label,
312 { text: ' '.repeat(Math.max(0, pad - t.labelLen)) },
313 { text: resetsOwnLine ? '' : t.resets, dim: true },
314 { text: t.reporting, dim: true },
315 ].filter(g => g.text)
316}
317
318export type ProviderBlock = { tool: string; rows: BandRow[] }
319
320/**
321 * Per other tool: pool totals over its members, one row per category present in any member.
322 * In a pool: the tool's pool of the same name (its accounts). Outside: its identities named like the current one.
323 */
324export const providerBlocks = (limits: LimitsEntry[] | null, r: Resolved): ProviderBlock[] => {
325 const all = limits ?? []
326 const tools = [...new Set(all.map(l => l.toolName))].filter(t => t !== 'claude').sort()
327 const out: ProviderBlock[] = []
328 for (const tool of tools) {
329 const ofTool = all.filter(l => l.toolName === tool)
330 let members: LimitsEntry[]
331 if (r.pool) {
332 const accounts = ofTool.find(l => l.identity.swapPool && l.identity.name === r.pool)?.identity.swapPool?.accounts
333 members = Array.isArray(accounts) ? ofTool.filter(l => !l.identity.swapPool && accounts.includes(l.identity.name)) : []
334 } else {
335 members = ofTool.filter(l => !l.identity.swapPool && l.identity.name === r.current)
336 }
337 const rows: BandRow[] = []
338 for (const name of ROW_NAMES) {
339 const ws = members.flatMap(l => pick(l.windows, name) ?? [])
340 if (!ws.length) continue
341 const ats = ws.map(w => w.resetsAtMs ?? w.resetsAt).filter(a => a !== undefined && a !== '' && !Number.isNaN(new Date(a).getTime()))
342 const first = ats.sort((x, y) => new Date(x!).getTime() - new Date(y!).getTime())[0]
343 rows.push({ name, active: ws.reduce((n, w) => n + w.usedPercent, 0) / ws.length, resets: resetLabel(first, name) })
344 }
345 if (rows.length) out.push({ tool, rows })
346 }
347 return out
348}
349
350/** Notice text when the pool's active member changed since `prev`; none on first sight or no change. */
351export const switchNotice = (prev: string, status: SwapStatus | null): string | undefined =>
352 status && prev && prev !== status.active
353 ? `AIS pool ${status.pool} switched account from ${prev} to ${status.active} since your last turn; limits shown now refer to ${status.active}.`
354 : undefined
355
356export type MemberBlock = { tool: string; members: { name: string; rows: BandRow[] }[] }
357
358const memberBand = (name: RowName, pctUsed: number, at: string | number | undefined): BandRow => ({
359 name,
360 active: pctUsed,
361 resets: resetLabel(at, name),
362})
363
364/** Pool case: the current pool's members (its member list, in order) with their own session and week rows; a member without windows has no rows. */
365export const memberBlocks = (status: SwapStatus | null, limits: LimitsEntry[] | null, r: Resolved): MemberBlock[] => {
366 const members = (r.members ?? []).map(name => ({
367 name,
368 rows: ([['session', 'fiveHour'], ['week', 'sevenDay']] as const).flatMap(([n, k]) => {
369 const w = memberWindow(status, limits, name, k)
370 return w ? [memberBand(n, w.pct, w.resetsAt)] : []
371 }).concat(memberExtra(status, limits, name)?.enabled ? [memberBand('extra', memberExtra(status, limits, name)?.usedPercent ?? 0, undefined)] : []),
372 }))
373 return members.length ? [{ tool: 'claude', members }] : []
374}
375
376export type Section = 'others' | 'members'
377
378const CARD_FIXED = NAME_COL + 1 + 2 + 4 + 2 + 6 // label, '[', '] ', pct, gap, resets
379const CARD_GAP = 3
380const CARD_MIN = 34
381
382/** Cards filling `cols`: k per row (each at least CARD_MIN wide), equal widths, rows wrap. Every line is at most `cols` wide. */
383export function memberCards(blocks: MemberBlock[], cols: number): Seg[][][] {
384 const n = blocks.reduce((t, b) => t + b.members.length, 0)
385 const per = Math.max(1, Math.min(n, Math.floor((cols + CARD_GAP) / (CARD_MIN + CARD_GAP))))
386 const w = Math.min(cols, Math.floor((cols - CARD_GAP * (per - 1)) / per))
387 const bw = Math.max(1, w - CARD_FIXED)
388 const fit = (t: string) => (t.length > w ? `${t.slice(0, w - 1)}\u2026` : t)
389 const card = (m: MemberBlock['members'][number]): Seg[][] => [
390 [{ text: fit(m.name), dim: true }],
391 ...(m.rows.length
392 ? m.rows.map((row): Seg[] => {
393 const cur = pct(row.active)
394 const c = cells(row.active, bw)
395 return [
396 { text: row.name.padEnd(NAME_COL) + '[' },
397 { text: '\u2588'.repeat(c), color: tone(row.active) },
398 { text: '\u2588'.repeat(bw - c), color: 'gray' },
399 { text: '] ' },
400 { text: cur.padStart(4), color: tone(row.active) },
401 { text: ` ${row.resets}`.slice(0, 8), dim: true },
402 ]
403 })
404 : [[{ text: 'n/a', dim: true }] as Seg[]]),
405 ]
406 const len = (l: Seg[]) => l.reduce((t, g) => t + g.text.length, 0)
407 return blocks.map(b => {
408 const lines: Seg[][] = []
409 const cards = b.members.map(card)
410 for (let i = 0; i < cards.length; i += per) {
411 const group = cards.slice(i, i + per)
412 for (let y = 0; y < Math.max(...group.map(c => c.length)); y++) {
413 const line: Seg[] = []
414 group.forEach((c, x) => {
415 const l = c[y] ?? []
416 if (x) line.push({ text: ' '.repeat(CARD_GAP) })
417 line.push(...l, { text: ' '.repeat(Math.max(0, w - len(l))) })
418 })
419 while (line.length && !line[line.length - 1]!.text.trim()) line.pop()
420 lines.push(line)
421 }
422 }
423 return lines
424 })
425}
426
427export type Snapshot = {
428 /** Current field; the two booleans are written for older readers and read when `open` is absent. */
429 open?: Section | null
430 othersOpen?: boolean
431 membersOpen?: boolean
432 limits: LimitsEntry[] | null
433 status: SwapStatus | null
434 updatedAt: number
435}
436
437/** Snapshot file name: the CLAUDE_CONFIG_DIR basename, plus the session id when given (toggles are per session). Sanitised. */
438export const snapshotName = (configDir: string | undefined, sessionId?: string): string => {
439 const clean = (v: string) => v.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '_')
440 const base = clean(norm(configDir ?? '').split('/').pop() ?? '') || 'default'
441 return `${base}${sessionId ? `.${clean(sessionId)}` : ''}.json`
442}
443
444/** Where the mod writes the snapshot `ais statusline` reads. */
445export const snapshotPath = (home: string, configDir: string | undefined, sessionId?: string): string =>
446 `${norm(home)}/.ais/cache/ais-usage/${snapshotName(configDir, sessionId)}`
447hooks/recovery.ts 161 lines1export type RecoveryStatus = {
2 active?: string
3 lastSwapAt?: string
4 accounts?: Array<{ name?: string; allowed?: boolean; usage?: { status?: string; maxUtilization?: number; windows?: Array<{ utilization?: number; resetsAt?: string }> } }>
5}
6
7export const createRecoveryLifecycle = () => {
8 let generation = 0
9 return {
10 capture: () => generation,
11 cancel: () => ++generation,
12 isCurrent: (candidate: number) => candidate === generation,
13 }
14}
15
16export const runSessionEndHook = <T>(lifecycle: ReturnType<typeof createRecoveryLifecycle>, event: unknown, next: (event: unknown) => T, cancelTimer: () => void): T => {
17 lifecycle.cancel()
18 cancelTimer()
19 return next(event)
20}
21
22const stringField = (record: Record<string, unknown>, key: string): string => typeof record[key] === 'string' ? record[key] as string : ''
23
24export const isUsageLimitFailure = (event: unknown): boolean => {
25 if (typeof event !== 'object' || event === null) return false
26 const details = stringField(event as Record<string, unknown>, 'error_details')
27 return /(?:hit|reached|exceeded)\s+(?:your\s+)?(?:\w+\s+){0,2}(?:usage\s+)?limit|(?:usage\s+)?quota\s+(?:is\s+)?(?:exhausted|reached)|limit\s+(?:will\s+)?reset|resets?\s+(?:at|in)\b/i.test(details)
28}
29
30export const isRateLimitFailure = (event: unknown): boolean => {
31 if (typeof event !== 'object' || event === null) return false
32 const record = event as Record<string, unknown>
33 return /^rate[_ -]?limit$/i.test(stringField(record, 'error')) || isUsageLimitFailure(event)
34}
35
36export const changedToAllowedMember = (before: RecoveryStatus, after: RecoveryStatus): boolean =>
37 Boolean(
38 before.active &&
39 after.active &&
40 before.active !== after.active &&
41 after.accounts?.some(account => account.name === after.active && account.allowed),
42 )
43
44export const activeMemberHasHeadroom = (status: RecoveryStatus): boolean => {
45 const active = status.accounts?.find(account => account.name === status.active)
46 return active?.allowed === true && active.usage?.status === 'live' && typeof active.usage.maxUtilization === 'number' && active.usage.maxUtilization < 100
47}
48
49export const swapRecordedSinceFailure = (status: RecoveryStatus, failureAtMs: number | undefined): boolean => {
50 if (typeof failureAtMs !== 'number' || !Number.isFinite(failureAtMs)) return false
51 const lastSwap = Date.parse(status.lastSwapAt ?? '')
52 return Number.isFinite(lastSwap) && lastSwap >= failureAtMs
53}
54
55export const canResumeActiveAfterReset = (status: RecoveryStatus, activeRetryAtMs: number | undefined, now: number): boolean =>
56 typeof activeRetryAtMs === 'number' && Number.isFinite(activeRetryAtMs) && activeRetryAtMs <= now && activeMemberHasHeadroom(status)
57
58export const createContinuationLimiter = (floorMs = 60_000, ceilingMs = 600_000) => {
59 const states = new Map<string, { attempts: number; nextAllowedAt: number; pending: boolean }>()
60 const maxAttempts = Math.ceil(Math.log2(ceilingMs / floorMs))
61 return {
62 reserve(member: string, now: number): number | undefined {
63 if (!member) return undefined
64 const state = states.get(member) ?? { attempts: 0, nextAllowedAt: 0, pending: false }
65 if (state.pending) return undefined
66 const backoff = Math.min(ceilingMs, floorMs * 2 ** Math.min(state.attempts, maxAttempts))
67 const deadline = now + Math.max(backoff, state.nextAllowedAt - now)
68 state.nextAllowedAt = deadline
69 state.pending = true
70 states.set(member, state)
71 return deadline - now
72 },
73 fired(member: string): void {
74 const state = states.get(member)
75 if (!state) return
76 state.pending = false
77 state.attempts = Math.min(maxAttempts, state.attempts + 1)
78 },
79 cancelled(member: string): void {
80 const state = states.get(member)
81 if (state) state.pending = false
82 },
83 reset(member: string | undefined): void {
84 if (member) states.delete(member)
85 },
86 }
87}
88
89export const createTurnRecoveryState = (initialStartedAtMs = Date.now()) => {
90 let startedAtMs = initialStartedAtMs
91 let hadStopFailure = false
92 return {
93 start(now: number): void {
94 startedAtMs = now
95 hadStopFailure = false
96 },
97 stopFailure(): number {
98 hadStopFailure = true
99 return startedAtMs
100 },
101 complete(reason: string, active: string | undefined, limiter: ReturnType<typeof createContinuationLimiter>): void {
102 if (reason === 'answer' && !hadStopFailure) limiter.reset(active)
103 hadStopFailure = false
104 },
105 }
106}
107
108export const runRateLimitRecoveryHook = async <T>(
109 event: unknown,
110 next: () => Promise<T>,
111 readAfter: () => Promise<RecoveryStatus | undefined>,
112 schedule: (before: RecoveryStatus, failureAtMs: number, assumeLimited: boolean, submitOnly: boolean, trustLimitEvidence: boolean) => void,
113 now: () => number = Date.now,
114 turnStartedAtMs?: number,
115): Promise<T> => {
116 const failureAtMs = turnStartedAtMs ?? now()
117 const result = await next()
118 if (!isRateLimitFailure(event)) return result
119 const before = await readAfter().catch(() => undefined)
120 if (!before?.active) return result
121 if (swapRecordedSinceFailure(before, failureAtMs)) return void schedule(before, failureAtMs, false, true, false), result
122 const trustLimitEvidence = isUsageLimitFailure(event)
123 if (activeMemberHasHeadroom(before) && !trustLimitEvidence) return void schedule(before, failureAtMs, false, true, false), result
124 const assumeLimited = trustLimitEvidence || !activeMemberHasHeadroom(before)
125 schedule(before, failureAtMs, assumeLimited, false, trustLimitEvidence)
126 return result
127}
128
129export const runRecoveryCycle = async (input: {
130 before: RecoveryStatus
131 failureAtMs?: number
132 activeRetryAtMs?: number
133 trustLimitEvidence?: boolean
134 isCurrent: () => boolean
135 readStatus: () => Promise<RecoveryStatus | undefined>
136 swap: () => Promise<{ action?: string; retryAtMs?: number; activeRetryAtMs?: number } | undefined>
137 submit: (active?: string) => void
138 retry: (retryAtMs?: number, activeRetryAtMs?: number, trustLimitEvidence?: boolean) => void
139 now?: () => number
140}): Promise<void> => {
141 const current = await input.readStatus()
142 if (!input.isCurrent() || !current?.active) return
143 if (swapRecordedSinceFailure(current, input.failureAtMs)) return input.submit(current.active)
144 if (changedToAllowedMember(input.before, current)) return input.submit(current.active)
145 if (current.active === input.before.active && activeMemberHasHeadroom(current) && !input.trustLimitEvidence) return input.submit(current.active)
146 if (current.active === input.before.active && canResumeActiveAfterReset(current, input.activeRetryAtMs, (input.now ?? Date.now)())) return input.submit(current.active)
147 if (current.active !== input.before.active) return
148 const outcome = await input.swap()
149 if (!input.isCurrent()) return
150 const after = await input.readStatus()
151 if (!input.isCurrent()) return
152 if (after && swapRecordedSinceFailure(after, input.failureAtMs)) return input.submit(after.active)
153 if (after && changedToAllowedMember(input.before, after)) return input.submit(after.active)
154 if (after && after.active === input.before.active && activeMemberHasHeadroom(after) && !input.trustLimitEvidence) return input.submit(after.active)
155 if (after && after.active === input.before.active && canResumeActiveAfterReset(after, outcome?.activeRetryAtMs ?? input.activeRetryAtMs, (input.now ?? Date.now)())) return input.submit(after.active)
156 if (outcome?.action === 'no-candidate' || outcome === undefined) input.retry(outcome?.retryAtMs, outcome?.activeRetryAtMs ?? input.activeRetryAtMs, input.trustLimitEvidence)
157}
158
159export const recoveryDelay = (retryAtMs: number | undefined, now: number, fallbackMs = 60_000): number =>
160 Math.max(fallbackMs, typeof retryAtMs === 'number' && Number.isFinite(retryAtMs) && retryAtMs > now ? retryAtMs - now : 0)
161types/index.d.ts 56 lines1export type Win = { label: string; category?: string; usedPercent: number; resetsAt?: string; resetsAtMs?: number }
2export type SwapPool = { accounts?: string[]; disallowed?: string[]; active?: string; auto?: boolean; thresholdPercent?: number }
3export type ExtraUsage = { enabled: boolean; available: boolean; usedPercent?: number; label: string }
4export type LimitsEntry = {
5 toolName: string
6 provider?: string
7 identity: { name: string; label?: string; configDir: string; aliases?: string[]; swapPool?: SwapPool }
8 windows: Win[]
9 status: string
10 error?: string
11 capturedAt?: string
12 extraUsage?: ExtraUsage
13}
14export type SwapAccount = {
15 name: string
16 active: boolean
17 allowed: boolean
18 usage?: {
19 status?: string
20 fiveHour?: { utilization: number; resetsAt?: string }
21 sevenDay?: { utilization: number; resetsAt?: string }
22 maxUtilization?: number
23 extraUsage?: ExtraUsage
24 }
25}
26export type SwapStatus = {
27 pool: string
28 active: string
29 auto: boolean
30 thresholdPercent: number
31 accounts: SwapAccount[]
32}
33export type SwapMember = { member: string; messages: number; input: number; output: number }
34export type SwapUsage = { pool: string; days: number; members: SwapMember[] }
35
36declare module 'claude-code' {
37 interface PluginState {
38 'ais-usage': {
39 status: SwapStatus | null
40 usage: SwapUsage | null
41 limits: LimitsEntry[] | null
42 /** Names of expanded "other identities" rows. */
43 expanded: string[]
44 /** Band toggles: "Other providers" and "Pool members" open. */
45 open: 'others' | 'members' | null
46 /** Epoch ms of the last completed fetch, 0 for none. */
47 updatedAt: number
48 /** Fetches in flight. */
49 loading: number
50 errors: string[]
51 /** Pool member seen at the previous prompt, '' for none. */
52 lastMember: string
53 }
54 }
55}
56