claude-accounts-usage 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  | 命令 | 作用 |
10
10
  |------|------|
11
11
  | `/usage` | 弹框显示所有账号的用量(5h / 7d / 7d-Sonnet 三个窗口,带进度条与重置倒计时) |
12
- | `/switch` | 弹框选择账号并切换为当前账号(立即生效) |
12
+ | `/switch` | 弹框选择账号并切换(立即生效);列表内联显示每个账号的 5h / 7d / 7d-Sonnet 用量,`↑↓` 选择、`enter` 切换、`esc` 关闭 |
13
13
 
14
14
  账号会在**插件加载时**以及每次 `/usage`、`/switch` 时**自动收录**当前 ex-machina 登录的账号,无需手动添加。
15
15
 
@@ -19,7 +19,7 @@
19
19
 
20
20
  工作方式:
21
21
 
22
- - **检测**:监听 OpenCode 的 `session.next.retried` / `session.error` 事件,只在 429 且命中 Anthropic 订阅额度签名(`anthropic-ratelimit-unified-*: rejected`,或响应体 `rate_limit_error` + 额度文案)时触发;瞬时限流、529 过载、401 鉴权错误都会被排除,避免误切号。
22
+ - **检测**:主要监听 OpenCode 的 `session.status` 的 retry 事件(同时也注册 `session.next.retried` / `session.error` 作为补充,但 TUI 插件通常只能收到 `session.status`),只在命中 Anthropic 订阅额度签名(`anthropic-ratelimit-unified-*: rejected`,或响应体/消息含 `rate_limit_error` + 额度文案,或 429 状态码)时触发;529 过载等会被排除,避免误切号。
23
23
  - **选号**:优先按用量挑剩余额度最多的账号(用 `/usage` 时缓存的数据,TTL 10 分钟),无缓存则轮询下一个;跳过正在冷却(已知额度未恢复)的账号。
24
24
  - **重发**:切号后将会话回退到失败的那条用户消息并用新账号重发(`revert` + `promptAsync`)。注意:若该轮中途已产生文件改动,回退会一并撤销并整轮重做。
25
25
  - **冷却**:撞限的账号按响应头给出的 reset 时间(缺省 60 分钟)进入冷却,持久化在 `tui.json` 的 KV 中;账号下次成功使用后自动解除冷却。
@@ -41,12 +41,14 @@ TUI 插件只在 `~/.config/opencode/tui.json` 配置,**不要**放进 `opencode
41
41
  ```json
42
42
  {
43
43
  "$schema": "https://opencode.ai/tui.json",
44
- "plugin": ["claude-accounts-usage"]
44
+ "plugin": ["claude-accounts-usage@0.1.3"]
45
45
  }
46
46
  ```
47
47
 
48
48
  OpenCode 会自动解析并安装该包,无需手动 `npm install`。
49
49
 
50
+ > **建议带上版本号**(如 `@0.1.3`)。OpenCode 按"含版本号的包名"建独立缓存目录:写死版本号后,以后升级只需把后缀改成新版本号;若不带版本号,会被首次安装的版本锁住,发布新版也不会自动更新。
51
+
50
52
  ### 方式二:本地 clone(开发/离线)
51
53
 
52
54
  ```bash
@@ -85,7 +87,7 @@ cd claude-accounts-usage && bun install
85
87
  ## 已知限制
86
88
 
87
89
  - ex-machina 同一时刻只持有一个账号,所以一个新账号必须先用 ex-machina 登录过一次,插件才能在下次加载/操作时收录它。
88
- - 自动切号依赖 OpenCode 的 `session.error` / `session.next.retried` 事件,因此只对经由 OpenCode(及 ex-machina)发出的 Anthropic 请求生效;额度恢复后的解除冷却需要该账号成功跑过一次对话。
90
+ - 自动切号依赖 OpenCode 的 `session.status` 事件(辅以 `session.next.retried` / `session.error`),因此只对经由 OpenCode(及 ex-machina)发出的 Anthropic 请求生效;额度恢复后的解除冷却需要该账号成功跑过一次对话。
89
91
 
90
92
  ## License
91
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-accounts-usage",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode TUI plugin to view multi-account Claude usage and switch the active account. Coexists with @ex-machina/opencode-anthropic-auth.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/constants.ts CHANGED
@@ -14,3 +14,9 @@ export const OAUTH_BETA = "oauth-2025-04-20"
14
14
  // Refresh slightly before real expiry so neither ex-machina nor the usage call
15
15
  // receives an already-stale access token.
16
16
  export const TOKEN_EXPIRY_BUFFER_MS = 60_000
17
+
18
+ // When /usage runs, proactively refresh INACTIVE accounts whose access token
19
+ // expires within this window, so an idle account stays fresh between /usage opens
20
+ // (and is ready for auto-switch). Never applied to the active account — ex-machina
21
+ // owns and rotates that one, so racing it would cause invalid_grant.
22
+ export const INACTIVE_REFRESH_THRESHOLD_MS = 30 * 60_000
package/src/dialogs.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { For, Show } from "solid-js"
2
+ import { createMemo, createSignal, For, Show } from "solid-js"
3
+ import { useKeyboard } from "@opentui/solid"
3
4
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
4
5
  import type { StoredAccount } from "./accounts.ts"
5
6
  import type { AccountUsage, UsageWindow } from "./usage.ts"
@@ -115,24 +116,135 @@ export function openUsageDialog(api: TuiPluginApi, state: () => UsageState): voi
115
116
  api.ui.dialog.replace(() => <UsageDialog api={api} state={state} />)
116
117
  }
117
118
 
119
+ function SwitchAccountRow(props: {
120
+ api: TuiPluginApi
121
+ account: StoredAccount
122
+ activeId?: string
123
+ usage?: AccountUsage
124
+ selected: boolean
125
+ loading: boolean
126
+ }) {
127
+ const theme = () => props.api.theme.current
128
+ const isActive = () => props.account.id === props.activeId
129
+ return (
130
+ <box flexDirection="column">
131
+ <box flexDirection="row" gap={1}>
132
+ <text fg={props.selected ? theme().primary : theme().textMuted}>{props.selected ? "▶" : " "}</text>
133
+ <text fg={props.selected ? theme().primary : theme().text}>
134
+ {isActive() ? "●" : "○"} {props.account.label}
135
+ {isActive() ? " (当前)" : ""}
136
+ </text>
137
+ </box>
138
+ <box flexDirection="column" paddingLeft={4}>
139
+ <Show when={props.usage?.error}>
140
+ <text fg={theme().error}>{props.usage!.error}</text>
141
+ </Show>
142
+ <Show when={props.usage?.usage}>
143
+ {(usage) => (
144
+ <box flexDirection="column">
145
+ <WindowRow api={props.api} name="5h" win={usage().five_hour} />
146
+ <WindowRow api={props.api} name="7d" win={usage().seven_day} />
147
+ <WindowRow api={props.api} name="Sonnet" win={usage().seven_day_sonnet} />
148
+ </box>
149
+ )}
150
+ </Show>
151
+ <Show when={props.loading && !props.usage?.usage && !props.usage?.error}>
152
+ <text fg={theme().textMuted}>加载中…</text>
153
+ </Show>
154
+ </box>
155
+ </box>
156
+ )
157
+ }
158
+
159
+ function SwitchDialog(props: {
160
+ api: TuiPluginApi
161
+ accounts: StoredAccount[]
162
+ activeId?: string
163
+ state: () => UsageState
164
+ onSwitch: (id: string) => void
165
+ }) {
166
+ const api = props.api
167
+ const theme = () => api.theme.current
168
+ const accounts = props.accounts
169
+ const start = accounts.findIndex((account) => account.id === props.activeId)
170
+ const [index, setIndex] = createSignal(start < 0 ? 0 : start)
171
+
172
+ const usageById = createMemo(() => {
173
+ const map = new Map<string, AccountUsage>()
174
+ for (const result of props.state().results) map.set(result.id, result)
175
+ return map
176
+ })
177
+
178
+ function move(delta: number): void {
179
+ setIndex((i) => Math.max(0, Math.min(accounts.length - 1, i + delta)))
180
+ }
181
+
182
+ function confirm(): void {
183
+ const account = accounts[index()]
184
+ if (!account) return
185
+ api.ui.dialog.clear()
186
+ props.onSwitch(account.id)
187
+ }
188
+
189
+ useKeyboard((evt) => {
190
+ if (evt.name === "return") {
191
+ evt.preventDefault()
192
+ evt.stopPropagation()
193
+ confirm()
194
+ return
195
+ }
196
+ if (evt.name === "up" || evt.name === "k") {
197
+ evt.preventDefault()
198
+ evt.stopPropagation()
199
+ move(-1)
200
+ return
201
+ }
202
+ if (evt.name === "down" || evt.name === "j") {
203
+ evt.preventDefault()
204
+ evt.stopPropagation()
205
+ move(1)
206
+ }
207
+ })
208
+
209
+ return (
210
+ <box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
211
+ <box flexDirection="row" justifyContent="space-between">
212
+ <text fg={theme().text}>
213
+ <b>切换 Claude 账号</b>
214
+ </text>
215
+ <text fg={theme().textMuted}>↑↓ 选择 · enter 切换 · esc 关闭</text>
216
+ </box>
217
+ <For each={accounts}>
218
+ {(account, i) => (
219
+ <SwitchAccountRow
220
+ api={api}
221
+ account={account}
222
+ activeId={props.activeId}
223
+ usage={usageById().get(account.id)}
224
+ selected={i() === index()}
225
+ loading={props.state().loading}
226
+ />
227
+ )}
228
+ </For>
229
+ <Show when={props.state().error}>
230
+ <text fg={theme().error}>{props.state().error}</text>
231
+ </Show>
232
+ <Show when={props.state().updatedAt}>
233
+ <text fg={theme().textMuted}>更新于 {clockTime(props.state().updatedAt!)}</text>
234
+ </Show>
235
+ </box>
236
+ )
237
+ }
238
+
118
239
  export function openSwitchDialog(
119
240
  api: TuiPluginApi,
120
241
  accounts: StoredAccount[],
121
242
  activeId: string | undefined,
243
+ state: () => UsageState,
122
244
  onSwitch: (id: string) => void | Promise<void>,
123
245
  ): 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
- )
246
+ api.ui.dialog.setSize("medium")
247
+ api.ui.dialog.replace(() => (
248
+ <SwitchDialog api={api} accounts={accounts} activeId={activeId} state={state} onSwitch={(id) => void onSwitch(id)} />
249
+ ))
138
250
  }
package/src/usage.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AuthToken, StoredAccount } from "./accounts.ts"
2
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"
3
+ import { CLIENT_ID, INACTIVE_REFRESH_THRESHOLD_MS, OAUTH_BETA, TOKEN_EXPIRY_BUFFER_MS, TOKEN_URL, USAGE_ENDPOINT } from "./constants.ts"
4
4
  import { debugLog } from "./debug.ts"
5
5
  import { fetchProfile } from "./profile.ts"
6
6
 
@@ -31,8 +31,8 @@ function errorMessage(error: unknown): string {
31
31
  return error instanceof Error ? error.message : String(error)
32
32
  }
33
33
 
34
- function isStale(token: { access?: string; expires?: number }): boolean {
35
- return !token.access || !token.expires || token.expires < Date.now() + TOKEN_EXPIRY_BUFFER_MS
34
+ function isStale(token: { access?: string; expires?: number }, bufferMs = TOKEN_EXPIRY_BUFFER_MS): boolean {
35
+ return !token.access || !token.expires || token.expires < Date.now() + bufferMs
36
36
  }
37
37
 
38
38
  function isRefresh429Cooldown(refresh: string): boolean {
@@ -48,7 +48,11 @@ function isRefresh429Cooldown(refresh: string): boolean {
48
48
  function doRefreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
49
49
  return fetch(TOKEN_URL, {
50
50
  method: "POST",
51
- headers: { "Content-Type": "application/json" },
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ Accept: "application/json, text/plain, */*",
54
+ "User-Agent": "axios/1.13.6",
55
+ },
52
56
  body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refresh, client_id: CLIENT_ID }),
53
57
  }).then(async (res) => {
54
58
  if (!res.ok) {
@@ -109,8 +113,8 @@ export async function autoCapture(): Promise<void> {
109
113
  })
110
114
  }
111
115
 
112
- async function ensureFresh(account: StoredAccount): Promise<{ access?: string; updated?: StoredAccount }> {
113
- if (!isStale(account)) return { access: account.access }
116
+ async function ensureFresh(account: StoredAccount, bufferMs?: number): Promise<{ access?: string; updated?: StoredAccount }> {
117
+ if (!isStale(account, bufferMs)) return { access: account.access }
114
118
  if (isRefresh429Cooldown(account.refresh)) {
115
119
  debugLog("refresh-skip-429-cooldown", { label: account.label }, true)
116
120
  return { access: account.access }
@@ -131,8 +135,9 @@ export async function collectAllUsage(): Promise<{ activeId?: string; results: A
131
135
 
132
136
  for (const account of file.accounts) {
133
137
  const base = { id: account.id, label: account.label, active: account.id === file.activeId }
138
+ const bufferMs = account.id === file.activeId ? TOKEN_EXPIRY_BUFFER_MS : INACTIVE_REFRESH_THRESHOLD_MS
134
139
  try {
135
- const { access, updated } = await ensureFresh(account)
140
+ const { access, updated } = await ensureFresh(account, bufferMs)
136
141
  if (updated) needsRefresh = true
137
142
  if (!access) {
138
143
  settled.push({ result: { ...base, error: "missing access token" }, updated })
package/tui.tsx CHANGED
@@ -60,7 +60,8 @@ const tui: TuiPlugin = async (api) => {
60
60
  api.ui.toast({ variant: "warning", message: "没有账号。请先用 ex-machina 登录 Claude" })
61
61
  return
62
62
  }
63
- openSwitchDialog(api, file.accounts, file.activeId, async (id) => {
63
+ setState((prev) => ({ ...prev, loading: true, error: undefined }))
64
+ openSwitchDialog(api, file.accounts, file.activeId, state, async (id) => {
64
65
  try {
65
66
  const account = await switchToAccount(id)
66
67
  api.ui.toast({ variant: "success", message: `已切换到 ${account.label},下次对话生效` })
@@ -68,6 +69,7 @@ const tui: TuiPlugin = async (api) => {
68
69
  api.ui.toast({ variant: "error", message: `切换失败: ${message(error)}` })
69
70
  }
70
71
  })
72
+ void refreshUsage()
71
73
  },
72
74
  },
73
75
  ])