Skip to content

Commit 87c3d2e

Browse files
authored
Merge pull request #12 from HOOLC/feat/current-mcp-account-state
fix: refresh runtime state and persist chatgpt auth
2 parents a0d4e1a + 5d51797 commit 87c3d2e

25 files changed

+6221
-3700
lines changed

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ npm install -g codex-team
1212

1313
After install, use the `codexm` command.
1414

15+
## Quick start
16+
17+
```bash
18+
npm install -g codex-team
19+
codexm add plus1
20+
codexm add team1
21+
codexm launch --watch
22+
```
23+
24+
`codexm add` opens a login flow and stores each account as a named snapshot. `codexm launch --watch` starts Codex Desktop and keeps a background watcher running so quota exhaustion can switch to the best available saved account.
25+
1526
## Shell completion
1627

1728
Generate a shell completion script and install it with your shell's standard mechanism:
@@ -47,12 +58,12 @@ Global flags: `--help`, `--version`, `--debug`
4758

4859
Use `--json` for machine-readable output and `--debug` for stderr diagnostics.
4960

50-
- `codexm current` keeps the default current-auth summary, prefers managed Desktop MCP account state when available, and labels whether the result came from MCP or local `auth.json`; when the running managed Desktop auth differs from local `auth.json`, it prints a warning. `--refresh` prefers managed Desktop MCP quota when a codexm-managed session is available and falls back to the usage API. JSON output adds a top-level `quota` field whenever usage data is available.
61+
- `codexm current` shows the active account. It uses the running managed Desktop session when available, falls back to local `auth.json`, and warns if those differ. Add `--refresh` to include current quota usage.
5162
- `codexm list` refreshes quota data before printing, shows the current managed account above the table, marks current rows with `*`, and includes top-level `current` plus per-row `is_current` fields in JSON mode. The default table shows normalized `CURRENT SCORE`; add `--verbose` for normalized `1H SCORE`, raw 5H/1W breakdown, and plan ratio details.
5263
- `codexm add <name>` creates a new managed account without changing the active `~/.codex/auth.json`. By default it uses the built-in browser ChatGPT login flow; add `--device-auth` for device-code login on remote/headless machines. `--with-api-key` reads an API key from stdin, for example `printenv OPENAI_API_KEY | codexm add work-api --with-api-key`.
53-
- `codexm launch` starts Codex Desktop with current auth, switches first when you pass a saved account name, or picks the best saved account with `codexm launch --auto`. Add `--watch` to ensure a detached background watcher is running after launch; by default that watcher auto-switches on terminal quota events, and `--no-auto-switch` turns it into a read-only quota watcher. A codexm-managed Desktop session can accept later `codexm switch` updates directly; unmanaged sessions only update local auth and point you back to `codexm launch`. Run `codexm launch` from an external terminal if you need to restart Desktop, and use `codexm switch --force` when you want to skip waiting for the current managed Desktop thread.
64+
- `codexm launch` starts Codex Desktop with current auth, switches first when you pass a saved account name, or picks the best saved account with `codexm launch --auto`. Add `--watch` to keep a detached watcher running after launch. Run `codexm launch` from an external terminal if you need to restart Desktop.
5465
- `codexm switch`, `codexm switch --auto`, and auth-changing `codexm launch` flows share a cross-process lock under `~/.codex-team/locks/switch.lock` so only one auth-changing operation runs at a time. If the lock is busy, the CLI reports the lock path and the owning command; `watch` skips that cycle instead of queueing behind an in-flight switch.
55-
- `codexm watch` attaches to the managed Codex Desktop DevTools session, tracks bridge-level quota signals, prints structured quota updates, and by default can trigger `switch --auto` on terminal quota events such as exhausted `account/rateLimits/*` payloads or `usageLimitExceeded`. Use `--no-auto-switch` to keep the same quota feed without changing accounts. `--detach` runs the watcher in the background and stores state in `~/.codex-team/watch-state.json` with logs under `~/.codex-team/logs/watch.log`; use `--status` and `--stop` to inspect or stop it.
66+
- `codexm watch` watches the managed Codex Desktop session, prints quota updates, and by default auto-switches when the active account is exhausted. Use `--no-auto-switch` for read-only watching. `--detach` runs it in the background; use `--status` and `--stop` to inspect or stop it.
5667

5768
Unknown commands and flags fail fast; when there is a close match, `codexm` suggests it.
5869

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# codexm 运行时通路说明
2+
3+
这份文档面向维护者,说明 `codexm` 当前和 Codex 运行时交互的两条通路、命令优先级,以及它们各自适合解决的问题。
4+
5+
README 只保留用户可见行为;这里记录设计意图和实现边界。
6+
7+
## 1. 背景总结
8+
9+
`codexm` 先有的是账号快照管理能力,后面逐步补了和 Codex Desktop 的联动能力。
10+
11+
到目前为止,这条演进大致分成两步:
12+
13+
1. `launch / switch / watch`
14+
- 通过受管 Desktop 状态文件识别由 `codexm` 拉起的桌面实例
15+
- 通过 DevTools + Electron bridge 读取 Desktop 当前 runtime 信息
16+
- 通过 `codex-app-server-restart` 在受管 Desktop 上触发切换生效
17+
- 通过 watch 监听 Desktop 页面里的 bridge 消息流,实时感知 quota 变化
18+
19+
2. direct client 读通路
20+
- 新增一条直接拉起 `codex app-server` 的 stdio JSON-RPC client
21+
- 用于一次性读取账号和 quota,不依赖 Desktop 当前前台状态
22+
- 作为 `current` 等读命令的 fallback,也为后续 `doctor` 铺路
23+
24+
最早的 launch 设计稿在:
25+
26+
- [2026-04-07-codexm-launch-design.md](/Users/bytedance/code/codex-team/docs/superpowers/specs/2026-04-07-codexm-launch-design.md)
27+
28+
这份 internal 文档不重复设计过程,只记录目前代码里的稳定结论。
29+
30+
## 2. 两条通路
31+
32+
### 2.1 Desktop bridge 通路
33+
34+
入口主要在:
35+
36+
- [codex-desktop-launch.ts](/Users/bytedance/code/codex-team/src/codex-desktop-launch.ts)
37+
38+
机制:
39+
40+
1. 通过受管 Desktop metadata 找到 `codexm` 拉起的 Codex Desktop
41+
2. 通过 DevTools websocket 连到 `app://-/index.html?hostId=local`
42+
3. 在页面内执行 JS
43+
4. 由页面里的 `window.electronBridge.sendMessageFromView(...)` 发送 `mcp-request`
44+
5. 通过 bridge 返回 `mcp-response`
45+
46+
它的优势是:看到的是 **Desktop 当前活体 runtime**,而不是磁盘上“理论上应该生效”的 auth 状态。
47+
48+
它适合:
49+
50+
- `current` 的优先读取
51+
- `switch` 等待当前 thread 完成
52+
- 受管 Desktop 上的 app-server restart
53+
- `watch` 这类依赖实时 bridge 消息流的能力
54+
55+
它不适合:
56+
57+
- Desktop 没启动时的一次性读取
58+
- 需要脱离前台 Desktop 的健康检查
59+
60+
### 2.2 direct client 通路
61+
62+
入口主要在:
63+
64+
- [codex-direct-client.ts](/Users/bytedance/code/codex-team/src/codex-direct-client.ts)
65+
66+
机制:
67+
68+
1. 本地直接拉起 `codex app-server`
69+
2. 通过 stdio 按行发送 JSON-RPC
70+
3.`initialize`
71+
4. 再发送一次性读取请求,如 `account/read``account/rateLimits/read`
72+
5. 读取完成后关闭子进程
73+
74+
它的优势是:不依赖 Desktop,不需要 DevTools,不要求前台页面活着。
75+
76+
它适合:
77+
78+
- 一次性读取账号信息
79+
- 一次性读取 quota
80+
- 后续 `doctor` 里验证“当前凭据能不能真正跑起来”
81+
82+
它不适合:
83+
84+
- 观察 Desktop 当前 loaded thread 集合
85+
- 观察 Desktop 页面 bridge 实时消息
86+
- 替代 `watch`
87+
- 替代 `switch` 对当前前台 Desktop 的等待与 restart
88+
89+
## 3. 命令优先级
90+
91+
当前约定如下。
92+
93+
### 3.1 `current`
94+
95+
`current` 保持 **Desktop 优先**
96+
97+
原因:
98+
99+
- 这个命令的语义更接近“当前这台受管 Desktop 里正在跑的 runtime 是谁”
100+
- 如果 Desktop 还没 reload 完,direct client 读到的只是磁盘 auth 对应的新 runtime,不一定等于用户眼前的 Desktop
101+
102+
因此优先级是:
103+
104+
1. 受管 Desktop 可用时,先读 Desktop bridge
105+
2. Desktop 不可用,或 bridge 读取失败时,回退到 direct client
106+
3. 如果这两条都失败,再由更上层决定是否继续回退到本地/usage API
107+
108+
### 3.2 `watch`
109+
110+
`watch` 只走 Desktop bridge。
111+
112+
原因:
113+
114+
- 它依赖 Desktop 页面里桥接出来的 `mcp-request` / `mcp-response` / notification 实时流
115+
- direct client 只能看到自己那次请求的返回值,看不到 Desktop 当前页面正在发生什么
116+
117+
### 3.3 `switch`
118+
119+
`switch` 只走 Desktop bridge。
120+
121+
原因:
122+
123+
- 需要知道 Desktop 当前 loaded threads 里是否还有 active thread
124+
- 需要在受管 Desktop 上发送 `codex-app-server-restart`
125+
126+
这两个动作都是 Desktop 运行态动作,不是一次性读取动作。
127+
128+
### 3.4 后续 `doctor`
129+
130+
`doctor` 使用 **direct 优先** 的检查顺序:
131+
132+
推荐分层:
133+
134+
1. 本地 auth/config 文件是否存在且结构合法
135+
2. direct client 能否 `initialize`
136+
3. direct client 能否成功 `account/read`
137+
4. direct client 能否成功 `account/rateLimits/read`
138+
5. 若受管 Desktop 存在,再比对 Desktop runtime 与本地状态是否一致
139+
140+
目前 CLI 上的 `codexm doctor` 落地为:
141+
142+
1. 复用 `store.doctor()` 检查本地存储结构、权限、损坏账号
143+
2. 单独检查当前 `~/.codex/auth.json` 是否缺失或损坏
144+
3. 通过 direct runtime 检查当前凭据是否真的能启动并返回账号信息
145+
4. direct quota probe 失败只记 warning,不直接判定 unhealthy
146+
5. 若受管 Desktop 可读,再补一层 Desktop runtime 与本地 / direct runtime 的 auth mode 一致性告警
147+
148+
退出码约定:
149+
150+
- `0`:没有 issue
151+
- `1`:存在 issue(例如 current auth 缺失 / 损坏,或 direct runtime account probe 失败)
152+
153+
## 4. 命名约定
154+
155+
这次代码里顺手统一了一层命名:
156+
157+
- `RuntimeAccountSnapshot`
158+
- `RuntimeQuotaSnapshot`
159+
- `readCurrentRuntimeAccountResult()`
160+
- `readCurrentRuntimeQuotaResult()`
161+
- `RuntimeReadSource`
162+
- `RuntimeReadResult<T>`
163+
164+
原因是 `current` 这条链路已经不再专属于“managed Desktop”,它需要把 `desktop` / `direct` 来源显式带出来,供 CLI 决定展示和 fallback 逻辑。
165+
166+
同时保留两类读法:
167+
168+
- `readManagedCurrentAccount()`
169+
- `readManagedCurrentQuota()`
170+
- `readCurrentRuntimeAccount()`
171+
- `readCurrentRuntimeQuota()`
172+
173+
语义分别是:
174+
175+
- `readManagedCurrent*`:Desktop-only,给 `watch` / `switch` 这类必须绑定 Desktop 活体状态的逻辑使用
176+
- `readCurrentRuntime*Result`:Desktop 优先,direct fallback,并显式返回来源
177+
- `readCurrentRuntime*`:只是对 `readCurrentRuntime*Result` 的无来源简化包装
178+
179+
## 5. 维护边界
180+
181+
后续如果再加读取类能力,默认先问两个问题:
182+
183+
1. 它读的是“Desktop 当前活体状态”吗?
184+
2. 它是否需要实时事件流,而不是一次性结果?
185+
186+
如果答案是:
187+
188+
- ****:优先放到 Desktop bridge
189+
- ****:优先考虑 direct client
190+
191+
不要在同一个操作里无差别同时跑两条通路;必须先定义主语义,再决定谁优先、谁 fallback。
192+
193+
当前仓库里的稳定边界就是:
194+
195+
- `current`:Desktop 优先,direct fallback
196+
- `watch`:Desktop only
197+
- `switch`:Desktop only
198+
- `doctor`:direct 优先,Desktop 只做补充一致性检查

src/auth-snapshot.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,25 @@ function normalizeAuthMode(authMode: string): string {
9393
return authMode.trim().toLowerCase();
9494
}
9595

96+
function isSupportedAuthMode(authMode: string): boolean {
97+
return isApiKeyAuthMode(authMode) || isSupportedChatGPTAuthMode(authMode);
98+
}
99+
100+
function assertSupportedAuthMode(authMode: string, fieldName: string): string {
101+
const normalized = normalizeAuthMode(authMode);
102+
if (!isSupportedAuthMode(normalized)) {
103+
throw new Error(`Unsupported ${fieldName}: ${authMode}`);
104+
}
105+
106+
return normalized;
107+
}
108+
96109
export function isApiKeyAuthMode(authMode: string): boolean {
97110
return normalizeAuthMode(authMode) === "apikey";
98111
}
99112

100113
export function isSupportedChatGPTAuthMode(authMode: string): boolean {
101-
const normalized = normalizeAuthMode(authMode);
102-
return normalized === "chatgpt" || normalized === "chatgpt_auth_tokens";
114+
return normalizeAuthMode(authMode) === "chatgpt";
103115
}
104116

105117
function extractAuthClaim(
@@ -273,7 +285,10 @@ export function parseAuthSnapshot(raw: string): AuthSnapshot {
273285
throw new Error("Auth snapshot must be a JSON object.");
274286
}
275287

276-
const authMode = asNonEmptyString(parsed.auth_mode, "auth_mode");
288+
const authMode = assertSupportedAuthMode(
289+
asNonEmptyString(parsed.auth_mode, "auth_mode"),
290+
"auth_mode",
291+
);
277292
const tokens = parsed.tokens;
278293

279294
if (tokens !== undefined && tokens !== null && !isRecord(tokens)) {
@@ -363,7 +378,10 @@ export function parseSnapshotMeta(raw: string): SnapshotMeta {
363378

364379
return {
365380
name: asNonEmptyString(parsed.name, "name"),
366-
auth_mode: asNonEmptyString(parsed.auth_mode, "auth_mode"),
381+
auth_mode: assertSupportedAuthMode(
382+
asNonEmptyString(parsed.auth_mode, "auth_mode"),
383+
"auth_mode",
384+
),
367385
account_id: asNonEmptyString(parsed.account_id, "account_id"),
368386
user_id: asOptionalString(parsed.user_id, "user_id"),
369387
created_at: asNonEmptyString(parsed.created_at, "created_at"),

0 commit comments

Comments
 (0)