codex-multi-auth 2.8.6 → 2.8.7

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.8.6",
3
+ "version": "2.8.7",
4
4
  "description": "Install and operate codex-multi-auth for the official @openai/codex CLI with multi-account OAuth rotation, switching, health checks, and recovery tools.",
5
5
  "interface": {
6
6
  "composerIcon": "./assets/codex-multi-auth-icon.svg"
package/README.md CHANGED
@@ -392,7 +392,7 @@ codex-multi-auth doctor --json
392
392
 
393
393
  ## Release Notes
394
394
 
395
- - Current stable: [docs/releases/v2.8.6.md](docs/releases/v2.8.6.md) — install via `npm i -g codex-multi-auth`
395
+ - Current stable: [docs/releases/v2.8.7.md](docs/releases/v2.8.7.md) — install via `npm i -g codex-multi-auth`
396
396
  - Previous stable: [docs/releases/v2.8.3.md](docs/releases/v2.8.3.md)
397
397
  - Previous stable: [docs/releases/v2.8.2.md](docs/releases/v2.8.2.md)
398
398
  - Previous stable: [docs/releases/v2.7.1.md](docs/releases/v2.7.1.md)
@@ -40,6 +40,33 @@ export interface PinnedUnavailableContext {
40
40
  pinSource?: "forced" | "manual" | null;
41
41
  /** Epoch ms when the blocking record ends (rate limit or cooldown). */
42
42
  resetAtMs?: number | null;
43
+ /**
44
+ * Which class of record supplies `resetAtMs`. That deadline is the max
45
+ * across every gating record, so a `rate-limited` skip reason can carry a
46
+ * deadline supplied by a breaker or cooldown that ends later, and the
47
+ * quota phrasing is only true when the rate limit itself is that bound.
48
+ *
49
+ * - `"rate-limit"` — the rate-limit records bound it; word it as a reset.
50
+ * - `"other"` — a cooldown or breaker ends later and bounds it instead.
51
+ * - `"unknown"` (the default) — the caller did not measure; trust the
52
+ * skip reason.
53
+ *
54
+ * Deliberately a value rather than the presence of a second deadline
55
+ * field. The previous shape branched on `"rateLimitResetAtMs" in context`,
56
+ * so a caller that spread the key with an `undefined` value got the
57
+ * opposite wording from one that omitted it — a contract no type could
58
+ * express and no compiler could check.
59
+ */
60
+ recoveryBound?: "rate-limit" | "other" | "unknown";
61
+ /**
62
+ * The pin's CURRENT runtime skip reason, re-read from account state at the
63
+ * moment the 503 is built. `accountSkipReasons` holds the SELECTION
64
+ * verdict, and on the dominant path the retry loop has already overwritten
65
+ * it with its own bookkeeping token ("already-attempted") — wording the
66
+ * sentence from that is what issue #675 reported. Omitted when the caller
67
+ * did not re-read runtime state.
68
+ */
69
+ currentSkipReason?: string | null;
43
70
  now?: number;
44
71
  }
45
72
  export declare function buildPinnedUnavailableErrorBody(pinnedIndex: number | null | undefined, accountSkipReasons: ReadonlyMap<number, string>, context?: PinnedUnavailableContext): PinnedUnavailableErrorBody;
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit-decision.d.ts","sourceRoot":"","sources":["../../../lib/request/rate-limit-decision.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAC5E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAe/C,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGlE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAAG,OAAO,CAWjG;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAapF;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAoBlF;AAYD,wBAAgB,0BAA0B,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAuB3E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBxE;AA0BD,wBAAgB,4BAA4B,CAC3C,OAAO,EAAE,OAAO,EAChB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,GACT,MAAM,CAcR;AAED,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAE1E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,0BAA0B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,kCAAkC,CAAC;IACzC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mEAAmE;IACnE,UAAU,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACvC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7C;AAQD,MAAM,WAAW,wBAAwB;IACxC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACvC,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,+BAA+B,CAC9C,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,kBAAkB,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/C,OAAO,CAAC,EAAE,wBAAwB,GAChC,0BAA0B,CAwD5B"}
1
+ {"version":3,"file":"rate-limit-decision.d.ts","sourceRoot":"","sources":["../../../lib/request/rate-limit-decision.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAE5E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAe/C,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGlE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAAG,OAAO,CAWjG;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAapF;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAoBlF;AAYD,wBAAgB,0BAA0B,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAuB3E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBxE;AA0BD,wBAAgB,4BAA4B,CAC3C,OAAO,EAAE,OAAO,EAChB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,GACT,MAAM,CAcR;AAED,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAE1E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,0BAA0B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,kCAAkC,CAAC;IACzC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mEAAmE;IACnE,UAAU,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACvC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7C;AAQD,MAAM,WAAW,wBAAwB;IACxC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACvC,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;;;;;;;;;;;;OAgBG;IACH,aAAa,CAAC,EAAE,YAAY,GAAG,OAAO,GAAG,SAAS,CAAC;IACnD;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAwID,wBAAgB,+BAA+B,CAC9C,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,kBAAkB,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/C,OAAO,CAAC,EAAE,wBAAwB,GAChC,0BAA0B,CA8E5B"}
@@ -177,12 +177,107 @@ export function normalizeExhaustionStatus(reason) {
177
177
  * throws RangeError (ECMAScript time-value limit, ±100,000,000 days).
178
178
  */
179
179
  const MAX_ECMASCRIPT_TIME_VALUE = 8_640_000_000_000_000;
180
+ const DEADLINE_SENTENCES = {
181
+ "available-again": (resetAt) => `the account is expected to be available again at ${resetAt}`,
182
+ "next-attempt": (resetAt) => `the next attempt is allowed at ${resetAt}`,
183
+ "cooldown-ends": (resetAt) => `the cooldown ends at ${resetAt}`,
184
+ "rate-limit-reset": (resetAt) => `the rate limit resets at ${resetAt}`,
185
+ };
186
+ /**
187
+ * Every cooldown variant is bounded by the same `coolingDownUntil` field, so
188
+ * they all word their deadline the same way. Two of them used to say "the next
189
+ * attempt is allowed at" while the rest said "the cooldown ends at", which
190
+ * reads as two different mechanisms to an operator comparing two 503s.
191
+ *
192
+ * `satisfies` is what keeps this honest: a new CooldownReason becomes a
193
+ * compile error here rather than falling through to the verbatim arm below,
194
+ * which would print the raw `cooling-down:<reason>` token in the sentence —
195
+ * the internal-token leak issue #675 was filed about.
196
+ */
197
+ const COOLDOWN_DESCRIPTIONS = {
198
+ "auth-failure": {
199
+ parenthetical: "cooling down after authentication failures",
200
+ deadlineNoun: "cooldown-ends",
201
+ },
202
+ "network-error": {
203
+ parenthetical: "cooling down after network errors",
204
+ deadlineNoun: "cooldown-ends",
205
+ },
206
+ "server-error": {
207
+ parenthetical: "cooling down after upstream server errors",
208
+ deadlineNoun: "cooldown-ends",
209
+ },
210
+ "rate-limit": {
211
+ parenthetical: "cooling down after a rate limit",
212
+ deadlineNoun: "cooldown-ends",
213
+ },
214
+ };
215
+ const BLOCKER_DESCRIPTIONS = new Map([
216
+ [
217
+ "rate-limited",
218
+ // Upgraded to "rate-limit-reset" below when the caller confirms the
219
+ // rate limit is what bounds the advertised deadline.
220
+ { parenthetical: "rate-limited", deadlineNoun: "available-again" },
221
+ ],
222
+ [
223
+ "circuit-open",
224
+ {
225
+ parenthetical: "paused after repeated upstream errors",
226
+ deadlineNoun: "next-attempt",
227
+ },
228
+ ],
229
+ ["cooling-down", { parenthetical: "cooling down", deadlineNoun: "cooldown-ends" }],
230
+ ...Object.entries(COOLDOWN_DESCRIPTIONS).map(([reason, description]) => [
231
+ `cooling-down:${reason}`,
232
+ description,
233
+ ]),
234
+ ]);
235
+ /** Whether the sentence has real wording for this token, or must echo it raw. */
236
+ function isDescribedBlocker(skipReason) {
237
+ return BLOCKER_DESCRIPTIONS.has(skipReason);
238
+ }
239
+ /**
240
+ * The human sentence derives its parenthetical and its deadline noun from the
241
+ * blocker class. The deadline is max(rate-limit reset, cooldown end, breaker
242
+ * next-attempt), so calling it a "limit" is only true for the rate-limit
243
+ * class: during a provider outage a tripped breaker or a server-error
244
+ * cooldown printed "the recorded limit resets at …", and operators read a
245
+ * backend incident as a blown subscription quota. Only the message changes —
246
+ * the machine-readable `reason` keeps the raw skip token.
247
+ */
248
+ function describePinnedBlocker(skipReason, rateLimitBoundsRecovery) {
249
+ if (skipReason === null) {
250
+ return {
251
+ parenthetical: null,
252
+ deadline: DEADLINE_SENTENCES["available-again"],
253
+ };
254
+ }
255
+ const described = BLOCKER_DESCRIPTIONS.get(skipReason);
256
+ if (described === undefined) {
257
+ // Permanent blockers never reach the deadline clause (the call site
258
+ // suppresses their reset time), and future or internal tokens stay
259
+ // legible verbatim.
260
+ return {
261
+ parenthetical: skipReason,
262
+ deadline: DEADLINE_SENTENCES["available-again"],
263
+ };
264
+ }
265
+ // A breaker or cooldown can outlive the rate limit; the deadline is the max
266
+ // of every gating record, so it is only worded as the limit's own reset
267
+ // when the rate limit actually supplies it.
268
+ const deadlineNoun = skipReason === "rate-limited" && rateLimitBoundsRecovery
269
+ ? "rate-limit-reset"
270
+ : described.deadlineNoun;
271
+ return {
272
+ parenthetical: described.parenthetical,
273
+ deadline: DEADLINE_SENTENCES[deadlineNoun],
274
+ };
275
+ }
180
276
  export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons, context) {
181
277
  const normalizedPinnedIndex = typeof pinnedIndex === "number" ? pinnedIndex : null;
182
278
  const skipReason = normalizedPinnedIndex !== null
183
279
  ? accountSkipReasons.get(normalizedPinnedIndex) ?? null
184
280
  : null;
185
- const reasonSuffix = skipReason ? ` (${skipReason})` : "";
186
281
  // On the desync path the pin index is unknown (null); claiming "account 1"
187
282
  // there would contradict the machine-readable pinnedAccountIndex: null.
188
283
  const accountPhrase = normalizedPinnedIndex === null
@@ -206,7 +301,27 @@ export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons,
206
301
  const now = context?.now ?? Date.now();
207
302
  const retryAfterMs = resetAtMs !== null ? Math.max(0, resetAtMs - now) : null;
208
303
  const resetAt = resetAtMs !== null ? new Date(resetAtMs).toISOString() : null;
209
- const waitSuffix = resetAt !== null ? `; the recorded limit resets at ${resetAt}` : "";
304
+ // "unknown" (the default) means the caller did not measure which record
305
+ // bounds the deadline — older callers and unit seams — so trust the skip
306
+ // reason. Only a measured "other" demotes to the neutral phrasing, which
307
+ // stays true for a live rate limit either way.
308
+ const rateLimitBoundsRecovery = (context?.recoveryBound ?? "unknown") !== "other";
309
+ // The machine-readable `reason` below stays the recorded SELECTION verdict;
310
+ // only the human sentence follows the blocker actually gating the pin. A
311
+ // recorded verdict that names a real blocker class wins, because the
312
+ // selection-only classes ("missing", "policy-blocked") have no
313
+ // runtime-state equivalent and would be lost. Otherwise the re-read runtime
314
+ // state is the truth and the recorded token is the retry loop's own
315
+ // bookkeeping — the dominant path in issue #675, where a 503/429 on the pin
316
+ // re-enters selection and records "already-attempted" over the real class.
317
+ const describedSkipReason = skipReason !== null && isDescribedBlocker(skipReason)
318
+ ? skipReason
319
+ : context?.currentSkipReason ?? skipReason;
320
+ const blocker = describePinnedBlocker(describedSkipReason, rateLimitBoundsRecovery);
321
+ const reasonSuffix = blocker.parenthetical
322
+ ? ` (${blocker.parenthetical})`
323
+ : "";
324
+ const waitSuffix = resetAt !== null ? `; ${blocker.deadline(resetAt)}` : "";
210
325
  // A forced pin belongs to the launching process, not to the persisted pin
211
326
  // state, so `unpin` would clear nothing — say what actually helps.
212
327
  const remedy = pinSource === "forced"
@@ -7,22 +7,51 @@ export declare function resolveActiveIndex(storage: {
7
7
  export declare function getRateLimitResetTimeForFamily(account: {
8
8
  rateLimitResetTimes?: Record<string, number | undefined>;
9
9
  }, now: number, family: ModelFamily): number | null;
10
+ export interface AccountRecoveryBounds {
11
+ /**
12
+ * The moment the account becomes usable again: `rateLimitAtMs` widened by
13
+ * any active cooldown, because the account stays skipped while ANY gating
14
+ * record is active. Null when nothing bounds recovery.
15
+ */
16
+ recoveryAtMs: number | null;
17
+ /**
18
+ * The rate-limit records' own contribution to `recoveryAtMs`, cooldowns
19
+ * excluded. The pinned-503 uses it to decide whether the deadline it
20
+ * advertises is in fact the rate limit's own reset — with a breaker or
21
+ * cooldown ending later, the full recovery bound outlives the rate limit
22
+ * and must not be worded as its reset. Null when no rate-limit record
23
+ * gates the request.
24
+ */
25
+ rateLimitAtMs: number | null;
26
+ }
10
27
  /**
11
- * The moment the account becomes usable again for a `family`/`model`
12
- * request: the LATEST bound among the records that actually gate that
13
- * request plus any active cooldown. Two deliberate differences from
14
- * getRateLimitResetTimeForFamily, whose earliest-reset answer feeds wait
15
- * displays: the account stays skipped while ANY gating record is active,
16
- * so the earliest reset would send clients back into a 503 — and only the
17
- * keys selection consults (`family`, plus `family:<model>` when a model is
18
- * known; see isRateLimitedForFamily) may contribute, because another
19
- * model's record does not block this request and would overstate its
20
- * recovery. Null when nothing bounds recovery.
28
+ * Both recovery bounds for a `family`/`model` request, from ONE pass over
29
+ * `rateLimitResetTimes` against a single `now`.
30
+ *
31
+ * Two deliberate differences from getRateLimitResetTimeForFamily, whose
32
+ * earliest-reset answer feeds wait displays: the latest bound wins, because
33
+ * the earliest reset would send clients back into a 503 — and only the keys
34
+ * selection consults (`family`, plus `family:<model>` when a model is known;
35
+ * see isRateLimitedForFamily) may contribute, because another model's record
36
+ * does not block this request and would overstate its recovery.
37
+ *
38
+ * A caller that needs both bounds must take them from one call. Measuring
39
+ * them separately lets a record expire between the two walks, which reports
40
+ * a rate-limited account as if a cooldown bounded its recovery.
21
41
  */
42
+ export declare function getAccountRecoveryBoundsForFamily(account: {
43
+ rateLimitResetTimes?: Record<string, number | undefined>;
44
+ coolingDownUntil?: number;
45
+ }, now: number, family: ModelFamily, model?: string | null): AccountRecoveryBounds;
46
+ /** The `recoveryAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
22
47
  export declare function getAccountRecoveryTimeForFamily(account: {
23
48
  rateLimitResetTimes?: Record<string, number | undefined>;
24
49
  coolingDownUntil?: number;
25
50
  }, now: number, family: ModelFamily, model?: string | null): number | null;
51
+ /** The `rateLimitAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
52
+ export declare function getRateLimitRecoveryTimeForFamily(account: {
53
+ rateLimitResetTimes?: Record<string, number | undefined>;
54
+ }, now: number, family: ModelFamily, model?: string | null): number | null;
26
55
  export declare function formatRateLimitEntry(account: {
27
56
  rateLimitResetTimes?: Record<string, number | undefined>;
28
57
  }, now: number, formatWaitTime: (ms: number) => string, family?: ModelFamily): string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE;IACR,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,OAAO,EAAE,CAAC;CACpB,EACD,MAAM,GAAE,WAAqB,GAC3B,MAAM,CAOR;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,GACjB,MAAM,GAAG,IAAI,CAgBf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAcf;AAED,wBAAgB,oBAAoB,CACnC,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,EACtC,MAAM,GAAE,WAAqB,GAC3B,MAAM,GAAG,IAAI,CAMf;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,MAAM,GACX,MAAM,GAAG,IAAI,CAcf"}
1
+ {"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE;IACR,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,OAAO,EAAE,CAAC;CACpB,EACD,MAAM,GAAE,WAAqB,GAC3B,MAAM,CAOR;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,GACjB,MAAM,GAAG,IAAI,CAgBf;AAeD,MAAM,WAAW,qBAAqB;IACrC;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,CAChD,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,qBAAqB,CAevB;AAED,6EAA6E;AAC7E,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAGf;AAED,8EAA8E;AAC9E,wBAAgB,iCAAiC,CAChD,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CACzD,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAGf;AAED,wBAAgB,oBAAoB,CACnC,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,EACtC,MAAM,GAAE,WAAqB,GAC3B,MAAM,GAAG,IAAI,CAMf;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,MAAM,GACX,MAAM,GAAG,IAAI,CAGf"}
@@ -26,36 +26,54 @@ export function getRateLimitResetTimeForFamily(account, now, family) {
26
26
  }
27
27
  return minReset;
28
28
  }
29
+ /** The value when it is still a live bound at `now`, otherwise null. */
30
+ function activeBound(value, now) {
31
+ if (typeof value !== "number" || !Number.isFinite(value))
32
+ return null;
33
+ return value > now ? value : null;
34
+ }
35
+ /** The later of two bounds, treating null as "does not bound". */
36
+ function laterBound(a, b) {
37
+ if (a === null)
38
+ return b;
39
+ if (b === null)
40
+ return a;
41
+ return a > b ? a : b;
42
+ }
29
43
  /**
30
- * The moment the account becomes usable again for a `family`/`model`
31
- * request: the LATEST bound among the records that actually gate that
32
- * request plus any active cooldown. Two deliberate differences from
33
- * getRateLimitResetTimeForFamily, whose earliest-reset answer feeds wait
34
- * displays: the account stays skipped while ANY gating record is active,
35
- * so the earliest reset would send clients back into a 503 — and only the
36
- * keys selection consults (`family`, plus `family:<model>` when a model is
37
- * known; see isRateLimitedForFamily) may contribute, because another
38
- * model's record does not block this request and would overstate its
39
- * recovery. Null when nothing bounds recovery.
44
+ * Both recovery bounds for a `family`/`model` request, from ONE pass over
45
+ * `rateLimitResetTimes` against a single `now`.
46
+ *
47
+ * Two deliberate differences from getRateLimitResetTimeForFamily, whose
48
+ * earliest-reset answer feeds wait displays: the latest bound wins, because
49
+ * the earliest reset would send clients back into a 503 — and only the keys
50
+ * selection consults (`family`, plus `family:<model>` when a model is known;
51
+ * see isRateLimitedForFamily) may contribute, because another model's record
52
+ * does not block this request and would overstate its recovery.
53
+ *
54
+ * A caller that needs both bounds must take them from one call. Measuring
55
+ * them separately lets a record expire between the two walks, which reports
56
+ * a rate-limited account as if a cooldown bounded its recovery.
40
57
  */
41
- export function getAccountRecoveryTimeForFamily(account, now, family, model) {
42
- let latest = null;
43
- const consider = (value) => {
44
- if (typeof value !== "number" || !Number.isFinite(value))
45
- return;
46
- if (value <= now)
47
- return;
48
- if (latest === null || value > latest)
49
- latest = value;
50
- };
58
+ export function getAccountRecoveryBoundsForFamily(account, now, family, model) {
51
59
  const times = account.rateLimitResetTimes;
52
- if (times) {
53
- consider(times[getQuotaKey(family)]);
54
- if (model)
55
- consider(times[getQuotaKey(family, model)]);
56
- }
57
- consider(account.coolingDownUntil);
58
- return latest;
60
+ const rateLimitAtMs = times
61
+ ? laterBound(activeBound(times[getQuotaKey(family)], now), model ? activeBound(times[getQuotaKey(family, model)], now) : null)
62
+ : null;
63
+ return {
64
+ rateLimitAtMs,
65
+ recoveryAtMs: laterBound(rateLimitAtMs, activeBound(account.coolingDownUntil, now)),
66
+ };
67
+ }
68
+ /** The `recoveryAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
69
+ export function getAccountRecoveryTimeForFamily(account, now, family, model) {
70
+ return getAccountRecoveryBoundsForFamily(account, now, family, model)
71
+ .recoveryAtMs;
72
+ }
73
+ /** The `rateLimitAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
74
+ export function getRateLimitRecoveryTimeForFamily(account, now, family, model) {
75
+ return getAccountRecoveryBoundsForFamily(account, now, family, model)
76
+ .rateLimitAtMs;
59
77
  }
60
78
  export function formatRateLimitEntry(account, now, formatWaitTime, family = "codex") {
61
79
  const resetAt = getRateLimitResetTimeForFamily(account, now, family);
@@ -86,19 +104,6 @@ export function formatRateLimitEntry(account, now, formatWaitTime, family = "cod
86
104
  * model key applies and should keep the family-wide union above.
87
105
  */
88
106
  export function getRateLimitResetTimeForModel(account, now, family, model) {
89
- const times = account.rateLimitResetTimes;
90
- if (!times)
91
- return null;
92
- let latest = null;
93
- const consider = (value) => {
94
- if (typeof value !== "number" || !Number.isFinite(value))
95
- return;
96
- if (value <= now)
97
- return;
98
- if (latest === null || value > latest)
99
- latest = value;
100
- };
101
- consider(times[getQuotaKey(family)]);
102
- consider(times[getQuotaKey(family, model)]);
103
- return latest;
107
+ return getAccountRecoveryBoundsForFamily(account, now, family, model)
108
+ .rateLimitAtMs;
104
109
  }
@@ -15,7 +15,7 @@ import { CodexValidationError } from "./errors.js";
15
15
  import { normalizeEmailKey } from "./storage/identity.js";
16
16
  import { buildPinnedUnavailableErrorBody, buildTokenInvalidationBody, extractErrorCodeFromBody, isTokenInvalidationError, normalizeExhaustionStatus, parseRetryAfterBodyMs, parseRetryAfterHeaderMs, } from "./request/rate-limit-decision.js";
17
17
  import { forwardStreamingResponse, HOP_BY_HOP_HEADERS, readErrorBody, responseHeadersForClient, withTimeout, } from "./request/stream-failover-runtime.js";
18
- import { getAccountRecoveryTimeForFamily } from "./runtime/account-status.js";
18
+ import { getAccountRecoveryBoundsForFamily } from "./runtime/account-status.js";
19
19
  import { chooseAccount } from "./runtime/rotation-account-selection.js";
20
20
  import { createRotationProxyState, recoverStaleRuntimeState, } from "./runtime/rotation-proxy-state.js";
21
21
  import { readStorageMetaFromDisk } from "./runtime/rotation-storage-meta.js";
@@ -1219,23 +1219,45 @@ async function handleRequestInner(state, req, res, traceId) {
1219
1219
  // recovery — and with several overlapping records the account stays
1220
1220
  // skipped until the LAST one expires, so the latest bound is the one
1221
1221
  // worth advertising.
1222
- const pinnedStateRecoveryAtMs = pinnedAccount === null
1222
+ //
1223
+ // One clock for the whole body. The recovery bound and the
1224
+ // rate-limit bound are compared against each other below, so
1225
+ // reading state.now() per lookup would let a record expire between
1226
+ // them and report a rate-limited pin as bounded by something else.
1227
+ const evaluatedAtMs = state.now();
1228
+ // Both bounds come from a single pass over the account's records.
1229
+ const pinnedRecoveryBounds = pinnedAccount === null
1223
1230
  ? null
1224
- : getAccountRecoveryTimeForFamily(pinnedAccount, state.now(), context.family, context.model);
1231
+ : getAccountRecoveryBoundsForFamily(pinnedAccount, evaluatedAtMs, context.family, context.model);
1232
+ const pinnedStateRecoveryAtMs = pinnedRecoveryBounds?.recoveryAtMs ?? null;
1225
1233
  // An open circuit outlives the short failure cooldowns that tripped
1226
1234
  // it; its deadline lives in the breaker, not the account record.
1227
1235
  const pinnedCircuitRecoveryAtMs = pinnedAccount === null
1228
1236
  ? null
1229
- : accountManager.getCircuitRecoveryTime(pinnedAccount, state.now());
1237
+ : accountManager.getCircuitRecoveryTime(pinnedAccount, evaluatedAtMs);
1230
1238
  const pinnedResetAtMs = pinnedBlockedPermanently ||
1231
1239
  (pinnedStateRecoveryAtMs === null && pinnedCircuitRecoveryAtMs === null)
1232
1240
  ? null
1233
1241
  : Math.max(pinnedStateRecoveryAtMs ?? 0, pinnedCircuitRecoveryAtMs ?? 0);
1242
+ // The message only words the recovery deadline as a rate-limit reset
1243
+ // when the rate-limit records are in fact what supplies it — a
1244
+ // breaker or cooldown can end later and then bounds it instead.
1245
+ const pinnedRateLimitResetAtMs = pinnedRecoveryBounds?.rateLimitAtMs ?? null;
1246
+ const recoveryBound = pinnedResetAtMs !== null &&
1247
+ pinnedRateLimitResetAtMs !== null &&
1248
+ pinnedRateLimitResetAtMs >= pinnedResetAtMs
1249
+ ? "rate-limit"
1250
+ : "other";
1234
1251
  const errorBody = buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons, {
1235
1252
  // typeof check so a forced index of 0 still reads as forced.
1236
1253
  pinSource: typeof state.forcedAccountIndex === "number" ? "forced" : "manual",
1237
1254
  resetAtMs: pinnedResetAtMs,
1238
- now: state.now(),
1255
+ recoveryBound,
1256
+ // The recorded verdict above is the retry loop's
1257
+ // "already-attempted" on the dominant path; the re-read runtime
1258
+ // state is what the operator-facing sentence should describe.
1259
+ currentSkipReason: pinnedCurrentSkipReason,
1260
+ now: evaluatedAtMs,
1239
1261
  });
1240
1262
  if (errorBody.reason === null) {
1241
1263
  state.status.lastError = `pinned-503 missing skip reason (pinnedIndex=${pinnedIndex})`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.8.6",
3
+ "version": "2.8.7",
4
4
  "description": "Codex CLI multi-account OAuth manager with account switching, health checks, runtime rotation, diagnostics, and recovery tools for @openai/codex",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/scripts/codex.js CHANGED
@@ -421,6 +421,14 @@ function extractForcedAccountFlag(args) {
421
421
  let sawFlag = false;
422
422
  for (let index = 0; index < args.length; index += 1) {
423
423
  const arg = args[index];
424
+ // `--` ends option parsing: everything after it is Codex's root
425
+ // `[PROMPT]` text. A `--account` spelled in there is the user's prompt,
426
+ // not a launcher flag — stripping it would both mangle the prompt and
427
+ // silently pin the run to an account nobody selected.
428
+ if (arg === "--") {
429
+ strippedArgs.push(...args.slice(index));
430
+ break;
431
+ }
424
432
  if (arg === "--account") {
425
433
  sawFlag = true;
426
434
  const next = args[index + 1];
@@ -1365,6 +1373,9 @@ function replaceRequestedModel(args, nextModel) {
1365
1373
  const nextArgs = [...args];
1366
1374
  for (let i = 0; i < nextArgs.length; i += 1) {
1367
1375
  const arg = nextArgs[i];
1376
+ // Past `--` the tokens are the user's prompt. Rewriting one in place
1377
+ // would edit the prompt text itself before re-forwarding it.
1378
+ if (arg === "--") break;
1368
1379
  if ((arg === "--model" || arg === "-m") && typeof nextArgs[i + 1] === "string") {
1369
1380
  nextArgs[i + 1] = nextModel;
1370
1381
  return nextArgs;
@@ -1859,6 +1870,11 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) {
1859
1870
  function hasCliAuthCredentialsStoreOverride(args) {
1860
1871
  for (let i = 0; i < args.length; i += 1) {
1861
1872
  const arg = args[i];
1873
+ // Everything after `--` is the root `[PROMPT]` positional, not options:
1874
+ // a prompt whose text spells `--config=cli_auth_credentials_store=...` is
1875
+ // not a caller override, and treating it as one would silently drop the
1876
+ // file auth store this wrapper depends on.
1877
+ if (arg === "--") break;
1862
1878
  if (arg === "-c" || arg === "--config") {
1863
1879
  const next = args[i + 1];
1864
1880
  if (!next || !next.includes("=")) continue;
@@ -2357,6 +2373,10 @@ function coerceReasoningEffortForModel(model, effort) {
2357
2373
  function extractRequestedModel(args) {
2358
2374
  for (let i = 0; i < args.length; i += 1) {
2359
2375
  const arg = args[i];
2376
+ // Past `--` every token is root `[PROMPT]` text; a `-m` in there names
2377
+ // no model, and reading one would coerce reasoning config for a model
2378
+ // the user never requested.
2379
+ if (arg === "--") break;
2360
2380
  if (arg === "--model" || arg === "-m") {
2361
2381
  const next = args[i + 1];
2362
2382
  if (typeof next === "string" && next.trim().length > 0) {
@@ -2427,6 +2447,8 @@ function rewriteReasoningConfigArgs(rawArgs) {
2427
2447
  const nextArgs = [...rawArgs];
2428
2448
  for (let i = 0; i < nextArgs.length; i += 1) {
2429
2449
  const arg = nextArgs[i];
2450
+ // Past `--` a `-c key=value` pair is prompt text, not a config override.
2451
+ if (arg === "--") break;
2430
2452
  if ((arg === "-c" || arg === "--config") && typeof nextArgs[i + 1] === "string") {
2431
2453
  nextArgs[i + 1] = rewriteReasoningConfigAssignment(
2432
2454
  nextArgs[i + 1],
@@ -4903,13 +4925,20 @@ async function createRuntimeRotationAppHelperContext(
4903
4925
  }
4904
4926
  };
4905
4927
 
4928
+ const injectedArgs = [
4929
+ ...helperArgs,
4930
+ "-c",
4931
+ `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`,
4932
+ ];
4906
4933
  return {
4907
- args: [
4908
- ...baseContext.args,
4909
- ...helperArgs,
4910
- "-c",
4911
- `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`,
4912
- ],
4934
+ // Root TUI argv can end in the optional `[PROMPT]` positional, and `--`
4935
+ // binds everything after it to that positional — overrides appended
4936
+ // there would become prompt text, so the interactive branch asks for
4937
+ // them to be injected ahead of the prompt instead.
4938
+ args:
4939
+ options.injectArgsBeforeRootPrompt === true
4940
+ ? insertArgsBeforeRootPrompt(baseContext.args, injectedArgs)
4941
+ : insertArgsBeforeForwardedSeparator(baseContext.args, injectedArgs),
4913
4942
  env: {
4914
4943
  ...baseContext.env,
4915
4944
  ...helperEnv,
@@ -4991,13 +5020,19 @@ async function createRuntimeRotationProxyContextIfEnabled(
4991
5020
  if (isCodexAppCommand(rawArgs)) {
4992
5021
  return startAppHelperContext();
4993
5022
  }
4994
- if (
4995
- isCodexInteractiveTuiCommand(rawArgs) ||
4996
- isCodexInteractiveResumeCommand(rawArgs)
4997
- ) {
5023
+ // Classify once: each predicate re-walks the whole argv, and the root-TUI
5024
+ // answer is needed twice below.
5025
+ const isRootTuiLaunch = isCodexInteractiveTuiCommand(rawArgs);
5026
+ if (isRootTuiLaunch || isCodexInteractiveResumeCommand(rawArgs)) {
4998
5027
  return startAppHelperContext({
4999
5028
  detachOnExit: true,
5000
5029
  useCanonicalHome: true,
5030
+ // Root TUI launches may carry the optional `[PROMPT]` positional
5031
+ // (possibly forced by `--`); the provider overrides must stay on the
5032
+ // option side of it. `resume`/`fork` keep the appended ordering their
5033
+ // argv has always forwarded with — their tokens are subcommand args,
5034
+ // not the root prompt.
5035
+ injectArgsBeforeRootPrompt: isRootTuiLaunch,
5001
5036
  });
5002
5037
  }
5003
5038
 
@@ -5088,90 +5123,257 @@ function isPureHelpOrVersionArgs(rawArgs) {
5088
5123
  );
5089
5124
  }
5090
5125
 
5126
+ // Hoisted: every argv walk calls consumesNextArg once per token, and a Set
5127
+ // rebuilt per call allocated this list on every one of them.
5128
+ const OPTIONS_CONSUMING_NEXT_ARG = new Set([
5129
+ "-c",
5130
+ "--config",
5131
+ "--enable",
5132
+ "--disable",
5133
+ "--listen",
5134
+ "--remote",
5135
+ "--remote-auth-token-env",
5136
+ "--ws-auth",
5137
+ "--ws-token-file",
5138
+ "--ws-token-sha256",
5139
+ "--ws-shared-secret-file",
5140
+ "--ws-issuer",
5141
+ "--ws-audience",
5142
+ "--ws-max-clock-skew-seconds",
5143
+ "--download-url",
5144
+ "-i",
5145
+ "--image",
5146
+ "-m",
5147
+ "--model",
5148
+ "--local-provider",
5149
+ "-p",
5150
+ "--profile",
5151
+ "-s",
5152
+ "--sandbox",
5153
+ "-a",
5154
+ "--ask-for-approval",
5155
+ "-C",
5156
+ "--cd",
5157
+ "--add-dir",
5158
+ "--output-schema",
5159
+ "--color",
5160
+ "-o",
5161
+ "--output-last-message",
5162
+ ]);
5163
+
5091
5164
  function consumesNextArg(arg) {
5092
- return new Set([
5093
- "-c",
5094
- "--config",
5095
- "--enable",
5096
- "--disable",
5097
- "--listen",
5098
- "--remote",
5099
- "--remote-auth-token-env",
5100
- "--ws-auth",
5101
- "--ws-token-file",
5102
- "--ws-token-sha256",
5103
- "--ws-shared-secret-file",
5104
- "--ws-issuer",
5105
- "--ws-audience",
5106
- "--ws-max-clock-skew-seconds",
5107
- "--download-url",
5108
- "-i",
5109
- "--image",
5110
- "-m",
5111
- "--model",
5112
- "--local-provider",
5113
- "-p",
5114
- "--profile",
5115
- "-s",
5116
- "--sandbox",
5117
- "-a",
5118
- "--ask-for-approval",
5119
- "-C",
5120
- "--cd",
5121
- "--add-dir",
5122
- "--output-schema",
5123
- "--color",
5124
- "-o",
5125
- "--output-last-message",
5126
- ]).has(arg);
5165
+ return OPTIONS_CONSUMING_NEXT_ARG.has(arg);
5127
5166
  }
5128
5167
 
5129
- function findForwardedCommand(rawArgs) {
5130
- if (!Array.isArray(rawArgs) || rawArgs.length === 0) {
5131
- return null;
5168
+ // Codex's root grammar is `codex [OPTIONS] [PROMPT]` / `codex [OPTIONS]
5169
+ // <COMMAND> [ARGS]`, so the first positional is only a command when it names a
5170
+ // real root subcommand — otherwise it is the optional initial prompt of an
5171
+ // interactive TUI launch. The allowlist mirrors `codex --help`; treating an
5172
+ // unknown positional as a prompt matches native Codex, where an unrecognized
5173
+ // token also falls through to the prompt positional rather than erroring as a
5174
+ // bad subcommand. Routing predicates all resolve through this map so aliases
5175
+ // classify like their canonical command.
5176
+ //
5177
+ // A real root subcommand missing from this set is misrouted, not merely
5178
+ // misnamed: it classifies as an interactive prompt and takes the app-helper
5179
+ // branch with `detachOnExit: true`, so a short-lived command leaves a detached
5180
+ // helper and its loopback proxy idling until the rotation idle timeout. Keep
5181
+ // the set in sync with the upstream grammar — clap publishes the authoritative
5182
+ // root list in its own completions, so
5183
+ // codex completion bash | grep -oE 'opts="[^"]*"' | grep ' exec '
5184
+ // prints every root command (and alias) for the installed codex-cli.
5185
+
5186
+ const CODEX_ROOT_COMMAND_ALIASES = new Map([
5187
+ ["e", "exec"],
5188
+ ["a", "apply"],
5189
+ ]);
5190
+ const CODEX_ROOT_COMMANDS = new Set([
5191
+ "exec",
5192
+ "review",
5193
+ "login",
5194
+ "logout",
5195
+ "mcp",
5196
+ "plugin",
5197
+ "mcp-server",
5198
+ "app-server",
5199
+ "remote-control",
5200
+ "app",
5201
+ "completion",
5202
+ "update",
5203
+ "doctor",
5204
+ "sandbox",
5205
+ "debug",
5206
+ "apply",
5207
+ "resume",
5208
+ "archive",
5209
+ "delete",
5210
+ "migrate-rollouts",
5211
+ "unarchive",
5212
+ "fork",
5213
+ "cloud",
5214
+ "exec-server",
5215
+ "features",
5216
+ "help",
5217
+ "auth",
5218
+ // Hidden root subcommands: absent from `codex --help`, but real — each is
5219
+ // documented by `codex help <name>`.
5220
+ "responses-api-proxy",
5221
+ "cloud-tasks",
5222
+ "execpolicy",
5223
+ // Internal stdio/UDS relay. Hidden like the three above, and short-lived:
5224
+ // omitting it classified `codex stdio-to-uds <socket>` as a root prompt,
5225
+ // which took the detached app-helper branch and leaked a helper per call.
5226
+ "stdio-to-uds",
5227
+ ]);
5228
+
5229
+ function resolveCodexRootCommand(token) {
5230
+ const command = CODEX_ROOT_COMMAND_ALIASES.get(token) ?? token;
5231
+ return CODEX_ROOT_COMMANDS.has(command) ? command : null;
5232
+ }
5233
+
5234
+ // `-i/--image` is greedy (`<FILE>...`): native Codex consumes every following
5235
+ // free token up to the next option-like token, so a prompt can only follow the
5236
+ // image list after another option or a `--`. Every argv walker below skips the
5237
+ // same span, or the wrapper would disagree with native Codex about where the
5238
+ // positional side starts.
5239
+ function consumesRemainingFreeArgs(arg) {
5240
+ return arg === "-i" || arg === "--image";
5241
+ }
5242
+
5243
+ function skipOptionValueSpan(args, i) {
5244
+ const arg = args[i];
5245
+ if (consumesRemainingFreeArgs(arg)) {
5246
+ let end = i;
5247
+ while (
5248
+ end + 1 < args.length &&
5249
+ typeof args[end + 1] === "string" &&
5250
+ args[end + 1].length > 0 &&
5251
+ (args[end + 1] === "-" || !args[end + 1].startsWith("-"))
5252
+ ) {
5253
+ end += 1;
5254
+ }
5255
+ return end;
5132
5256
  }
5133
- for (let i = 0; i < rawArgs.length; i += 1) {
5134
- const arg = rawArgs[i];
5257
+ return consumesNextArg(arg) ? i + 1 : i;
5258
+ }
5259
+
5260
+ /**
5261
+ * The single definition of where an argv's option side ends. Walking from
5262
+ * `startIndex`, skips option flags and the values they consume — including a
5263
+ * greedy `-i a.png b.png` span — and reports what stopped the walk:
5264
+ *
5265
+ * - `index`: the first free (positional) token, or null when there is none.
5266
+ * - `terminatorIndex`: the bare `--` that ended option parsing, or null.
5267
+ * - `boundary`: where option-side content ends, i.e. the positional, the
5268
+ * `--`, or `args.length`. This is where injected overrides belong.
5269
+ *
5270
+ * Callers keep their own policy for `--` (the root grammar binds everything
5271
+ * after it to `[PROMPT]`; a subcommand's does not), but they must not keep
5272
+ * their own copy of the walk: a classifier and an injector that disagree by
5273
+ * one token put a `-c` override into prompt text or read prompt text as a
5274
+ * subcommand.
5275
+ */
5276
+ function findRootPositional(args, startIndex = 0) {
5277
+ if (!Array.isArray(args)) {
5278
+ return { index: null, terminatorIndex: null, boundary: 0 };
5279
+ }
5280
+ for (let i = startIndex; i < args.length; i += 1) {
5281
+ const arg = args[i];
5135
5282
  if (typeof arg !== "string" || arg.length === 0) continue;
5136
5283
  if (arg === "--") {
5137
- return i + 1 < rawArgs.length
5138
- ? { command: rawArgs[i + 1], index: i + 1 }
5139
- : null;
5284
+ return { index: null, terminatorIndex: i, boundary: i };
5140
5285
  }
5141
5286
  if (arg.startsWith("--config=")) {
5142
5287
  continue;
5143
5288
  }
5144
5289
  if (arg.startsWith("--") || (arg.startsWith("-") && arg !== "-")) {
5145
- if (consumesNextArg(arg)) {
5146
- i += 1;
5147
- }
5290
+ i = skipOptionValueSpan(args, i);
5148
5291
  continue;
5149
5292
  }
5150
- return { command: arg, index: i };
5293
+ return { index: i, terminatorIndex: null, boundary: i };
5294
+ }
5295
+ return { index: null, terminatorIndex: null, boundary: args.length };
5296
+ }
5297
+
5298
+ function findForwardedCommand(rawArgs) {
5299
+ if (!Array.isArray(rawArgs) || rawArgs.length === 0) {
5300
+ return null;
5301
+ }
5302
+ // `--` ends option parsing: everything after it binds to the root
5303
+ // `[PROMPT]` positional, never to a subcommand, even when the text happens
5304
+ // to spell a command name. `findRootPositional` reports it as a terminator
5305
+ // and yields no positional, which settles the launch as interactive.
5306
+ //
5307
+ // Native Codex fills the root `[PROMPT]` slot with the first free token and
5308
+ // still tries a later one as a subcommand (`codex hello exec …` runs exec),
5309
+ // so an unknown positional keeps the scan going.
5310
+ for (
5311
+ let cursor = findRootPositional(rawArgs, 0);
5312
+ cursor.index !== null;
5313
+ cursor = findRootPositional(rawArgs, cursor.index + 1)
5314
+ ) {
5315
+ const command = resolveCodexRootCommand(rawArgs[cursor.index]);
5316
+ if (command !== null) {
5317
+ return { command, index: cursor.index };
5318
+ }
5151
5319
  }
5152
5320
 
5153
5321
  return null;
5154
5322
  }
5155
5323
 
5324
+ // Inserts option-side args ahead of the root `[PROMPT]` positional (or the
5325
+ // `--` that forces one), so injected overrides cannot be read as prompt text.
5326
+ // With no positional at all this is a plain append, which keeps bare-TUI argv
5327
+ // byte-identical to the pre-insertion shape.
5328
+ function insertArgsBeforeRootPrompt(baseArgs, injectedArgs) {
5329
+ const { boundary } = findRootPositional(baseArgs);
5330
+ return [
5331
+ ...baseArgs.slice(0, boundary),
5332
+ ...injectedArgs,
5333
+ ...baseArgs.slice(boundary),
5334
+ ];
5335
+ }
5336
+
5337
+ // Inserts args before a `--` separator when one is present, else appends.
5338
+ // Subcommand argv keeps its leading subcommand token — `resume`/`fork` argv is
5339
+ // pinned to it — but anything appended past a `--` lands in the subcommand's
5340
+ // own positional list: Codex rejects that outright for `exec`/`review`
5341
+ // ("unexpected argument '-c' found"), and worse, silently forwards it as
5342
+ // arguments for `sandbox` and into the stored config for `mcp add`. The
5343
+ // separator bounds the append.
5344
+ function insertArgsBeforeForwardedSeparator(baseArgs, injectedArgs) {
5345
+ // Deliberately not `findRootPositional`: that reports where the option side
5346
+ // ends, stopping at the first positional, and a subcommand's own arguments
5347
+ // are positionals — it would answer `exec` for `exec -- hi`. The question
5348
+ // here is only where the separator sits, and a bare `indexOf` answers it
5349
+ // exactly: clap never consumes `--` as an option value (`codex --cd -- hi`
5350
+ // is "a value is required for '--cd'"), so the first `--` is always the
5351
+ // separator, and it always follows the subcommand token because
5352
+ // `findForwardedCommand` returns null the moment it sees one.
5353
+ const separatorIndex = baseArgs.indexOf("--");
5354
+ if (separatorIndex === -1) {
5355
+ return [...baseArgs, ...injectedArgs];
5356
+ }
5357
+ return [
5358
+ ...baseArgs.slice(0, separatorIndex),
5359
+ ...injectedArgs,
5360
+ ...baseArgs.slice(separatorIndex),
5361
+ ];
5362
+ }
5363
+
5156
5364
  function findForwardedSubcommand(rawArgs, commandIndex) {
5157
- for (let i = commandIndex + 1; i < rawArgs.length; i += 1) {
5158
- const arg = rawArgs[i];
5159
- if (typeof arg !== "string" || arg.length === 0) continue;
5160
- if (arg === "--") {
5161
- return i + 1 < rawArgs.length ? rawArgs[i + 1] : null;
5162
- }
5163
- if (arg.startsWith("--config=")) {
5164
- continue;
5165
- }
5166
- if (arg.startsWith("--") || (arg.startsWith("-") && arg !== "-")) {
5167
- if (consumesNextArg(arg)) {
5168
- i += 1;
5169
- }
5170
- continue;
5171
- }
5172
- return arg;
5365
+ const { index, terminatorIndex } = findRootPositional(
5366
+ rawArgs,
5367
+ commandIndex + 1,
5368
+ );
5369
+ // Unlike the root grammar, a subcommand's `--` still precedes its own
5370
+ // positional args, so the token after it is the nested subcommand.
5371
+ if (terminatorIndex !== null) {
5372
+ return terminatorIndex + 1 < rawArgs.length
5373
+ ? rawArgs[terminatorIndex + 1]
5374
+ : null;
5173
5375
  }
5174
- return null;
5376
+ return index === null ? null : rawArgs[index];
5175
5377
  }
5176
5378
 
5177
5379
  function hasHelpFlagAfterCommand(rawArgs, commandIndex) {
@@ -5179,8 +5381,8 @@ function hasHelpFlagAfterCommand(rawArgs, commandIndex) {
5179
5381
  const arg = rawArgs[i];
5180
5382
  if (arg === "--") return false;
5181
5383
  if (arg === "--help" || arg === "-h" || arg === "help") return true;
5182
- if (typeof arg === "string" && consumesNextArg(arg)) {
5183
- i += 1;
5384
+ if (typeof arg === "string") {
5385
+ i = skipOptionValueSpan(rawArgs, i);
5184
5386
  }
5185
5387
  }
5186
5388
  return false;
@@ -5194,6 +5396,11 @@ function isCodexAppServerCommand(rawArgs) {
5194
5396
  return findForwardedCommand(rawArgs)?.command === "app-server";
5195
5397
  }
5196
5398
 
5399
+ // True for every root TUI launch: bare `codex [OPTIONS]`, and `codex
5400
+ // [OPTIONS] [PROMPT]` with the optional initial prompt — including a prompt
5401
+ // forced by `--`. Both shapes start the same interactive session and need the
5402
+ // canonical home, or Codex stalls rebuilding the omitted thread index inside
5403
+ // the shadow mirror before it can submit the already-delivered prompt.
5197
5404
  function isCodexInteractiveTuiCommand(rawArgs) {
5198
5405
  return findForwardedCommand(rawArgs) === null;
5199
5406
  }
@@ -5219,7 +5426,14 @@ function shouldUseRuntimeRoutingForForwardedArgs(rawArgs) {
5219
5426
 
5220
5427
  const command = findForwardedCommand(rawArgs);
5221
5428
  if (!command) {
5222
- return true;
5429
+ // A help flag alongside a root prompt (`codex "prompt" --help`) prints
5430
+ // help and exits clean, so it must skip the transport for the same
5431
+ // reason as the request-command help forms below: the interactive
5432
+ // branch detaches its helper on a clean exit, and a helper started
5433
+ // just to print help would idle until its detached timeout. The scan
5434
+ // stops at `--`, so help-looking text inside a forced prompt still
5435
+ // routes normally.
5436
+ return !hasHelpFlagAfterCommand(rawArgs, -1);
5223
5437
  }
5224
5438
 
5225
5439
  const requestCommands = new Set(["exec", "review", "resume", "fork", "app"]);
@@ -5450,12 +5664,16 @@ function buildForwardArgs(rawArgs) {
5450
5664
  return { args: compatibilityArgs, requestedModel };
5451
5665
  }
5452
5666
 
5667
+ const authStoreArgs = ["-c", 'cli_auth_credentials_store="file"'];
5453
5668
  return {
5454
- args: [
5455
- ...compatibilityArgs,
5456
- "-c",
5457
- 'cli_auth_credentials_store="file"',
5458
- ],
5669
+ // A root launch may end in the `[PROMPT]` positional — possibly forced
5670
+ // by `--`, where an appended `-c` pair would be read as prompt text —
5671
+ // so the override is inserted on the option side of the prompt there.
5672
+ // Subcommand argv keeps the appended ordering it has always forwarded.
5673
+ args:
5674
+ findForwardedCommand(compatibilityArgs) === null
5675
+ ? insertArgsBeforeRootPrompt(compatibilityArgs, authStoreArgs)
5676
+ : insertArgsBeforeForwardedSeparator(compatibilityArgs, authStoreArgs),
5459
5677
  requestedModel,
5460
5678
  };
5461
5679
  }