SLOPSHOPPER

ais-usage

AIS pool and identity usage pane

newpanespinnercommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ais-usage
│ ┃ AIS usage ✕ › fix the failing auth test and add an audit log call │ ┃ unknown identity (CLAUDE_CONFIG_DIR unset) │ ┃ Other identities ⏺ Read(src/auth.ts) │ ┃ usage: exit 0: ⎿ Read 6 lines │ ┃ [ Refresh ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ais-usage │ ⎿ ais-usage: AIS usage pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · AIS usage
unknown identity (CLAUDE_CONFIG_DIR unset) Other identities usage: exit 0: [ Refresh ]
README

AiProfileSwitcher

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.

Why

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.

How it works

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"]
  1. --identity=<name> — explicit, always wins.
  2. Env var already set — 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).
  3. Directory match — the cwd matches a pattern in an identity's directories list in identities.json. Resolved silently, no prompt.
  4. Interactive picker — @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.

Global memory

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.

Memory served from a git-base base checkout

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.

Shared Codex defaults

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.

Directory layout on disk

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.

Module map

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.

Setup

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 migrate restructures your live ~/.claude and ~/.codex directories into the identities/<name>/ layout above. It takes a full backup first (a git commit in the persistent repo at ~/.ais/backups) and refuses to run if a claude/codex process 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/ ~/.codex content only. ~/.grok/identities.json, ~/.kimi-code/identities.json, and ~/.zai/identities.json all just start empty; create your first grok/kimi/zai identity via ais identities create --tool=grok / --tool=kimi / --tool=zai or the interactive picker. For zai specifically, pass --api-key=<your Z.ai key> (or answer the prompt for it) at creation time — see "ZAI" below.

Usage

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's claude or nvm's codex, the official Grok CLI installer appends export PATH="$HOME/.grok/bin:$PATH" to your shell rc, and Kimi Code's installer does the same with export PATH="$HOME/.kimi-code/bin:$PATH" (confirmed at ~/.zshrc:166-167 on this machine) — either line can end up ahead of your ~/.local/bin export, depending on where it lands relative to it. Check with which grok / which kimi after bun 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. See AGENTS.md for the full caveat. zai has no equivalent risk — there's no real zai binary anywhere to lose a PATH race against; the real binary it execs is crush, resolved the same way every other wrapper resolves its own real binary.

ZAI

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,

Source 4 files
hooks/register.tsx 488 lines
1import { 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}
488
hooks/model.ts 447 lines
1import 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)}`
447
hooks/recovery.ts 161 lines
1export 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)
161
types/index.d.ts 56 lines
1export 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