claude-accounts-usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vince.dai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # claude-accounts-usage
2
+
3
+ 一个 OpenCode **TUI 插件**,用来查看多个 Claude(Pro/Max)账号的订阅用量,并在账号之间切换。
4
+
5
+ 它**不接管** `anthropic` auth provider,因此可以和 [`@ex-machina/opencode-anthropic-auth`](https://github.com/ex-machina-co/opencode-anthropic-auth) **共存** —— ex-machina 继续负责 OAuth 登录与请求注入,本插件只在工具层做"账号档案 + 切换 + 用量展示"。
6
+
7
+ ## 功能
8
+
9
+ | 命令 | 作用 |
10
+ |------|------|
11
+ | `/usage` | 弹框显示所有账号的用量(5h / 7d / 7d-Sonnet 三个窗口,带进度条与重置倒计时) |
12
+ | `/switch` | 弹框选择账号并切换为当前账号(立即生效) |
13
+
14
+ 账号会在**插件加载时**以及每次 `/usage`、`/switch` 时**自动收录**当前 ex-machina 登录的账号,无需手动添加。
15
+
16
+ ## 限流自动切号(自动重试)
17
+
18
+ 当**当前账号撞到订阅额度上限**(5h 窗口或周/全模型窗口的 429)时,插件会**自动切到下一个可用账号并重发刚才失败的那条消息**,无需手动干预。该能力**始终开启**。
19
+
20
+ 工作方式:
21
+
22
+ - **检测**:监听 OpenCode 的 `session.next.retried` / `session.error` 事件,只在 429 且命中 Anthropic 订阅额度签名(`anthropic-ratelimit-unified-*: rejected`,或响应体 `rate_limit_error` + 额度文案)时触发;瞬时限流、529 过载、401 鉴权错误都会被排除,避免误切号。
23
+ - **选号**:优先按用量挑剩余额度最多的账号(用 `/usage` 时缓存的数据,TTL 10 分钟),无缓存则轮询下一个;跳过正在冷却(已知额度未恢复)的账号。
24
+ - **重发**:切号后将会话回退到失败的那条用户消息并用新账号重发(`revert` + `promptAsync`)。注意:若该轮中途已产生文件改动,回退会一并撤销并整轮重做。
25
+ - **冷却**:撞限的账号按响应头给出的 reset 时间(缺省 60 分钟)进入冷却,持久化在 `tui.json` 的 KV 中;账号下次成功使用后自动解除冷却。
26
+ - **耗尽**:当所有账号都在冷却时停止切换,并弹出最近恢复时间的倒计时提示。
27
+
28
+ > 调试:设环境变量 `CLAUDE_AUTOSWITCH_DEBUG=1` 可把未命中谓词的 429 样本追加到 `~/.config/opencode/claude-autoswitch.log`,便于校准检测规则。
29
+
30
+ ## 前置条件
31
+
32
+ - 已安装并使用 `@ex-machina/opencode-anthropic-auth` 登录 Claude Pro/Max。
33
+ - **无需移除 ex-machina**,两者共存。
34
+
35
+ ## 安装
36
+
37
+ TUI 插件只在 `~/.config/opencode/tui.json` 配置,**不要**放进 `opencode.json`。
38
+
39
+ ### 方式一:npm(推荐)
40
+
41
+ ```json
42
+ {
43
+ "$schema": "https://opencode.ai/tui.json",
44
+ "plugin": ["claude-accounts-usage"]
45
+ }
46
+ ```
47
+
48
+ OpenCode 会自动解析并安装该包,无需手动 `npm install`。
49
+
50
+ ### 方式二:本地 clone(开发/离线)
51
+
52
+ ```bash
53
+ git clone https://github.com/Daiwenxi798673133/claude-accounts-usage.git
54
+ cd claude-accounts-usage && bun install
55
+ ```
56
+
57
+ 然后让 `tui.json` 指向克隆下来的 `tui.tsx` 绝对路径:
58
+
59
+ ```json
60
+ {
61
+ "$schema": "https://opencode.ai/tui.json",
62
+ "plugin": ["/绝对路径/claude-accounts-usage/tui.tsx"]
63
+ }
64
+ ```
65
+
66
+ 修改配置后**完全退出并重新打开** OpenCode。
67
+
68
+ ## 账号管理流程
69
+
70
+ 1. 用 ex-machina 登录账号 A:`opencode auth login` → Claude Pro/Max。
71
+ 2. 打开 OpenCode,插件自动收录账号 A(标签为其邮箱)。
72
+ 3. 想加更多账号:用 ex-machina 登录账号 B,然后重新打开 OpenCode 或运行一次 `/usage` / `/switch`,插件自动收录 B。
73
+ 4. 之后用 `/switch` 在账号间切换,用 `/usage` 查看全部用量。
74
+
75
+ > 标签默认是账号邮箱。想改名?直接编辑 `~/.config/opencode/claude-accounts.json` 里对应账号的 `label`(自动收录不会覆盖你改过的标签)。
76
+
77
+ ## 工作原理
78
+
79
+ - 账号档案保存在 `~/.config/opencode/claude-accounts.json`(权限 `0600`),每个账号含 OAuth `refresh` / `access` / `expires`、邮箱 `label`,以及来自 Anthropic profile 的账号 `uuid`。
80
+ - **自动收录**:读 `auth.json` 当前账号 → 调 `oauth/profile` 拿到稳定的账号 `uuid` 和邮箱 → 按 `uuid` upsert。`uuid` 跨 token 刷新保持不变,因此同一账号只会被更新(不重复),换成新账号则自动新增。
81
+ - **切换**:把目标账号的 token 写入 `auth.json` 的 `anthropic` 条目。ex-machina 每次请求都会重新读取 `auth.json`,所以切换立即生效(下一条消息就用新账号),无需重启。
82
+ - **查看用量**:对每个账号调用 Anthropic 的 `oauth/usage` 接口;若 access token 过期,会用 refresh token 刷新并回写档案。
83
+ - 始终只读 / 谨慎写 `auth.json` 的 `anthropic` 一项,保留其他 provider 条目不动。
84
+
85
+ ## 已知限制
86
+
87
+ - ex-machina 同一时刻只持有一个账号,所以一个新账号必须先用 ex-machina 登录过一次,插件才能在下次加载/操作时收录它。
88
+ - 自动切号依赖 OpenCode 的 `session.error` / `session.next.retried` 事件,因此只对经由 OpenCode(及 ex-machina)发出的 Anthropic 请求生效;额度恢复后的解除冷却需要该账号成功跑过一次对话。
89
+
90
+ ## License
91
+
92
+ MIT
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "claude-accounts-usage",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode TUI plugin to view multi-account Claude usage and switch the active account. Coexists with @ex-machina/opencode-anthropic-auth.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "vince.dai",
8
+ "homepage": "https://github.com/Daiwenxi798673133/claude-accounts-usage#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Daiwenxi798673133/claude-accounts-usage.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Daiwenxi798673133/claude-accounts-usage/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "claude",
20
+ "anthropic",
21
+ "usage",
22
+ "quota",
23
+ "multi-account",
24
+ "tui"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "import": "./tui.tsx"
29
+ },
30
+ "./tui": {
31
+ "import": "./tui.tsx"
32
+ }
33
+ },
34
+ "files": [
35
+ "tui.tsx",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "engines": {
41
+ "opencode": ">=1.3.13",
42
+ "node": ">=22"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "typecheck": "tsc --noEmit",
49
+ "prepublishOnly": "tsc --noEmit"
50
+ },
51
+ "dependencies": {
52
+ "@opencode-ai/plugin": "^1.17.4",
53
+ "@opentui/solid": "^0.4.1",
54
+ "solid-js": "^1.9.13"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^25.9.3",
58
+ "typescript": "^5.9.3"
59
+ }
60
+ }
@@ -0,0 +1,141 @@
1
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { join, dirname } from "node:path"
4
+
5
+ export type StoredAccount = {
6
+ id: string
7
+ label: string
8
+ refresh: string
9
+ access?: string
10
+ expires?: number
11
+ }
12
+
13
+ export type AccountsFile = {
14
+ version: number
15
+ activeId?: string
16
+ accounts: StoredAccount[]
17
+ }
18
+
19
+ export type AnthropicOauth = {
20
+ type: "oauth"
21
+ access?: string
22
+ refresh?: string
23
+ expires?: number
24
+ }
25
+
26
+ export type AuthToken = {
27
+ refresh: string
28
+ access?: string
29
+ expires?: number
30
+ }
31
+
32
+ const ACCOUNTS_PATH = join(homedir(), ".config", "opencode", "claude-accounts.json")
33
+
34
+ function authJsonCandidates(): string[] {
35
+ const list: string[] = []
36
+ if (process.env.XDG_DATA_HOME) list.push(join(process.env.XDG_DATA_HOME, "opencode", "auth.json"))
37
+ list.push(join(homedir(), ".local", "share", "opencode", "auth.json"))
38
+ list.push(join(homedir(), "Library", "Application Support", "opencode", "auth.json"))
39
+ return list
40
+ }
41
+
42
+ async function readJson<T>(path: string): Promise<T | undefined> {
43
+ try {
44
+ return JSON.parse(await readFile(path, "utf8")) as T
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ async function atomicWriteJson(path: string, data: unknown): Promise<void> {
51
+ await mkdir(dirname(path), { recursive: true })
52
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
53
+ await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
54
+ await rename(tmp, path)
55
+ }
56
+
57
+ async function resolveAuthJsonPath(): Promise<string> {
58
+ const candidates = authJsonCandidates()
59
+ for (const candidate of candidates) {
60
+ if (await readJson(candidate)) return candidate
61
+ }
62
+ return candidates[0]
63
+ }
64
+
65
+ // Serializes auth.json / claude-accounts.json read-modify-writes. NOT reentrant:
66
+ // never nest withAuthLock inside another withAuthLock or it deadlocks.
67
+ let authLock: Promise<unknown> = Promise.resolve()
68
+
69
+ export function withAuthLock<T>(fn: () => Promise<T>): Promise<T> {
70
+ const run = authLock.then(fn, fn)
71
+ authLock = run.then(
72
+ () => undefined,
73
+ () => undefined,
74
+ )
75
+ return run
76
+ }
77
+
78
+ export async function readActiveId(): Promise<string | undefined> {
79
+ return (await loadAccounts()).activeId
80
+ }
81
+
82
+ export async function loadAccounts(): Promise<AccountsFile> {
83
+ const data = await readJson<Partial<AccountsFile>>(ACCOUNTS_PATH)
84
+ return {
85
+ version: data?.version ?? 1,
86
+ activeId: data?.activeId,
87
+ accounts: Array.isArray(data?.accounts)
88
+ ? (data!.accounts as StoredAccount[]).filter((account) => typeof account.id === "string" && account.id.length > 0)
89
+ : [],
90
+ }
91
+ }
92
+
93
+ export async function saveAccounts(file: AccountsFile): Promise<void> {
94
+ await atomicWriteJson(ACCOUNTS_PATH, file)
95
+ }
96
+
97
+ export async function readAuthAnthropic(): Promise<AnthropicOauth | undefined> {
98
+ const auth = await readJson<Record<string, unknown>>(await resolveAuthJsonPath())
99
+ const entry = auth?.["anthropic"]
100
+ if (entry && typeof entry === "object" && (entry as AnthropicOauth).type === "oauth") {
101
+ return entry as AnthropicOauth
102
+ }
103
+ return undefined
104
+ }
105
+
106
+ export async function writeAuthAnthropic(token: AuthToken): Promise<void> {
107
+ const path = await resolveAuthJsonPath()
108
+ const auth = (await readJson<Record<string, unknown>>(path)) ?? {}
109
+ auth["anthropic"] = {
110
+ type: "oauth",
111
+ access: token.access ?? "",
112
+ refresh: token.refresh,
113
+ expires: token.expires ?? 0,
114
+ }
115
+ await atomicWriteJson(path, auth)
116
+ }
117
+
118
+ export async function upsertAccount(id: string, label: string, token: AuthToken): Promise<AccountsFile> {
119
+ const file = await loadAccounts()
120
+ const index = file.accounts.findIndex((account) => account.id === id)
121
+ if (index >= 0) {
122
+ file.accounts[index] = {
123
+ ...file.accounts[index],
124
+ refresh: token.refresh,
125
+ access: token.access,
126
+ expires: token.expires,
127
+ }
128
+ } else {
129
+ file.accounts.push({ id, label, refresh: token.refresh, access: token.access, expires: token.expires })
130
+ }
131
+ file.activeId = id
132
+ await saveAccounts(file)
133
+ return file
134
+ }
135
+
136
+ export async function setActiveId(id: string): Promise<void> {
137
+ const file = await loadAccounts()
138
+ if (!file.accounts.some((account) => account.id === id)) return
139
+ file.activeId = id
140
+ await saveAccounts(file)
141
+ }
@@ -0,0 +1,486 @@
1
+ import { appendFile } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
5
+ import { loadAccounts, readActiveId, type AccountsFile, type StoredAccount } from "./accounts.ts"
6
+ import { collectAllUsage, switchToAccount, type AccountUsage, type UsageResponse } from "./usage.ts"
7
+
8
+ const ENABLED = true
9
+ const DEFAULT_COOLDOWN_MS = 60 * 60_000
10
+ const USAGE_CACHE_TTL_MS = 10 * 60_000
11
+ const RECENT_SWITCH_GUARD_MS = 4_000
12
+ const IDLE_WAIT_TIMEOUT_MS = 4_000
13
+ const IDLE_POLL_MS = 150
14
+ const COOLDOWN_KV_KEY = "claude-accounts-usage.autoswitch.cooldown"
15
+
16
+ type StateParts = ReturnType<TuiPluginApi["state"]["part"]>
17
+ type StateMessage = ReturnType<TuiPluginApi["state"]["session"]["messages"]>[number]
18
+ type AssistantMsg = Extract<StateMessage, { role: "assistant" }>
19
+ type PromptParts = NonNullable<Parameters<TuiPluginApi["client"]["session"]["promptAsync"]>[0]["parts"]>
20
+
21
+ type RetryErrorLike = {
22
+ statusCode?: number
23
+ responseHeaders?: Record<string, string>
24
+ responseBody?: string
25
+ message?: string
26
+ }
27
+
28
+ export type AutoSwitchController = {
29
+ dispose: () => void
30
+ setUsageCache: (results: AccountUsage[]) => void
31
+ }
32
+
33
+ function lowerKeys(headers?: Record<string, string>): Record<string, string> {
34
+ const out: Record<string, string> = {}
35
+ if (headers) for (const [key, value] of Object.entries(headers)) out[key.toLowerCase()] = value
36
+ return out
37
+ }
38
+
39
+ function safeJson(body?: string): { error?: { type?: unknown; message?: unknown } } | undefined {
40
+ if (!body) return undefined
41
+ try {
42
+ const value = JSON.parse(body)
43
+ return typeof value === "object" && value !== null ? (value as { error?: { type?: unknown; message?: unknown } }) : undefined
44
+ } catch {
45
+ return undefined
46
+ }
47
+ }
48
+
49
+ // Detects a Claude Pro/Max rate-limit / quota rejection so we can switch accounts.
50
+ // 529 overloads are excluded (switching won't help). Anthropic surfaces this through
51
+ // several shapes depending on path (unified headers, JSON body type, or just a message
52
+ // string like "This request would exceed your account's rate limit"), so we match on
53
+ // ANY of: 429 status, rate_limit_error type, or rate-limit message text — the message
54
+ // regex is the one maintenance point as Anthropic's wording may drift.
55
+ function isUsageLimit(error?: RetryErrorLike): boolean {
56
+ if (!error) return false
57
+ const body = error.responseBody ?? ""
58
+ if (/overloaded_error/i.test(body)) return false
59
+ const headers = lowerKeys(error.responseHeaders)
60
+ const unifiedRejected = Object.entries(headers).some(
61
+ ([key, value]) =>
62
+ key.startsWith("anthropic-ratelimit-unified") && key.endsWith("status") && String(value).toLowerCase().includes("rejected"),
63
+ )
64
+ if (unifiedRejected) return true
65
+ const parsed = safeJson(body)?.error
66
+ const type = typeof parsed?.type === "string" ? parsed.type : ""
67
+ const text = `${typeof parsed?.message === "string" ? parsed.message : ""} ${error.message ?? ""}`.toLowerCase()
68
+ const rateLimitText = /rate limit|usage limit|limit reached|too many requests|out of (?:usage|quota)|5[- ]?hour|weekly limit|exceed/.test(text)
69
+ return error.statusCode === 429 || type === "rate_limit_error" || rateLimitText
70
+ }
71
+
72
+ function parseResetMs(error: RetryErrorLike): number | undefined {
73
+ const headers = lowerKeys(error.responseHeaders)
74
+ const reset = Number(headers["anthropic-ratelimit-unified-reset"])
75
+ if (Number.isFinite(reset) && reset > 0) return reset * 1000
76
+ const retryAfter = Number(headers["retry-after"])
77
+ if (Number.isFinite(retryAfter) && retryAfter > 0) return Date.now() + retryAfter * 1000
78
+ return undefined
79
+ }
80
+
81
+ function toErrorData(error: unknown): RetryErrorLike | undefined {
82
+ if (typeof error !== "object" || error === null) return undefined
83
+ const candidate = error as { name?: unknown; data?: RetryErrorLike }
84
+ if (candidate.name === "APIError" && candidate.data && typeof candidate.data === "object") return candidate.data
85
+ return undefined
86
+ }
87
+
88
+ function score(usage?: UsageResponse): number {
89
+ if (!usage) return Number.POSITIVE_INFINITY
90
+ return Math.max(
91
+ usage.five_hour?.utilization ?? 0,
92
+ usage.seven_day?.utilization ?? 0,
93
+ usage.seven_day_sonnet?.utilization ?? 0,
94
+ usage.seven_day_opus?.utilization ?? 0,
95
+ )
96
+ }
97
+
98
+ function fmtDuration(ms: number): string {
99
+ const minutes = Math.max(1, Math.round(ms / 60_000))
100
+ if (minutes < 60) return `${minutes} 分钟`
101
+ const hours = Math.floor(minutes / 60)
102
+ const rest = minutes % 60
103
+ return rest > 0 ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`
104
+ }
105
+
106
+ function sleep(ms: number): Promise<void> {
107
+ return new Promise((resolve) => setTimeout(resolve, ms))
108
+ }
109
+
110
+ function debugLog(tag: string, payload: unknown): void {
111
+ if (!process.env.CLAUDE_AUTOSWITCH_DEBUG) return
112
+ let serialized: string
113
+ try {
114
+ serialized = JSON.stringify(payload)
115
+ } catch {
116
+ serialized = String(payload)
117
+ }
118
+ const line = `${new Date().toISOString()} [${tag}] ${serialized}\n`
119
+ void appendFile(join(homedir(), ".config", "opencode", "claude-autoswitch.log"), line).catch(() => undefined)
120
+ }
121
+
122
+ export function installAutoSwitch(api: TuiPluginApi): AutoSwitchController {
123
+ const cooldown = new Map<string, number>()
124
+ const attempted = new Map<string, Set<string>>()
125
+ const sessionLocks = new Map<string, Promise<unknown>>()
126
+ const repromptInFlight = new Set<string>()
127
+ const lastAction = new Map<string, number>()
128
+ const seen = new Set<string>()
129
+ let usageCache: { at: number; byId: Map<string, UsageResponse> } = { at: 0, byId: new Map() }
130
+ let refreshing = false
131
+ let lastSwitch: { id?: string; sessionID?: string; at: number } = { at: 0 }
132
+
133
+ const stored = api.kv.get<Record<string, number>>(COOLDOWN_KV_KEY, {})
134
+ if (stored) for (const [id, until] of Object.entries(stored)) cooldown.set(id, until)
135
+
136
+ function persistCooldown(): void {
137
+ const now = Date.now()
138
+ const snapshot: Record<string, number> = {}
139
+ for (const [id, until] of cooldown) if (until > now) snapshot[id] = until
140
+ api.kv.set(COOLDOWN_KV_KEY, snapshot)
141
+ }
142
+
143
+ function markCooldown(id: string, untilMs?: number): void {
144
+ cooldown.set(id, untilMs ?? Date.now() + DEFAULT_COOLDOWN_MS)
145
+ persistCooldown()
146
+ }
147
+
148
+ function clearCooldown(id: string): void {
149
+ if (cooldown.delete(id)) persistCooldown()
150
+ }
151
+
152
+ function isCooled(id: string, now: number): boolean {
153
+ const until = cooldown.get(id)
154
+ return typeof until === "number" && until > now
155
+ }
156
+
157
+ function setUsageCache(results: AccountUsage[]): void {
158
+ const byId = new Map<string, UsageResponse>()
159
+ for (const result of results) if (result.usage) byId.set(result.id, result.usage)
160
+ usageCache = { at: Date.now(), byId }
161
+ }
162
+
163
+ async function refreshUsageInBackground(): Promise<void> {
164
+ if (refreshing) return
165
+ refreshing = true
166
+ try {
167
+ const { results } = await collectAllUsage()
168
+ setUsageCache(results)
169
+ } catch {
170
+ // best-effort cache warming; selection falls back to round-robin
171
+ } finally {
172
+ refreshing = false
173
+ }
174
+ }
175
+
176
+ function dedup(id: string): boolean {
177
+ if (seen.has(id)) return false
178
+ seen.add(id)
179
+ if (seen.size > 1000) {
180
+ seen.clear()
181
+ seen.add(id)
182
+ }
183
+ return true
184
+ }
185
+
186
+ function runExclusive<T>(key: string, fn: () => Promise<T>): Promise<T> {
187
+ const prev = sessionLocks.get(key) ?? Promise.resolve()
188
+ const run = prev.then(fn, fn)
189
+ sessionLocks.set(
190
+ key,
191
+ run.then(
192
+ () => undefined,
193
+ () => undefined,
194
+ ),
195
+ )
196
+ return run
197
+ }
198
+
199
+ function labelOf(file: AccountsFile, id?: string): string {
200
+ return file.accounts.find((account) => account.id === id)?.label ?? "当前账号"
201
+ }
202
+
203
+ function lastAssistant(sessionID: string): AssistantMsg | undefined {
204
+ const messages = api.state.session.messages(sessionID)
205
+ for (let i = messages.length - 1; i >= 0; i--) {
206
+ const message = messages[i]
207
+ if (message.role === "assistant") return message
208
+ }
209
+ return undefined
210
+ }
211
+
212
+ function isAnthropicSession(sessionID: string): boolean {
213
+ const assistant = lastAssistant(sessionID)
214
+ return !assistant || assistant.providerID === "anthropic"
215
+ }
216
+
217
+ function pickNext(file: AccountsFile, tried: Set<string>, activeId?: string): StoredAccount | undefined {
218
+ const now = Date.now()
219
+ const candidates = file.accounts.filter(
220
+ (account) => account.id !== activeId && !tried.has(account.id) && !isCooled(account.id, now),
221
+ )
222
+ if (candidates.length === 0) return undefined
223
+
224
+ const cacheFresh = usageCache.at > 0 && now - usageCache.at <= USAGE_CACHE_TTL_MS
225
+ if (cacheFresh) {
226
+ return [...candidates].sort((a, b) => score(usageCache.byId.get(a.id)) - score(usageCache.byId.get(b.id)))[0]
227
+ }
228
+
229
+ const order = file.accounts.map((account) => account.id)
230
+ const start = activeId ? order.indexOf(activeId) : -1
231
+ for (let offset = 1; offset <= order.length; offset++) {
232
+ const id = order[(start + offset + order.length) % order.length]
233
+ const match = candidates.find((account) => account.id === id)
234
+ if (match) return match
235
+ }
236
+ return candidates[0]
237
+ }
238
+
239
+ function standDown(file: AccountsFile): void {
240
+ const now = Date.now()
241
+ const times = file.accounts
242
+ .map((account) => cooldown.get(account.id))
243
+ .filter((until): until is number => typeof until === "number" && until > now)
244
+ const soonest = times.length > 0 ? Math.min(...times) : undefined
245
+ const message = soonest
246
+ ? `所有账号都已达额度上限,约 ${fmtDuration(soonest - now)} 后恢复`
247
+ : "所有账号都已达额度上限"
248
+ debugLog("standdown", { accounts: file.accounts.length, soonest })
249
+ api.ui.toast({ variant: "error", message })
250
+ }
251
+
252
+ async function doSwitch(sessionID: string, error: RetryErrorLike, activeId?: string): Promise<boolean> {
253
+ if (activeId) markCooldown(activeId, parseResetMs(error))
254
+
255
+ const file = await loadAccounts()
256
+ const tried = attempted.get(sessionID) ?? new Set<string>()
257
+ attempted.set(sessionID, tried)
258
+ if (file.accounts.length <= 1) {
259
+ standDown(file)
260
+ return false
261
+ }
262
+
263
+ for (let i = 0; i < file.accounts.length; i++) {
264
+ const next = pickNext(file, tried, activeId)
265
+ if (!next) break
266
+ try {
267
+ const account = await switchToAccount(next.id)
268
+ tried.add(next.id)
269
+ lastSwitch = { id: account.id, sessionID, at: Date.now() }
270
+ debugLog("switched", { from: labelOf(file, activeId), to: account.label })
271
+ api.ui.toast({
272
+ variant: "warning",
273
+ message: `「${labelOf(file, activeId)}」额度已满,已切到「${account.label}」并自动重试`,
274
+ })
275
+ void refreshUsageInBackground()
276
+ return true
277
+ } catch {
278
+ tried.add(next.id)
279
+ markCooldown(next.id, undefined)
280
+ }
281
+ }
282
+
283
+ standDown(file)
284
+ return false
285
+ }
286
+
287
+ function toInputParts(parts: StateParts): PromptParts {
288
+ const out: PromptParts = []
289
+ for (const part of parts) {
290
+ if (part.type === "text") {
291
+ if (part.synthetic || part.ignored) continue
292
+ if (part.text && part.text.trim().length > 0) out.push({ type: "text", text: part.text })
293
+ } else if (part.type === "file") {
294
+ out.push({ type: "file", mime: part.mime, filename: part.filename, url: part.url, source: part.source })
295
+ }
296
+ }
297
+ return out
298
+ }
299
+
300
+ function findFailedAssistant(messages: ReturnType<TuiPluginApi["state"]["session"]["messages"]>): AssistantMsg | undefined {
301
+ for (let i = messages.length - 1; i >= 0; i--) {
302
+ const message = messages[i]
303
+ if (message.role === "assistant" && message.error) return message
304
+ }
305
+ for (let i = messages.length - 1; i >= 0; i--) {
306
+ const message = messages[i]
307
+ if (message.role === "assistant") return message
308
+ }
309
+ return undefined
310
+ }
311
+
312
+ function findUserMessage(
313
+ messages: ReturnType<TuiPluginApi["state"]["session"]["messages"]>,
314
+ failed?: AssistantMsg,
315
+ ): StateMessage | undefined {
316
+ if (failed?.parentID) {
317
+ const parent = messages.find((message) => message.id === failed.parentID && message.role === "user")
318
+ if (parent) return parent
319
+ }
320
+ const failedIndex = failed ? messages.findIndex((message) => message.id === failed.id) : messages.length
321
+ const from = (failedIndex < 0 ? messages.length : failedIndex) - 1
322
+ for (let i = from; i >= 0; i--) {
323
+ if (messages[i].role === "user") return messages[i]
324
+ }
325
+ return undefined
326
+ }
327
+
328
+ async function waitIdle(sessionID: string): Promise<void> {
329
+ const deadline = Date.now() + IDLE_WAIT_TIMEOUT_MS
330
+ while (Date.now() < deadline) {
331
+ const status = api.state.session.status(sessionID)
332
+ if (!status || status.type === "idle") return
333
+ await sleep(IDLE_POLL_MS)
334
+ }
335
+ }
336
+
337
+ async function repromptFailedTurn(sessionID: string, abortFirst: boolean): Promise<void> {
338
+ if (repromptInFlight.has(sessionID)) return
339
+ repromptInFlight.add(sessionID)
340
+ const guidance = () => api.ui.toast({ variant: "info", message: "已切换账号,请手动重新发送上一条消息" })
341
+ try {
342
+ if (abortFirst) {
343
+ try {
344
+ await api.client.session.abort({ sessionID })
345
+ } catch {
346
+ // ignore: stream may already be settling
347
+ }
348
+ }
349
+ await waitIdle(sessionID)
350
+
351
+ const messages = api.state.session.messages(sessionID)
352
+ const failed = findFailedAssistant(messages)
353
+ const userMessage = findUserMessage(messages, failed)
354
+ if (!userMessage) return guidance()
355
+
356
+ const parts = toInputParts(api.state.part(userMessage.id))
357
+ if (parts.length === 0) return guidance()
358
+
359
+ const reverted = await api.client.session.revert({ sessionID, messageID: userMessage.id })
360
+ if (reverted.error) return guidance()
361
+
362
+ const prompted = await api.client.session.promptAsync({ sessionID, parts })
363
+ if (prompted.error) guidance()
364
+ } catch {
365
+ guidance()
366
+ } finally {
367
+ repromptInFlight.delete(sessionID)
368
+ }
369
+ }
370
+
371
+ async function handleLimit(sessionID: string, error: RetryErrorLike, mode: "retry" | "error"): Promise<void> {
372
+ await runExclusive(sessionID, async () => {
373
+ const now = Date.now()
374
+ // Coalesce the burst of retry/error events a single failed turn emits: once we have
375
+ // acted for this session, ignore further limit events until that action settles.
376
+ if (now - (lastAction.get(sessionID) ?? 0) < RECENT_SWITCH_GUARD_MS) return
377
+
378
+ const activeId = await readActiveId()
379
+ // Cross-session race: another session just switched to this fresh account, so the
380
+ // failure predates the switch. Reuse the fresh account instead of cooling it again.
381
+ const reuseFresh =
382
+ !!activeId && lastSwitch.id === activeId && lastSwitch.sessionID !== sessionID && now - lastSwitch.at < RECENT_SWITCH_GUARD_MS
383
+ const usable = reuseFresh ? true : await doSwitch(sessionID, error, activeId)
384
+ if (!usable) return
385
+
386
+ lastAction.set(sessionID, Date.now())
387
+ await repromptFailedTurn(sessionID, mode === "retry")
388
+ })
389
+ }
390
+
391
+ async function onRetried(event: { id: string; properties: { sessionID: string; error: RetryErrorLike } }): Promise<void> {
392
+ const error = event.properties.error
393
+ debugLog("retried", {
394
+ sessionID: event.properties.sessionID,
395
+ statusCode: error?.statusCode,
396
+ message: error?.message,
397
+ headerKeys: Object.keys(error?.responseHeaders ?? {}),
398
+ body: (error?.responseBody ?? "").slice(0, 300),
399
+ })
400
+ if (!ENABLED || !dedup(event.id)) return
401
+ const matched = isUsageLimit(error)
402
+ const anthropic = isAnthropicSession(event.properties.sessionID)
403
+ debugLog("retried-decision", { matched, anthropic })
404
+ if (!matched || !anthropic) return
405
+ await handleLimit(event.properties.sessionID, error, "retry")
406
+ }
407
+
408
+ async function onStatus(event: {
409
+ id: string
410
+ properties: { sessionID: string; status?: { type: string; message?: string; next?: number } }
411
+ }): Promise<void> {
412
+ const status = event.properties.status
413
+ debugLog("status", {
414
+ sessionID: event.properties.sessionID,
415
+ type: status?.type,
416
+ message: status?.type === "retry" ? status.message : undefined,
417
+ })
418
+ if (status?.type !== "retry" || !ENABLED || !dedup(event.id)) return
419
+ const error: RetryErrorLike = { message: status.message }
420
+ const matched = isUsageLimit(error)
421
+ const anthropic = isAnthropicSession(event.properties.sessionID)
422
+ debugLog("status-decision", { matched, anthropic })
423
+ if (!matched || !anthropic) return
424
+ await handleLimit(event.properties.sessionID, error, "retry")
425
+ }
426
+
427
+ async function onError(event: { id: string; properties: { sessionID?: string; error?: unknown } }): Promise<void> {
428
+ const sessionID = event.properties.sessionID
429
+ const error = toErrorData(event.properties.error)
430
+ debugLog("error", {
431
+ sessionID,
432
+ raw: event.properties.error,
433
+ statusCode: error?.statusCode,
434
+ message: error?.message,
435
+ })
436
+ if (!ENABLED || !dedup(event.id) || !sessionID) return
437
+ const matched = !!error && isUsageLimit(error)
438
+ const anthropic = isAnthropicSession(sessionID)
439
+ debugLog("error-decision", { matched, anthropic })
440
+ if (!matched || !anthropic) return
441
+ await handleLimit(sessionID, error, "error")
442
+ }
443
+
444
+ async function onIdle(sessionID: string): Promise<void> {
445
+ const assistant = lastAssistant(sessionID)
446
+ if (assistant && !assistant.error) {
447
+ const activeId = await readActiveId()
448
+ if (activeId) clearCooldown(activeId)
449
+ }
450
+ }
451
+
452
+ debugLog("installed", { enabled: ENABLED })
453
+
454
+ const offs = [
455
+ api.event.on("session.status", (event) => {
456
+ void onStatus(event)
457
+ }),
458
+ api.event.on("session.next.retried", (event) => {
459
+ void onRetried(event)
460
+ }),
461
+ api.event.on("session.error", (event) => {
462
+ void onError(event)
463
+ }),
464
+ api.event.on("session.next.prompted", (event) => {
465
+ attempted.delete(event.properties.sessionID)
466
+ lastAction.delete(event.properties.sessionID)
467
+ }),
468
+ api.event.on("session.idle", (event) => {
469
+ void onIdle(event.properties.sessionID)
470
+ }),
471
+ ]
472
+
473
+ return {
474
+ dispose: () => {
475
+ for (const off of offs) {
476
+ try {
477
+ off()
478
+ } catch {
479
+ // ignore unsubscribe failures during teardown
480
+ }
481
+ }
482
+ persistCooldown()
483
+ },
484
+ setUsageCache,
485
+ }
486
+ }
@@ -0,0 +1,16 @@
1
+ // Shared Anthropic OAuth client_id used by the official Claude Pro/Max flow
2
+ // (same value as @ex-machina/opencode-anthropic-auth), so refresh tokens stored
3
+ // by that plugin are accepted by the refresh endpoint below.
4
+ export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
5
+
6
+ export const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
7
+
8
+ export const USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
9
+
10
+ export const PROFILE_ENDPOINT = "https://api.anthropic.com/api/oauth/profile"
11
+
12
+ export const OAUTH_BETA = "oauth-2025-04-20"
13
+
14
+ // Refresh slightly before real expiry so neither ex-machina nor the usage call
15
+ // receives an already-stale access token.
16
+ export const TOKEN_EXPIRY_BUFFER_MS = 60_000
@@ -0,0 +1,138 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { For, Show } from "solid-js"
3
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
4
+ import type { StoredAccount } from "./accounts.ts"
5
+ import type { AccountUsage, UsageWindow } from "./usage.ts"
6
+
7
+ export type UsageState = {
8
+ loading: boolean
9
+ results: AccountUsage[]
10
+ updatedAt?: number
11
+ error?: string
12
+ }
13
+
14
+ function bar(util: number, width = 18): string {
15
+ const pct = Math.max(0, Math.min(100, util))
16
+ const fill = Math.round((pct / 100) * width)
17
+ return `[${"#".repeat(fill)}${"-".repeat(width - fill)}]`
18
+ }
19
+
20
+ function percent(util: number): string {
21
+ return `${Math.round(util)}%`
22
+ }
23
+
24
+ function tone(api: TuiPluginApi, util: number) {
25
+ const theme = api.theme.current
26
+ if (util >= 85) return theme.error
27
+ if (util >= 60) return theme.warning
28
+ return theme.success
29
+ }
30
+
31
+ function resetIn(iso: string): string {
32
+ const ms = new Date(iso).getTime() - Date.now()
33
+ if (ms <= 0) return "now"
34
+ const hours = Math.floor(ms / 3_600_000)
35
+ const minutes = Math.floor((ms % 3_600_000) / 60_000)
36
+ if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
37
+ if (hours > 0) return `${hours}h ${minutes}m`
38
+ return `${minutes}m`
39
+ }
40
+
41
+ function clockTime(ts: number): string {
42
+ return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
43
+ }
44
+
45
+ function WindowRow(props: { api: TuiPluginApi; name: string; win?: UsageWindow }) {
46
+ const theme = () => props.api.theme.current
47
+ return (
48
+ <Show when={props.win}>
49
+ {(win) => (
50
+ <box flexDirection="row" gap={1}>
51
+ <text fg={theme().textMuted}>{props.name.padEnd(6)}</text>
52
+ <text fg={tone(props.api, win().utilization)}>
53
+ {bar(win().utilization)} {percent(win().utilization)}
54
+ </text>
55
+ <Show when={win().resets_at}>
56
+ <text fg={theme().textMuted}>重置 {resetIn(win().resets_at!)}</text>
57
+ </Show>
58
+ </box>
59
+ )}
60
+ </Show>
61
+ )
62
+ }
63
+
64
+ function AccountBlock(props: { api: TuiPluginApi; item: AccountUsage }) {
65
+ const theme = () => props.api.theme.current
66
+ return (
67
+ <box flexDirection="column">
68
+ <text fg={theme().text}>
69
+ {props.item.active ? "●" : "○"} {props.item.label}
70
+ {props.item.active ? " (当前)" : ""}
71
+ </text>
72
+ <Show when={props.item.error}>
73
+ <text fg={theme().error}> {props.item.error}</text>
74
+ </Show>
75
+ <Show when={props.item.usage}>
76
+ {(usage) => (
77
+ <box flexDirection="column" paddingLeft={2}>
78
+ <WindowRow api={props.api} name="5h" win={usage().five_hour} />
79
+ <WindowRow api={props.api} name="7d" win={usage().seven_day} />
80
+ <WindowRow api={props.api} name="Sonnet" win={usage().seven_day_sonnet} />
81
+ </box>
82
+ )}
83
+ </Show>
84
+ </box>
85
+ )
86
+ }
87
+
88
+ function UsageDialog(props: { api: TuiPluginApi; state: () => UsageState }) {
89
+ const api = props.api
90
+ const theme = () => api.theme.current
91
+ return (
92
+ <box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
93
+ <box flexDirection="row" justifyContent="space-between">
94
+ <text fg={theme().text}>
95
+ <b>Claude 账号用量</b>
96
+ </text>
97
+ <text fg={theme().textMuted}>esc 关闭</text>
98
+ </box>
99
+ <Show when={props.state().loading && props.state().results.length === 0}>
100
+ <text fg={theme().textMuted}>加载中…</text>
101
+ </Show>
102
+ <Show when={props.state().error}>
103
+ <text fg={theme().error}>{props.state().error}</text>
104
+ </Show>
105
+ <For each={props.state().results}>{(item) => <AccountBlock api={api} item={item} />}</For>
106
+ <Show when={props.state().updatedAt}>
107
+ <text fg={theme().textMuted}>更新于 {clockTime(props.state().updatedAt!)}</text>
108
+ </Show>
109
+ </box>
110
+ )
111
+ }
112
+
113
+ export function openUsageDialog(api: TuiPluginApi, state: () => UsageState): void {
114
+ api.ui.dialog.setSize("medium")
115
+ api.ui.dialog.replace(() => <UsageDialog api={api} state={state} />)
116
+ }
117
+
118
+ export function openSwitchDialog(
119
+ api: TuiPluginApi,
120
+ accounts: StoredAccount[],
121
+ activeId: string | undefined,
122
+ onSwitch: (id: string) => void | Promise<void>,
123
+ ): void {
124
+ api.ui.dialog.replace(() =>
125
+ api.ui.DialogSelect<string>({
126
+ title: "切换 Claude 账号",
127
+ current: activeId,
128
+ options: accounts.map((account) => ({
129
+ title: account.id === activeId ? `${account.label} (当前)` : account.label,
130
+ value: account.id,
131
+ })),
132
+ onSelect: (option) => {
133
+ api.ui.dialog.clear()
134
+ void onSwitch(option.value)
135
+ },
136
+ }),
137
+ )
138
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { OAUTH_BETA, PROFILE_ENDPOINT } from "./constants.ts"
2
+
3
+ export type Profile = {
4
+ uuid: string
5
+ email: string
6
+ displayName: string
7
+ }
8
+
9
+ export async function fetchProfile(access: string): Promise<Profile> {
10
+ const res = await fetch(PROFILE_ENDPOINT, {
11
+ headers: { Authorization: `Bearer ${access}`, "anthropic-beta": OAUTH_BETA },
12
+ })
13
+ if (!res.ok) throw new Error(`profile request failed (${res.status})`)
14
+
15
+ const json = (await res.json()) as {
16
+ account?: { uuid?: string; email?: string; display_name?: string; full_name?: string }
17
+ }
18
+ const account = json.account
19
+ if (!account?.uuid) throw new Error("profile response missing account uuid")
20
+
21
+ return {
22
+ uuid: account.uuid,
23
+ email: account.email ?? account.uuid,
24
+ displayName: account.display_name ?? account.full_name ?? account.email ?? account.uuid,
25
+ }
26
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { AuthToken, StoredAccount } from "./accounts.ts"
2
+ import { loadAccounts, readAuthAnthropic, saveAccounts, upsertAccount, withAuthLock, writeAuthAnthropic } from "./accounts.ts"
3
+ import { CLIENT_ID, OAUTH_BETA, TOKEN_EXPIRY_BUFFER_MS, TOKEN_URL, USAGE_ENDPOINT } from "./constants.ts"
4
+ import { fetchProfile } from "./profile.ts"
5
+
6
+ export type UsageWindow = { utilization: number; resets_at?: string }
7
+
8
+ export type UsageResponse = {
9
+ five_hour?: UsageWindow
10
+ seven_day?: UsageWindow
11
+ seven_day_sonnet?: UsageWindow
12
+ seven_day_opus?: UsageWindow
13
+ }
14
+
15
+ export type AccountUsage = {
16
+ id: string
17
+ label: string
18
+ active: boolean
19
+ usage?: UsageResponse
20
+ error?: string
21
+ }
22
+
23
+ function errorMessage(error: unknown): string {
24
+ return error instanceof Error ? error.message : String(error)
25
+ }
26
+
27
+ function isStale(token: { access?: string; expires?: number }): boolean {
28
+ return !token.access || !token.expires || token.expires < Date.now() + TOKEN_EXPIRY_BUFFER_MS
29
+ }
30
+
31
+ export async function refreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
32
+ const res = await fetch(TOKEN_URL, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refresh, client_id: CLIENT_ID }),
36
+ })
37
+ if (!res.ok) throw new Error(`token refresh failed (${res.status})`)
38
+ const json = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
39
+ return {
40
+ access: json.access_token,
41
+ refresh: json.refresh_token,
42
+ expires: Date.now() + json.expires_in * 1000,
43
+ }
44
+ }
45
+
46
+ export async function fetchUsage(access: string): Promise<UsageResponse> {
47
+ const res = await fetch(USAGE_ENDPOINT, {
48
+ headers: { Authorization: `Bearer ${access}`, "anthropic-beta": OAUTH_BETA },
49
+ })
50
+ if (!res.ok) throw new Error(`usage request failed (${res.status})`)
51
+ return (await res.json()) as UsageResponse
52
+ }
53
+
54
+ // Identify whatever account ex-machina currently holds in auth.json by its profile
55
+ // uuid (stable across token rotation) and upsert it: the same account is updated in
56
+ // place, a genuinely new login is added — so no manual /account-add is needed.
57
+ export async function autoCapture(): Promise<void> {
58
+ await withAuthLock(async () => {
59
+ const auth = await readAuthAnthropic()
60
+ if (!auth?.refresh) return
61
+
62
+ let token: AuthToken = { refresh: auth.refresh, access: auth.access, expires: auth.expires }
63
+ if (isStale(token)) {
64
+ token = await refreshToken(token.refresh)
65
+ await writeAuthAnthropic(token)
66
+ }
67
+
68
+ const profile = await fetchProfile(token.access!)
69
+ await upsertAccount(profile.uuid, profile.email, token)
70
+ })
71
+ }
72
+
73
+ async function ensureFresh(account: StoredAccount): Promise<{ access?: string; updated?: StoredAccount }> {
74
+ if (!isStale(account)) return { access: account.access }
75
+ const fresh = await refreshToken(account.refresh)
76
+ return { access: fresh.access, updated: { ...account, ...fresh } }
77
+ }
78
+
79
+ export async function collectAllUsage(): Promise<{ activeId?: string; results: AccountUsage[] }> {
80
+ const file = await loadAccounts()
81
+
82
+ const settled = await Promise.all(
83
+ file.accounts.map(async (account): Promise<{ result: AccountUsage; updated?: StoredAccount }> => {
84
+ const base = { id: account.id, label: account.label, active: account.id === file.activeId }
85
+ try {
86
+ const { access, updated } = await ensureFresh(account)
87
+ if (!access) return { result: { ...base, error: "missing access token" }, updated }
88
+ return { result: { ...base, usage: await fetchUsage(access) }, updated }
89
+ } catch (error) {
90
+ return { result: { ...base, error: errorMessage(error) } }
91
+ }
92
+ }),
93
+ )
94
+
95
+ const updated = settled.flatMap((entry) => (entry.updated ? [entry.updated] : []))
96
+ if (updated.length > 0) {
97
+ await withAuthLock(async () => {
98
+ const current = await loadAccounts()
99
+ for (const account of updated) {
100
+ const index = current.accounts.findIndex((existing) => existing.id === account.id)
101
+ if (index >= 0) current.accounts[index] = { ...current.accounts[index], ...account }
102
+ }
103
+ await saveAccounts(current)
104
+ })
105
+ }
106
+
107
+ return { activeId: file.activeId, results: settled.map((entry) => entry.result) }
108
+ }
109
+
110
+ export async function switchToAccount(id: string): Promise<StoredAccount> {
111
+ return withAuthLock(async () => {
112
+ const file = await loadAccounts()
113
+ const index = file.accounts.findIndex((account) => account.id === id)
114
+ if (index < 0) throw new Error("account not found")
115
+
116
+ let account = file.accounts[index]
117
+ if (isStale(account)) {
118
+ const fresh = await refreshToken(account.refresh)
119
+ account = { ...account, ...fresh }
120
+ file.accounts[index] = account
121
+ }
122
+
123
+ file.activeId = id
124
+ await saveAccounts(file)
125
+ await writeAuthAnthropic({ refresh: account.refresh, access: account.access, expires: account.expires })
126
+ return account
127
+ })
128
+ }
package/tui.tsx ADDED
@@ -0,0 +1,78 @@
1
+ import { createSignal } from "solid-js"
2
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { loadAccounts } from "./src/accounts.ts"
4
+ import { autoCapture, collectAllUsage, switchToAccount } from "./src/usage.ts"
5
+ import { installAutoSwitch } from "./src/autoswitch.ts"
6
+ import { openSwitchDialog, openUsageDialog, type UsageState } from "./src/dialogs.tsx"
7
+
8
+ const ID = "claude-accounts-usage"
9
+
10
+ function message(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error)
12
+ }
13
+
14
+ const tui: TuiPlugin = async (api) => {
15
+ const [state, setState] = createSignal<UsageState>({ loading: false, results: [] })
16
+
17
+ const autoSwitch = installAutoSwitch(api)
18
+ api.lifecycle.onDispose(autoSwitch.dispose)
19
+
20
+ const refreshUsage = async () => {
21
+ try {
22
+ await autoCapture()
23
+ const { results } = await collectAllUsage()
24
+ autoSwitch.setUsageCache(results)
25
+ setState({ loading: false, results, updatedAt: Date.now() })
26
+ } catch (error) {
27
+ setState((prev) => ({ loading: false, results: prev.results, error: message(error) }))
28
+ }
29
+ }
30
+
31
+ void autoCapture().catch(() => undefined)
32
+
33
+ const command = api.command
34
+ if (!command) {
35
+ api.ui.toast({ variant: "error", message: "当前 OpenCode 不支持命令注册 API,请更新 OpenCode" })
36
+ return
37
+ }
38
+
39
+ command.register(() => [
40
+ {
41
+ title: "Claude: 查看账号用量",
42
+ value: `${ID}.usage`,
43
+ category: "Claude",
44
+ slash: { name: "usage" },
45
+ onSelect: () => {
46
+ setState((prev) => ({ ...prev, loading: true, error: undefined }))
47
+ openUsageDialog(api, state)
48
+ void refreshUsage()
49
+ },
50
+ },
51
+ {
52
+ title: "Claude: 切换账号",
53
+ value: `${ID}.switch`,
54
+ category: "Claude",
55
+ slash: { name: "switch" },
56
+ onSelect: async () => {
57
+ await autoCapture().catch(() => undefined)
58
+ const file = await loadAccounts()
59
+ if (file.accounts.length === 0) {
60
+ api.ui.toast({ variant: "warning", message: "没有账号。请先用 ex-machina 登录 Claude" })
61
+ return
62
+ }
63
+ openSwitchDialog(api, file.accounts, file.activeId, async (id) => {
64
+ try {
65
+ const account = await switchToAccount(id)
66
+ api.ui.toast({ variant: "success", message: `已切换到 ${account.label},下次对话生效` })
67
+ } catch (error) {
68
+ api.ui.toast({ variant: "error", message: `切换失败: ${message(error)}` })
69
+ }
70
+ })
71
+ },
72
+ },
73
+ ])
74
+ }
75
+
76
+ const plugin: TuiPluginModule & { id: string } = { id: ID, tui }
77
+
78
+ export default plugin