codex-multi-auth 2.1.13-beta.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +3 -3
  2. package/dist/lib/accounts.d.ts.map +1 -1
  3. package/dist/lib/accounts.js +30 -1
  4. package/dist/lib/accounts.js.map +1 -1
  5. package/dist/lib/auth/org-override.d.ts +19 -0
  6. package/dist/lib/auth/org-override.d.ts.map +1 -0
  7. package/dist/lib/auth/org-override.js +23 -0
  8. package/dist/lib/auth/org-override.js.map +1 -0
  9. package/dist/lib/circuit-breaker.d.ts +6 -0
  10. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  11. package/dist/lib/circuit-breaker.js +8 -0
  12. package/dist/lib/circuit-breaker.js.map +1 -1
  13. package/dist/lib/codex-manager/account-manager-commands.d.ts +14 -0
  14. package/dist/lib/codex-manager/account-manager-commands.d.ts.map +1 -0
  15. package/dist/lib/codex-manager/account-manager-commands.js +43 -0
  16. package/dist/lib/codex-manager/account-manager-commands.js.map +1 -0
  17. package/dist/lib/codex-manager/commands/best.d.ts +1 -1
  18. package/dist/lib/codex-manager/commands/best.d.ts.map +1 -1
  19. package/dist/lib/codex-manager/commands/best.js +2 -1
  20. package/dist/lib/codex-manager/commands/best.js.map +1 -1
  21. package/dist/lib/codex-manager/commands/debug-bundle.d.ts +16 -0
  22. package/dist/lib/codex-manager/commands/debug-bundle.d.ts.map +1 -1
  23. package/dist/lib/codex-manager/commands/debug-bundle.js +80 -5
  24. package/dist/lib/codex-manager/commands/debug-bundle.js.map +1 -1
  25. package/dist/lib/codex-manager/commands/forecast.d.ts +1 -1
  26. package/dist/lib/codex-manager/commands/forecast.d.ts.map +1 -1
  27. package/dist/lib/codex-manager/commands/forecast.js +2 -1
  28. package/dist/lib/codex-manager/commands/forecast.js.map +1 -1
  29. package/dist/lib/codex-manager/commands/report.d.ts.map +1 -1
  30. package/dist/lib/codex-manager/commands/report.js +2 -2
  31. package/dist/lib/codex-manager/commands/report.js.map +1 -1
  32. package/dist/lib/codex-manager/commands/status.d.ts +2 -0
  33. package/dist/lib/codex-manager/commands/status.d.ts.map +1 -1
  34. package/dist/lib/codex-manager/commands/status.js +84 -25
  35. package/dist/lib/codex-manager/commands/status.js.map +1 -1
  36. package/dist/lib/codex-manager/commands/switch.d.ts.map +1 -1
  37. package/dist/lib/codex-manager/commands/switch.js +7 -0
  38. package/dist/lib/codex-manager/commands/switch.js.map +1 -1
  39. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts.map +1 -1
  40. package/dist/lib/codex-manager/experimental-settings-prompt.js +13 -3
  41. package/dist/lib/codex-manager/experimental-settings-prompt.js.map +1 -1
  42. package/dist/lib/codex-manager/forecast-report-commands.d.ts.map +1 -1
  43. package/dist/lib/codex-manager/forecast-report-commands.js +3 -3
  44. package/dist/lib/codex-manager/forecast-report-commands.js.map +1 -1
  45. package/dist/lib/codex-manager/repair-commands.d.ts.map +1 -1
  46. package/dist/lib/codex-manager/repair-commands.js +11 -1
  47. package/dist/lib/codex-manager/repair-commands.js.map +1 -1
  48. package/dist/lib/codex-manager/settings-hub/experimental.d.ts.map +1 -1
  49. package/dist/lib/codex-manager/settings-hub/experimental.js +8 -0
  50. package/dist/lib/codex-manager/settings-hub/experimental.js.map +1 -1
  51. package/dist/lib/codex-manager.d.ts +3 -0
  52. package/dist/lib/codex-manager.d.ts.map +1 -1
  53. package/dist/lib/codex-manager.js +53 -65
  54. package/dist/lib/codex-manager.js.map +1 -1
  55. package/dist/lib/config.d.ts.map +1 -1
  56. package/dist/lib/config.js +110 -5
  57. package/dist/lib/config.js.map +1 -1
  58. package/dist/lib/context-overflow.d.ts +7 -2
  59. package/dist/lib/context-overflow.d.ts.map +1 -1
  60. package/dist/lib/context-overflow.js +55 -37
  61. package/dist/lib/context-overflow.js.map +1 -1
  62. package/dist/lib/errors.d.ts +16 -0
  63. package/dist/lib/errors.d.ts.map +1 -1
  64. package/dist/lib/errors.js +23 -0
  65. package/dist/lib/errors.js.map +1 -1
  66. package/dist/lib/local-bridge.d.ts +8 -0
  67. package/dist/lib/local-bridge.d.ts.map +1 -1
  68. package/dist/lib/local-bridge.js +81 -7
  69. package/dist/lib/local-bridge.js.map +1 -1
  70. package/dist/lib/logger.d.ts +9 -1
  71. package/dist/lib/logger.d.ts.map +1 -1
  72. package/dist/lib/logger.js +104 -19
  73. package/dist/lib/logger.js.map +1 -1
  74. package/dist/lib/oc-chatgpt-orchestrator.d.ts +7 -1
  75. package/dist/lib/oc-chatgpt-orchestrator.d.ts.map +1 -1
  76. package/dist/lib/oc-chatgpt-orchestrator.js +68 -9
  77. package/dist/lib/oc-chatgpt-orchestrator.js.map +1 -1
  78. package/dist/lib/policy/runtime-policy.d.ts.map +1 -1
  79. package/dist/lib/policy/runtime-policy.js +15 -1
  80. package/dist/lib/policy/runtime-policy.js.map +1 -1
  81. package/dist/lib/prompts/codex.d.ts.map +1 -1
  82. package/dist/lib/prompts/codex.js +124 -29
  83. package/dist/lib/prompts/codex.js.map +1 -1
  84. package/dist/lib/prompts/fetch-utils.d.ts +72 -0
  85. package/dist/lib/prompts/fetch-utils.d.ts.map +1 -0
  86. package/dist/lib/prompts/fetch-utils.js +165 -0
  87. package/dist/lib/prompts/fetch-utils.js.map +1 -0
  88. package/dist/lib/prompts/host-codex-prompt.d.ts.map +1 -1
  89. package/dist/lib/prompts/host-codex-prompt.js +24 -10
  90. package/dist/lib/prompts/host-codex-prompt.js.map +1 -1
  91. package/dist/lib/quota-cache.d.ts.map +1 -1
  92. package/dist/lib/quota-cache.js +10 -1
  93. package/dist/lib/quota-cache.js.map +1 -1
  94. package/dist/lib/quota-probe.d.ts +21 -0
  95. package/dist/lib/quota-probe.d.ts.map +1 -1
  96. package/dist/lib/quota-probe.js +39 -0
  97. package/dist/lib/quota-probe.js.map +1 -1
  98. package/dist/lib/quota-readiness.d.ts +3 -1
  99. package/dist/lib/quota-readiness.d.ts.map +1 -1
  100. package/dist/lib/quota-readiness.js +16 -3
  101. package/dist/lib/quota-readiness.js.map +1 -1
  102. package/dist/lib/recovery/storage.d.ts +7 -0
  103. package/dist/lib/recovery/storage.d.ts.map +1 -1
  104. package/dist/lib/recovery/storage.js +178 -10
  105. package/dist/lib/recovery/storage.js.map +1 -1
  106. package/dist/lib/refresh-lease.d.ts +1 -1
  107. package/dist/lib/refresh-lease.d.ts.map +1 -1
  108. package/dist/lib/refresh-lease.js +25 -3
  109. package/dist/lib/refresh-lease.js.map +1 -1
  110. package/dist/lib/refresh-queue.d.ts.map +1 -1
  111. package/dist/lib/refresh-queue.js +31 -17
  112. package/dist/lib/refresh-queue.js.map +1 -1
  113. package/dist/lib/request/fetch-helpers.d.ts +0 -5
  114. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  115. package/dist/lib/request/fetch-helpers.js +36 -7
  116. package/dist/lib/request/fetch-helpers.js.map +1 -1
  117. package/dist/lib/rotation.d.ts +12 -0
  118. package/dist/lib/rotation.d.ts.map +1 -1
  119. package/dist/lib/rotation.js +36 -0
  120. package/dist/lib/rotation.js.map +1 -1
  121. package/dist/lib/runtime/account-check-types.d.ts +1 -0
  122. package/dist/lib/runtime/account-check-types.d.ts.map +1 -1
  123. package/dist/lib/runtime/account-check-types.js +1 -0
  124. package/dist/lib/runtime/account-check-types.js.map +1 -1
  125. package/dist/lib/runtime/account-check.d.ts.map +1 -1
  126. package/dist/lib/runtime/account-check.js +15 -4
  127. package/dist/lib/runtime/account-check.js.map +1 -1
  128. package/dist/lib/runtime/runtime-observability.d.ts.map +1 -1
  129. package/dist/lib/runtime/runtime-observability.js +24 -2
  130. package/dist/lib/runtime/runtime-observability.js.map +1 -1
  131. package/dist/lib/runtime-rotation-proxy.d.ts +1 -0
  132. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  133. package/dist/lib/runtime-rotation-proxy.js +163 -16
  134. package/dist/lib/runtime-rotation-proxy.js.map +1 -1
  135. package/dist/lib/storage/account-clear.d.ts.map +1 -1
  136. package/dist/lib/storage/account-clear.js +4 -2
  137. package/dist/lib/storage/account-clear.js.map +1 -1
  138. package/dist/lib/storage/flagged-storage-io.d.ts.map +1 -1
  139. package/dist/lib/storage/flagged-storage-io.js +4 -1
  140. package/dist/lib/storage/flagged-storage-io.js.map +1 -1
  141. package/dist/lib/storage/import-export.d.ts.map +1 -1
  142. package/dist/lib/storage/import-export.js +29 -9
  143. package/dist/lib/storage/import-export.js.map +1 -1
  144. package/dist/lib/storage/paths.d.ts.map +1 -1
  145. package/dist/lib/storage/paths.js +93 -0
  146. package/dist/lib/storage/paths.js.map +1 -1
  147. package/dist/lib/storage/storage-parser.d.ts.map +1 -1
  148. package/dist/lib/storage/storage-parser.js +7 -1
  149. package/dist/lib/storage/storage-parser.js.map +1 -1
  150. package/dist/lib/storage.d.ts.map +1 -1
  151. package/dist/lib/storage.js +13 -9
  152. package/dist/lib/storage.js.map +1 -1
  153. package/dist/lib/table-formatter.d.ts.map +1 -1
  154. package/dist/lib/table-formatter.js +20 -2
  155. package/dist/lib/table-formatter.js.map +1 -1
  156. package/dist/lib/types.d.ts +7 -0
  157. package/dist/lib/types.d.ts.map +1 -1
  158. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  159. package/dist/lib/ui/auth-menu.js +8 -2
  160. package/dist/lib/ui/auth-menu.js.map +1 -1
  161. package/dist/lib/ui/display-width.d.ts +27 -0
  162. package/dist/lib/ui/display-width.d.ts.map +1 -0
  163. package/dist/lib/ui/display-width.js +194 -0
  164. package/dist/lib/ui/display-width.js.map +1 -0
  165. package/dist/lib/ui/select.d.ts +23 -0
  166. package/dist/lib/ui/select.d.ts.map +1 -1
  167. package/dist/lib/ui/select.js +64 -16
  168. package/dist/lib/ui/select.js.map +1 -1
  169. package/dist/lib/ui/theme.d.ts +13 -0
  170. package/dist/lib/ui/theme.d.ts.map +1 -1
  171. package/dist/lib/ui/theme.js +34 -1
  172. package/dist/lib/ui/theme.js.map +1 -1
  173. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -0
  174. package/node_modules/@codex-ai/sdk/dist/index.js +2 -0
  175. package/node_modules/@codex-ai/sdk/package.json +8 -0
  176. package/package.json +9 -6
  177. package/scripts/codex-routing.js +3 -0
  178. package/scripts/codex.js +508 -1
  179. package/scripts/mcodex +117 -0
package/scripts/codex.js CHANGED
@@ -18,9 +18,10 @@ import {
18
18
  symlinkSync,
19
19
  writeFileSync,
20
20
  } from "node:fs";
21
+ import { rm as rmAsync } from "node:fs/promises";
21
22
  import { createRequire } from "node:module";
22
23
  import { homedir, tmpdir } from "node:os";
23
- import { basename, delimiter, dirname, join, resolve as resolvePath } from "node:path";
24
+ import { basename, delimiter, dirname, join, resolve as resolvePath, sep } from "node:path";
24
25
  import process from "node:process";
25
26
  import { StringDecoder } from "node:string_decoder";
26
27
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -81,6 +82,9 @@ const APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS = 15_000;
81
82
  const APP_SERVER_SHIM_DIR_NAME = "app-server-shims";
82
83
  const APP_SERVER_SHIM_HELPER_PREFIX = "helper-";
83
84
  const DEFAULT_STARTUP_UPDATE_NOTICE_BUDGET_MS = 3_000;
85
+ const DEFAULT_STATUS_QUOTA_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
86
+ const STATUS_QUOTA_REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
87
+ const STATUS_QUOTA_REFRESH_LOCK_DIR = "status-quota-refresh.lock";
84
88
  const STARTUP_UPDATE_NOTICE_TIMED_OUT = Symbol("startup-update-notice-timed-out");
85
89
  let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt(
86
90
  process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0",
@@ -163,6 +167,28 @@ function removeDirectoryWithRetry(targetPath) {
163
167
  }
164
168
  }
165
169
 
170
+ /**
171
+ * Best-effort async directory removal for hot-path callbacks (e.g. the
172
+ * status-refresh child's close/error handlers, which fire on the PARENT event
173
+ * loop while Codex is running). Unlike removeDirectoryWithRetry this never calls
174
+ * the Atomics.wait-backed sleepSync, so a retryable Windows lock (EBUSY/EPERM/
175
+ * ENOTEMPTY) can't stall the event loop for up to ~200ms. fsPromises.rm has its
176
+ * own internal retry (maxRetries) and yields between attempts. On persistent
177
+ * failure we give up silently — the 10-minute stale-lock recovery reclaims it.
178
+ */
179
+ async function removeDirectoryBestEffortAsync(targetPath) {
180
+ try {
181
+ await rmAsync(targetPath, {
182
+ recursive: true,
183
+ force: true,
184
+ maxRetries: 3,
185
+ retryDelay: 20,
186
+ });
187
+ } catch {
188
+ // Best-effort; stale-lock recovery handles any leftover.
189
+ }
190
+ }
191
+
166
192
  function hydrateCliVersionEnv() {
167
193
  try {
168
194
  const require = createRequire(import.meta.url);
@@ -176,6 +202,485 @@ function hydrateCliVersionEnv() {
176
202
  }
177
203
  }
178
204
 
205
+ function readJsonFileQuiet(path) {
206
+ try {
207
+ if (!existsSync(path)) return null;
208
+ return JSON.parse(readFileSync(path, "utf8"));
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ function resolveMultiAuthDirFromEnv(env = process.env) {
215
+ const configured = (env.CODEX_MULTI_AUTH_DIR ?? "").trim();
216
+ if (configured.length > 0) return configured;
217
+ return join(resolveCodexHomeDir(env), "multi-auth");
218
+ }
219
+
220
+ function resolveAccountsPath(env = process.env, dir = resolveMultiAuthDirFromEnv(env)) {
221
+ return join(dir, "openai-codex-accounts.json");
222
+ }
223
+
224
+ function resolveQuotaCachePath(env = process.env, dir = resolveMultiAuthDirFromEnv(env)) {
225
+ return join(dir, "quota-cache.json");
226
+ }
227
+
228
+ function resolveRuntimeObservabilityPath(env = process.env, dir = resolveMultiAuthDirFromEnv(env)) {
229
+ return join(dir, "runtime-observability.json");
230
+ }
231
+
232
+ /**
233
+ * Resolve the multi-auth dir the status line should read ACCOUNTS from, mirroring
234
+ * the runtime's own account scoping (lib/runtime/account-scope.ts):
235
+ * - when perProjectAccounts is enabled, Codex CLI sync is OFF, an explicit
236
+ * CODEX_MULTI_AUTH_DIR is NOT set, and the cwd resolves to a git/project root,
237
+ * accounts live in the per-project pool at
238
+ * <configDir>/projects/<project-key>/openai-codex-accounts.json;
239
+ * - otherwise the global dir is used.
240
+ * Only the ACCOUNTS pool is per-project; quota-cache.json and
241
+ * runtime-observability.json remain global (lib: getCodexMultiAuthDir). Reusing
242
+ * the built dist helpers (never re-deriving project keys from raw paths, per
243
+ * AGENTS.md) keeps the status line consistent with what Codex routes through.
244
+ * Any failure falls back to the global dir so the launcher never breaks.
245
+ */
246
+ async function resolveStatusAccountsDir(env = process.env) {
247
+ const globalDir = resolveMultiAuthDirFromEnv(env);
248
+ try {
249
+ const [configMod, pathsMod, stateMod] = await Promise.all([
250
+ import("../dist/lib/config.js"),
251
+ import("../dist/lib/storage/paths.js"),
252
+ import("../dist/lib/codex-cli/state.js"),
253
+ ]);
254
+ if (
255
+ typeof configMod.loadPluginConfig !== "function" ||
256
+ typeof configMod.getPerProjectAccounts !== "function" ||
257
+ typeof pathsMod.findProjectRoot !== "function" ||
258
+ typeof pathsMod.resolveProjectStorageIdentityRoot !== "function" ||
259
+ typeof pathsMod.getProjectGlobalConfigDir !== "function"
260
+ ) {
261
+ return globalDir;
262
+ }
263
+ const pluginConfig = configMod.loadPluginConfig();
264
+ if (configMod.getPerProjectAccounts(pluginConfig) !== true) return globalDir;
265
+ // Codex CLI sync forces the global pool (account-scope.ts), so honor that.
266
+ if (typeof stateMod.isCodexCliSyncEnabled === "function" && stateMod.isCodexCliSyncEnabled()) {
267
+ return globalDir;
268
+ }
269
+ const projectRoot = pathsMod.findProjectRoot(process.cwd());
270
+ if (!projectRoot) return globalDir;
271
+ // getProjectGlobalConfigDir grounds the per-project pool in the dist config
272
+ // dir, which itself honors CODEX_MULTI_AUTH_DIR / CODEX_HOME — so the pool is
273
+ // nested under an explicit dir exactly as the runtime writes it. No special
274
+ // casing of CODEX_MULTI_AUTH_DIR here, or the status line would diverge.
275
+ const identityRoot = pathsMod.resolveProjectStorageIdentityRoot(projectRoot);
276
+ return pathsMod.getProjectGlobalConfigDir(identityRoot);
277
+ } catch {
278
+ return globalDir;
279
+ }
280
+ }
281
+
282
+ function normalizeAccountIdentifier(value) {
283
+ return typeof value === "string" && value.trim().length > 0
284
+ ? value.trim().toLowerCase()
285
+ : "";
286
+ }
287
+
288
+ function findAccountIndexByIdOrEmail(accounts, id, email) {
289
+ const normalizedId = normalizeAccountIdentifier(id);
290
+ const normalizedEmail = normalizeAccountIdentifier(email);
291
+ for (let index = 0; index < accounts.length; index += 1) {
292
+ const account = accounts[index];
293
+ if (!account || typeof account !== "object") continue;
294
+ if (
295
+ normalizedId &&
296
+ normalizeAccountIdentifier(account.accountId) === normalizedId
297
+ ) {
298
+ return index;
299
+ }
300
+ if (
301
+ normalizedEmail &&
302
+ normalizeAccountIdentifier(account.email) === normalizedEmail
303
+ ) {
304
+ return index;
305
+ }
306
+ }
307
+ return -1;
308
+ }
309
+
310
+ function resolveModelFamilyForStatus(model) {
311
+ const normalized = typeof model === "string" ? model.trim().toLowerCase() : "";
312
+ if (normalized.startsWith("gpt-5.2")) return "gpt-5.2";
313
+ if (normalized.startsWith("gpt-5.1")) return "gpt-5.1";
314
+ if (normalized.includes("codex-max")) return "codex-max";
315
+ if (normalized.includes("codex")) return "codex";
316
+ if (normalized.startsWith("gpt-5")) return "gpt-5-codex";
317
+ return null;
318
+ }
319
+
320
+ function resolveStatusAccountIndex(storage, runtime, model) {
321
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
322
+ if (accounts.length === 0) return -1;
323
+
324
+ const runtimeUpdatedAt =
325
+ typeof runtime?.lastAccountUpdatedAt === "number"
326
+ ? runtime.lastAccountUpdatedAt
327
+ : typeof runtime?.updatedAt === "number"
328
+ ? runtime.updatedAt
329
+ : 0;
330
+ if (Date.now() - runtimeUpdatedAt <= 60 * 60 * 1000) {
331
+ const runtimeIndex = findAccountIndexByIdOrEmail(
332
+ accounts,
333
+ runtime?.lastAccountId,
334
+ runtime?.lastAccountEmail,
335
+ );
336
+ if (runtimeIndex >= 0) return runtimeIndex;
337
+ if (
338
+ typeof runtime?.lastAccountIndex === "number" &&
339
+ runtime.lastAccountIndex >= 0 &&
340
+ runtime.lastAccountIndex < accounts.length
341
+ ) {
342
+ return runtime.lastAccountIndex;
343
+ }
344
+ }
345
+
346
+ const family = resolveModelFamilyForStatus(model);
347
+ const familyIndex =
348
+ family && storage?.activeIndexByFamily && typeof storage.activeIndexByFamily[family] === "number"
349
+ ? storage.activeIndexByFamily[family]
350
+ : undefined;
351
+ if (
352
+ typeof familyIndex === "number" &&
353
+ familyIndex >= 0 &&
354
+ familyIndex < accounts.length
355
+ ) {
356
+ return familyIndex;
357
+ }
358
+ if (
359
+ typeof storage?.activeIndex === "number" &&
360
+ storage.activeIndex >= 0 &&
361
+ storage.activeIndex < accounts.length
362
+ ) {
363
+ return storage.activeIndex;
364
+ }
365
+ return 0;
366
+ }
367
+
368
+ function extractConfigAssignmentValue(rawConfig, key) {
369
+ const pattern = new RegExp(`^\\s*${key}\\s*=\\s*([^\\n#]+)`, "m");
370
+ const match = rawConfig.match(pattern);
371
+ if (!match) return null;
372
+ const rawValue = (match[1] ?? "").trim();
373
+ const quoted = rawValue.match(/^["'](.*)["']$/);
374
+ return (quoted ? quoted[1] : rawValue).trim() || null;
375
+ }
376
+
377
+ function readCodexConfigValue(env, key) {
378
+ const configPath = join(resolveCodexHomeDir(env), "config.toml");
379
+ try {
380
+ if (!existsSync(configPath)) return null;
381
+ return extractConfigAssignmentValue(readFileSync(configPath, "utf8"), key);
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ function extractArgValue(args, longName, shortName) {
388
+ for (let index = 0; index < args.length; index += 1) {
389
+ const arg = args[index];
390
+ if (arg === longName || (shortName && arg === shortName)) {
391
+ const next = args[index + 1];
392
+ return typeof next === "string" ? next : null;
393
+ }
394
+ if (arg.startsWith(`${longName}=`)) {
395
+ return arg.slice(longName.length + 1);
396
+ }
397
+ }
398
+ return null;
399
+ }
400
+
401
+ function extractConfigOverrideValue(args, key) {
402
+ for (let index = 0; index < args.length; index += 1) {
403
+ const arg = args[index];
404
+ let assignment = null;
405
+ if (arg === "-c" || arg === "--config") {
406
+ assignment = args[index + 1] ?? null;
407
+ } else if (arg.startsWith("-c=")) {
408
+ assignment = arg.slice(3);
409
+ } else if (arg.startsWith("--config=")) {
410
+ assignment = arg.slice("--config=".length);
411
+ }
412
+ if (typeof assignment !== "string") continue;
413
+ const separator = assignment.indexOf("=");
414
+ if (separator <= 0) continue;
415
+ if (assignment.slice(0, separator).trim() !== key) continue;
416
+ return assignment
417
+ .slice(separator + 1)
418
+ .trim()
419
+ .replace(/^["']|["']$/g, "");
420
+ }
421
+ return null;
422
+ }
423
+
424
+ function resolveStatusModel(args, env = process.env) {
425
+ return (
426
+ extractArgValue(args, "--model", "-m") ??
427
+ extractConfigOverrideValue(args, "model") ??
428
+ readCodexConfigValue(env, "model") ??
429
+ "unknown-model"
430
+ );
431
+ }
432
+
433
+ function resolveStatusReasoningEffort(args, env = process.env) {
434
+ return (
435
+ extractConfigOverrideValue(args, "model_reasoning_effort") ??
436
+ readCodexConfigValue(env, "model_reasoning_effort") ??
437
+ "unknown"
438
+ );
439
+ }
440
+
441
+ function formatStatusPath(cwd = process.cwd(), home = homedir()) {
442
+ const resolvedCwd = resolvePath(cwd);
443
+ const resolvedHome = resolvePath(home);
444
+ if (resolvedCwd === resolvedHome) return "~";
445
+ // Use the platform separator, not a hardcoded "/": on Windows resolvePath
446
+ // returns backslash paths (C:\Users\user\project), so the old "/"-anchored
447
+ // prefix check never matched and the cwd was never abbreviated to ~. `sep`
448
+ // keeps the boundary check correct on both POSIX and Windows.
449
+ const prefix = `${resolvedHome}${sep}`;
450
+ if (resolvedCwd.startsWith(prefix)) {
451
+ // Normalize the remainder to forward slashes for a stable, readable status
452
+ // line regardless of the host separator.
453
+ return `~/${resolvedCwd.slice(prefix.length).split(sep).join("/")}`;
454
+ }
455
+ return resolvedCwd;
456
+ }
457
+
458
+ function formatStatusResetTime(resetAtMs) {
459
+ if (typeof resetAtMs !== "number" || !Number.isFinite(resetAtMs) || resetAtMs <= 0) {
460
+ return null;
461
+ }
462
+ const date = new Date(resetAtMs);
463
+ if (!Number.isFinite(date.getTime())) return null;
464
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
465
+ }
466
+
467
+ function formatStatusResetDate(resetAtMs) {
468
+ if (typeof resetAtMs !== "number" || !Number.isFinite(resetAtMs) || resetAtMs <= 0) {
469
+ return null;
470
+ }
471
+ const date = new Date(resetAtMs);
472
+ if (!Number.isFinite(date.getTime())) return null;
473
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
474
+ }
475
+
476
+ function formatCacheAge(updatedAt) {
477
+ if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt) || updatedAt <= 0) {
478
+ return "stale";
479
+ }
480
+ const ageMs = Math.max(0, Date.now() - updatedAt);
481
+ if (ageMs < 60_000) return "now";
482
+ if (ageMs < 60 * 60_000) return `${Math.floor(ageMs / 60_000)}m`;
483
+ return `${Math.floor(ageMs / (60 * 60_000))}h`;
484
+ }
485
+
486
+ function getQuotaEntryForAccount(quotaCache, account) {
487
+ const byAccountId =
488
+ quotaCache && typeof quotaCache === "object" && quotaCache.byAccountId
489
+ ? quotaCache.byAccountId
490
+ : {};
491
+ const byEmail =
492
+ quotaCache && typeof quotaCache === "object" && quotaCache.byEmail
493
+ ? quotaCache.byEmail
494
+ : {};
495
+ const accountId = typeof account?.accountId === "string" ? account.accountId : "";
496
+ const email = typeof account?.email === "string" ? account.email.toLowerCase() : "";
497
+ return byAccountId?.[accountId] ?? byEmail?.[email] ?? null;
498
+ }
499
+
500
+ function formatUsageWindow(label, window, resetFormatter) {
501
+ const used =
502
+ typeof window?.usedPercent === "number" && Number.isFinite(window.usedPercent)
503
+ ? Math.max(0, Math.min(100, Math.round(window.usedPercent)))
504
+ : null;
505
+ const reset = resetFormatter(window?.resetAtMs);
506
+ if (used === null && !reset) return null;
507
+ if (used === null) return `${label} resets ${reset}`;
508
+ if (!reset) return `${label} ${used}%`;
509
+ return `${label} ${used}% ${reset}`;
510
+ }
511
+
512
+ function formatUsageSegment(entry) {
513
+ const primary = formatUsageWindow("5h", entry?.primary, formatStatusResetTime);
514
+ const secondary = formatUsageWindow("week", entry?.secondary, formatStatusResetDate);
515
+ const parts = [primary, secondary].filter(Boolean);
516
+ return parts.length > 0 ? parts.join(" | ") : "usage cached";
517
+ }
518
+
519
+ function formatPlan(planType) {
520
+ if (typeof planType !== "string" || planType.trim().length === 0) return "Plan?";
521
+ const normalized = planType.trim();
522
+ if (normalized.length <= 1) return normalized.toUpperCase();
523
+ return `${normalized[0].toUpperCase()}${normalized.slice(1).toLowerCase()}`;
524
+ }
525
+
526
+ function shouldShowForwardStatus(args, env = process.env) {
527
+ const override = (env.CODEX_MULTI_AUTH_STATUSLINE ?? "").trim().toLowerCase();
528
+ if (new Set(["0", "false", "no", "off"]).has(override)) return false;
529
+ if (new Set(["1", "true", "yes", "on"]).has(override)) return true;
530
+ if ((env.CODEX_MULTI_AUTH_STATUS_REFRESH_CHILD ?? "").trim() === "1") return false;
531
+ if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V")) {
532
+ return false;
533
+ }
534
+ return process.stderr.isTTY === true;
535
+ }
536
+
537
+ function formatForwardStatusLine(rawArgs, env = process.env, accountsDir = resolveMultiAuthDirFromEnv(env)) {
538
+ // Only the accounts pool is per-project; quota-cache.json and
539
+ // runtime-observability.json are global (lib: getCodexMultiAuthDir), so read
540
+ // accounts from the (possibly project-scoped) dir and the rest from global.
541
+ const storage = readJsonFileQuiet(resolveAccountsPath(env, accountsDir));
542
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
543
+ if (accounts.length === 0) return null;
544
+
545
+ const runtime = readJsonFileQuiet(resolveRuntimeObservabilityPath(env));
546
+ const quotaCache = readJsonFileQuiet(resolveQuotaCachePath(env));
547
+ const model = resolveStatusModel(rawArgs, env);
548
+ const effort = resolveStatusReasoningEffort(rawArgs, env);
549
+ const accountIndex = resolveStatusAccountIndex(storage, runtime, model);
550
+ const account = accounts[accountIndex];
551
+ if (!account || typeof account !== "object") return null;
552
+
553
+ const quotaEntry = getQuotaEntryForAccount(quotaCache, account);
554
+ const email = typeof account.email === "string" && account.email.trim()
555
+ ? account.email.trim()
556
+ : `Account ${accountIndex + 1}`;
557
+ const plan = formatPlan(quotaEntry?.planType);
558
+ const usage = formatUsageSegment(quotaEntry);
559
+ const cacheAge = formatCacheAge(quotaEntry?.updatedAt);
560
+ const parts = [
561
+ "codex-multi-auth",
562
+ `${model} ${effort}`,
563
+ formatStatusPath(),
564
+ `Account ${accountIndex + 1}`,
565
+ usage,
566
+ `${email}(${plan})`,
567
+ `cache ${cacheAge}`,
568
+ ];
569
+ return parts.join(" | ");
570
+ }
571
+
572
+ async function maybePrintForwardStatusLine(rawArgs, env = process.env) {
573
+ if (!shouldShowForwardStatus(rawArgs, env)) return;
574
+ const accountsDir = await resolveStatusAccountsDir(env);
575
+ const line = formatForwardStatusLine(rawArgs, env, accountsDir);
576
+ if (!line) return;
577
+ process.stderr.write(`${line}\n`);
578
+ }
579
+
580
+ function parseDurationMs(value, fallback) {
581
+ const trimmed = typeof value === "string" ? value.trim() : "";
582
+ if (trimmed.length === 0) return fallback;
583
+ const parsed = Number.parseInt(trimmed, 10);
584
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback;
585
+ return parsed;
586
+ }
587
+
588
+ function quotaCacheNeedsRefresh(env = process.env) {
589
+ const intervalMs = parseDurationMs(
590
+ env.CODEX_MULTI_AUTH_STATUS_QUOTA_REFRESH_INTERVAL_MS,
591
+ DEFAULT_STATUS_QUOTA_REFRESH_INTERVAL_MS,
592
+ );
593
+ if (intervalMs <= 0) return false;
594
+ const cache = readJsonFileQuiet(resolveQuotaCachePath(env));
595
+ const entries = [
596
+ ...Object.values(cache?.byAccountId ?? {}),
597
+ ...Object.values(cache?.byEmail ?? {}),
598
+ ].filter((entry) => entry && typeof entry === "object");
599
+ if (entries.length === 0) return true;
600
+ const newest = Math.max(
601
+ ...entries.map((entry) =>
602
+ typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
603
+ ? entry.updatedAt
604
+ : 0,
605
+ ),
606
+ );
607
+ return newest <= 0 || Date.now() - newest >= intervalMs;
608
+ }
609
+
610
+ function acquireStatusRefreshLock(env = process.env) {
611
+ const lockPath = join(resolveMultiAuthDirFromEnv(env), STATUS_QUOTA_REFRESH_LOCK_DIR);
612
+ try {
613
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
614
+ mkdirSync(lockPath);
615
+ writeFileSync(
616
+ join(lockPath, "owner.json"),
617
+ `${JSON.stringify({ pid: process.pid, createdAt: Date.now() })}\n`,
618
+ { mode: 0o600 },
619
+ );
620
+ return { lockPath, acquired: true };
621
+ } catch {
622
+ try {
623
+ const stat = statSync(lockPath);
624
+ if (Date.now() - stat.mtimeMs > STATUS_QUOTA_REFRESH_LOCK_STALE_MS) {
625
+ // Known, bounded TOCTOU: two processes that both observe a stale lock
626
+ // will both rmSync({force:true}) (both succeed) then race on mkdirSync;
627
+ // only one wins, so dual lock acquisition is still prevented. The
628
+ // residual risk is evicting a refresh owner that is merely SLOW (alive,
629
+ // holding the lock past the stale threshold) rather than dead, which can
630
+ // briefly yield two `forecast --live --json` children. That is benign
631
+ // here — the refresh is idempotent, read-mostly, and rate-limited by the
632
+ // cache TTL — so we accept it rather than add a heavier liveness probe.
633
+ removeDirectoryWithRetry(lockPath);
634
+ mkdirSync(lockPath);
635
+ writeFileSync(
636
+ join(lockPath, "owner.json"),
637
+ `${JSON.stringify({ pid: process.pid, createdAt: Date.now(), recovered: true })}\n`,
638
+ { mode: 0o600 },
639
+ );
640
+ return { lockPath, acquired: true };
641
+ }
642
+ } catch {
643
+ // Another process can win the race; skip refresh.
644
+ }
645
+ }
646
+ return { lockPath, acquired: false };
647
+ }
648
+
649
+ function maybeRefreshQuotaCacheInBackground(env = process.env) {
650
+ if ((env.CODEX_MULTI_AUTH_STATUS_REFRESH_CHILD ?? "").trim() === "1") return;
651
+ if (!quotaCacheNeedsRefresh(env)) return;
652
+ const { lockPath, acquired } = acquireStatusRefreshLock(env);
653
+ if (!acquired) return;
654
+
655
+ const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "codex-multi-auth.js");
656
+ // Note: the child is detached + unref'd so it outlives this short-lived launcher
657
+ // process. The close/error handlers below remove the lock dir, but if the parent
658
+ // exits before the child settles they will NOT fire — in that case the lock is
659
+ // reclaimed by the 10-minute stale-lock recovery in acquireStatusRefreshLock.
660
+ const child = spawn(
661
+ process.execPath,
662
+ [scriptPath, "forecast", "--live", "--json"],
663
+ {
664
+ env: {
665
+ ...env,
666
+ CODEX_MULTI_AUTH_STATUS_REFRESH_CHILD: "1",
667
+ CODEX_MULTI_AUTH_STATUSLINE: "0",
668
+ },
669
+ stdio: "ignore",
670
+ detached: true,
671
+ },
672
+ );
673
+ child.once("close", () => {
674
+ // Async, non-blocking: these handlers fire on the parent event loop while
675
+ // Codex is running, so the cleanup must never block it on a Windows lock.
676
+ void removeDirectoryBestEffortAsync(lockPath);
677
+ });
678
+ child.once("error", () => {
679
+ void removeDirectoryBestEffortAsync(lockPath);
680
+ });
681
+ child.unref();
682
+ }
683
+
179
684
  function isRotationEnableCommand(args) {
180
685
  return args[0] === "auth" && args[1] === "rotation" && args[2] === "enable";
181
686
  }
@@ -4262,6 +4767,8 @@ async function main() {
4262
4767
  }
4263
4768
 
4264
4769
  await autoSyncManagerActiveSelectionIfEnabled();
4770
+ await maybePrintForwardStatusLine(rawArgs);
4771
+ maybeRefreshQuotaCacheInBackground();
4265
4772
  try {
4266
4773
  return await withForwardedRuntimeObservability(rawArgs, () =>
4267
4774
  forwardToRealCodex(realCodexBin, rawArgs),
package/scripts/mcodex ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ monitor_interval="${MCODEX_MONITOR_INTERVAL:-5}"
5
+ live_accounts=0
6
+ tmux_history_limit="${MCODEX_TMUX_HISTORY_LIMIT:-50000}"
7
+
8
+ # Security: monitor_interval is embedded into `watch -n <n> ...` command strings
9
+ # that tmux hands to a shell. An attacker-controlled MCODEX_MONITOR_INTERVAL like
10
+ # "5; rm -rf ~" would otherwise execute. Require a positive integer (optionally
11
+ # with a fractional part for sub-second intervals) and fall back to the default
12
+ # otherwise, so no shell metacharacters can ever reach the command string.
13
+ if ! [[ "$monitor_interval" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
14
+ echo "mcodex: invalid MCODEX_MONITOR_INTERVAL '$monitor_interval'; using 5" >&2
15
+ monitor_interval=5
16
+ fi
17
+
18
+ # Same hardening for the tmux scrollback history limit (used as a tmux option
19
+ # argument): must be a plain integer.
20
+ if ! [[ "$tmux_history_limit" =~ ^[0-9]+$ ]]; then
21
+ echo "mcodex: invalid MCODEX_TMUX_HISTORY_LIMIT '$tmux_history_limit'; using 50000" >&2
22
+ tmux_history_limit=50000
23
+ fi
24
+
25
+ quote_args() {
26
+ local quoted=()
27
+ local arg
28
+ for arg in "$@"; do
29
+ printf -v arg '%q' "$arg"
30
+ quoted+=("$arg")
31
+ done
32
+ # Join with single spaces and NO trailing space (a trailing space would become
33
+ # part of the interpolated tmux command string).
34
+ local IFS=' '
35
+ printf '%s' "${quoted[*]}"
36
+ }
37
+
38
+ require_watch() {
39
+ if ! command -v watch >/dev/null 2>&1; then
40
+ echo "mcodex: 'watch' is not installed; the live account monitor requires it (install procps / procps-ng)." >&2
41
+ return 1
42
+ fi
43
+ }
44
+
45
+ run_monitor() {
46
+ require_watch || return 1
47
+ watch -n "$monitor_interval" 'codex-multi-auth list'
48
+ }
49
+
50
+ configure_tmux_scrollback() {
51
+ local target="${1:-}"
52
+ local target_args=()
53
+ if [[ -n "$target" ]]; then
54
+ target_args=(-t "$target")
55
+ fi
56
+
57
+ tmux set-option "${target_args[@]}" mouse on >/dev/null 2>&1
58
+ tmux set-option "${target_args[@]}" history-limit "$tmux_history_limit" >/dev/null 2>&1
59
+ tmux bind-key -T root WheelUpPane copy-mode -e >/dev/null 2>&1
60
+ tmux bind-key -T copy-mode WheelUpPane send-keys -X scroll-up >/dev/null 2>&1
61
+ tmux bind-key -T copy-mode WheelDownPane send-keys -X scroll-down >/dev/null 2>&1
62
+ tmux bind-key -T copy-mode-vi WheelUpPane send-keys -X scroll-up >/dev/null 2>&1
63
+ tmux bind-key -T copy-mode-vi WheelDownPane send-keys -X scroll-down >/dev/null 2>&1
64
+ }
65
+
66
+ if [[ "${1:-}" == "--monitor" ]]; then
67
+ # --monitor takes no extra args; the live account list is fixed. Propagate the
68
+ # watch-availability check's exit code instead of masking it with `exit 0`.
69
+ run_monitor
70
+ exit $?
71
+ fi
72
+
73
+ if [[ "${1:-}" == "--tmux" || "${1:-}" == "-t" ]]; then
74
+ shift
75
+ if [[ "${1:-}" == "--live-accounts" ]]; then
76
+ live_accounts=1
77
+ shift
78
+ fi
79
+ if ! command -v tmux >/dev/null 2>&1; then
80
+ echo "mcodex: tmux is not installed; launching without tmux" >&2
81
+ exec codex-multi-auth-codex "$@"
82
+ fi
83
+
84
+ if [[ -n "${TMUX:-}" ]]; then
85
+ configure_tmux_scrollback
86
+ if [[ "$live_accounts" == "1" ]] && require_watch; then
87
+ tmux split-window -h "watch -n $monitor_interval 'codex-multi-auth list'"
88
+ fi
89
+ exec codex-multi-auth-codex "$@"
90
+ fi
91
+
92
+ session="${MCODEX_TMUX_SESSION:-mcodex}"
93
+ suffix="$(date +%H%M%S)"
94
+ if tmux has-session -t "$session" 2>/dev/null; then
95
+ session="${session}-${suffix}"
96
+ fi
97
+
98
+ # Build the inner shell-command as a single string. quote_args runs each arg
99
+ # through printf %q, so every token is already shell-safe (embedded quotes,
100
+ # spaces, $, backticks are escaped); the surrounding expansion is not
101
+ # re-evaluated, so the command tmux receives is exactly this string.
102
+ cmd="codex-multi-auth-codex"
103
+ args="$(quote_args "$@")"
104
+ if [[ -n "$args" ]]; then
105
+ cmd="$cmd $args"
106
+ fi
107
+ tmux new-session -d -s "$session" -n codex "$cmd"
108
+ configure_tmux_scrollback "$session"
109
+ if [[ "$live_accounts" == "1" ]] && require_watch; then
110
+ tmux split-window -h -t "$session:0" "watch -n $monitor_interval 'codex-multi-auth list'"
111
+ fi
112
+ tmux select-pane -t "$session:0.0"
113
+ tmux attach-session -t "$session"
114
+ exit 0
115
+ fi
116
+
117
+ exec codex-multi-auth-codex "$@"