codex-account-orchestrator 1.0.2 → 1.1.1

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 CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.1] - 2026-01-27
9
+
10
+ ### Fixed
11
+
12
+ - Made `npm run test` bash-compatible by using `node --test test/*.test.js`
13
+ - Restored CI stability across the GitHub Actions Node.js matrix
14
+
15
+ ## [1.1.0] - 2026-01-27
16
+
17
+ ### Added
18
+
19
+ - `cao status` command for detailed per-account inspection
20
+ - `cao list --details` for quick detailed status views
21
+ - Persisted account signals in `account_status.json` (attempt/success/quota/cooldown)
22
+ - Node.js test suite for the account status store
23
+
24
+ ### Changed
25
+
26
+ - Gateway and fallback flows now update persisted account status signals
27
+ - CI now runs tests (build + Node test runner)
28
+ - README refreshed with badges, observability guidance, and clearer quick-start flows
29
+
30
+ ### Fixed
31
+
32
+ - Eliminated shared empty-registry state in account status loading
33
+
8
34
  ## [1.0.2] - 2026-01-27
9
35
 
10
36
  ### Added
package/README.md CHANGED
@@ -1,6 +1,23 @@
1
- # codex-account-orchestrator
1
+ # codex-account-orchestrator (CAO)
2
2
 
3
- Codex OAuth account fallback orchestrator. It keeps **separate `CODEX_HOME` directories per account** and automatically falls back to the next account when quota is exhausted.
3
+ [![CI](https://github.com/DAWNCR0W/codex-account-orchestrator/actions/workflows/ci.yml/badge.svg)](https://github.com/DAWNCR0W/codex-account-orchestrator/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/codex-account-orchestrator.svg)](https://www.npmjs.com/package/codex-account-orchestrator)
5
+ [![npm downloads](https://img.shields.io/npm/dm/codex-account-orchestrator.svg)](https://www.npmjs.com/package/codex-account-orchestrator)
6
+ [![license](https://img.shields.io/npm/l/codex-account-orchestrator.svg)](LICENSE)
7
+ [![node](https://img.shields.io/node/v/codex-account-orchestrator.svg)](https://nodejs.org/)
8
+ [![typescript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
9
+
10
+ Codex OAuth account fallback orchestrator. CAO keeps **separate `CODEX_HOME` directories per account** and automatically falls back to the next account when quota is exhausted.
11
+
12
+ > If you juggle multiple Codex accounts, CAO removes the friction and keeps you moving.
13
+
14
+ ## Highlights
15
+
16
+ - Per-account isolation via separate `CODEX_HOME` directories
17
+ - Automatic fallback on quota exhaustion (keyword-based detector)
18
+ - Gateway mode for seamless account switching without session drops
19
+ - Lightweight observability via `cao status` and `cao list --details`
20
+ - Strict TypeScript build with a small, dependency-light CLI
4
21
 
5
22
  ## Requirements
6
23
 
@@ -23,9 +40,9 @@ npm run build
23
40
  npm link # Makes 'cao' command available globally
24
41
  ```
25
42
 
26
- ## Usage
43
+ ## Quick Start
27
44
 
28
- ### Add accounts
45
+ ### 1. Add accounts
29
46
 
30
47
  ```bash
31
48
  cao add accountA
@@ -44,70 +61,64 @@ If you prefer device auth:
44
61
  cao add accountA --device-auth
45
62
  ```
46
63
 
47
- ### Set default account
64
+ ### 2. Set the default account
48
65
 
49
66
  ```bash
50
67
  cao use accountA
51
68
  ```
52
69
 
53
- ### List accounts
70
+ ### 3. Run with fallback
54
71
 
55
72
  ```bash
56
- cao list
73
+ cao run
57
74
  ```
58
75
 
59
- ### Remove an account
76
+ To pass arguments to Codex, put them after `--`:
60
77
 
61
78
  ```bash
62
- cao remove accountB
79
+ cao run -- exec "summarize README"
63
80
  ```
64
81
 
65
- To keep files on disk:
82
+ To recheck all accounts when everyone is quota-limited, use multiple passes:
66
83
 
67
84
  ```bash
68
- cao remove accountB --keep-files
85
+ cao run --max-passes 2 --retry-delay 5
69
86
  ```
70
87
 
71
- ### Run with fallback
72
-
73
- ```bash
74
- cao run
75
- ```
88
+ ## Account Status & Observability
76
89
 
77
- To pass arguments to Codex, put them after `--`:
90
+ ### List accounts (quick)
78
91
 
79
92
  ```bash
80
- cao run -- exec "summarize README"
93
+ cao list
81
94
  ```
82
95
 
83
- To recheck all accounts when everyone is quota-limited, use multiple passes:
96
+ ### Detailed status (recommended)
84
97
 
85
98
  ```bash
86
- cao run --max-passes 2 --retry-delay 5
99
+ cao status
87
100
  ```
88
101
 
89
- ### Run a specific account (no fallback)
102
+ You can also use:
90
103
 
91
104
  ```bash
92
- cao run --no-fallback --account accountB -- codex
105
+ cao list --details
93
106
  ```
94
107
 
95
- ### Custom data directory
108
+ ### JSON output for scripting
96
109
 
97
110
  ```bash
98
- cao --data-dir /path/to/data run -- codex
111
+ cao status --json
99
112
  ```
100
113
 
101
- ## How it works
114
+ This includes useful signals such as:
102
115
 
103
- - Each account is stored in its own `CODEX_HOME` directory under:
104
- - `~/.codex-account-orchestrator/<account>/`
105
- - A `config.toml` is created with:
106
- - `cli_auth_credentials_store = "file"`
107
- - `forced_login_method = "chatgpt"`
108
- - On quota errors (detected via output keywords), the CLI re-runs Codex with the next account and can recheck all accounts for a configurable number of passes.
116
+ - Token expiry time
117
+ - Last refresh time
118
+ - Last attempt / success / quota-hit timestamps
119
+ - Cooldown window and consecutive failures
109
120
 
110
- ## Gateway mode (no session drop)
121
+ ## Gateway Mode (No Session Drop)
111
122
 
112
123
  Gateway mode keeps the Codex session open while switching accounts on quota errors. It requires routing Codex traffic through the local gateway.
113
124
 
@@ -153,17 +164,59 @@ If `~/.local/bin` is not in your PATH, add it so the shim is used.
153
164
  cao gateway status
154
165
  ```
155
166
 
156
- ### Debug logging
167
+ ## How It Works
168
+
169
+ - Each account is stored in its own `CODEX_HOME` directory: `~/.codex-account-orchestrator/<account>/`
170
+ - A `config.toml` is created with the following values:
171
+
172
+ ```toml
173
+ cli_auth_credentials_store = "file"
174
+ forced_login_method = "chatgpt"
175
+ ```
176
+
177
+ - On quota errors (detected via output keywords), the CLI re-runs Codex with the next account and can recheck all accounts for a configurable number of passes.
178
+ - CAO now persists lightweight status signals to `account_status.json` for visibility.
179
+
180
+ ## Data Layout
181
+
182
+ Default base directory:
183
+
184
+ ```text
185
+ ~/.codex-account-orchestrator/
186
+ ```
187
+
188
+ Key files:
189
+
190
+ - `registry.json`: registered accounts and default account
191
+ - `account_status.json`: persisted last-attempt/success/quota/cooldown signals
192
+ - `<account>/auth.json`: account-scoped tokens managed by Codex
193
+ - `<account>/config.toml`: account-scoped Codex configuration
194
+
195
+ ## Development
196
+
197
+ Build:
198
+
199
+ ```bash
200
+ npm run build
201
+ ```
202
+
203
+ Test (build + Node test runner):
157
204
 
158
- Set `CAO_DEBUG_HEADERS=1` to log sanitized request/response headers in the gateway process. To capture the last request body for inspection, also set `CAO_CAPTURE_BODY=1` (saved to `/tmp/cao-last-body.json` by default).
205
+ ```bash
206
+ npm run test
207
+ ```
159
208
 
160
- Additional debug flags:
209
+ Run locally after build:
161
210
 
162
- - `CAO_DEBUG_BODY=1` to log a sanitized body snippet.
163
- - `CAO_CAPTURE_BODY_PATH=/path/to/file.json` to override the capture path.
164
- - `CAO_FORCE_QUOTA_ACCOUNTS=accountA,accountB` to simulate quota failures for specific accounts.
211
+ ```bash
212
+ node dist/cli_main.js --help
213
+ ```
165
214
 
166
215
  ## Notes
167
216
 
168
217
  - Fallback requires capturing output; this may make Codex detect a non-TTY stdout. If you want a pure TTY session, use `--no-fallback`.
169
218
  - The quota detector is keyword-based and can be extended in `src/constants.ts`.
219
+
220
+ ## Changelog
221
+
222
+ See `CHANGELOG.md` for release notes and version history.
@@ -0,0 +1,14 @@
1
+ import { type AccountStatus } from "./account_status_store";
2
+ import { type TokenDetails } from "./gateway/token_utils";
3
+ export interface AccountInspection {
4
+ name: string;
5
+ isDefault: boolean;
6
+ accountDir: string;
7
+ authFilePath: string;
8
+ loggedIn: boolean;
9
+ lastRefreshAtMs?: number;
10
+ accountId?: string;
11
+ tokenDetails?: TokenDetails;
12
+ status?: AccountStatus;
13
+ }
14
+ export declare function inspectAccounts(baseDir: string): AccountInspection[];
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.inspectAccounts = inspectAccounts;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const account_status_store_1 = require("./account_status_store");
10
+ const paths_1 = require("./paths");
11
+ const constants_1 = require("./constants");
12
+ const registry_store_1 = require("./registry_store");
13
+ const token_utils_1 = require("./gateway/token_utils");
14
+ function parseLastRefresh(lastRefresh) {
15
+ if (typeof lastRefresh !== "string" || lastRefresh.trim().length === 0) {
16
+ return undefined;
17
+ }
18
+ const ms = Date.parse(lastRefresh);
19
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
20
+ }
21
+ function loadAuthInspection(accountDir) {
22
+ const authFilePath = path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
23
+ if (!fs_1.default.existsSync(authFilePath)) {
24
+ return { loggedIn: false };
25
+ }
26
+ let parsed;
27
+ try {
28
+ const raw = fs_1.default.readFileSync(authFilePath, "utf8");
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ return { loggedIn: false };
33
+ }
34
+ const accessToken = parsed.tokens?.access_token;
35
+ const idToken = parsed.tokens?.id_token;
36
+ if (!accessToken || typeof accessToken !== "string") {
37
+ return { loggedIn: false };
38
+ }
39
+ const tokenDetails = (0, token_utils_1.deriveTokenDetails)(accessToken, idToken);
40
+ const lastRefreshAtMs = parseLastRefresh(parsed.last_refresh);
41
+ return {
42
+ loggedIn: true,
43
+ lastRefreshAtMs,
44
+ accountId: parsed.tokens?.account_id ?? tokenDetails.chatgptAccountId,
45
+ tokenDetails
46
+ };
47
+ }
48
+ function inspectAccounts(baseDir) {
49
+ const registry = (0, registry_store_1.loadRegistry)(baseDir);
50
+ if (registry.accounts.length === 0) {
51
+ return [];
52
+ }
53
+ const statuses = (0, account_status_store_1.loadAccountStatuses)(baseDir);
54
+ const inspections = [];
55
+ for (const name of registry.accounts) {
56
+ const accountDir = (0, paths_1.getAccountDir)(baseDir, name);
57
+ const authFilePath = path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
58
+ const authInspection = loadAuthInspection(accountDir);
59
+ const status = statuses[name];
60
+ inspections.push({
61
+ name,
62
+ isDefault: registry.default_account === name,
63
+ accountDir,
64
+ authFilePath,
65
+ loggedIn: authInspection.loggedIn,
66
+ lastRefreshAtMs: authInspection.lastRefreshAtMs,
67
+ accountId: authInspection.accountId,
68
+ tokenDetails: authInspection.tokenDetails,
69
+ status
70
+ });
71
+ }
72
+ return inspections;
73
+ }
@@ -13,6 +13,7 @@ exports.removeAccount = removeAccount;
13
13
  exports.getAccountOrder = getAccountOrder;
14
14
  const fs_1 = __importDefault(require("fs"));
15
15
  const path_1 = __importDefault(require("path"));
16
+ const account_status_store_1 = require("./account_status_store");
16
17
  const constants_1 = require("./constants");
17
18
  const paths_1 = require("./paths");
18
19
  const registry_store_1 = require("./registry_store");
@@ -55,6 +56,7 @@ function addAccount(baseDir, accountName) {
55
56
  if (!registry.default_account) {
56
57
  registry.default_account = normalizedName;
57
58
  }
59
+ (0, account_status_store_1.updateAccountStatus)(baseDir, normalizedName, () => ({}));
58
60
  (0, registry_store_1.saveRegistry)(baseDir, registry);
59
61
  return registry;
60
62
  }
@@ -87,6 +89,7 @@ function removeAccount(baseDir, accountName, removeFiles) {
87
89
  fs_1.default.rmSync(accountDir, { recursive: true, force: true });
88
90
  }
89
91
  }
92
+ (0, account_status_store_1.deleteAccountStatus)(baseDir, normalizedName);
90
93
  (0, registry_store_1.saveRegistry)(baseDir, registry);
91
94
  return registry;
92
95
  }
@@ -0,0 +1,24 @@
1
+ export interface AccountStatus {
2
+ lastAttemptAtMs?: number;
3
+ lastSuccessAtMs?: number;
4
+ lastQuotaAtMs?: number;
5
+ cooldownUntilMs?: number;
6
+ consecutiveFailures?: number;
7
+ lastError?: string;
8
+ }
9
+ export interface AccountStatusRegistry {
10
+ statuses: Record<string, AccountStatus>;
11
+ }
12
+ /**
13
+ * Loads the persisted per-account status registry. If the file is missing or
14
+ * invalid, a safe empty registry is returned.
15
+ */
16
+ export declare function loadAccountStatusRegistry(baseDir: string): AccountStatusRegistry;
17
+ export declare function loadAccountStatuses(baseDir: string): Record<string, AccountStatus>;
18
+ export declare function getAccountStatus(baseDir: string, accountName: string): AccountStatus | undefined;
19
+ /**
20
+ * Updates the stored status for a single account using a functional updater
21
+ * to avoid accidental partial writes.
22
+ */
23
+ export declare function updateAccountStatus(baseDir: string, accountName: string, updater: (previous: AccountStatus) => AccountStatus): AccountStatus;
24
+ export declare function deleteAccountStatus(baseDir: string, accountName: string): void;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadAccountStatusRegistry = loadAccountStatusRegistry;
7
+ exports.loadAccountStatuses = loadAccountStatuses;
8
+ exports.getAccountStatus = getAccountStatus;
9
+ exports.updateAccountStatus = updateAccountStatus;
10
+ exports.deleteAccountStatus = deleteAccountStatus;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const paths_1 = require("./paths");
14
+ function createEmptyRegistry() {
15
+ return { statuses: {} };
16
+ }
17
+ function sanitizeTimestamp(value) {
18
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
19
+ return undefined;
20
+ }
21
+ return Math.floor(value);
22
+ }
23
+ function sanitizeCount(value) {
24
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
25
+ return undefined;
26
+ }
27
+ return Math.floor(value);
28
+ }
29
+ function sanitizeStatus(status) {
30
+ if (!status) {
31
+ return {};
32
+ }
33
+ const lastAttemptAtMs = sanitizeTimestamp(status.lastAttemptAtMs);
34
+ const lastSuccessAtMs = sanitizeTimestamp(status.lastSuccessAtMs);
35
+ const lastQuotaAtMs = sanitizeTimestamp(status.lastQuotaAtMs);
36
+ const cooldownUntilMs = sanitizeTimestamp(status.cooldownUntilMs);
37
+ const consecutiveFailures = sanitizeCount(status.consecutiveFailures);
38
+ const lastError = typeof status.lastError === "string" && status.lastError.trim().length > 0
39
+ ? status.lastError.trim()
40
+ : undefined;
41
+ return {
42
+ lastAttemptAtMs,
43
+ lastSuccessAtMs,
44
+ lastQuotaAtMs,
45
+ cooldownUntilMs,
46
+ consecutiveFailures,
47
+ lastError
48
+ };
49
+ }
50
+ function normalizeStatuses(statuses) {
51
+ if (!statuses || typeof statuses !== "object") {
52
+ return {};
53
+ }
54
+ const normalized = {};
55
+ for (const [name, status] of Object.entries(statuses)) {
56
+ const trimmedName = name.trim();
57
+ if (!trimmedName) {
58
+ continue;
59
+ }
60
+ normalized[trimmedName] = sanitizeStatus(status);
61
+ }
62
+ return normalized;
63
+ }
64
+ /**
65
+ * Loads the persisted per-account status registry. If the file is missing or
66
+ * invalid, a safe empty registry is returned.
67
+ */
68
+ function loadAccountStatusRegistry(baseDir) {
69
+ const statusPath = (0, paths_1.getAccountStatusPath)(baseDir);
70
+ if (!fs_1.default.existsSync(statusPath)) {
71
+ return createEmptyRegistry();
72
+ }
73
+ const raw = fs_1.default.readFileSync(statusPath, "utf8");
74
+ try {
75
+ const parsed = JSON.parse(raw);
76
+ return { statuses: normalizeStatuses(parsed.statuses) };
77
+ }
78
+ catch {
79
+ const backupPath = `${statusPath}.corrupt-${Date.now()}`;
80
+ fs_1.default.writeFileSync(backupPath, raw, "utf8");
81
+ process.stderr.write(`Warning: account status file was invalid and has been backed up to ${backupPath}.\n`);
82
+ return createEmptyRegistry();
83
+ }
84
+ }
85
+ function loadAccountStatuses(baseDir) {
86
+ const registry = loadAccountStatusRegistry(baseDir);
87
+ return { ...registry.statuses };
88
+ }
89
+ function saveAccountStatusRegistry(baseDir, registry) {
90
+ const statusPath = (0, paths_1.getAccountStatusPath)(baseDir);
91
+ const dir = path_1.default.dirname(statusPath);
92
+ if (!fs_1.default.existsSync(dir)) {
93
+ fs_1.default.mkdirSync(dir, { recursive: true });
94
+ }
95
+ const payload = JSON.stringify(registry, null, 2) + "\n";
96
+ fs_1.default.writeFileSync(statusPath, payload, "utf8");
97
+ }
98
+ function getAccountStatus(baseDir, accountName) {
99
+ const registry = loadAccountStatusRegistry(baseDir);
100
+ return registry.statuses[accountName];
101
+ }
102
+ /**
103
+ * Updates the stored status for a single account using a functional updater
104
+ * to avoid accidental partial writes.
105
+ */
106
+ function updateAccountStatus(baseDir, accountName, updater) {
107
+ const registry = loadAccountStatusRegistry(baseDir);
108
+ const previous = sanitizeStatus(registry.statuses[accountName]);
109
+ const next = sanitizeStatus(updater(previous));
110
+ registry.statuses[accountName] = next;
111
+ saveAccountStatusRegistry(baseDir, registry);
112
+ return next;
113
+ }
114
+ function deleteAccountStatus(baseDir, accountName) {
115
+ const registry = loadAccountStatusRegistry(baseDir);
116
+ if (!(accountName in registry.statuses)) {
117
+ return;
118
+ }
119
+ delete registry.statuses[accountName];
120
+ saveAccountStatusRegistry(baseDir, registry);
121
+ }
package/dist/cli_main.js CHANGED
@@ -8,6 +8,8 @@ const commander_1 = require("commander");
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const account_manager_1 = require("./account_manager");
11
+ const account_inspector_1 = require("./account_inspector");
12
+ const account_status_store_1 = require("./account_status_store");
11
13
  const codex_auth_1 = require("./codex_auth");
12
14
  const constants_1 = require("./constants");
13
15
  const codex_config_1 = require("./gateway/codex_config");
@@ -61,21 +63,38 @@ program
61
63
  program
62
64
  .command("list")
63
65
  .description("List registered accounts")
64
- .action(() => {
66
+ .option("--details", "Show detailed account and usage status")
67
+ .action((options) => {
65
68
  const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
66
69
  (0, account_manager_1.ensureBaseDir)(baseDir);
67
- const registry = (0, registry_store_1.loadRegistry)(baseDir);
68
- if (registry.accounts.length === 0) {
70
+ const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
71
+ if (inspections.length === 0) {
72
+ process.stdout.write("No accounts registered. Use `cao add <name>` first.\n");
73
+ return;
74
+ }
75
+ if (!options.details) {
76
+ renderAccountSummary(inspections);
77
+ return;
78
+ }
79
+ renderAccountDetails(inspections);
80
+ });
81
+ program
82
+ .command("status")
83
+ .description("Show detailed account status and cooldown/usage signals")
84
+ .option("--json", "Output account status as JSON")
85
+ .action((options) => {
86
+ const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
87
+ (0, account_manager_1.ensureBaseDir)(baseDir);
88
+ const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
89
+ if (inspections.length === 0) {
69
90
  process.stdout.write("No accounts registered. Use `cao add <name>` first.\n");
70
91
  return;
71
92
  }
72
- for (const name of registry.accounts) {
73
- const marker = registry.default_account === name ? "*" : " ";
74
- const accountDir = (0, paths_1.getAccountDir)(baseDir, name);
75
- const loggedIn = fs_1.default.existsSync(getAuthFilePath(accountDir));
76
- const status = loggedIn ? "logged-in" : "not-logged-in";
77
- process.stdout.write(`${marker} ${name} (${status})\n`);
93
+ if (options.json) {
94
+ renderAccountDetailsJson(inspections);
95
+ return;
78
96
  }
97
+ renderAccountDetails(inspections);
79
98
  });
80
99
  program
81
100
  .command("use")
@@ -156,17 +175,24 @@ gateway
156
175
  .description("Show gateway config and account readiness")
157
176
  .action(() => {
158
177
  const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
178
+ (0, account_manager_1.ensureBaseDir)(baseDir);
159
179
  const config = (0, gateway_config_1.resolveGatewayConfig)((0, gateway_config_1.loadGatewayConfig)());
160
180
  const pool = account_pool_1.AccountPool.loadFromRegistry(baseDir);
181
+ const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
182
+ const inspectionsByName = new Map(inspections.map((inspection) => [inspection.name, inspection]));
183
+ const nowMs = Date.now();
161
184
  process.stdout.write(`Bind: ${config.bindAddress}:${config.port}\n`);
162
185
  process.stdout.write(`Upstream: ${config.baseUrl}\n`);
163
186
  process.stdout.write(`Override auth: ${config.overrideAuth ? "yes" : "no"}\n`);
164
187
  process.stdout.write(`Accounts: ${pool.getAccounts().length}\n`);
165
188
  for (const account of pool.getAccounts()) {
166
- const cooldown = account.cooldownUntilMs > Date.now()
167
- ? `cooldown ${Math.ceil((account.cooldownUntilMs - Date.now()) / 1000)}s`
189
+ const inspection = inspectionsByName.get(account.name);
190
+ const cooldown = account.cooldownUntilMs > nowMs
191
+ ? `cooldown ${Math.ceil((account.cooldownUntilMs - nowMs) / 1000)}s`
168
192
  : "ready";
169
- process.stdout.write(`- ${account.name} (${cooldown})\n`);
193
+ const tokenExpiry = formatTimestampWithRelative(inspection?.tokenDetails?.expiresAtMs, nowMs);
194
+ const lastRefresh = formatTimestampWithRelative(inspection?.lastRefreshAtMs, nowMs);
195
+ process.stdout.write(`- ${account.name} (${cooldown}) | token_expires_at: ${tokenExpiry} | last_refresh_at: ${lastRefresh}\n`);
170
196
  }
171
197
  });
172
198
  gateway
@@ -250,6 +276,122 @@ function normalizeCodexArgs(args, codexBin) {
250
276
  function getAuthFilePath(accountDir) {
251
277
  return path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
252
278
  }
279
+ function renderAccountSummary(inspections) {
280
+ for (const inspection of inspections) {
281
+ const marker = inspection.isDefault ? "*" : " ";
282
+ const status = inspection.loggedIn ? "logged-in" : "not-logged-in";
283
+ process.stdout.write(`${marker} ${inspection.name} (${status})\n`);
284
+ }
285
+ }
286
+ function toAccountDetailRecord(inspection, referenceMs) {
287
+ const status = inspection.status ?? {};
288
+ const tokenExpiresAtMs = inspection.tokenDetails?.expiresAtMs;
289
+ const cooldownUntilMs = status.cooldownUntilMs;
290
+ const cooldownRemainingMs = cooldownUntilMs && cooldownUntilMs > referenceMs ? cooldownUntilMs - referenceMs : 0;
291
+ return {
292
+ name: inspection.name,
293
+ isDefault: inspection.isDefault,
294
+ loggedIn: inspection.loggedIn,
295
+ accountId: inspection.accountId ?? null,
296
+ organizationId: inspection.tokenDetails?.organizationId ?? null,
297
+ tokenExpiresAtMs: tokenExpiresAtMs ?? null,
298
+ tokenExpiresAtIso: tokenExpiresAtMs ? new Date(tokenExpiresAtMs).toISOString() : null,
299
+ tokenExpiresInMs: tokenExpiresAtMs ? Math.max(0, tokenExpiresAtMs - referenceMs) : null,
300
+ lastRefreshAtMs: inspection.lastRefreshAtMs ?? null,
301
+ lastRefreshAtIso: inspection.lastRefreshAtMs
302
+ ? new Date(inspection.lastRefreshAtMs).toISOString()
303
+ : null,
304
+ lastAttemptAtMs: status.lastAttemptAtMs ?? null,
305
+ lastAttemptAtIso: status.lastAttemptAtMs
306
+ ? new Date(status.lastAttemptAtMs).toISOString()
307
+ : null,
308
+ lastSuccessAtMs: status.lastSuccessAtMs ?? null,
309
+ lastSuccessAtIso: status.lastSuccessAtMs
310
+ ? new Date(status.lastSuccessAtMs).toISOString()
311
+ : null,
312
+ lastQuotaAtMs: status.lastQuotaAtMs ?? null,
313
+ lastQuotaAtIso: status.lastQuotaAtMs
314
+ ? new Date(status.lastQuotaAtMs).toISOString()
315
+ : null,
316
+ cooldownUntilMs: cooldownUntilMs ?? null,
317
+ cooldownUntilIso: cooldownUntilMs
318
+ ? new Date(cooldownUntilMs).toISOString()
319
+ : null,
320
+ cooldownRemainingMs,
321
+ consecutiveFailures: status.consecutiveFailures ?? 0,
322
+ lastError: status.lastError ?? null,
323
+ accountDir: inspection.accountDir,
324
+ authFilePath: inspection.authFilePath
325
+ };
326
+ }
327
+ function renderAccountDetails(inspections, referenceMs = Date.now()) {
328
+ const indent = " ";
329
+ for (const inspection of inspections) {
330
+ const marker = inspection.isDefault ? "*" : " ";
331
+ const status = inspection.status ?? {};
332
+ const loginStatus = inspection.loggedIn ? "logged-in" : "not-logged-in";
333
+ process.stdout.write(`${marker} ${inspection.name}\n`);
334
+ process.stdout.write(`${indent}status: ${loginStatus}\n`);
335
+ process.stdout.write(`${indent}account_id: ${inspection.accountId ?? "(unknown)"}\n`);
336
+ process.stdout.write(`${indent}organization_id: ${inspection.tokenDetails?.organizationId ?? "(unknown)"}\n`);
337
+ process.stdout.write(`${indent}token_expires_at: ${formatTimestampWithRelative(inspection.tokenDetails?.expiresAtMs, referenceMs)}\n`);
338
+ process.stdout.write(`${indent}last_refresh_at: ${formatTimestampWithRelative(inspection.lastRefreshAtMs, referenceMs)}\n`);
339
+ process.stdout.write(`${indent}last_attempt_at: ${formatTimestampWithRelative(status.lastAttemptAtMs, referenceMs)}\n`);
340
+ process.stdout.write(`${indent}last_success_at: ${formatTimestampWithRelative(status.lastSuccessAtMs, referenceMs)}\n`);
341
+ process.stdout.write(`${indent}last_quota_at: ${formatTimestampWithRelative(status.lastQuotaAtMs, referenceMs)}\n`);
342
+ process.stdout.write(`${indent}cooldown_until: ${formatCooldown(status.cooldownUntilMs, referenceMs)}\n`);
343
+ process.stdout.write(`${indent}consecutive_failures: ${status.consecutiveFailures ?? 0}\n`);
344
+ process.stdout.write(`${indent}last_error: ${status.lastError ?? "none"}\n`);
345
+ process.stdout.write(`${indent}account_dir: ${inspection.accountDir}\n`);
346
+ process.stdout.write("\n");
347
+ }
348
+ }
349
+ function renderAccountDetailsJson(inspections) {
350
+ const referenceMs = Date.now();
351
+ const payload = inspections.map((inspection) => toAccountDetailRecord(inspection, referenceMs));
352
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
353
+ }
354
+ function formatDuration(durationMs) {
355
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
356
+ if (totalSeconds < 60) {
357
+ return `${totalSeconds}s`;
358
+ }
359
+ const minutes = Math.floor(totalSeconds / 60);
360
+ if (minutes < 60) {
361
+ const seconds = totalSeconds % 60;
362
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
363
+ }
364
+ const hours = Math.floor(minutes / 60);
365
+ if (hours < 24) {
366
+ const remainingMinutes = minutes % 60;
367
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
368
+ }
369
+ const days = Math.floor(hours / 24);
370
+ const remainingHours = hours % 24;
371
+ return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
372
+ }
373
+ function formatTimestampWithRelative(timestampMs, referenceMs) {
374
+ if (!timestampMs) {
375
+ return "(unknown)";
376
+ }
377
+ const iso = new Date(timestampMs).toISOString();
378
+ const diffMs = timestampMs - referenceMs;
379
+ if (Math.abs(diffMs) < 5_000) {
380
+ return `${iso} (now)`;
381
+ }
382
+ const relative = diffMs > 0 ? `in ${formatDuration(diffMs)}` : `${formatDuration(-diffMs)} ago`;
383
+ return `${iso} (${relative})`;
384
+ }
385
+ function formatCooldown(cooldownUntilMs, referenceMs) {
386
+ if (!cooldownUntilMs) {
387
+ return "none";
388
+ }
389
+ if (cooldownUntilMs <= referenceMs) {
390
+ const iso = new Date(cooldownUntilMs).toISOString();
391
+ return `${iso} (elapsed)`;
392
+ }
393
+ return formatTimestampWithRelative(cooldownUntilMs, referenceMs);
394
+ }
253
395
  function buildGatewayOverrides(options) {
254
396
  const overrides = {};
255
397
  overrides.bindAddress = options.bind;
@@ -300,22 +442,59 @@ async function runWithFallback(options, baseDir, accounts, codexArgs) {
300
442
  const name = accounts[index];
301
443
  const accountDir = (0, account_manager_1.ensureAccountDir)(baseDir, name);
302
444
  (0, account_manager_1.ensureAccountConfig)(accountDir);
445
+ const attemptAtMs = Date.now();
446
+ (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
447
+ ...previous,
448
+ lastAttemptAtMs: attemptAtMs
449
+ }));
303
450
  process.stderr.write(`Using account: ${name}\n`);
304
451
  const result = await (0, process_runner_1.runCodexOnce)(codexBin, codexArgs, accountDir, options.fallback);
305
452
  lastExitCode = result.exitCode;
306
453
  if (result.exitCode === 0) {
454
+ (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
455
+ ...previous,
456
+ lastAttemptAtMs: attemptAtMs,
457
+ lastSuccessAtMs: Date.now(),
458
+ consecutiveFailures: 0,
459
+ cooldownUntilMs: undefined,
460
+ lastError: undefined
461
+ }));
307
462
  process.exit(0);
308
463
  return;
309
464
  }
310
465
  if (!options.fallback) {
466
+ (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
467
+ ...previous,
468
+ lastAttemptAtMs: attemptAtMs,
469
+ consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
470
+ cooldownUntilMs: undefined,
471
+ lastError: `exit_code_${result.exitCode}`
472
+ }));
311
473
  process.exit(result.exitCode);
312
474
  return;
313
475
  }
314
476
  if (!result.quotaError) {
477
+ (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
478
+ ...previous,
479
+ lastAttemptAtMs: attemptAtMs,
480
+ consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
481
+ cooldownUntilMs: undefined,
482
+ lastError: `exit_code_${result.exitCode}`
483
+ }));
315
484
  process.exit(result.exitCode);
316
485
  return;
317
486
  }
318
487
  quotaFailures += 1;
488
+ const quotaAtMs = Date.now();
489
+ const cooldownUntilMs = retryDelayMs > 0 ? quotaAtMs + retryDelayMs : undefined;
490
+ (0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
491
+ ...previous,
492
+ lastAttemptAtMs: attemptAtMs,
493
+ lastQuotaAtMs: quotaAtMs,
494
+ cooldownUntilMs,
495
+ consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
496
+ lastError: "usage_limit_reached"
497
+ }));
319
498
  const nextName = accounts[index + 1];
320
499
  if (nextName) {
321
500
  process.stderr.write(`Quota exhausted. Falling back to: ${nextName}\n`);
@@ -1,5 +1,6 @@
1
1
  export declare const REGISTRY_FILE_NAME = "registry.json";
2
2
  export declare const AUTH_FILE_NAME = "auth.json";
3
+ export declare const ACCOUNT_STATUS_FILE_NAME = "account_status.json";
3
4
  export declare const DEFAULT_CONFIG_TOML: string;
4
5
  export declare const QUOTA_ERROR_PATTERNS: RegExp[];
5
6
  export declare const MAX_CAPTURED_LINES = 200;
package/dist/constants.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_CAPTURED_LINES = exports.QUOTA_ERROR_PATTERNS = exports.DEFAULT_CONFIG_TOML = exports.AUTH_FILE_NAME = exports.REGISTRY_FILE_NAME = void 0;
3
+ exports.MAX_CAPTURED_LINES = exports.QUOTA_ERROR_PATTERNS = exports.DEFAULT_CONFIG_TOML = exports.ACCOUNT_STATUS_FILE_NAME = exports.AUTH_FILE_NAME = exports.REGISTRY_FILE_NAME = void 0;
4
4
  exports.REGISTRY_FILE_NAME = "registry.json";
5
5
  exports.AUTH_FILE_NAME = "auth.json";
6
+ exports.ACCOUNT_STATUS_FILE_NAME = "account_status.json";
6
7
  exports.DEFAULT_CONFIG_TOML = [
7
8
  "# Codex config for this account",
8
9
  "cli_auth_credentials_store = \"file\"",
@@ -8,15 +8,17 @@ export interface AccountState {
8
8
  consecutiveFailures: number;
9
9
  }
10
10
  export declare class AccountPool {
11
+ private readonly baseDir;
11
12
  private readonly accounts;
12
13
  private readonly sessionAssignments;
13
- constructor(accounts: AccountState[]);
14
+ constructor(baseDir: string, accounts: AccountState[]);
14
15
  static loadFromRegistry(baseDir: string): AccountPool;
15
16
  getAccounts(): AccountState[];
16
17
  getStickyAccount(sessionKey: string): AccountState | undefined;
17
18
  assignAccount(sessionKey: string, accountName: string): void;
18
19
  clearAssignment(sessionKey: string): void;
19
20
  pickNextAvailable(excluded: Set<string>): AccountState | undefined;
21
+ markAttempt(account: AccountState): void;
20
22
  markQuota(account: AccountState, cooldownSeconds: number, resetsAtMs?: number): void;
21
23
  markAuthFailure(account: AccountState, message: string): void;
22
24
  markSuccess(account: AccountState): void;
@@ -6,14 +6,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AccountPool = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const account_status_store_1 = require("../account_status_store");
9
10
  const paths_1 = require("../paths");
10
11
  const registry_store_1 = require("../registry_store");
11
12
  const account_manager_1 = require("../account_manager");
12
13
  const token_utils_1 = require("./token_utils");
13
14
  class AccountPool {
15
+ baseDir;
14
16
  accounts;
15
17
  sessionAssignments = new Map();
16
- constructor(accounts) {
18
+ constructor(baseDir, accounts) {
19
+ this.baseDir = baseDir;
17
20
  this.accounts = accounts;
18
21
  }
19
22
  static loadFromRegistry(baseDir) {
@@ -34,7 +37,7 @@ class AccountPool {
34
37
  consecutiveFailures: 0
35
38
  });
36
39
  }
37
- return new AccountPool(accounts);
40
+ return new AccountPool(baseDir, accounts);
38
41
  }
39
42
  getAccounts() {
40
43
  return this.accounts;
@@ -65,24 +68,65 @@ class AccountPool {
65
68
  }
66
69
  return undefined;
67
70
  }
71
+ markAttempt(account) {
72
+ const attemptAtMs = Date.now();
73
+ (0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
74
+ ...previous,
75
+ lastAttemptAtMs: attemptAtMs
76
+ }));
77
+ }
68
78
  markQuota(account, cooldownSeconds, resetsAtMs) {
79
+ const quotaAtMs = Date.now();
69
80
  account.consecutiveFailures += 1;
70
81
  account.lastError = "usage_limit_reached";
71
- const until = resetsAtMs && resetsAtMs > Date.now() ? resetsAtMs : Date.now() + cooldownSeconds * 1000;
82
+ const until = resetsAtMs && resetsAtMs > quotaAtMs ? resetsAtMs : quotaAtMs + cooldownSeconds * 1000;
72
83
  account.cooldownUntilMs = until;
84
+ (0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
85
+ ...previous,
86
+ lastAttemptAtMs: quotaAtMs,
87
+ lastQuotaAtMs: quotaAtMs,
88
+ cooldownUntilMs: until,
89
+ consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
90
+ lastError: "usage_limit_reached"
91
+ }));
73
92
  }
74
93
  markAuthFailure(account, message) {
94
+ const failureAtMs = Date.now();
95
+ const cooldownUntilMs = failureAtMs + 60 * 1000;
75
96
  account.consecutiveFailures += 1;
76
97
  account.lastError = message;
77
- account.cooldownUntilMs = Date.now() + 60 * 1000;
98
+ account.cooldownUntilMs = cooldownUntilMs;
99
+ (0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
100
+ ...previous,
101
+ lastAttemptAtMs: failureAtMs,
102
+ cooldownUntilMs,
103
+ consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
104
+ lastError: message
105
+ }));
78
106
  }
79
107
  markSuccess(account) {
108
+ const successAtMs = Date.now();
80
109
  account.consecutiveFailures = 0;
81
110
  account.lastError = undefined;
111
+ (0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
112
+ ...previous,
113
+ lastAttemptAtMs: successAtMs,
114
+ lastSuccessAtMs: successAtMs,
115
+ consecutiveFailures: 0,
116
+ cooldownUntilMs: undefined,
117
+ lastError: undefined
118
+ }));
82
119
  }
83
120
  updateTokens(account, tokens) {
84
121
  account.tokens = tokens;
85
122
  persistTokens(account.accountDir, tokens);
123
+ (0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
124
+ ...previous,
125
+ lastSuccessAtMs: Date.now(),
126
+ consecutiveFailures: 0,
127
+ cooldownUntilMs: undefined,
128
+ lastError: undefined
129
+ }));
86
130
  }
87
131
  isTokenFresh(account, bufferSeconds) {
88
132
  return (0, token_utils_1.isTokenFresh)(account.tokens.expiresAtMs, bufferSeconds);
@@ -84,11 +84,13 @@ class OpenAiGateway {
84
84
  selectAccount(sessionKey, excluded) {
85
85
  const sticky = this.pool.getStickyAccount(sessionKey);
86
86
  if (sticky && !excluded.has(sticky.name) && sticky.cooldownUntilMs <= Date.now()) {
87
+ this.pool.markAttempt(sticky);
87
88
  return sticky;
88
89
  }
89
90
  const picked = this.pool.pickNextAvailable(excluded);
90
91
  if (picked) {
91
92
  this.pool.assignAccount(sessionKey, picked.name);
93
+ this.pool.markAttempt(picked);
92
94
  }
93
95
  return picked;
94
96
  }
package/dist/paths.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export declare function getBaseDir(dataDir?: string): string;
2
2
  export declare function getAccountDir(baseDir: string, accountName: string): string;
3
3
  export declare function getRegistryPath(baseDir: string): string;
4
+ export declare function getAccountStatusPath(baseDir: string): string;
package/dist/paths.js CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getBaseDir = getBaseDir;
7
7
  exports.getAccountDir = getAccountDir;
8
8
  exports.getRegistryPath = getRegistryPath;
9
+ exports.getAccountStatusPath = getAccountStatusPath;
9
10
  const os_1 = __importDefault(require("os"));
10
11
  const path_1 = __importDefault(require("path"));
11
12
  const constants_1 = require("./constants");
@@ -21,3 +22,6 @@ function getAccountDir(baseDir, accountName) {
21
22
  function getRegistryPath(baseDir) {
22
23
  return path_1.default.join(baseDir, constants_1.REGISTRY_FILE_NAME);
23
24
  }
25
+ function getAccountStatusPath(baseDir) {
26
+ return path_1.default.join(baseDir, constants_1.ACCOUNT_STATUS_FILE_NAME);
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-account-orchestrator",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Codex OAuth account fallback orchestrator with seamless gateway mode",
5
5
  "main": "dist/cli_main.js",
6
6
  "types": "dist/cli_main.d.ts",
@@ -11,6 +11,8 @@
11
11
  "scripts": {
12
12
  "build": "tsc",
13
13
  "clean": "rm -rf dist",
14
+ "test": "npm run build && node --test test/*.test.js",
15
+ "check": "npm run test",
14
16
  "prepublishOnly": "npm run clean && npm run build",
15
17
  "start": "node dist/cli_main.js"
16
18
  },