@maxiaochao/pi-toolkit 0.11.9 → 0.11.10
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/CHANGELOG.md +6 -0
- package/README.md +15 -28
- package/package.json +2 -4
- package/packages/openai-account-pool/CHANGELOG.md +0 -15
- package/packages/openai-account-pool/README.md +0 -67
- package/packages/openai-account-pool/TESTING.md +0 -28
- package/packages/openai-account-pool/docs/demo.gif +0 -0
- package/packages/openai-account-pool/docs/screenshot.png +0 -0
- package/packages/openai-account-pool/extensions/openai-account-pool.ts +0 -368
- package/packages/openai-account-pool/package.json +0 -45
- package/packages/openai-account-pool/src/auth.ts +0 -418
- package/packages/openai-account-pool/src/availability.ts +0 -230
- package/packages/openai-account-pool/src/command-completions.ts +0 -18
- package/packages/openai-account-pool/src/enrollment.ts +0 -339
- package/packages/openai-account-pool/src/lease.ts +0 -128
- package/packages/openai-account-pool/src/lock.ts +0 -171
- package/packages/openai-account-pool/src/login-slot.ts +0 -158
- package/packages/openai-account-pool/src/policy.ts +0 -176
- package/packages/openai-account-pool/src/provider.ts +0 -511
- package/packages/openai-account-pool/src/status-widget.ts +0 -62
- package/packages/openai-account-pool/src/store.ts +0 -259
- package/packages/openai-account-pool/src/usage.ts +0 -291
- package/packages/openai-account-pool/tests/command-completions.mjs +0 -10
- package/packages/openai-account-pool/tests/enrollment.mjs +0 -322
- package/packages/openai-account-pool/tests/failover-stream.mjs +0 -554
- package/packages/openai-account-pool/tests/helpers/fixtures.mjs +0 -40
- package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +0 -58
- package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +0 -24
- package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +0 -29
- package/packages/openai-account-pool/tests/integration-login-ownership.mjs +0 -299
- package/packages/openai-account-pool/tests/login-slot.mjs +0 -131
- package/packages/openai-account-pool/tests/multiprocess.mjs +0 -73
- package/packages/openai-account-pool/tests/selection.mjs +0 -286
- package/packages/openai-account-pool/tests/store-ownership.mjs +0 -369
- package/packages/openai-account-pool/tests/usage.mjs +0 -428
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 2026-10-14 - v0.11.10
|
|
2
|
+
|
|
3
|
+
- openai-account-pool v0.1.2: document that toolkit 0.11.9 was the last version to embed this package
|
|
4
|
+
- Stop enabling `openai-account-pool` in `pi install npm:@maxiaochao/pi-toolkit`: the toolkit tarball and default loadout no longer contain it, and its source stays an independently installable child package.
|
|
5
|
+
- Document which five child packages a toolkit install enables by default and which child stays a separate install. The toolkit README no longer describes Todo.
|
|
6
|
+
|
|
1
7
|
## 2026-10-14 - v0.11.9
|
|
2
8
|
|
|
3
9
|
- bundle openai-account-pool v0.1.1: publish the initial OpenAI Codex account pool and fix coordinated initial-release detection
|
package/README.md
CHANGED
|
@@ -10,51 +10,38 @@ Install the complete toolkit:
|
|
|
10
10
|
pi install npm:@maxiaochao/pi-toolkit
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
That one install enables five child packages by default, because their source is embedded in the toolkit tarball: `pi-cache-export`, `pi-codex-edit`, `pi-herdr-subagents`, `pi-scheduler`, and `pi-worktree`.
|
|
14
|
+
|
|
15
|
+
`pi-openai-account-pool` is not enabled and stays a separate install:
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
|
-
pi
|
|
18
|
+
pi install npm:@maxiaochao/pi-openai-account-pool
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Existing toolkit users can pick up the change with:
|
|
21
|
+
Update it:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
pi update npm:@maxiaochao/pi-toolkit
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Restart Pi after updating.
|
|
27
|
+
Restart Pi after updating. `pi update --extensions` has the same effect when updating all installed packages. Explicitly versioned sources remain pinned and must be changed or reinstalled without the old version suffix.
|
|
28
28
|
|
|
29
29
|
## Child packages
|
|
30
30
|
|
|
31
|
-
| Package | Capability | Install | Update | Why |
|
|
32
|
-
|
|
33
|
-
| [pi-
|
|
34
|
-
| [pi-
|
|
35
|
-
| [pi-
|
|
36
|
-
| [pi-
|
|
37
|
-
| [pi-
|
|
38
|
-
| [pi-
|
|
39
|
-
| [pi-worktree](packages/worktree/README.md) | Worktree CLI, current-session switching Extension, and agent skill | `pi install npm:@maxiaochao/pi-worktree` | `pi update npm:@maxiaochao/pi-worktree` | In addition to the basic worktree CLI + skill, what can be achieved is to directly switch to the worktree path in the main conversation to continue the task conversation after communicating the requirements in the main conversation |
|
|
31
|
+
| Package | Capability | Installed by `pi-toolkit` | Install | Update | Why |
|
|
32
|
+
|---|---|---|---|---|---|
|
|
33
|
+
| [pi-cache-export](packages/cache-export/README.md) | Tau-aligned interactive cache dashboard for Pi sessions | Yes | `pi install npm:@maxiaochao/pi-cache-export` | `pi update npm:@maxiaochao/pi-cache-export` | Because I watched the cache video on @alejandro.ao YouTube, which has a Tau style page for viewing cache |
|
|
34
|
+
| [pi-codex-edit](packages/codex-edit/README.md) | Codex-harness `apply_patch` routing for selected GPT models | Yes | `pi install npm:@maxiaochao/pi-codex-edit` | `pi update npm:@maxiaochao/pi-codex-edit` | The core lies in the fact that the Codex model is naturally good at apply_patch, and the model is trained on this during training. However, hash-based in-context patch schemes have high token costs and complexity |
|
|
35
|
+
| [pi-herdr-subagents](packages/herdr-subagents/README.md) | Visible Herdr Pi Agents coordinated by a small task state machine | Yes | `pi install npm:@maxiaochao/pi-herdr-subagents` | `pi update npm:@maxiaochao/pi-herdr-subagents` | I wanted simple serial or bounded-parallel dispatch while each task stays a real interactive Pi session in its own Herdr pane, and the main conversation only receives short summaries instead of every child's full transcript |
|
|
36
|
+
| [pi-openai-account-pool](packages/openai-account-pool/README.md) | Several ChatGPT Plus/Pro logins behind `openai-codex`, switching accounts on a proven quota limit | No | `pi install npm:@maxiaochao/pi-openai-account-pool` | `pi update npm:@maxiaochao/pi-openai-account-pool` | One `/login openai-codex` credential overwrites the previous one, so hitting the subscription limit meant logging in again by hand mid-task; this keeps the accounts I authorized side by side and continues the same request on the next usable one |
|
|
37
|
+
| [pi-scheduler](packages/scheduler/README.md) | Session-scoped interval prompts with Agent and `/schedule` controls | Yes | `pi install npm:@maxiaochao/pi-scheduler` | `pi update npm:@maxiaochao/pi-scheduler` | I don't recommend this, it's just that it sends messages to the main session at loop intervals |
|
|
38
|
+
| [pi-worktree](packages/worktree/README.md) | Worktree CLI, current-session switching Extension, and agent skill | Yes | `pi install npm:@maxiaochao/pi-worktree` | `pi update npm:@maxiaochao/pi-worktree` | In addition to the basic worktree CLI + skill, what can be achieved is to directly switch to the worktree path in the main conversation to continue the task conversation after communicating the requirements in the main conversation |
|
|
40
39
|
|
|
41
40
|
Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
|
|
42
41
|
|
|
43
42
|
Herdr Subagents personas can opt into local extension files or npm/git Pi packages through `extensions`. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples.
|
|
44
43
|
|
|
45
|
-
Multiple standalone children may be installed together. `pi-
|
|
46
|
-
|
|
47
|
-
To keep Todo after updating the toolkit:
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
pi install npm:@maxiaochao/pi-todo
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
If Todo was already installed separately and should be removed completely:
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
pi remove npm:@maxiaochao/pi-todo
|
|
57
|
-
```
|
|
44
|
+
Multiple standalone children may be installed together. `pi-openai-account-pool` may also be installed alongside the complete toolkit because the toolkit does not embed it. Every other child in the table above is embedded, so a second install exposes the same resource twice.
|
|
58
45
|
|
|
59
46
|
Use `--local` with install or remove when the package declaration belongs to a project's `.pi/settings.json` rather than personal settings.
|
|
60
47
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.10",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
"packages/cache-export",
|
|
17
17
|
"packages/codex-edit",
|
|
18
18
|
"packages/herdr-subagents",
|
|
19
|
-
"packages/openai-account-pool",
|
|
20
19
|
"packages/scheduler",
|
|
21
20
|
"packages/worktree",
|
|
22
21
|
"themes",
|
|
@@ -36,7 +35,6 @@
|
|
|
36
35
|
"./extensions/btw/index.ts",
|
|
37
36
|
"./packages/codex-edit/extensions/codex-edit.ts",
|
|
38
37
|
"./packages/herdr-subagents/extensions/herdr-subagents.ts",
|
|
39
|
-
"./packages/openai-account-pool/extensions/openai-account-pool.ts",
|
|
40
38
|
"./packages/scheduler/extensions/scheduler.ts",
|
|
41
39
|
"./packages/worktree/extensions/worktree.ts"
|
|
42
40
|
],
|
|
@@ -62,7 +60,7 @@
|
|
|
62
60
|
},
|
|
63
61
|
"scripts": {
|
|
64
62
|
"test": "node tests/package-layout.mjs && npm --prefix packages/todo test && npm --prefix packages/cache-export test && npm --prefix packages/codex-edit test && npm --prefix packages/herdr-subagents test && npm --prefix packages/openai-account-pool test && npm --prefix packages/scheduler test && npm --prefix packages/worktree test && node --experimental-strip-types extensions/btw/tests/history.mjs",
|
|
65
|
-
"load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/
|
|
63
|
+
"load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/scheduler/extensions/scheduler.ts && pi -ne -e ./packages/worktree/extensions/worktree.ts",
|
|
66
64
|
"eval:agents": "node evaluations/agents-md/run.mjs",
|
|
67
65
|
"eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
|
|
68
66
|
"postinstall": "node scripts/install-agents.mjs"
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
## 2026-10-14 - v0.1.1
|
|
2
|
-
|
|
3
|
-
- publish the initial OpenAI Codex account pool and fix coordinated initial-release detection
|
|
4
|
-
|
|
5
|
-
## 2026-10-14 - v0.1.0
|
|
6
|
-
|
|
7
|
-
- add OpenAI Codex account pooling with safe quota failover and live usage status
|
|
8
|
-
- add multi-account failover for the openai-codex provider
|
|
9
|
-
- Add `/codex_pool` with a continue/end loop that adds any number of ChatGPT accounts through the native OAuth flow and keeps an existing single login.
|
|
10
|
-
- Switch accounts on proven subscription limits or proven authorization failures only, before any output reaches the conversation.
|
|
11
|
-
- Share cooldown and probe state between pi processes in the same agent directory, and keep auth.json authoritative for credentials.
|
|
12
|
-
- Show live per-account Codex usage and the last successful account, recover locally cooled accounts with one bounded usage sweep, and report the earliest server-stated recovery time.
|
|
13
|
-
- Keep usage recovery race-safe across processes with freshness-guarded state updates, probe leases, bounded private-endpoint reads, model-scoped estimates, and non-secret failure codes.
|
|
14
|
-
- Show `/codex_pool status` in a temporary colored panel above the editor that appears immediately, displays cached state, refreshes live quota in the background, and removes itself after 15 seconds; keep non-TUI text output and place `●当前` at the end of the account line.
|
|
15
|
-
- Stop returning an identical argument completion after a subcommand is fully typed, so one Enter submits `/codex_pool status` instead of merely accepting `status`.
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# Pi OpenAI Account Pool
|
|
2
|
-
|
|
3
|
-
Pool several ChatGPT Plus/Pro logins behind pi's built-in `openai-codex` provider. When the account in use proves it is out of subscription quota, the same request continues on another account — same conversation, same model, same thinking level, no replayed tools.
|
|
4
|
-
|
|
5
|
-

|
|
6
|
-
|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
## Why
|
|
10
|
-
|
|
11
|
-
`/login openai-codex` stores one credential per provider: logging in a second account overwrites the first. Hitting the subscription limit therefore means logging in again by hand, in the middle of the work. This package keeps every authorized account side by side and picks the next usable one for you.
|
|
12
|
-
|
|
13
|
-
It does not create accounts, share them between people, or work around a limit on one account. It only uses logins you authorized yourself, one at a time.
|
|
14
|
-
|
|
15
|
-
## Install
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
pi install npm:@maxiaochao/pi-openai-account-pool
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Update it with `pi update npm:@maxiaochao/pi-openai-account-pool`. Installing alone changes nothing: the pool turns on the first time you add an account.
|
|
22
|
-
|
|
23
|
-
## User command
|
|
24
|
-
|
|
25
|
-
```text
|
|
26
|
-
/codex_pool 添加账号:反复确认「继续 / 删除 / 结束」,继续一次就登录一个账号
|
|
27
|
-
/codex_pool status 输入框上方立即显示彩色临时面板并在后台查询官网 5 小时/周额度;最近成功使用的账号在行末标 ●当前
|
|
28
|
-
/codex_pool use 下拉选择默认从哪个账号开始(`use auto` 恢复自动)
|
|
29
|
-
/codex_pool remove 下拉选择要删除的账号(也可写 `remove 2` 或 `remove 邮箱`)
|
|
30
|
-
/codex_pool off 恢复原生单账号登录
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
`/codex_pool` 先显示确认界面,不会无提示打开浏览器。选择继续就走一次原生 Codex 登录;成功后立即原子保存,并回到同一个界面,可以一直加下去。结束只结束添加,账号池和自动切换保留;Esc 取消的是当前这次授权。
|
|
34
|
-
|
|
35
|
-
账号按登录邮箱显示(如 `#2 (you@example.com)`),邮箱取自令牌自带的身份声明;没有该声明时回落到脱敏标识。去重按 ChatGPT 账号 id:重新登录同一个账号只更新它的凭据和标签,编号和总数不变,这也是授权失效后重新授权的做法。
|
|
36
|
-
|
|
37
|
-
第一次成功添加会把原有的 `openai-codex` 登录一起并入池,不会丢失。
|
|
38
|
-
|
|
39
|
-
## When it switches
|
|
40
|
-
|
|
41
|
-
| 结构化证据 | 行为 |
|
|
42
|
-
|---|---|
|
|
43
|
-
| `usage_limit_reached` | 记录额度冷却,换到下一个可用账号 |
|
|
44
|
-
| 明确失效的授权(`invalid_grant`、刷新后仍 401) | 标记需重新授权,换到下一个可用账号 |
|
|
45
|
-
| 普通 429、`rate_limit_exceeded`、5xx、网络错误 | 原账号原有重试,不换账号 |
|
|
46
|
-
| `usage_not_included`、403、400、未知错误 | 停止,不猜测额度耗尽 |
|
|
47
|
-
|
|
48
|
-
切换只在请求还没有向外输出文字、思考或工具调用时发生,所以不会出现重复回答。已经输出后才遇到额度耗尽的请求会正常结束,冷却仍被记录,下一次请求自动跳过那个账号。
|
|
49
|
-
|
|
50
|
-
## Semantics and limits
|
|
51
|
-
|
|
52
|
-
- 凭据的权威来源仍是 `~/.pi/agent/auth.json`:每个账号一条独立条目,主 `openai-codex` 条目只存非秘密引用,不能当令牌使用。
|
|
53
|
-
- 冷却与探测状态放在 `~/.pi/agent/openai-account-pool/state.json`,同机多个 pi 进程共享:一个进程撞到的额度上限,另一个进程下一次请求就会跳过。
|
|
54
|
-
- 默认 sticky:一直用当前可用的账号,不轮询分摊。`/codex_pool use` 指定下次优先尝试哪个账号,跨进程生效,但 status 不给这个设置加徽标;只有最近成功承接模型请求的账号显示 `●当前`。它额度用完或需要重新授权时仍会自动切走。
|
|
55
|
-
- 令牌过期是自动处理的:每次请求前在 `auth.json` 锁下用 refresh token 刷新,多进程只会兑换一次。只有 refresh token 本身被证明失效时才标为需重新授权,此时跑 `/codex_pool` 重新登录该账号即可。
|
|
56
|
-
- 每个逻辑请求里每个账号最多作为一次切换候选;全部不可用时立即返回每个账号的脱敏状态,不会后台等待几小时。
|
|
57
|
-
- 服务器给出恢复时间就显示恢复时间;没有给出就显示「未知」,只做短时本地探测退避。
|
|
58
|
-
- 只对原生 Codex endpoint 使用池凭据。显式传入的 API key 和自定义 endpoint 不会被替换。
|
|
59
|
-
- `print`/JSON 模式没有交互界面,不能添加账号;已有账号池的模型请求照常工作。
|
|
60
|
-
|
|
61
|
-
## Remove
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
pi remove npm:@maxiaochao/pi-openai-account-pool
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
先运行 `/codex_pool off` 恢复单账号登录,再卸载,否则 `openai-codex` 会留下一条无法使用的池引用。不要同时安装独立包和已经内置本包的 toolkit 版本,否则命令会注册两次。
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Testing Pi OpenAI Account Pool
|
|
2
|
-
|
|
3
|
-
Run from the repository root:
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
npm --prefix packages/openai-account-pool test
|
|
7
|
-
npm --prefix packages/openai-account-pool run load-test
|
|
8
|
-
npm pack ./packages/openai-account-pool --dry-run
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Every test runs against an isolated temporary agent directory with fixture credentials. No real token, refresh token or authorization callback URL appears in this repository, in the tests, or in the generated media.
|
|
12
|
-
|
|
13
|
-
- `tests/store-ownership.mjs` covers the credential store: unrelated provider entries survive, the lock is mutually exclusive with pi's own writes in both directions, cancelled lock waits, stale lock reclaim, a compromised lock refusing to write, single token rotation under concurrency, ownership across plain login/logout/re-enable, monotonic generations, corrupt JSON and symlink refusal, and enrollment lease recovery.
|
|
14
|
-
- `tests/selection.mjs` covers classification and selection: only structured `usage_limit_reached` counts as a subscription limit, a plain 429 with the same friendly wording does not, reset times are validated, and shared state ignores stale generations and late successes.
|
|
15
|
-
- `tests/command-completions.mjs` covers slash-command argument completion, including the real TUI regression where returning an identical completion for a fully typed `status` consumed the first Enter instead of submitting the command.
|
|
16
|
-
- `tests/enrollment.mjs` covers the `/codex_pool` loop: repeated continue adds more than two accounts, an existing plain login is migrated instead of replaced, ending immediately changes nothing, failures are redacted, cancelling keeps what was already saved, two processes cannot enroll at once, and status/off/remove speak in masked labels.
|
|
17
|
-
- `tests/usage.mjs` covers live `wham/usage` normalization and headers, OAuth refresh, sequential account queries, bounded response size and time, non-secret failures, stale-observation races, cross-process probe leases, model-scoped recovery estimates, stored exhaustion after a failed live lookup, and the immediately rendered colored status widget with loading state and line-final `●当前`. Every HTTP path uses an injected fixture transport.
|
|
18
|
-
- `tests/failover-stream.mjs` runs through real pi-ai stream plumbing: one external start, no replay after output, a repaired 401, websocket-style evidence, cancellation, one live-usage sweep when all accounts are locally unavailable, recovered-account selection, and exhausted-pool earliest-ready messages.
|
|
19
|
-
- `tests/multiprocess.mjs` spawns real child processes: two pi processes racing an expired rotating refresh token exchange it exactly once and converge on the same credential, concurrent writes from five processes lose no entry, and a valid credential is never refreshed.
|
|
20
|
-
- `tests/integration-login-ownership.mjs` uses the real `Models` implementation to show that a second plain login still overwrites the first, and that the pool keeps every account.
|
|
21
|
-
|
|
22
|
-
For a manual check, run pi with `-e ./packages/openai-account-pool/extensions/openai-account-pool.ts`, run `/codex_pool`, authorize two different ChatGPT accounts, and confirm that the same model keeps answering after the first account reports its limit. Do not exhaust a paid quota on purpose; record what was and was not executed.
|
|
23
|
-
|
|
24
|
-
Regenerate package media with:
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
node scripts/capture-package-media.mjs openai-account-pool
|
|
28
|
-
```
|
|
Binary file
|
|
Binary file
|
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
import type { AuthEvent, AuthPrompt, OAuthCredential, Provider, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
2
|
-
import { lazyStream } from "@earendil-works/pi-ai";
|
|
3
|
-
import {
|
|
4
|
-
getAgentDir,
|
|
5
|
-
LoginDialogComponent,
|
|
6
|
-
type ExtensionAPI,
|
|
7
|
-
type ExtensionCommandContext,
|
|
8
|
-
type ExtensionContext,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
|
|
11
|
-
import { accountLabel, loadPoolSnapshot, maskAccountId, type PoolAccount } from "../src/auth.ts";
|
|
12
|
-
import { readPreferredAccountId } from "../src/availability.ts";
|
|
13
|
-
import { codexPoolArgumentCompletions } from "../src/command-completions.ts";
|
|
14
|
-
import {
|
|
15
|
-
AccountQueryError,
|
|
16
|
-
confirmText,
|
|
17
|
-
endPool,
|
|
18
|
-
isCancellation,
|
|
19
|
-
poolStatus,
|
|
20
|
-
redactMessage,
|
|
21
|
-
removePoolAccount,
|
|
22
|
-
resolveAccount,
|
|
23
|
-
runEnrollment,
|
|
24
|
-
setDefaultAccount,
|
|
25
|
-
type EnrollmentUi,
|
|
26
|
-
type PoolStatus,
|
|
27
|
-
} from "../src/enrollment.ts";
|
|
28
|
-
import { EnrollmentInProgressError } from "../src/lease.ts";
|
|
29
|
-
import { LoginSlot, type LoginDialog, type LoginSlotUi } from "../src/login-slot.ts";
|
|
30
|
-
import { createPoolProvider } from "../src/provider.ts";
|
|
31
|
-
import { CodexPoolStatusWidget, formatAccountStatusLine } from "../src/status-widget.ts";
|
|
32
|
-
import { authPathFor, poolStatePathFor, type AuthEntry } from "../src/store.ts";
|
|
33
|
-
|
|
34
|
-
const PROVIDER_ID = "openai-codex";
|
|
35
|
-
const PROVIDER_NAME = "OpenAI (ChatGPT Plus/Pro)";
|
|
36
|
-
/** Marks our wrapper so a reload cannot wrap the wrapper again. */
|
|
37
|
-
const POOL_WRAPPER = Symbol.for("pi-openai-account-pool.wrapper");
|
|
38
|
-
|
|
39
|
-
const CONTINUE = "继续:登录并添加一个账号";
|
|
40
|
-
const REMOVE = "删除:从账号池里移除一个账号";
|
|
41
|
-
const END = "结束:保留账号池和自动切换";
|
|
42
|
-
const BACK = "返回:什么都不做";
|
|
43
|
-
const AUTOMATIC = "自动:按编号顺序,用着哪个顺手就留在哪个";
|
|
44
|
-
const DIALOG_TITLE = "添加 Codex 账号";
|
|
45
|
-
const STATUS_WIDGET_KEY = "openai-account-pool-status";
|
|
46
|
-
const STATUS_VISIBLE_MS = 15_000;
|
|
47
|
-
|
|
48
|
-
function agentPaths() {
|
|
49
|
-
const agentDir = getAgentDir();
|
|
50
|
-
return { authPath: authPathFor(agentDir), statePath: poolStatePathFor(agentDir) };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Drives the native OAuth flow through pi's own login dialog, exactly like `/login` does.
|
|
55
|
-
*
|
|
56
|
-
* The dialog and pi's selectors share one slot below the chat, so `LoginSlot` owns that slot and
|
|
57
|
-
* decides what is mounted when.
|
|
58
|
-
*/
|
|
59
|
-
async function loginWithDialog(ctx: ExtensionContext, oauth: NonNullable<Provider["auth"]["oauth"]>): Promise<OAuthCredential> {
|
|
60
|
-
const ui: LoginSlotUi = {
|
|
61
|
-
mount: (register) =>
|
|
62
|
-
ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
|
63
|
-
const dialog = new LoginDialogComponent(tui, PROVIDER_ID, () => done(undefined), PROVIDER_NAME, DIALOG_TITLE);
|
|
64
|
-
register(dialog satisfies LoginDialog, () => done(undefined));
|
|
65
|
-
return dialog;
|
|
66
|
-
}),
|
|
67
|
-
select: (message, options, signal) => ctx.ui.select(message, [...options], { signal }),
|
|
68
|
-
};
|
|
69
|
-
const slot = new LoginSlot(ui);
|
|
70
|
-
try {
|
|
71
|
-
return await oauth.login(slot.interaction);
|
|
72
|
-
} finally {
|
|
73
|
-
await slot.close();
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function selectThroughDialog(ctx: ExtensionContext, request: AuthPrompt & { type: "select" }): Promise<string> {
|
|
78
|
-
const labels = request.options.map((option) => option.label);
|
|
79
|
-
const chosen = await ctx.ui.select(request.message, labels);
|
|
80
|
-
const index = chosen === undefined ? -1 : labels.indexOf(chosen);
|
|
81
|
-
if (index < 0) throw new Error("Login cancelled");
|
|
82
|
-
return request.options[index].id;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Dialog-capable modes without a terminal, such as RPC, use the standard dialogs. */
|
|
86
|
-
async function loginWithDialogs(ctx: ExtensionContext, oauth: NonNullable<Provider["auth"]["oauth"]>): Promise<OAuthCredential> {
|
|
87
|
-
const controller = new AbortController();
|
|
88
|
-
const prompt = async (request: AuthPrompt): Promise<string> => {
|
|
89
|
-
if (request.type === "select") return selectThroughDialog(ctx, request);
|
|
90
|
-
const answer = await ctx.ui.input(request.message, request.placeholder, { signal: request.signal });
|
|
91
|
-
if (answer === undefined) throw new Error("Login cancelled");
|
|
92
|
-
return answer;
|
|
93
|
-
};
|
|
94
|
-
const notify = (event: AuthEvent) => {
|
|
95
|
-
if (event.type === "auth_url") ctx.ui.notify(`在浏览器中打开以授权:${event.url}`);
|
|
96
|
-
else if (event.type === "device_code") ctx.ui.notify(`在 ${event.verificationUri} 输入代码 ${event.userCode}`);
|
|
97
|
-
else ctx.ui.notify(event.message);
|
|
98
|
-
};
|
|
99
|
-
return oauth.login({ signal: controller.signal, prompt, notify } satisfies ProviderAuthInteraction);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Lets the user point at one account with the arrow keys instead of typing an identifier. */
|
|
103
|
-
async function pickAccount(ctx: ExtensionContext, message: string, accounts: readonly PoolAccount[], extra: string[] = []): Promise<PoolAccount | string | undefined> {
|
|
104
|
-
const labels = accounts.map((account) => accountLabel(account));
|
|
105
|
-
const chosen = await ctx.ui.select(message, [...labels, ...extra, BACK]);
|
|
106
|
-
if (chosen === undefined || chosen === BACK) return undefined;
|
|
107
|
-
const index = labels.indexOf(chosen);
|
|
108
|
-
return index >= 0 ? accounts[index] : chosen;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function enrollmentUi(ctx: ExtensionContext, oauth: NonNullable<Provider["auth"]["oauth"]>): EnrollmentUi {
|
|
112
|
-
return {
|
|
113
|
-
confirm: async (view) => {
|
|
114
|
-
const options = view.savedAccounts > 0 ? [CONTINUE, REMOVE, END] : [CONTINUE, END];
|
|
115
|
-
const choice = await ctx.ui.select(confirmText(view), options);
|
|
116
|
-
if (choice === CONTINUE) return "continue";
|
|
117
|
-
return choice === REMOVE ? "remove" : "end";
|
|
118
|
-
},
|
|
119
|
-
login: async () => {
|
|
120
|
-
const credential = ctx.mode === "tui" ? await loginWithDialog(ctx, oauth) : await loginWithDialogs(ctx, oauth);
|
|
121
|
-
return credential as unknown as AuthEntry;
|
|
122
|
-
},
|
|
123
|
-
pickRemoval: async (accounts) => {
|
|
124
|
-
const chosen = await pickAccount(ctx, "删除哪个账号?会删掉本地凭据,不会撤销 OpenAI 上的授权。", accounts);
|
|
125
|
-
return typeof chosen === "object" ? chosen : undefined;
|
|
126
|
-
},
|
|
127
|
-
notify: (message, kind) => ctx.ui.notify(message, kind ?? "info"),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function readStatus(ctx: ExtensionContext, live: boolean, signal?: AbortSignal): Promise<PoolStatus> {
|
|
132
|
-
const { authPath, statePath } = agentPaths();
|
|
133
|
-
const oauth = ctx.modelRegistry.getProvider(PROVIDER_ID)?.auth.oauth;
|
|
134
|
-
return poolStatus({
|
|
135
|
-
authPath,
|
|
136
|
-
statePath,
|
|
137
|
-
modelId: ctx.model?.id,
|
|
138
|
-
refresh: live && oauth
|
|
139
|
-
? async (credential, signal) =>
|
|
140
|
-
oauth.refresh(credential as unknown as OAuthCredential, signal ?? new AbortController().signal) as unknown as AuthEntry
|
|
141
|
-
: undefined,
|
|
142
|
-
signal,
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function showStatus(ctx: ExtensionContext, showTuiStatus: (ctx: ExtensionContext) => void): Promise<void> {
|
|
147
|
-
if (ctx.mode === "tui") {
|
|
148
|
-
showTuiStatus(ctx);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
const status = await readStatus(ctx, true);
|
|
152
|
-
const lines = [status.summary, ...status.accounts.map(formatAccountStatusLine)];
|
|
153
|
-
ctx.ui.notify(lines.join("\n"));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function handleCommand(
|
|
157
|
-
args: string,
|
|
158
|
-
ctx: ExtensionCommandContext,
|
|
159
|
-
provider: () => Provider | undefined,
|
|
160
|
-
showTuiStatus: (ctx: ExtensionContext) => void,
|
|
161
|
-
): Promise<void> {
|
|
162
|
-
const [subcommand = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
163
|
-
const { authPath, statePath } = agentPaths();
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
if (subcommand === "status") {
|
|
167
|
-
await showStatus(ctx, showTuiStatus);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (subcommand === "off") {
|
|
172
|
-
const snapshot = await loadPoolSnapshot(authPath);
|
|
173
|
-
if (!snapshot.enabled) {
|
|
174
|
-
ctx.ui.notify("账号池未启用,无需关闭。", "warning");
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const keep = [...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal)[0];
|
|
178
|
-
const confirmed = await ctx.ui.confirm(
|
|
179
|
-
"关闭 Codex 账号池?",
|
|
180
|
-
`将恢复为单账号登录(保留 ${maskAccountId(keep.id)}),其余 ${snapshot.accounts.length - 1} 个账号的本地凭据会保留但不再使用。`,
|
|
181
|
-
);
|
|
182
|
-
if (!confirmed) return;
|
|
183
|
-
const result = await endPool({ authPath, statePath, keepAccountId: keep.id });
|
|
184
|
-
ctx.ui.notify(`已关闭账号池,恢复为单账号登录(${maskAccountId(result.kept.id)})。`);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (subcommand === "use") {
|
|
189
|
-
const snapshot = await loadPoolSnapshot(authPath);
|
|
190
|
-
if (!snapshot.enabled) throw new AccountQueryError("账号池未启用,没有可切换的账号。");
|
|
191
|
-
const query = rest.join(" ");
|
|
192
|
-
if (query === "auto") {
|
|
193
|
-
await setDefaultAccount({ authPath, statePath, accountId: undefined });
|
|
194
|
-
ctx.ui.notify("已恢复自动选号。");
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
const accounts = [...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal);
|
|
198
|
-
const current = await readPreferredAccountId(statePath);
|
|
199
|
-
const chosen = query
|
|
200
|
-
? resolveAccount(snapshot, query)
|
|
201
|
-
: await pickAccount(ctx, `默认用哪个账号?当前:${current ? accountLabel(accounts.find((account) => account.id === current) ?? accounts[0]) : "自动"}`, accounts, [AUTOMATIC]);
|
|
202
|
-
if (chosen === undefined) return;
|
|
203
|
-
if (chosen === AUTOMATIC) {
|
|
204
|
-
await setDefaultAccount({ authPath, statePath, accountId: undefined });
|
|
205
|
-
ctx.ui.notify("已恢复自动选号。");
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const account = chosen as PoolAccount;
|
|
209
|
-
await setDefaultAccount({ authPath, statePath, accountId: account.id });
|
|
210
|
-
ctx.ui.notify(`以后从 ${accountLabel(account)} 开始。它额度用完或需要重新授权时,仍会自动切到其他账号。`);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (subcommand === "remove") {
|
|
215
|
-
const snapshot = await loadPoolSnapshot(authPath);
|
|
216
|
-
if (!snapshot.enabled) throw new AccountQueryError("账号池未启用,没有可删除的池内账号。");
|
|
217
|
-
const query = rest.join(" ");
|
|
218
|
-
const accounts = [...snapshot.accounts].sort((left, right) => left.ordinal - right.ordinal);
|
|
219
|
-
const chosen = query
|
|
220
|
-
? resolveAccount(snapshot, query)
|
|
221
|
-
: await pickAccount(ctx, "删除哪个账号?", accounts);
|
|
222
|
-
if (chosen === undefined) return;
|
|
223
|
-
const account = chosen as PoolAccount;
|
|
224
|
-
const confirmed = await ctx.ui.confirm("删除这个 Codex 账号?", `将删除 ${accountLabel(account)} 的本地凭据,不会撤销 OpenAI 上的授权。`);
|
|
225
|
-
if (!confirmed) return;
|
|
226
|
-
const result = await removePoolAccount({ authPath, statePath, accountId: account.id });
|
|
227
|
-
ctx.ui.notify(
|
|
228
|
-
result.poolDisabled
|
|
229
|
-
? `已删除最后一个账号 ${accountLabel(account)},账号池已关闭,openai-codex 现在没有登录。`
|
|
230
|
-
: `已删除账号 ${accountLabel(account)},还剩 ${result.remaining} 个。`,
|
|
231
|
-
);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (subcommand) {
|
|
236
|
-
ctx.ui.notify("用法:/codex_pool [status|use [编号|邮箱|auto]|remove [编号|邮箱]|off]", "warning");
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (!ctx.hasUI) {
|
|
241
|
-
ctx.ui.notify("当前模式没有交互界面,无法添加账号。请在交互式 pi 中运行 /codex_pool;已有账号池的模型请求不受影响。", "error");
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const oauth = provider()?.auth.oauth;
|
|
246
|
-
if (!oauth) {
|
|
247
|
-
ctx.ui.notify("找不到 openai-codex 的 OAuth 登录方式。", "error");
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const result = await runEnrollment({ authPath, statePath, ui: enrollmentUi(ctx, oauth) });
|
|
252
|
-
if (result.cancelled) ctx.ui.notify(`已取消这次授权。当前共 ${result.accounts} 个账号。`, "warning");
|
|
253
|
-
else if (result.added + result.updated + result.removed === 0) ctx.ui.notify(`没有改动。当前共 ${result.accounts} 个账号。`);
|
|
254
|
-
else ctx.ui.notify(`账号池共 ${result.accounts} 个账号${result.accounts > 1 ? ",额度耗尽时会自动切换" : ",暂无备用账号"}。`);
|
|
255
|
-
} catch (error) {
|
|
256
|
-
if (error instanceof EnrollmentInProgressError) {
|
|
257
|
-
ctx.ui.notify("另一个 pi 正在添加 Codex 账号,请等它结束后再试。", "warning");
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
if (error instanceof AccountQueryError) {
|
|
261
|
-
ctx.ui.notify(error.message, "warning");
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
if (isCancellation(error)) {
|
|
265
|
-
ctx.ui.notify("已取消。", "warning");
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
ctx.ui.notify(`/codex_pool 失败:${redactMessage(error)}`, "error");
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export default function openaiAccountPool(pi: ExtensionAPI) {
|
|
273
|
-
let base: Provider | undefined;
|
|
274
|
-
let latest: ExtensionContext | undefined;
|
|
275
|
-
let statusPanel: { component: CodexPoolStatusWidget; tui: { requestRender(): void } } | undefined;
|
|
276
|
-
let statusController: AbortController | undefined;
|
|
277
|
-
let statusTimer: ReturnType<typeof setTimeout> | undefined;
|
|
278
|
-
let statusGeneration = 0;
|
|
279
|
-
|
|
280
|
-
const clearStatusPanel = (ctx: ExtensionContext) => {
|
|
281
|
-
statusController?.abort();
|
|
282
|
-
statusController = undefined;
|
|
283
|
-
if (statusTimer) clearTimeout(statusTimer);
|
|
284
|
-
statusTimer = undefined;
|
|
285
|
-
ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
|
|
286
|
-
statusPanel = undefined;
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
const showTuiStatus = (ctx: ExtensionContext) => {
|
|
290
|
-
clearStatusPanel(ctx);
|
|
291
|
-
const generation = ++statusGeneration;
|
|
292
|
-
const controller = new AbortController();
|
|
293
|
-
statusController = controller;
|
|
294
|
-
let status: PoolStatus | undefined;
|
|
295
|
-
let loading = true;
|
|
296
|
-
let updateFailed = false;
|
|
297
|
-
const render = () => {
|
|
298
|
-
if (generation !== statusGeneration) return;
|
|
299
|
-
statusPanel?.component.update(status, { loading, updateFailed });
|
|
300
|
-
statusPanel?.tui.requestRender();
|
|
301
|
-
};
|
|
302
|
-
ctx.ui.setWidget(STATUS_WIDGET_KEY, (tui, theme) => {
|
|
303
|
-
const component = new CodexPoolStatusWidget(theme);
|
|
304
|
-
statusPanel = { component, tui };
|
|
305
|
-
render();
|
|
306
|
-
return component;
|
|
307
|
-
}, { placement: "aboveEditor" });
|
|
308
|
-
void (async () => {
|
|
309
|
-
try {
|
|
310
|
-
status = await readStatus(ctx, false, controller.signal);
|
|
311
|
-
if (generation !== statusGeneration) return;
|
|
312
|
-
render();
|
|
313
|
-
status = await readStatus(ctx, true, controller.signal);
|
|
314
|
-
if (generation !== statusGeneration) return;
|
|
315
|
-
loading = false;
|
|
316
|
-
render();
|
|
317
|
-
} catch {
|
|
318
|
-
if (controller.signal.aborted || generation !== statusGeneration) return;
|
|
319
|
-
loading = false;
|
|
320
|
-
updateFailed = true;
|
|
321
|
-
render();
|
|
322
|
-
} finally {
|
|
323
|
-
if (!controller.signal.aborted && generation === statusGeneration) {
|
|
324
|
-
statusTimer = setTimeout(() => clearStatusPanel(ctx), STATUS_VISIBLE_MS);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
})();
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
const install = (ctx: ExtensionContext) => {
|
|
331
|
-
latest = ctx;
|
|
332
|
-
const current = ctx.modelRegistry.getProvider(PROVIDER_ID);
|
|
333
|
-
if (!current || (current as Record<symbol, unknown>)[POOL_WRAPPER]) {
|
|
334
|
-
base ??= current;
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
base = current;
|
|
338
|
-
const { authPath, statePath } = agentPaths();
|
|
339
|
-
const wrapped = createPoolProvider(current, {
|
|
340
|
-
lazyStream,
|
|
341
|
-
authPath,
|
|
342
|
-
statePath,
|
|
343
|
-
// Switch notes go to whichever context is current when the request runs.
|
|
344
|
-
notify: (message) => latest?.ui.notify(message, "warning"),
|
|
345
|
-
});
|
|
346
|
-
Object.defineProperty(wrapped, POOL_WRAPPER, { value: true, enumerable: false });
|
|
347
|
-
ctx.modelRegistry.registerProvider(wrapped);
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
pi.on("session_start", (_event, ctx) => {
|
|
351
|
-
install(ctx);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
355
|
-
statusGeneration += 1;
|
|
356
|
-
if (ctx.mode === "tui") clearStatusPanel(ctx);
|
|
357
|
-
latest = undefined;
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
pi.registerCommand("codex_pool", {
|
|
361
|
-
description: "添加并管理多个 ChatGPT 账号,额度耗尽时自动切换",
|
|
362
|
-
getArgumentCompletions: codexPoolArgumentCompletions,
|
|
363
|
-
handler: async (args, ctx) => {
|
|
364
|
-
install(ctx);
|
|
365
|
-
await handleCommand(args, ctx, () => base, showTuiStatus);
|
|
366
|
-
},
|
|
367
|
-
});
|
|
368
|
-
}
|