From 13d02fcc38a3e4b35889e5f61390b57273fd540b Mon Sep 17 00:00:00 2001 From: ghost Date: Sat, 16 May 2026 04:47:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(quota):=20Phase-1=20scaffold=20=E2=80=94?= =?UTF-8?q?=20multi-account=20Claude=20limits=20+=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web deep-research + exhaustive 12-repo prior-art survey → hexa-native `quota` plugin. Phase 1 (read-only, verified live): - /quota slash + `quota` tool + quota_summary + `wilson quota` CLI (g10, _cmd_quota mirrors _cmd_inbox; core owns argv dispatch, not a g8 patch). - Reads current account (~/.claude.json oauthAccount), access token (linux file ∨ mac keychain), LIVE 5h/7d via the OAuth usage endpoint (curl through exec_with_status; g2/g4). wilson-owned account registry. - Opt-in (not in _bundle, g9): `wilson build --with quota`. - Phase-3 (live cred switch + seamless --resume failover) contracted fail-loud (g3); clean-room recipe locked in RESEARCH.md §c3. - Agent SDK monthly credit: honest not_implemented stub (post-2026-06-15 source unknown — no fakery, n16). ToS: the 2026 OpenClaw ban was reinstated/restructured (metered Agent SDK credit eff. 2026-06-15), not abolished — RESEARCH §D, @F f1 rescoped. Verified: `wilson build --with quota` OK (30 plugins) · `wilson test` 23/23 · loader taxonomy clean · `wilson quota status` live against the real account. Prior-art cloned out-of-tree (~/core/reference/), GPL-3 cux study-only. Session log: docs/sessions/2026-05-16-quota-plugin-scaffold.md Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 1 + core/loader.hexa | 2 + core/main.hexa | 46 +++ .../2026-05-16-quota-plugin-scaffold.md | 74 ++++ plugins/quota/RESEARCH.md | 173 +++++++++ plugins/quota/RESEARCH.tape | 222 ++++++++++++ plugins/quota/main.hexa | 338 ++++++++++++++++++ plugins/quota/plugin.hexa | 79 ++++ plugins/quota/test_quota.hexa | 66 ++++ 9 files changed, 1001 insertions(+) create mode 100644 docs/sessions/2026-05-16-quota-plugin-scaffold.md create mode 100644 plugins/quota/RESEARCH.md create mode 100644 plugins/quota/RESEARCH.tape create mode 100644 plugins/quota/main.hexa create mode 100644 plugins/quota/plugin.hexa create mode 100644 plugins/quota/test_quota.hexa diff --git a/AGENTS.md b/AGENTS.md index bb14990..6399e5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,7 @@ Wilson 의 **디자인 기둥 중 하나** — 결정적인 도구 (read/write/e | **noun-verb 패밀리** | `wilson pool ` → `pool_*` (plugins/pool/) | | | `wilson task ` → `task_*` (+ tasks.jsonl) | | | `wilson endpoint ` → `endpoint_*` + `~/.wilson/providers.json` | +| | `wilson quota ` → `quota_*` (plugins/quota/ · Claude 5h/7d limits + multi-account registry · opt-in `wilson build --with quota`) | | | `wilson web ` → `web_fetch` / `web_search` | | | `wilson image ` → `image_info` | | **단일 도구 shortcut** | `wilson git/gh [args] [--cwd D]` → bridge-git / bridge-gh | diff --git a/core/loader.hexa b/core/loader.hexa index c97d17a..fd1317d 100644 --- a/core/loader.hexa +++ b/core/loader.hexa @@ -245,6 +245,7 @@ pub fn plugin_family(id: string) -> string { if id == "mcp" { return "integration" } if id == "slash-core" { return "ux" } if id == "inbox" { return "policy" } + if id == "quota" { return "infra" } if id == "bridge-hexa" { return "io" } if id == "bridge-git" { return "io" } if id == "bridge-gh" { return "io" } @@ -293,6 +294,7 @@ pub fn plugin_category(id: string) -> string { if id == "mcp" { return "protocol" } if id == "slash-core" { return "slashes" } if id == "inbox" { return "knowledge" } + if id == "quota" { return "accounts" } if id == "bridge-hexa" { return "bridge" } if id == "bridge-git" { return "bridge" } if id == "bridge-gh" { return "bridge" } diff --git a/core/main.hexa b/core/main.hexa index 18b25f1..566ecef 100644 --- a/core/main.hexa +++ b/core/main.hexa @@ -79,6 +79,7 @@ fn _run(orig_argv: [string]) -> int { if arg1 == "domain" { return _cmd_domain(_rest(argv, 2)) } // list | show — governance #4 root .md inventory (cost-routing) if arg1 == "endpoint" { return _cmd_endpoint(_rest(argv, 2)) } // list | show | test | resolve — ~/.wilson/providers.json (cost-routing) if arg1 == "inbox" { return _cmd_inbox(_rest(argv, 2)) } // add [--to ] — cross-project handoff scaffolder (g7 cross-project-handoff-via-inbox) + if arg1 == "quota" { return _cmd_quota(_rest(argv, 2)) } // status | list | add | show | help — Claude account 5h/7d limits + registry (g10; plugins/quota/, opt-in --with quota) if arg1 == "colors" { return _cmd_colors(_rest(argv, 2)) } // print the wilson TUI palette as ANSI swatches to stdout if arg1 == "test" { return _cmd_test(_rest(argv, 2)) } // headless smoke — boot + invariants + /help dispatch; exit 0=pass, 1=fail if arg1 == "--test" { return _cmd_test(_rest(argv, 2)) } // alias of `wilson test` for the `--` muscle memory @@ -2033,6 +2034,51 @@ fn _cmd_inbox(rest: [string]) -> int { return 2 } +// ── wilson quota (cost-routing shortcut) ── +// LLM-bypass — mirrors the in-TUI `/quota` slash. Boots the host, dispatches +// into the `quota` plugin via host_plugin_call(host, "quota", "cli:", …). +// Phase 1 = read-only: current account + LIVE 5h/7d (OAuth usage endpoint) + +// wilson-owned registry. Not in the default bundle — `wilson build --with +// quota`. Design: plugins/quota/RESEARCH.md + RESEARCH.tape (g10; g9). +fn _cmd_quota(rest: [string]) -> int { + let sub = _at(rest, 0, "") + let host = _boot_full() + if _is_err(host) { return 3 } + if has_key(host.registry.tools, "quota") == false { + eprintln("[wilson] quota plugin not bundled in this build — rebuild with `wilson build --with quota`") + return 3 + } + if sub == "" || sub == "status" { return _quota_emit(host, "cli:status") } + if sub == "show" { return _quota_emit(host, "cli:show") } + if sub == "list" || sub == "ls" { return _quota_emit(host, "cli:list") } + if sub == "help" || sub == "--help" || sub == "-h" { return _quota_emit(host, "cli:help") } + if sub == "add" { + let r = host_plugin_call(host, "quota", "cli:add", #{}) + if _quota_env_ok(r) { println("✓ " + str(r["text"])) ; return 0 } + eprintln("[wilson] quota add failed: " + _quota_env_err(r)) + return 1 + } + if sub == "switch" { + eprintln("[wilson] quota switch: not_implemented:phase3 — live cred switch + seamless failover (recipe: plugins/quota/RESEARCH.md §c3)") + return 2 + } + eprintln("[wilson] unknown `quota` subcommand: " + sub + " (status | list | add | show | help)") + return 2 +} +fn _quota_emit(host: any, op: string) -> int { + let r = host_plugin_call(host, "quota", op, #{}) + if _quota_env_ok(r) { println(str(r["text"])) ; return 0 } + eprintln("[wilson] quota " + op + " failed: " + _quota_env_err(r)) + return 1 +} +fn _quota_env_ok(r: any) -> bool { + return type_of(r) == "map" && has_key(r, "ok") && r["ok"] == true +} +fn _quota_env_err(r: any) -> string { + if type_of(r) == "map" && has_key(r, "error") { return str(r["error"]) } + return "unknown error" +} + fn _inbox_envelope_ok(r: any) -> bool { return type_of(r) == "map" && has_key(r, "ok") && r["ok"] == true } diff --git a/docs/sessions/2026-05-16-quota-plugin-scaffold.md b/docs/sessions/2026-05-16-quota-plugin-scaffold.md new file mode 100644 index 0000000..00cd8f0 --- /dev/null +++ b/docs/sessions/2026-05-16-quota-plugin-scaffold.md @@ -0,0 +1,74 @@ +# 2026-05-16 — `quota` plugin scaffold (multi-account Claude limits) + +## What this session did + +1. **Web deep-research** — landscape of multi-account Claude limit-tracking + + account-switching + failover tooling. +2. **Prior-art clone + exhaustive survey** — 12 repos cloned to + `~/core/reference/claude-multi-account/` (OUT of the wilson git tree, no + nested `.git`). 3 parallel Explore passes (cred-store · limit-read · + failover). `cux` flagged GPL-3.0 (study/clean-room only). +3. **ToS correction** — user flagged "밴조건 폐기됨"; verified via 1P Claude + Code legal docs: the 2026 OpenClaw ban was *reinstated/restructured*, not + abolished — from **2026-06-15** `claude -p`/Agent SDK on subscriptions + draws a separate metered monthly **Agent SDK credit**. Constraint moved + prohibition → metering; `@F f1` rescoped (not deleted). +4. **`quota` plugin Phase-1 scaffold** built + verified. + +## Files (uncommitted — no commit requested) + +- `plugins/quota/RESEARCH.md` · `RESEARCH.tape` — prior-art SSOT + locked + constants + phased design (`@D d_phase1`) + ToS timeline + Agent-SDK gap. +- `plugins/quota/plugin.hexa` — manifest (id=quota, kind command+tool, + cap exec, `/quota`, opt-out `WILSON_NO_QUOTA`) + `quota_dispatch`. +- `plugins/quota/main.hexa` — current account (`~/.claude.json` oauthAccount) + · token (linux file ∨ mac keychain) · **live 5h/7d via OAuth usage + endpoint** (curl through `exec_with_status`; g2/g4) · wilson-owned registry + · `quota_summary` · Phase-3 switch/failover fail-loud (g3). +- `plugins/quota/test_quota.hexa` — fake_host selftest (network-free; public + dispatch surface), matches the test_hello/test_inbox convention. +- `core/main.hexa` — `wilson quota` CLI (g10): argv route + `_cmd_quota` + (mirrors `_cmd_inbox`) + `_quota_env_ok/_err`. **core owns its argv + dispatch — not a g8 plugin-patch** (inbox/pool precedent). +- `core/loader.hexa` — `plugin_family("quota")="infra"` / + `plugin_category="accounts"` (taxonomy registry; silences loader warn). +- `AGENTS.md` — cost-routing table row for `wilson quota` (g10 in-sync). + +## Verification (real gates — bare `hexa run` of any plugin selftest is NOT a +gate; canonical `test_hello`/`test_inbox` also fail bare → run via full build) + +- `wilson build --with quota` → **OK, 30 plugins incl. quota, exit 0**. The + two `codegen_c2 ... to_upper_case` lines are **pre-existing harness-cli + `chip_cap`** (n19: has a fallback, harmless) — **not** from quota; no + quota `error:`/HX####. +- `wilson test` (full boot incl. quota) → **PASS 23 · FAIL 0 · 3s** (no + regression). +- n19 runtime CODEGEN check (`echo /quota help | wilson-custom`) → **no new + codegen error from quota**. +- `wilson quota help` / `list` → exit 0, correct output. +- `wilson quota status` → **live OAuth data path proven** against the real + account: `Account: mkgt3rs@proton.me`, `5h util=45.0 + resets=…`, `7d util=6.0 resets=…`, Agent-SDK honest stub, exit 0. + +## Findings / deferred (cite artifact) + +- **OAuth usage `utilization` scale is 0–100, not 0–1** (empirically: + `util=45.0`). Survey (claude-swap reading) had assumed 0–1. Scaffold + displays the **raw** value faithfully (correct for P1). Normalization to + `NN%` + reset countdown = **Phase 2** (`RESEARCH.tape d_phase1.phase2`). +- **Agent SDK monthly credit** — no source in any of 12 repos (too new); + fail-loud `not_implemented:agent-sdk-credit`. Recheck after **2026-06-15** + (`RESEARCH.tape n_agentsdk_gap`). +- **Phase 3** (live credential SWITCH + seamless `--resume` failover) — + contracted fail-loud; recipe locked in `RESEARCH.md §c3` / + `RESEARCH.tape n_failover_recipe` (clean-room from cux GPL-3). Warrants its + own step-by-step gate (governance #7). +- `quota` is **opt-in** (not in `plugins/_bundle`, g9) — ships via + `wilson build --with quota`. + +## Repo state + +Branch `main`. Working tree dirty: `plugins/quota/*` (new), +`core/main.hexa`, `core/loader.hexa`, `AGENTS.md`, `docs/sessions/` (this), +`plugins/pool/main.hexa` (pre-existing, untouched this session). No commit +made (none requested). Rebuild after the loader taxonomy fix in flight. diff --git a/plugins/quota/RESEARCH.md b/plugins/quota/RESEARCH.md new file mode 100644 index 0000000..4fd0753 --- /dev/null +++ b/plugins/quota/RESEARCH.md @@ -0,0 +1,173 @@ +# quota — prior-art research (multi-account Claude limit/switch tooling) + +> Landscape survey that informs the `quota` plugin design. The plugin must +> deliver **all three** capabilities below in one unit: +> +> 1. **멀티계정 한도 한눈에 + 한도차면 자동전환** (multi-account limit dashboard + auto-switch on limit) +> 2. **수동 전환 + 한도 표시, 크로스플랫폼** (manual switch + limit display, cross-platform) +> 3. **끊김없는 자동 페일오버** (seamless auto-failover, context preserved) +> +> Source: web deep-research sweep 2026-05-16 (GitHub READMEs + Anthropic +> support + press on the 2026 ToS enforcement). Companion: `RESEARCH.tape`. + +--- + +## Why this matters to wilson + +`provider-claude-cli` (default provider, memory `n5`) shells +`claude -p … --resume ` over OAuth — no API key. So account-limit +tracking and rotation is a *provider-layer* concern wilson already half-owns. +The 2026 "OpenClaw" ToS enforcement (§D below) directly targets the +proxy/pooling pattern, which is a hard design constraint for capability 3. + +--- + +## A. Usage / limit tracking + +| Tool | Form | Multi-account | Tracks | Install | Stars | +|---|---|---|---|---|---| +| **ccusage** `ryoppippi/ccusage` | CLI, local-JSONL parse | △ `--instances` (per-project, not per-login) | daily/monthly/session tokens·cost·model | `npx ccusage@latest` | **14.2k** · TS · MIT | +| **Claude-Code-Usage-Monitor** `Maciek-roboblog` | terminal live dashboard | ✗ single | 5h session, ML-P90 depletion forecast, plan auto-detect (Pro/Max5/Max20) | `uv tool install claude-monitor` | **8k+** · Py · MIT | +| **Claude-Usage-Tracker** `hamed-elfayome` | **macOS menubar app** | ✅ **unlimited profiles** | session/weekly/API-console, **auto-switch profile on session-limit** | Homebrew/Nix | **2.5k** · Swift · MIT | +| claude-code-limit-tracker `TylerGallenbeck` | statusline embed | ✗ | per-model quota in status line (`⚡15/40p · 📅12.5h/80h`) | `uv run install.py` | 21 · Py | + +Built-in: Claude Code `/usage` (=`/cost`,`/stats`) + Settings→Usage bars; +since v2.1.92 rate-limit data is exposed in the statusline JSON. + +**Closest to capability 1**: `hamed-elfayome/Claude-Usage-Tracker` — only one +that combines multi-account limit monitor + auto-switch (macOS-only). + +## B. Account switchers (credential swap — rotates which single account CC uses) + +| Tool | Mechanism | Limit display | Platform | Stars | +|---|---|---|---|---| +| **claude-swap** `realiti4` | `cswap --switch/--tui`, Keychain·CredMgr store | ✅ `--list` shows **5h/7d usage + reset** | cross-platform | **367** · Py · MIT | +| **Symbioose/claude-account-switcher** | macOS menubar, swaps Keychain + `~/.claude.json` | ✅ per-account 5h/7d util in menu | macOS 12+ | 29 · MIT | +| **cux** `inulute/cux` | **pools accounts in one session**, auto-switch on rate-limit via `--resume` (drain/balanced/manual) | usage snapshot + swap history | Go/npm | 12 · GPL-3 | +| ukogan/claude-account-switcher | per-account isolated config dir (symlinks) → parallel terminals | — | — | — | +| ming86/cc-account-switcher · Leuconoe/ClaudeCodeMultiAccounts | simple snapshot swap | — | mac/linux/wsl | — | + +**Capability 2 reference**: `claude-swap` (cross-platform + limit display). +**Capability 3 reference**: `cux` (single-session pooling, `--resume` +context-preserving auto-failover, swap strategies). + +## C. Multi-account proxy / load-balancer (account pool behind one API) + +| Tool | Behaviour | Stars | +|---|---|---| +| **ccflare** (was `snipeship/claude-balancer`) | local proxy; strategies (LeastReq/RR/Weighted/**sticky-5h**); dashboard `:8080`+TUI; request history + rate-limit state + analytics | **972** · TS · MIT | +| **teamclaude** `KarpelesLab` | reads `anthropic-ratelimit-unified-*` headers, round-robin at 98% util, tracks 5h+weekly, 429 `retry-after` handling | 26 · JS · MIT | +| meridian `rynfar` · CLIProxyAPI · ccproxy-api · Antigravity-Claude-Code-Proxy | bridge Claude Max into 3rd-party tools / multi-provider gateway | — | + +## D. ToS status (2026 timeline — corrected; the constraint changed shape, did not vanish) + +The blanket "OpenClaw ban" was **reinstated/restructured, not abolished** — +the constraint moved from *prohibition* to *metering*: + +- **2026-02~03**: server-side blocks on subscription OAuth in non-official + clients; ToS revised to restrict OAuth to Claude Code + claude.ai. +- **2026-04-04**: full enforcement — subscriptions no longer cover + third-party programmatic use (`claude -p` / Agent SDK). +- **2026-05 (reinstatement, "with a catch")**: Anthropic re-allowed + programmatic / third-party agent use, but **decoupled from the general + subscription pool** → a new **Agent SDK credit** for paid subscribers + (~**$20–$200/mo by plan, non-rollover, monthly reset, billed at API rates**). +- **2026-06-15 (1P-confirmed, authoritative)** — Claude Code legal docs: + > "Starting June 15, 2026, Agent SDK and `claude -p` usage on + > subscription plans will draw from a **new monthly Agent SDK credit, + > separate from your interactive usage limits.**" + +**Still-live constraints (unchanged, 1P verbatim):** +- "OAuth authentication is intended exclusively for … *ordinary use* of + Claude Code and other native Anthropic applications." +- "Anthropic does **not permit** third-party developers to … route requests + through Free, Pro, or Max plan credentials **on behalf of their users**." +- "Advertised usage limits … assume **ordinary, individual usage**." +- "Anthropic reserves the right to … enforce … without prior notice." + +**Net for `quota`**: wilson's own `claude -p` path (provider-claude-cli) on +the *user's own* subscription is now *sanctioned* — but from 2026-06-15 it +burns a separate, hard, monthly **Agent SDK credit**. That is precisely the +quantity a `quota` plugin should track. The red line that remains is +*serving other users / circumventing "ordinary individual usage"* — not +single-user, serial, own-account rotation. + +## Design implications for the `quota` plugin + +| Capability | Take from | Risk posture (post 2026-05 reinstatement) | +|---|---|---| +| 1 — multi-account limit dashboard + auto-switch | hamed-elfayome (model), claude-swap `--list` (5h/7d+reset read) | low — read interactive 5h/7d **and** the new Agent SDK credit balance | +| 2 — manual switch + limit display, cross-platform | claude-swap (Keychain/CredMgr/XDG store), Symbioose (`~/.claude.json` swap) | low — serial single-account, single-user | +| 3 — seamless auto-failover (context preserved) | cux (`--resume` mid-conversation swap, drain/balanced strategies) | acceptable for **own** accounts, serial; **red line** = serving other users / routing on behalf of others / circumventing "ordinary individual usage" | + +Concrete posture for wilson: `quota` rotates the **single** account +`provider-claude-cli` uses (serial credential swap + `--resume`), and tracks +**both** the interactive 5h/7d limits **and** the post-2026-06-15 monthly +**Agent SDK credit** that `claude -p` now draws from. It must not become a +multi-tenant proxy that routes other users' traffic through one subscription +or otherwise breaks the "ordinary, individual usage" assumption. Within that +line all three capabilities are clean — and capability 1 is now *more* +valuable, since the Agent SDK credit is a hard, non-rollover monthly cap. + +--- + +## Code survey → design lock (exhaustive, 12 repos cloned) + +Prior-art cloned to `~/core/reference/claude-multi-account/` (12 repos, **out of +the wilson git tree** — no nested `.git`). `cux` is **GPL-3.0**: study / +clean-room only, no code copy. Survey via 3 parallel Explore passes. + +**Locked data sources (the decisive finding):** + +| Need | Source | Constant | +|---|---|---| +| 5h / 7d limits + reset | OAuth usage endpoint (claude-swap `oauth.py:149`) | `GET https://api.anthropic.com/api/oauth/usage` · `Authorization: Bearer ` · `anthropic-beta: oauth-2025-04-20` → JSON `five_hour`/`seven_day`.{`utilization` 0–1, `resets_at` ISO} | +| (alt) live headers | any API response (teamclaude `account-manager.js:166`) | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}` + `…-status` — *not usable from wilson*: we shell `claude -p`, no raw HTTP response access → **OAuth endpoint is the fit** | +| access token | mac Keychain (claude-swap `switcher.py:197`) / linux file | mac: `security find-generic-password -s "Claude Code-credentials" -w` · linux: `~/.claude/.credentials.json` → `.claudeAiOauth.accessToken` | +| current account id | `~/.claude.json` | `.oauthAccount.{emailAddress, accountUuid, organizationUuid, organizationName}` | +| offline fallback | local JSONL (ccusage `_consts.ts:51`) | `~/.claude/projects/**/*.jsonl` (timestamp+token aggregation) | +| **Agent SDK monthly credit** | **NOT FOUND in any of 12 repos** (post-2026-06-15, too new) | → honest stub; likely an `/api/oauth/usage` field or new header — recheck after 06-15 | + +**Failover recipe (c3, clean-room from cux Go — patterns generic, code not copied):** +`cux` installs Claude Code hooks (`hookinstall.go:36`): `PostToolUseFailure`→ +detect strings `"rate_limit"|"rate limit"|"usage limit"|"overloaded_error"` +(`hooks.go:788`); `SessionStart`→capture session id (fallback: newest +`~/.claude/projects//*.jsonl`); on limit → pick next own-account by +strategy → swap creds under a lock → relaunch `claude --resume `; wait +for `Stop` before a *proactive* swap, immediate for *reactive*. teamclaude +adds the `429 retry-after` then round-robin-at-0.98 pattern. + +**Design lock — phased, governance-disciplined (g3 no silent stubs, g9 not bundled):** + +- **Phase 1 (this scaffold)**: `/quota` command + `quota` tool + `quota_summary` + dispatch. Reads current account from `~/.claude.json`, access token from + linux file *or* mac keychain, **live 5h/7d via the OAuth usage endpoint** + (curl through `exec_with_status`, provider-anthropic idiom). wilson-owned + account registry (`~/.wilson/quota/accounts.json`) — list/add/show. Pure, + headless-testable; no live-cred mutation, no hooks. +- **Phase 2**: human % + reset countdown; offline JSONL fallback; harness-cli + status-row integration via `quota_summary` (PI-MONO-UIUX style). +- **Phase 3**: live credential **switch** (write target creds back to + keychain/file, locked, rollback) + **seamless failover** (the cux-pattern + hooks + `--resume`). Contracted now as fail-loud `not_implemented:phase3`. +- **Agent SDK credit**: tracked-by-design, fetch unimplemented until the + post-2026-06-15 source is identified (fail-loud, never faked). + +## Log + +- 2026-05-16 — initial landscape survey (web deep-research). Plugin id + decided: `quota` (3-capability axis = 5h/7d quota). Sources in + `RESEARCH.tape` `@X` entries. +- 2026-05-16 — **Phase-1 scaffold landed + verified**. `wilson build --with + quota` OK (30 plugins), `wilson test` 23/23, `wilson quota status` proves + the OAuth data path live (`Account: mkgt3rs@proton.me`, 5h/7d returned). + **Empirical correction**: OAuth usage `utilization` is **0–100** (saw + `util=45.0`), not 0–1 as the claude-swap reading implied — P1 shows raw + faithfully; `NN%` + countdown normalization deferred to Phase 2. Session + log: `docs/sessions/2026-05-16-quota-plugin-scaffold.md`. +- 2026-05-16 — **§D corrected** after user flag ("밴조건 폐기됨"). The + blanket OpenClaw ban was reinstated/restructured (2026-05), not abolished: + programmatic `claude -p` use is sanctioned again but, from **2026-06-15**, + draws a separate metered **Agent SDK credit** (1P Claude Code legal docs). + Risk frame shifted prohibition → metering; "ordinary individual usage" + + no-routing-on-behalf-of-others remain live. `@F f1` rescoped (not deleted). diff --git a/plugins/quota/RESEARCH.tape b/plugins/quota/RESEARCH.tape new file mode 100644 index 0000000..636994f --- /dev/null +++ b/plugins/quota/RESEARCH.tape @@ -0,0 +1,222 @@ +#!/usr/bin/env tape +# ══════════════════════════════════════════════════════════════════════ +# quota/RESEARCH.tape — prior-art survey for the `quota` plugin +# ══════════════════════════════════════════════════════════════════════ +# Purpose: structured landscape of multi-account Claude limit-tracking + +# account-switching + auto-failover tooling, sourced from a web deep- +# research sweep (2026-05-16). Feeds the `quota` plugin design. Companion +# to RESEARCH.md (human-prose) — this tape carries the machine-readable +# citations + scope decision + the ToS forbidden-pattern. +# +# @X = surveyed tool / press cite (url · scope · stars · license). +# @N = requirement / wilson-relevance note. @D = scope decision. +# @F = design forbidden-pattern (the 2026 enforcement line). +# +# Tape grammar primer + entry-type semantics: see top of CLAUDE.md. +# ══════════════════════════════════════════════════════════════════════ + +@V := "tape" :: spec [active] + version = "1.2" + +@I id001 := "quota-plugin-prior-art" :: identity-claim [d=2026-05-16 active] + scope = "multi-account Claude limit/switch/failover tooling landscape" + parent-tape = "AGENTS.tape (project SSOT) · companion RESEARCH.md" + plugin = "plugins/quota/ (id chosen 2026-05-16; axis = 5h/7d quota)" + source = "web deep-research sweep 2026-05-16 (GitHub READMEs + Anthropic support + 2026 ToS press)" + requirement = "ONE plugin must deliver all 3 capabilities — see [[n1]]" + +@N n1 := "three-capability-requirement" :: note [d=2026-05-16 active] + c1 = "멀티계정 한도 한눈에 + 한도차면 자동전환 (multi-account limit dashboard + auto-switch)" + c2 = "수동 전환 + 한도 표시, 크로스플랫폼 (manual switch + limit display, cross-platform)" + c3 = "끊김없는 자동 페일오버 (seamless auto-failover, conversation context preserved)" + unifying-axis = "all 3 rotate around 5h-session / 7d-weekly quota → plugin id `quota`" + +@N n2 := "wilson-relevance" :: note [d=2026-05-16 active] + text <<~EOF + provider-claude-cli (default provider, memory n5) shells + `claude -p … --resume ` over OAuth — no API key. Per [@x_cc_legal] + this EXACT path, from 2026-06-15, draws the new metered monthly Agent + SDK credit (separate from interactive 5h/7d). So wilson is directly + metered → a `quota` plugin is not just relevant but arguably needed: + track the Agent SDK credit + interactive limits, rotate own accounts. + Live constraint is multi-tenant/circumvention only ([[f1]], [[d_posture]]). + EOF + +# ─── §A Usage / limit tracking ─────────────────────────────────────── + +@X t_ccusage := "ccusage" :: tool [d=2026-05-16 active] + url = "github.com/ryoppippi/ccusage" + scope = "CLI, parses local JSONL; daily/monthly/session token+cost+model reports; `--instances` per-project (not per-login)" + stars = "14.2k" · lang = "TypeScript" · license = "MIT" + fit = "reporting only — no plan-limit / no live-switch" + +@X t_ccmonitor := "Claude-Code-Usage-Monitor" :: tool [d=2026-05-16 active] + url = "github.com/Maciek-roboblog/Claude-Code-Usage-Monitor" + scope = "terminal live dashboard; ML-P90 depletion forecast + alerts; plan auto-detect Pro/Max5/Max20; single-account" + stars = "8k+" · lang = "Python" · license = "MIT" + fit = "best single-account predictive monitor" + +@X t_usagetracker := "Claude-Usage-Tracker" :: tool [d=2026-05-16 active] + url = "github.com/hamed-elfayome/Claude-Usage-Tracker" + scope = "macOS menubar; UNLIMITED profiles; session/weekly/API-console; auto-switch profile on session-limit" + stars = "2.5k" · lang = "Swift" · license = "MIT" + fit = "closest existing analogue to capability c1 (macOS-only)" + => "model for the quota dashboard + auto-switch UX" + +@X t_limittracker := "claude-code-limit-tracker" :: tool [d=2026-05-16 active] + url = "github.com/TylerGallenbeck/claude-code-limit-tracker" + scope = "statusline embed; per-model quota (`⚡15/40p · 📅12.5h/80h`); single-account" + stars = "21" · lang = "Python" + +@N n3 := "builtin-signal" :: note [d=2026-05-16 active] + text = "Claude Code `/usage` (=`/cost`,`/stats`) + Settings→Usage bars; since v2.1.92 rate-limit data is in the statusline JSON — the low-risk source the quota plugin should read." + +# ─── §B Account switchers (serial credential swap) ─────────────────── + +@X t_claudeswap := "claude-swap" :: tool [d=2026-05-16 active] + url = "github.com/realiti4/claude-swap" + scope = "`cswap --switch/--tui`; Keychain·CredMgr·XDG store; `--list` shows 5h/7d usage + reset; cross-platform" + stars = "367" · lang = "Python" · license = "MIT" + fit = "capability c2 reference (cross-platform + limit display + secure store)" + +@X t_symbioose := "Symbioose/claude-account-switcher" :: tool [d=2026-05-16 active] + url = "github.com/Symbioose/claude-account-switcher" + scope = "macOS menubar; swaps Keychain + ~/.claude.json; per-account 5h/7d util in menu" + stars = "29" · license = "MIT" + +@X t_cux := "cux" :: tool [d=2026-05-16 active] + url = "github.com/inulute/cux" + scope = "pools accounts in ONE session; auto-switch on rate-limit via `--resume` (context preserved); drain/balanced/manual strategies; in-session /switch" + stars = "12" · lang = "Go" · license = "GPL-3.0-only" + fit = "capability c3 reference (mechanism) — BUT pooling pattern is risk-flagged [[f1]]" + !> f1 + +@X t_otherswitchers := "ukogan · ming86 · Leuconoe switchers" :: tool [d=2026-05-16 active] + url = "github.com/ukogan/claude-account-switcher · github.com/ming86/cc-account-switcher · github.com/Leuconoe/ClaudeCodeMultiAccounts" + scope = "per-account isolated config dir via symlinks (parallel terminals) / simple snapshot swap (mac·linux·wsl)" + +# ─── §C Multi-account proxy / load-balancer (account pool) ─────────── + +@X t_ccflare := "ccflare (was claude-balancer)" :: tool [d=2026-05-16 active] + url = "github.com/snipeship/claude-balancer" + scope = "local proxy; strategies LeastReq/RR/Weighted/sticky-5h; dashboard :8080 + TUI; request history + rate-limit state + analytics" + stars = "972" · lang = "TypeScript" · license = "MIT" + fit = "category-C exemplar — strong analytics, but proxy fan-out = risk [[f1]]" + !> f1 + +@X t_teamclaude := "teamclaude" :: tool [d=2026-05-16 active] + url = "github.com/KarpelesLab/teamclaude" + scope = "proxy; reads anthropic-ratelimit-unified-* headers; round-robin at 98% util; tracks 5h+weekly; 429 retry-after" + stars = "26" · lang = "JavaScript" · license = "MIT" + !> f1 + +@X t_bridges := "meridian · CLIProxyAPI · ccproxy-api · Antigravity-CC-Proxy" :: tool [d=2026-05-16 active] + url = "github.com/rynfar/opencode-claude-max-proxy · github.com/router-for-me/CLIProxyAPI · github.com/CaddyGlow/ccproxy-api" + scope = "bridge Claude Max into 3rd-party tools / multi-provider gateway" + !> f1 + +# ─── §D ToS timeline — corrected: prohibition → metering, not abolished ── + +@X x_openclaw_register := "The Register — Anthropic clarifies 3rd-party ban" :: press [d=2026-02-20 superseded] + url = "theregister.com/2026/02/20/anthropic_clarifies_ban_third_party_claude_access/" + ~> x_cc_legal +@X x_openclaw_ban_vb := "VentureBeat — cuts off subscription use for OpenClaw" :: press [d=2026-04-04 superseded] + url = "venturebeat.com/technology/anthropic-cuts-off-the-ability-to-use-claude-subscriptions-with-openclaw-and" + ~> x_reinstate_vb +@X x_reinstate_vb := "VentureBeat — REINSTATES OpenClaw/3rd-party agent use (with a catch)" :: press [d=2026-05 active] + url = "venturebeat.com/technology/anthropic-reinstates-openclaw-and-third-party-agent-usage-on-claude-subscriptions-with-a-catch" + scope = "ban restructured, not abolished: programmatic/3rd-party use re-allowed but decoupled from general subscription pool → metered Agent SDK credit (~$20–$200/mo by plan · non-rollover · monthly reset · API rates)" +@X x_cc_legal := "Claude Code legal-and-compliance (1P, authoritative)" :: doc [d=2026-05-16 active] + url = "code.claude.com/docs/en/legal-and-compliance" + quote-1 = "Starting June 15, 2026, Agent SDK and `claude -p` usage on subscription plans will draw from a new monthly Agent SDK credit, separate from your interactive usage limits." + quote-2 = "Anthropic does not permit third-party developers to … route requests through Free, Pro, or Max plan credentials on behalf of their users." + quote-3 = "Advertised usage limits for Pro and Max plans assume ordinary, individual usage." +@X x_agentsdk_plan := "Claude support — Agent SDK with your Claude plan" :: doc [d=2026-05-16 active] + url = "support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan" + scope = "the new Agent SDK monthly-credit mechanics referenced by [@x_cc_legal]" + +@F f1 := "multi-tenant-or-circumvention use of own subscription" :: governance [required d=2026-05-16 active] + pattern <<~EOF + Post-2026-05 reinstatement the line is METERING, not prohibition. + Still forbidden for `quota`: (a) routing OTHER users' traffic through + one Free/Pro/Max subscription (multi-tenant proxy — category C + behaviour when serving non-owners); (b) account fan-out whose purpose + is to circumvent the "ordinary, individual usage" assumption or the + new monthly Agent SDK credit cap. NOT forbidden: a single user + rotating their OWN account(s), serial, for their own work. + EOF + remedy <<~EOF + `quota` rotates the SINGLE account provider-claude-cli uses — serial + credential swap + `claude --resume` for context — and tracks BOTH the + interactive 5h/7d limits AND the post-2026-06-15 monthly Agent SDK + credit that `claude -p` now draws from. Single-user, own-accounts, + no multi-tenant routing. Capability c1 gains value: the Agent SDK + credit is a hard non-rollover monthly cap worth surfacing/predicting. + EOF + authority = "[@x_cc_legal] (1P) · [@x_reinstate_vb] · [@x_agentsdk_plan]" + history <<~EOF + 2026-05-16 v1 framed this as "subscription-fan-out-proxy = OpenClaw + ban" (cited The Register 2026-02-20 + the 2026-04-04 cutoff). User + flagged "밴조건 폐기됨"; verified via 1P Claude Code legal docs — the + blanket ban was reinstated/restructured into a metered Agent SDK + credit (eff. 2026-06-15), NOT abolished. v2 rescoped: prohibition → + metering; the live red line is multi-tenant / circumvention, not + self-serve own-account rotation. Old ban-press marked [superseded]. + EOF + +@D d_posture := "quota plugin scope + risk posture" :: decision [d=2026-05-16 active] + c1-take = "[@t_usagetracker] dashboard model + [@t_claudeswap] 5h/7d+reset read + NEW: track the post-2026-06-15 monthly Agent SDK credit (`claude -p` draws it — [@x_cc_legal])" + c2-take = "[@t_claudeswap] secure-store swap + [@t_symbioose] ~/.claude.json swap" + c3-take = "[@t_cux] `--resume` mid-conversation swap + drain/balanced strategy" + invariant = "single-user, own-account(s), serial — no multi-tenant routing, no circumvention of ordinary-individual-usage / Agent-SDK-credit cap ([[f1]])" + => "all 3 capabilities delivered within the post-2026-05 metered model; c1 gains value (Agent SDK credit = hard monthly cap to predict)" + next = "phase-1 scaffold IN PROGRESS — see [[d_phase1]]; this tape is the prior-art + locked-constants input" + @> RESEARCH.md + +# ─── §E Exhaustive code survey → locked design ─────────────────────── + +@X x_priorart_clone := "12 prior-art repos cloned" :: reference [d=2026-05-16 active] + loc = "~/core/reference/claude-multi-account/ (OUT of wilson git tree — no nested .git)" + members = "claude-swap · cux · teamclaude · ccusage · Claude-Code-Usage-Monitor · claude-balancer · Claude-Usage-Tracker · claude-account-switcher(Symbioose) · ukogan-claude-account-switcher · cc-account-switcher · ClaudeCodeMultiAccounts · claude-code-limit-tracker" + license-flag = "cux = GPL-3.0 → study / clean-room only, NO code copy. rest MIT/permissive." + survey = "3 parallel Explore passes (cred-store · limit-read · failover) — conclusions folded into [@c_endpoints] + RESEARCH.md §Code-survey" + +@C c_endpoints := "locked data-source constants" :: config [d=2026-05-16 active] + oauth_usage_url = "https://api.anthropic.com/api/oauth/usage" + oauth_usage_auth = "Authorization: Bearer " + oauth_usage_beta = "anthropic-beta: oauth-2025-04-20" + oauth_usage_fields = "five_hour / seven_day → { utilization, resets_at: ISO }" + utilization_scale = "EMPIRICAL 2026-05-16: 0–100 (live `util=45.0`), NOT 0–1 — claude-swap oauth.py read implied 0–1. P1 shows raw; %+countdown normalization = phase2." + src = "claude-swap src/claude_swap/oauth.py:149-178 + live `wilson quota status`" + token_mac = "security find-generic-password -s \"Claude Code-credentials\" -w (returns the creds JSON blob)" + token_linux = "~/.claude/.credentials.json → .claudeAiOauth.accessToken" + account_id = "~/.claude.json → .oauthAccount.{emailAddress, accountUuid, organizationUuid, organizationName}" + offline_fallback = "~/.claude/projects/**/*.jsonl (timestamp+token aggregation — ccusage apps/ccusage/src/_consts.ts:51)" + ratelimit_headers = "anthropic-ratelimit-unified-{5h,7d}-{utilization,reset} + -status (teamclaude account-manager.js:166) — NOT usable: wilson shells `claude -p`, no raw HTTP response" + +@N n_failover_recipe := "c3 failover recipe (clean-room from cux Go)" :: note [d=2026-05-16 active] + hooks = "cux installs Claude Code hooks (hookinstall.go:36): PostToolUseFailure + SessionStart + Stop + UserPromptSubmit" + detect = "PostToolUseFailure payload contains `rate_limit|rate limit|usage limit|overloaded_error` (cux hooks.go:788) OR teamclaude HTTP 429 + retry-after (server.js:248)" + sid-capture = "SessionStart hook → session id; fallback newest ~/.claude/projects//*.jsonl (cux wrapper.go:302-333)" + resume = "swap creds under lock → relaunch `claude --resume `; gate proactive swap on a flushed Stop, reactive immediate (cux wrapper.go:164-191)" + clean-room = "strategy selection + signal/poll loop are GENERIC patterns (reimplement, don't copy GPL-3 cux). Hook-event names + `--resume` are Claude-Code public surface." + +@N n_agentsdk_gap := "Agent SDK monthly credit — not yet sourced" :: note [d=2026-05-16 active] + finding = "grep across all 12 repos: NO reference to a post-2026-06-15 Agent SDK / `claude -p` separate monthly credit (feature too new)" + posture = "tracked-by-design; fetch returns fail-loud `not_implemented:agent-sdk-credit` until the source (likely an /api/oauth/usage field or new header) is identified. NEVER faked ([[f1]] honesty; n16 no-misleading-half-features)." + recheck = "after 2026-06-15 — re-inspect /api/oauth/usage JSON + claude -p response headers" + +@D d_phase1 := "quota plugin — phased build (g3/g9 disciplined)" :: decision [d=2026-05-16 active] + not-bundled = "NOT added to plugins/_bundle (g9 default-bundle-small); ships via `wilson build --with quota`" + phase1 <<~EOF + /quota command + `quota` tool + quota_summary dispatch + `wilson quota` + CLI (g10, _cmd_quota mirrors _cmd_inbox). Reads current account from + ~/.claude.json, token from linux file OR mac keychain, LIVE 5h/7d via + [@c_endpoints] oauth_usage_url (curl through exec_with_status — g2 not-bare + + g4 rc+stderr). wilson-owned registry ~/.wilson/quota/accounts.json + (list/add/show). Headless-testable; no live-cred mutation, no hooks. + EOF + phase2 = "human % + reset countdown · offline JSONL fallback · harness-cli status-row via quota_summary (PI-MONO-UIUX style)" + phase3 = "live credential SWITCH (write target back to keychain/file, locked, rollback) + seamless failover ([[n_failover_recipe]]). Contracted now as fail-loud not_implemented:phase3." + invariant = "[[f1]] — single-user own-account serial; no multi-tenant; Agent SDK credit honest-stub ([[n_agentsdk_gap]])" + @> RESEARCH.md diff --git a/plugins/quota/main.hexa b/plugins/quota/main.hexa new file mode 100644 index 0000000..7dd9d55 --- /dev/null +++ b/plugins/quota/main.hexa @@ -0,0 +1,338 @@ +// plugins/quota/main.hexa — Claude account limits + multi-account registry impl +// Scaffold 2026-05-16 (Phase 1). hexa-lang only. +// +// Locked data sources (RESEARCH.tape c_endpoints — exhaustive 12-repo survey): +// 5h/7d limits : GET https://api.anthropic.com/api/oauth/usage +// Authorization: Bearer +// anthropic-beta: oauth-2025-04-20 +// → JSON five_hour / seven_day { utilization 0..1, resets_at ISO } +// access token : ~/.claude/.credentials.json → .claudeAiOauth.accessToken +// (linux) | macOS keychain svc "Claude Code-credentials" (blob) +// account id : ~/.claude.json → .oauthAccount.{emailAddress, accountUuid, +// organizationUuid, organizationName} +// registry : ~/.wilson/quota/accounts.json (wilson-owned; this plugin's) +// +// Governance: exec only via exec_with_status (g2 — not bare exec); rc AND body +// both checked (g4 — rc untrusted). No silent catch (g3) — every failure path +// returns a fail-loud envelope or host_log. Phase 3 (live cred switch + seamless +// failover) is contracted fail-loud, never a silent stub. + +use "core/types" +use "core/host" +use "core/portability" + +// ── constants (RESEARCH.tape c_endpoints) ──────────────────────────── +let QUOTA_TOOL_DESC = "Claude account 5h/7d limits + multi-account registry" +let QUOTA_TOOL_SCHEMA = "{\"type\":\"object\",\"properties\":{\"action\":{\"type\":\"string\",\"enum\":[\"status\",\"list\"]}}}" // G4 jsonschema string +let QUOTA_OAUTH_URL = "https://api.anthropic.com/api/oauth/usage" +let QUOTA_OAUTH_BETA = "oauth-2025-04-20" +let QUOTA_KEYCHAIN_SVC = "Claude Code-credentials" +let QUOTA_AGENTSDK_STUB = "not_implemented:agent-sdk-credit — post-2026-06-15 source unknown (RESEARCH.md §D / RESEARCH.tape n_agentsdk_gap); never faked" + +// ── module state ───────────────────────────────────────────────────── +let mut quota_HOST: any = void // set at activate (for state dir) + +// ── activate / deactivate ──────────────────────────────────────────── +pub fn quota_activate(host) -> any { + quota_HOST = host + host_register_command(host, "/quota", "quota", "Claude account limits + registry") + let def = ToolDef { name: "quota", description: QUOTA_TOOL_DESC, schema_json: QUOTA_TOOL_SCHEMA, parallel_safe: true } + host_register_tool(host, def, "quota") + return #{ "ok": true } +} + +pub fn quota_deactivate(host) -> any { quota_HOST = void ; return #{ "ok": true } } + +// ── path helpers ───────────────────────────────────────────────────── +fn quota_home() -> string { return env("HOME") } +fn quota_claude_json() -> string { return quota_home() + "/.claude.json" } +fn quota_linux_creds() -> string { return quota_home() + "/.claude/.credentials.json" } + +fn quota_state_dir() -> string { + if type_of(quota_HOST) != "void" { return host_state_dir(quota_HOST, "quota") } + let d = quota_home() + "/.wilson/quota" + let _m = fs_mkdir_p(d) + return d +} +fn quota_registry_path() -> string { return quota_state_dir() + "/accounts.json" } + +// ── small generic helpers ──────────────────────────────────────────── +fn quota_g(m: any, k: string) -> string { + if type_of(m) == "map" && has_key(m, k) { return str(m[k]) } + return "" +} +fn quota_ok(m: any) -> bool { + return type_of(m) == "map" && has_key(m, "ok") && m["ok"] == true +} +fn quota_is_array(v: any) -> bool { + return type_of(v) == "array" || type_of(v) == "list" +} +fn quota_clip(s: string, n: int) -> string { + if len(s) < n + 1 { return s } + return s.substr(0, n) + "…" +} + +// ── current account (~/.claude.json :: oauthAccount) ───────────────── +fn quota_read_oauth_account() -> any { + let p = quota_claude_json() + if file_exists(p) == false { return #{} } + let v = json_parse(fs_read_text(p)) + if type_of(v) != "map" { return #{} } + if has_key(v, "oauthAccount") && type_of(v["oauthAccount"]) == "map" { return v["oauthAccount"] } + return #{} +} + +// ── raw credentials blob: linux file, then macOS keychain ──────────── +fn quota_read_creds_json() -> string { + let lp = quota_linux_creds() + if file_exists(lp) { + let s = fs_read_text(lp) + if len(s) > 0 { return s } + } + // macOS fallback — the creds JSON blob lives in the login keychain. + // @allow-bare-exec — exec_with_status used (not bare exec); rc checked below + // and the body is re-validated by the JSON parse (g4: rc is untrusted). + let base = "/tmp/wilson-quota-kc-" + str(timestamp()) + let outf = base + ".out" + let cmd = "security find-generic-password -s " + shquote(QUOTA_KEYCHAIN_SVC) + " -w > " + shquote(outf) + " 2>/dev/null" + let r = exec_with_status(cmd) + let rc = r[1] + let mut out = "" + if file_exists(outf) { out = fs_read_text(outf) } + // best-effort tmp cleanup. @allow-silent-exit: rm of our own tmp; `; true` + // makes the result irrelevant — nothing downstream depends on it. + let _c = exec_with_status("rm -f " + shquote(outf) + " ; true") + if rc != 0 { return "" } + return out +} + +// ── LIVE 5h/7d via the OAuth usage endpoint ────────────────────────── +fn quota_parse_limits(body: string) -> any { + let v = json_parse(body) + if type_of(v) != "map" { return #{ "ok": false, "error": "usage endpoint body not JSON: " + quota_clip(body, 160) } } + let fh = if has_key(v, "five_hour") && type_of(v["five_hour"]) == "map" { v["five_hour"] } else { #{} } + let sd = if has_key(v, "seven_day") && type_of(v["seven_day"]) == "map" { v["seven_day"] } else { #{} } + return #{ "ok": true, "five_hour": fh, "seven_day": sd } +} + +fn quota_fetch_limits() -> any { + let creds_raw = quota_read_creds_json() + if len(creds_raw) == 0 { + return #{ "ok": false, "error": "no Claude credentials (looked: ~/.claude/.credentials.json + keychain '" + QUOTA_KEYCHAIN_SVC + "') — run `claude /login`" } + } + let cv = json_parse(creds_raw) + if type_of(cv) != "map" || has_key(cv, "claudeAiOauth") == false { + return #{ "ok": false, "error": "credentials not in {claudeAiOauth:{accessToken}} shape" } + } + let oauth = cv["claudeAiOauth"] + let token = if type_of(oauth) == "map" && has_key(oauth, "accessToken") { str(oauth["accessToken"]) } else { "" } + if len(token) == 0 { return #{ "ok": false, "error": "accessToken missing in credentials" } } + // @allow-bare-exec — exec_with_status used (not bare exec); rc AND body both + // checked (g4). curl -f → rc!=0 on HTTP error; 2>&1 folds the error text in. + let cmd = "curl -sS -f --max-time 20" + + " -H " + shquote("Authorization: Bearer " + token) + + " -H " + shquote("anthropic-beta: " + QUOTA_OAUTH_BETA) + + " " + shquote(QUOTA_OAUTH_URL) + " 2>&1" + let r = exec_with_status(cmd) + let body = str(r[0]) + let rc = r[1] + if rc != 0 { + return #{ "ok": false, "error": "OAuth usage fetch failed (curl rc=" + str(rc) + "): " + quota_clip(body, 200) } + } + if len(body) == 0 { return #{ "ok": false, "error": "OAuth usage endpoint returned empty body (rc=0)" } } + return quota_parse_limits(body) +} + +fn quota_fmt_window(w: any) -> string { + if type_of(w) != "map" { return "(unavailable)" } + let u = if has_key(w, "utilization") { str(w["utilization"]) } else { "?" } + let t = if has_key(w, "resets_at") { str(w["resets_at"]) } else { "?" } + return "util=" + u + " resets=" + t // Phase 2: human % + countdown +} + +// ── wilson-owned account registry (path-parameterized = testable) ──── +fn quota_registry_load(path: string) -> any { + if file_exists(path) == false { return #{ "active": 0, "accounts": [] } } + let v = json_parse(fs_read_text(path)) + if type_of(v) != "map" { return #{ "active": 0, "accounts": [] } } + let accts = if has_key(v, "accounts") && quota_is_array(v["accounts"]) { v["accounts"] } else { [] } + let act = if has_key(v, "active") && type_of(v["active"]) == "int" { v["active"] } else { 0 } + return #{ "active": act, "accounts": accts } +} +fn quota_registry_save(path: string, reg: any) -> bool { + return fs_write_text(path, json_stringify(reg)) +} +fn quota_registry_add(reg: any, rec: any) -> any { + let src = reg["accounts"] + let mut na: [any] = [] + let mut i = 0 + let n = len(src) + while i < n { + let e = src[i] + let dup = type_of(e) == "map" && len(quota_g(rec, "account_uuid")) > 0 && quota_g(e, "account_uuid") == quota_g(rec, "account_uuid") + if dup { return reg } // already registered — no-op + na.push(e) + i = i + 1 + } + na.push(rec) + return #{ "active": reg["active"], "accounts": na } +} +fn quota_account_rec(oa: any) -> any { + return #{ + "email": quota_g(oa, "emailAddress"), + "account_uuid": quota_g(oa, "accountUuid"), + "org_uuid": quota_g(oa, "organizationUuid"), + "org_name": quota_g(oa, "organizationName"), + "added": str(timestamp()) + } +} + +// ── shared renderers ───────────────────────────────────────────────── +fn quota_status_lines() -> [string] { + let oa = quota_read_oauth_account() + let email = quota_g(oa, "emailAddress") + let who = if len(email) > 0 { email } else { "(no ~/.claude.json oauthAccount — run `claude /login`)" } + let mut lines: [string] = [ "Account: " + who ] + if len(quota_g(oa, "organizationName")) > 0 { lines.push("Org: " + quota_g(oa, "organizationName")) } + let lim = quota_fetch_limits() + if quota_ok(lim) { + lines.push("5h: " + quota_fmt_window(lim["five_hour"])) + lines.push("7d: " + quota_fmt_window(lim["seven_day"])) + } else { + lines.push("Limits: (unavailable) " + str(lim["error"])) + } + lines.push("Agent SDK credit: " + QUOTA_AGENTSDK_STUB) + let reg = quota_registry_load(quota_registry_path()) + lines.push("Registry: " + str(len(reg["accounts"])) + " account(s) (`wilson quota list`)") + return lines +} +fn quota_join(lines: [string]) -> string { + let mut out = "" + let mut i = 0 + let n = len(lines) + while i < n { + out = out + lines[i] + if i < n - 1 { out = out + "\n" } + i = i + 1 + } + return out +} +fn quota_registry_text() -> string { + let reg = quota_registry_load(quota_registry_path()) + let accts = reg["accounts"] + let n = len(accts) + if n == 0 { return "(no registered accounts — `wilson quota add` snapshots the current ~/.claude.json account)" } + let mut lines: [string] = [ "Registered accounts (" + str(n) + "):" ] + let mut i = 0 + while i < n { + let e = accts[i] + let mark = if i == reg["active"] { "* " } else { " " } + lines.push(mark + str(i) + " " + quota_g(e, "email") + " " + quota_g(e, "org_name")) + i = i + 1 + } + return quota_join(lines) +} + +// ── /quota slash ───────────────────────────────────────────────────── +pub fn quota_cmd_quota(payload: any) -> any { + let args = payload["args"] + let sub = if len(args) > 0 { str(args[0]) } else { "status" } + if sub == "help" || sub == "-h" || sub == "--help" { + return #{ "output": quota_help_text(), "side_effect": false, "reload_context": false } + } + if sub == "list" || sub == "ls" { + return #{ "output": quota_registry_text(), "side_effect": false, "reload_context": false } + } + if sub == "add" { + let r = quota_cli_add(#{}) + return #{ "output": if quota_ok(r) { str(r["text"]) } else { "✗ " + str(r["error"]) }, "side_effect": quota_ok(r), "reload_context": false } + } + if sub == "switch" { + let r = quota_not_impl_phase3("switch active account") + return #{ "output": "✗ " + str(r["error"]), "side_effect": false, "reload_context": false } + } + // default: status + return #{ "output": quota_join(quota_status_lines()), "side_effect": false, "reload_context": false } +} + +// ── `quota` tool (LLM-invokable; read-only) ────────────────────────── +pub fn quota_invoke(payload: any) -> any { + let args = payload["args"] + let action = if type_of(args) == "map" && has_key(args, "action") { str(args["action"]) } else { "status" } + if action == "list" { + return ToolResult { ok: true, content: quota_registry_text(), is_error: false, metadata: #{ "action": "list" } } + } + let lim = quota_fetch_limits() + let txt = quota_join(quota_status_lines()) + return ToolResult { ok: true, content: txt, is_error: false, metadata: #{ "action": "status", "limits_ok": quota_ok(lim) } } +} + +// ── machine-readable summary (read-only; harness-cli status row, Phase 2) ─ +pub fn quota_summary(_payload: any) -> any { + let oa = quota_read_oauth_account() + let lim = quota_fetch_limits() + return #{ + "ok": true, + "account": quota_g(oa, "emailAddress"), + "org": quota_g(oa, "organizationName"), + "limits_ok": quota_ok(lim), + "five_hour": if quota_ok(lim) { lim["five_hour"] } else { #{} }, + "seven_day": if quota_ok(lim) { lim["seven_day"] } else { #{} }, + "agent_sdk_credit": QUOTA_AGENTSDK_STUB, + "registered": len(quota_registry_load(quota_registry_path())["accounts"]) + } +} + +// ── cost-routing CLI surface (g10 — core/main.hexa::_cmd_quota) ─────── +pub fn quota_cli_status(_payload: any) -> any { return #{ "ok": true, "text": quota_join(quota_status_lines()) } } +pub fn quota_cli_list(_payload: any) -> any { return #{ "ok": true, "text": quota_registry_text() } } +pub fn quota_cli_show(_payload: any) -> any { return #{ "ok": true, "text": quota_join(quota_status_lines()) } } +pub fn quota_cli_help(_payload: any) -> any { return #{ "ok": true, "text": quota_help_text() } } +pub fn quota_cli_add(_payload: any) -> any { + let oa = quota_read_oauth_account() + if len(quota_g(oa, "emailAddress")) == 0 && len(quota_g(oa, "accountUuid")) == 0 { + return #{ "ok": false, "error": "no current account in ~/.claude.json (run `claude /login` first)" } + } + let path = quota_registry_path() + let reg = quota_registry_load(path) + let rec = quota_account_rec(oa) + let next = quota_registry_add(reg, rec) + if len(next["accounts"]) == len(reg["accounts"]) { + return #{ "ok": true, "text": "already registered: " + quota_g(rec, "email") } + } + if quota_registry_save(path, next) == false { + return #{ "ok": false, "error": "failed to write registry: " + path } + } + return #{ "ok": true, "text": "registered " + quota_g(rec, "email") + " → " + path } +} + +// ── Phase 3 (contracted, fail-loud — NOT a silent stub, g3) ────────── +// Live credential SWITCH + seamless failover. Recipe is fully specified in +// plugins/quota/RESEARCH.md §c3 / RESEARCH.tape n_failover_recipe (clean-room +// from cux GPL-3: PostToolUseFailure hook → detect rate-limit strings → +// SessionStart sid capture → swap creds under lock → relaunch `claude +// --resume `). Implemented in a later phase + a step-by-step gate. +fn quota_not_impl_phase3(what: string) -> any { + return #{ "ok": false, "error": "not_implemented:phase3 — " + what + + " (live cred switch + failover; recipe in plugins/quota/RESEARCH.md §c3)" } +} + +fn quota_help_text() -> string { + return quota_join([ + "wilson quota — Claude account 5h/7d limits + multi-account registry", + "", + " wilson quota status current account + live 5h/7d limits (default)", + " wilson quota list registered accounts", + " wilson quota add snapshot current ~/.claude.json account into the registry", + " wilson quota show alias of status", + " wilson quota help this text", + "", + " (in-TUI: /quota [status|list|add|help])", + " switch / seamless failover = Phase 3 (contracted; see plugins/quota/RESEARCH.md)" + ]) +} + +// (str, len, type_of, has_key, env, timestamp, json_parse, json_stringify from +// stdlib; file_exists / fs_read_text / fs_write_text / fs_mkdir_p / shquote / +// exec_with_status from core/portability; host_register_* / host_state_dir +// from core/host; ToolDef / ToolResult / PluginManifest from core/types.) diff --git a/plugins/quota/plugin.hexa b/plugins/quota/plugin.hexa new file mode 100644 index 0000000..8b18a00 --- /dev/null +++ b/plugins/quota/plugin.hexa @@ -0,0 +1,79 @@ +// plugins/quota/plugin.hexa — Claude account limits + multi-account registry +// (manifest + dispatch entry). hexa-lang only. Scaffold 2026-05-16 (Phase 1). +// +// Prior-art + locked design: plugins/quota/RESEARCH.md + RESEARCH.tape. +// +// Contract: +// - kind = ["command", "tool"]; capability `exec` (curl the OAuth usage +// endpoint + read the macOS keychain). NOT in the default bundle (g9); +// ship via `wilson build --with quota`. +// - actions = activate / deactivate / cmd:/quota / invoke / summary +// + the cost-routing CLI surface cli:status|list|add|show|help +// (g10 — `wilson quota …` routes here via core/main.hexa::_cmd_quota). +// - quota_dispatch(action, payload) routes; unknown_action = fail-loud. +// +// Phase boundaries (RESEARCH.tape d_phase1): +// P1 (here): read current account + LIVE 5h/7d via OAuth usage endpoint +// + wilson-owned registry. No live-cred mutation, no hooks. +// P3 (contracted, fail-loud): live credential SWITCH + seamless failover. + +use "core/types" +use "core/host" +use "plugins/quota/main" // brings the quota_* impl into the bundle + +@plugin( + id = "quota", + version = "0.1.0", + description = "Claude account 5h/7d limits + multi-account registry (cost-routing: `wilson quota`)", + family = "infra", + category = "accounts", + kind = ["command", "tool"], + capabilities = ["exec"], + actions = ["activate", "deactivate", "cmd:/quota", "invoke", "summary", + "cli:status", "cli:list", "cli:add", "cli:show", "cli:help"], + commands = [ #{ "name": "/quota", "hint": "Claude account limits + registry" } ], + requires_host = ">=1.0", + link = "static", + entry = "main.hexa", + opt_out_env = "WILSON_NO_QUOTA" +) +let quota_plugin_meta = #{ + "id": "quota", "version": "0.1.0", + "kind": ["command", "tool"], + "actions": ["activate", "deactivate", "cmd:/quota", "invoke", "summary", + "cli:status", "cli:list", "cli:add", "cli:show", "cli:help"] +} + +// manifest accessor — `pub` so core/dispatch_table::bundled_manifests() can read it. +pub fn quota_manifest() -> PluginManifest { + return PluginManifest { + id: "quota", version: "0.1.0", + description: "Claude account 5h/7d limits + multi-account registry (cost-routing: `wilson quota`)", + kind: ["command", "tool"], + capabilities: ["exec"], + actions: ["activate", "deactivate", "cmd:/quota", "invoke", "summary", + "cli:status", "cli:list", "cli:add", "cli:show", "cli:help"], + hooks: #{}, + commands: [ #{ "name": "/quota", "hint": "Claude account limits + registry" } ], + views: [], daemons: [], + provides: #{}, consumes: #{}, + deps: [], before: [], after: [], + requires_host: ">=1.0", link: "static", entry: "main.hexa", + opt_out_env: "WILSON_NO_QUOTA" + } +} + +// dispatch entry — the one symbol core calls (loader_plugin_call -> quota_dispatch). +pub fn quota_dispatch(action: string, payload: any) -> any { + if action == "activate" { return quota_activate(payload["host"]) } + if action == "deactivate" { return quota_deactivate(payload["host"]) } + if action == "cmd:/quota" { return quota_cmd_quota(payload) } + if action == "invoke" { return quota_invoke(payload) } + if action == "summary" { return quota_summary(payload) } + if action == "cli:status" { return quota_cli_status(payload) } + if action == "cli:list" { return quota_cli_list(payload) } + if action == "cli:add" { return quota_cli_add(payload) } + if action == "cli:show" { return quota_cli_show(payload) } + if action == "cli:help" { return quota_cli_help(payload) } + return #{ "ok": false, "error": "unknown_action: " + action } +} diff --git a/plugins/quota/test_quota.hexa b/plugins/quota/test_quota.hexa new file mode 100644 index 0000000..9a52f2a --- /dev/null +++ b/plugins/quota/test_quota.hexa @@ -0,0 +1,66 @@ +// plugins/quota/test_quota.hexa — selftest for the `quota` plugin +// Run standalone: `hexa run plugins/quota/test_quota.hexa` (sibling main.hexa + +// plugin.hexa flatten into the same namespace — same convention as test_inbox). +// +// Scope = PUBLIC dispatch surface only, NETWORK-FREE (mirrors test_hello): +// fake_host_new() -> quota_dispatch("activate", #{host}) +// -> assert `/quota` cmd + `quota` tool registered +// -> cmd:/quota help -> usage text +// -> cmd:/quota list -> empty-registry text (fake state dir; no net) +// -> invoke {action:list} -> ToolResult ok=true (registry only; no net) +// -> cli:help -> { ok:true, text } envelope +// -> zzz_unknown -> { ok:false } fail-loud +// The status/summary/fetch paths hit curl + keychain → deliberately NOT +// exercised here (no network/side-effects in a unit test). + +use "core/types" +use "core/test_fixture" + +fn main() -> int { + let host = fake_host_new() + + // 1. activate — registers the slash command + the tool. + let ra = quota_dispatch("activate", #{ "host": host }) + assert ra["ok"] == true + assert fake_registered_tool(host, "quota") + assert _registered_command(host, "/quota") + + // 2. cmd:/quota help -> usage text. + let rh = quota_dispatch("cmd:/quota", #{ "cmd": "/quota", "args": ["help"], "ctx": #{ "session_id": "t", "host": host } }) + assert _contains(str(rh["output"]), "wilson quota") + assert rh["side_effect"] == false + + // 3. cmd:/quota list -> empty-registry text (fake state dir; network-free). + let rl = quota_dispatch("cmd:/quota", #{ "cmd": "/quota", "args": ["list"], "ctx": #{ "session_id": "t", "host": host } }) + assert _contains(str(rl["output"]), "no registered accounts") + + // 4. invoke {action:list} -> ToolResult ok (registry only; no net). + let tr = quota_dispatch("invoke", #{ "name": "quota", "args": #{ "action": "list" }, "ctx": #{ "cwd": ".", "session_id": "t", "host": host } }) + assert tr.ok == true + assert tr.is_error == false + + // 5. cli:help -> { ok:true, text } envelope (the `wilson quota` CLI path). + let rc = quota_dispatch("cli:help", #{}) + assert rc["ok"] == true + assert _contains(str(rc["text"]), "wilson quota") + + // 6. unknown action -> fail-loud envelope. + let u = quota_dispatch("zzz_unknown", #{}) + assert u["ok"] == false + assert _contains(str(u["error"]), "unknown_action") + + println("test_quota: ok") + return 0 +} + +// helpers (same shape as test_hello / test_inbox) +fn _registered_command(h: any, name: string) -> bool { + for c in h.registered["command"] { if _name_of(c) == name { return true } } + return false +} +fn _name_of(x: any) -> string { if has_key(x, "name") { return str(x["name"]) } ; return "" } +fn _contains(hay: string, needle: string) -> bool { return index_of(hay, needle) > -1 } + +// (assert, str, has_key, index_of, println from stdlib; fake_host_new / +// fake_registered_tool from core/test_fixture; quota_dispatch / ToolResult +// from the plugin + core/types.)