codex-multi-auth 2.8.4 → 2.8.6

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 (44) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +4 -4
  3. package/dist/lib/accounts.d.ts +7 -0
  4. package/dist/lib/accounts.d.ts.map +1 -1
  5. package/dist/lib/accounts.js +10 -0
  6. package/dist/lib/auth/auth.d.ts.map +1 -1
  7. package/dist/lib/auth/auth.js +8 -3
  8. package/dist/lib/codex-manager/commands/best.d.ts +3 -0
  9. package/dist/lib/codex-manager/commands/best.d.ts.map +1 -1
  10. package/dist/lib/codex-manager/commands/best.js +10 -1
  11. package/dist/lib/codex-manager/commands/forecast.d.ts +3 -0
  12. package/dist/lib/codex-manager/commands/forecast.d.ts.map +1 -1
  13. package/dist/lib/codex-manager/commands/forecast.js +18 -1
  14. package/dist/lib/codex-manager/commands/report.d.ts.map +1 -1
  15. package/dist/lib/codex-manager/commands/report.js +14 -0
  16. package/dist/lib/codex-manager/commands/rotation.d.ts.map +1 -1
  17. package/dist/lib/codex-manager/commands/rotation.js +43 -20
  18. package/dist/lib/forecast.d.ts +13 -0
  19. package/dist/lib/forecast.d.ts.map +1 -1
  20. package/dist/lib/forecast.js +18 -3
  21. package/dist/lib/request/rate-limit-decision.d.ts +19 -1
  22. package/dist/lib/request/rate-limit-decision.d.ts.map +1 -1
  23. package/dist/lib/request/rate-limit-decision.js +34 -2
  24. package/dist/lib/runtime/account-status.d.ts +38 -0
  25. package/dist/lib/runtime/account-status.d.ts.map +1 -1
  26. package/dist/lib/runtime/account-status.js +68 -0
  27. package/dist/lib/runtime/app-bind.d.ts +7 -0
  28. package/dist/lib/runtime/app-bind.d.ts.map +1 -1
  29. package/dist/lib/runtime/app-bind.js +174 -57
  30. package/dist/lib/runtime/app-helper-selection.d.ts +69 -0
  31. package/dist/lib/runtime/app-helper-selection.d.ts.map +1 -0
  32. package/dist/lib/runtime/app-helper-selection.js +100 -0
  33. package/dist/lib/runtime/rotation-server-types.d.ts +7 -0
  34. package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
  35. package/dist/lib/runtime/runtime-current-account.d.ts +3 -2
  36. package/dist/lib/runtime/runtime-current-account.d.ts.map +1 -1
  37. package/dist/lib/runtime/runtime-current-account.js +34 -26
  38. package/dist/lib/runtime-constants.d.ts +30 -0
  39. package/dist/lib/runtime-constants.d.ts.map +1 -1
  40. package/dist/lib/runtime-constants.js +53 -0
  41. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  42. package/dist/lib/runtime-rotation-proxy.js +72 -2
  43. package/package.json +1 -2
  44. package/scripts/codex.js +601 -49
@@ -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,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7C;AAED,wBAAgB,+BAA+B,CAC9C,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,kBAAkB,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7C,0BAA0B,CA0B5B"}
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"}
@@ -172,7 +172,12 @@ export function getQuotaNearExhaustionWaitMs(headers, remainingThreshold, now) {
172
172
  export function normalizeExhaustionStatus(reason) {
173
173
  return reason === "rate-limit" ? HTTP_STATUS.TOO_MANY_REQUESTS : 503;
174
174
  }
175
- export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons) {
175
+ /**
176
+ * Largest epoch-ms value `new Date(...).toISOString()` accepts; anything beyond
177
+ * throws RangeError (ECMAScript time-value limit, ±100,000,000 days).
178
+ */
179
+ const MAX_ECMASCRIPT_TIME_VALUE = 8_640_000_000_000_000;
180
+ export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons, context) {
176
181
  const normalizedPinnedIndex = typeof pinnedIndex === "number" ? pinnedIndex : null;
177
182
  const skipReason = normalizedPinnedIndex !== null
178
183
  ? accountSkipReasons.get(normalizedPinnedIndex) ?? null
@@ -183,11 +188,38 @@ export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons)
183
188
  const accountPhrase = normalizedPinnedIndex === null
184
189
  ? "The pinned account"
185
190
  : `Pinned account ${normalizedPinnedIndex + 1}`;
191
+ const pinSource = context?.pinSource ?? null;
192
+ // Upper bound as well as lower: resetAtMs comes from persisted account state
193
+ // (rateLimitResetTimes, coolingDownUntil), and markAccountCoolingDown clamps
194
+ // only the low side while nothing re-validates either on load. A finite but
195
+ // absurd deadline past the ECMAScript time limit would make toISOString below
196
+ // throw a RangeError inside handleRequestInner, collapsing this diagnostic 503
197
+ // into a generic 500 that carries no pinnedAccountIndex, reason, or skip map —
198
+ // the exact payload this branch exists to deliver. Such a value bounds nothing
199
+ // usable anyway, so treat it as "no known recovery" instead.
200
+ const resetAtMs = typeof context?.resetAtMs === "number" &&
201
+ Number.isFinite(context.resetAtMs) &&
202
+ context.resetAtMs > 0 &&
203
+ context.resetAtMs <= MAX_ECMASCRIPT_TIME_VALUE
204
+ ? context.resetAtMs
205
+ : null;
206
+ const now = context?.now ?? Date.now();
207
+ const retryAfterMs = resetAtMs !== null ? Math.max(0, resetAtMs - now) : null;
208
+ const resetAt = resetAtMs !== null ? new Date(resetAtMs).toISOString() : null;
209
+ const waitSuffix = resetAt !== null ? `; the recorded limit resets at ${resetAt}` : "";
210
+ // A forced pin belongs to the launching process, not to the persisted pin
211
+ // state, so `unpin` would clear nothing — say what actually helps.
212
+ const remedy = pinSource === "forced"
213
+ ? "the pin was set by this session's launcher, so relaunch to select a different account"
214
+ : "run `codex-multi-auth status` for details, or `codex-multi-auth unpin` to allow rotation";
186
215
  return {
187
- message: `${accountPhrase} is currently unavailable${reasonSuffix}; run \`codex-multi-auth status\` for details, or \`codex-multi-auth unpin\` to allow rotation.`,
216
+ message: `${accountPhrase} is currently unavailable${reasonSuffix}${waitSuffix}; ${remedy}.`,
188
217
  code: "codex_pinned_account_unavailable",
189
218
  pinnedAccountIndex: normalizedPinnedIndex,
190
219
  reason: skipReason,
220
+ pin_source: pinSource,
221
+ reset_at: resetAt,
222
+ retry_after_ms: retryAfterMs,
191
223
  account_skip_reasons: Object.fromEntries([...accountSkipReasons.entries()].map(([index, reason]) => [
192
224
  String(index),
193
225
  reason,
@@ -7,7 +7,45 @@ 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
+ /**
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.
21
+ */
22
+ export declare function getAccountRecoveryTimeForFamily(account: {
23
+ rateLimitResetTimes?: Record<string, number | undefined>;
24
+ coolingDownUntil?: number;
25
+ }, now: number, family: ModelFamily, model?: string | null): number | null;
10
26
  export declare function formatRateLimitEntry(account: {
11
27
  rateLimitResetTimes?: Record<string, number | undefined>;
12
28
  }, now: number, formatWaitTime: (ms: number) => string, family?: ModelFamily): string | null;
29
+ /**
30
+ * When a request for `family`/`model` stops being rate limited: the LATEST
31
+ * active bound among exactly the two keys selection consults — the family-wide
32
+ * key and `family:<model>` (see `isRateLimitedForFamily`). Null when neither is
33
+ * active.
34
+ *
35
+ * Deliberately narrower and later than `getRateLimitResetTimeForFamily`, whose
36
+ * earliest-reset-across-every-`family:*`-key answer feeds wait displays and
37
+ * model-less callers:
38
+ *
39
+ * - narrower, because `markRateLimitedWithReason` keys token/concurrency limits
40
+ * under `family:<model>`, and a sibling model's record does not gate this
41
+ * request — folding it in reports a delay the runtime proxy would not impose;
42
+ * - later, because the account stays skipped while EITHER key is active, so the
43
+ * earliest reset understates the wait when both are set.
44
+ *
45
+ * Requires a model by construction: a caller without one cannot know which
46
+ * model key applies and should keep the family-wide union above.
47
+ */
48
+ export declare function getRateLimitResetTimeForModel(account: {
49
+ rateLimitResetTimes?: Record<string, number | undefined>;
50
+ }, now: number, family: ModelFamily, model: string): number | null;
13
51
  //# sourceMappingURL=account-status.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AAAA,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,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"}
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,3 +1,4 @@
1
+ import { getQuotaKey } from "../accounts/rate-limits.js";
1
2
  export function resolveActiveIndex(storage, family = "codex") {
2
3
  const total = storage.accounts.length;
3
4
  if (total === 0)
@@ -25,6 +26,37 @@ export function getRateLimitResetTimeForFamily(account, now, family) {
25
26
  }
26
27
  return minReset;
27
28
  }
29
+ /**
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.
40
+ */
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
+ };
51
+ 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;
59
+ }
28
60
  export function formatRateLimitEntry(account, now, formatWaitTime, family = "codex") {
29
61
  const resetAt = getRateLimitResetTimeForFamily(account, now, family);
30
62
  if (typeof resetAt !== "number")
@@ -34,3 +66,39 @@ export function formatRateLimitEntry(account, now, formatWaitTime, family = "cod
34
66
  return null;
35
67
  return `resets in ${formatWaitTime(remaining)}`;
36
68
  }
69
+ /**
70
+ * When a request for `family`/`model` stops being rate limited: the LATEST
71
+ * active bound among exactly the two keys selection consults — the family-wide
72
+ * key and `family:<model>` (see `isRateLimitedForFamily`). Null when neither is
73
+ * active.
74
+ *
75
+ * Deliberately narrower and later than `getRateLimitResetTimeForFamily`, whose
76
+ * earliest-reset-across-every-`family:*`-key answer feeds wait displays and
77
+ * model-less callers:
78
+ *
79
+ * - narrower, because `markRateLimitedWithReason` keys token/concurrency limits
80
+ * under `family:<model>`, and a sibling model's record does not gate this
81
+ * request — folding it in reports a delay the runtime proxy would not impose;
82
+ * - later, because the account stays skipped while EITHER key is active, so the
83
+ * earliest reset understates the wait when both are set.
84
+ *
85
+ * Requires a model by construction: a caller without one cannot know which
86
+ * model key applies and should keep the family-wide union above.
87
+ */
88
+ 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;
104
+ }
@@ -111,6 +111,13 @@ export declare function stopRuntimeRotationRouterProcess(router: Pick<AppBindRou
111
111
  /** Parse the documented local-time format emitted by `ps -o lstart=`. */
112
112
  export declare function parsePosixProcessStartTime(value: string): number | null;
113
113
  export declare function stopDetachedProcess(pid: number | null, platform: NodeJS.Platform, options?: DetachedProcessStopOptions): Promise<boolean>;
114
+ /**
115
+ * How many helper records unbind processes at once. Exported so a regression
116
+ * test can observe the bound rather than only its effects — with a handful of
117
+ * records any width behaves identically, so an edit to `Infinity` would
118
+ * otherwise ship green.
119
+ */
120
+ export declare const UNBIND_HELPER_CONCURRENCY = 8;
114
121
  export declare function stopRuntimeRotationAppHelperProcess(helper: RuntimeRotationAppHelperStatus, options?: DetachedProcessStopOptions & {
115
122
  platform?: NodeJS.Platform;
116
123
  }): Promise<boolean>;
@@ -1 +1 @@
1
- {"version":3,"file":"app-bind.d.ts","sourceRoot":"","sources":["../../../lib/runtime/app-bind.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app-bind.d.ts","sourceRoot":"","sources":["../../../lib/runtime/app-bind.ts"],"names":[],"mappings":"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"}
@@ -1,14 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
4
- import { mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
4
+ import { mkdir, open, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import process from "node:process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { withFileOperationRetry } from "../fs-retry.js";
10
10
  import { getCodexMultiAuthDir } from "../runtime-paths.js";
11
- import { APP_RUNTIME_HELPER_OWNER_FILE, APP_RUNTIME_HELPER_STATUS_FILE, } from "../runtime-constants.js";
11
+ import { APP_RUNTIME_HELPER_OWNER_FILE, listRuntimeHelperOwnerPaths, listRuntimeHelperStatusPaths, } from "../runtime-constants.js";
12
12
  import { configHasRuntimeRotationProvider, restoreConfigTomlFromRuntimeRotationProvider, restoreConfigTomlFromRuntimeRotationProviderWithoutBackup, rewriteConfigTomlForRuntimeRotationProvider, } from "./config-toml.js";
13
13
  const APP_BIND_DIR_NAME = "app-bind";
14
14
  const APP_BIND_STATE_FILE = "runtime-rotation-app-bind.json";
@@ -879,6 +879,44 @@ function resolveRuntimeHelperOwnerPath(baseDir, pid) {
879
879
  return null;
880
880
  return join(baseDir, APP_RUNTIME_HELPER_OWNER_FILE.replace(/\.json$/i, `.${pid}.json`));
881
881
  }
882
+ /**
883
+ * How many helper records unbind processes at once. Exported so a regression
884
+ * test can observe the bound rather than only its effects — with a handful of
885
+ * records any width behaves identically, so an edit to `Infinity` would
886
+ * otherwise ship green.
887
+ */
888
+ export const UNBIND_HELPER_CONCURRENCY = 8;
889
+ /**
890
+ * `Promise.all` over `items` with at most `limit` in flight, preserving input
891
+ * order in the result. Used where the per-item work is independent but not
892
+ * free — a helper stop pays a SIGTERM, a graceful wait and possibly a SIGKILL —
893
+ * so serialising it multiplies a single stop window by the number of stale
894
+ * helpers, and unbounded parallelism signals every one of them at once.
895
+ */
896
+ async function mapWithConcurrency(items, limit, worker) {
897
+ const results = new Array(items.length);
898
+ let next = 0;
899
+ const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
900
+ for (;;) {
901
+ const index = next;
902
+ next += 1;
903
+ // Only running past the end retires a runner. Folding the
904
+ // `undefined` check into the same `return` would make a sparse
905
+ // array or a `(T | undefined)[]` silently drop every item after
906
+ // the first hole — on a cleanup path whose failure mode is
907
+ // "helpers left running while the user is told the app was
908
+ // unbound".
909
+ if (index >= items.length)
910
+ return;
911
+ const item = items[index];
912
+ if (item === undefined)
913
+ continue;
914
+ results[index] = await worker(item, index);
915
+ }
916
+ });
917
+ await Promise.all(runners);
918
+ return results;
919
+ }
882
920
  export async function stopRuntimeRotationAppHelperProcess(helper, options = {}) {
883
921
  if (helper.kind !== "codex-app-runtime-rotation-helper" ||
884
922
  helper.state !== "running" ||
@@ -1054,66 +1092,146 @@ async function unbindCodexAppRuntimeRotationLocked(options, paths) {
1054
1092
  if (router?.pid && (!routerStopped || isProcessAlive(router.pid))) {
1055
1093
  options.log?.(`Warning: runtime router (pid ${router.pid}) did not stop; continuing cleanup`);
1056
1094
  }
1057
- const helperStatusPath = join(dirname(paths.bindDir), APP_RUNTIME_HELPER_STATUS_FILE);
1058
- const helperRead = await readRuntimeHelperStatus(helperStatusPath);
1059
- let removeHelperStatus = false;
1060
- let removeHelperOwner = false;
1061
- let helperOwnerPath = null;
1062
- if (helperRead.kind === "valid") {
1095
+ // Helpers publish per-PID status files (`runtime-rotation-app-helper.<pid>.json`);
1096
+ // the un-suffixed name is the legacy shared path from before that change,
1097
+ // still checked so a pre-upgrade helper is torn down too. Every candidate
1098
+ // walks the same per-helper logic the single file used to get: stopping is
1099
+ // gated on ownership verification (status/owner identity-token agreement
1100
+ // plus process identity), so unbind reaps each helper it can prove is one
1101
+ // of ours and preserves — with a warning — anything it cannot.
1102
+ const helperBaseDir = dirname(paths.bindDir);
1103
+ let helperDirEntries = [];
1104
+ try {
1105
+ helperDirEntries = await withFileOperationRetry(() => readdir(helperBaseDir));
1106
+ }
1107
+ catch (error) {
1108
+ // Degrading to legacy-only cleanup while reporting success would leave
1109
+ // every per-PID helper running with the user told the app was unbound —
1110
+ // say so. ENOENT just means no helper ever ran.
1111
+ const code = error && typeof error === "object" && "code" in error
1112
+ ? String(error.code)
1113
+ : "unknown";
1114
+ if (code !== "ENOENT") {
1115
+ options.log?.(`Warning: could not enumerate runtime app helper status files (${code}); only the legacy helper path is checked`);
1116
+ }
1117
+ helperDirEntries = [];
1118
+ }
1119
+ const helperStatusPaths = listRuntimeHelperStatusPaths(helperBaseDir, helperDirEntries);
1120
+ // Each candidate is independent — a read, a liveness check, and at most one
1121
+ // SIGTERM/graceful-wait/SIGKILL sequence — and on the machine from #663 there
1122
+ // were 183 of them. Run them in a bounded pool rather than one after another,
1123
+ // so unbind costs roughly one stop window instead of N of them; the bound
1124
+ // keeps a machine full of stale helpers from being hit with 183 concurrent
1125
+ // signal sequences.
1126
+ const helperResults = await mapWithConcurrency(helperStatusPaths, UNBIND_HELPER_CONCURRENCY, async (helperStatusPath) => {
1127
+ const helperRead = await readRuntimeHelperStatus(helperStatusPath);
1128
+ if (helperRead.kind !== "valid")
1129
+ return null;
1063
1130
  const helper = helperRead.status;
1064
- if (helper.kind === "codex-app-runtime-rotation-helper") {
1065
- helperOwnerPath = resolveRuntimeHelperOwnerPath(dirname(paths.bindDir), helper.pid);
1066
- const helperOwner = helperOwnerPath
1067
- ? await readRuntimeHelperOwner(helperOwnerPath)
1068
- : null;
1069
- const helperOwnershipMatches = !helper.identityToken ||
1070
- (helperOwner !== null &&
1071
- helper.identityToken === helperOwner.identityToken);
1072
- if (helper.state === "running") {
1073
- if (helper.pid === null) {
1074
- options.log?.("Warning: runtime app helper status has no valid PID; preserving status");
1131
+ if (helper.kind !== "codex-app-runtime-rotation-helper")
1132
+ return null;
1133
+ let removeHelperStatus = false;
1134
+ let removeHelperOwner = false;
1135
+ const helperOwnerPath = resolveRuntimeHelperOwnerPath(helperBaseDir, helper.pid);
1136
+ const helperOwner = helperOwnerPath
1137
+ ? await readRuntimeHelperOwner(helperOwnerPath)
1138
+ : null;
1139
+ const helperOwnershipMatches = !helper.identityToken ||
1140
+ (helperOwner !== null &&
1141
+ helper.identityToken === helperOwner.identityToken);
1142
+ if (helper.state === "running") {
1143
+ if (helper.pid === null) {
1144
+ options.log?.("Warning: runtime app helper status has no valid PID; preserving status");
1145
+ }
1146
+ else {
1147
+ const wasAlive = isProcessAlive(helper.pid);
1148
+ if (!wasAlive) {
1149
+ // Decisive, and deliberately not gated on ownership (#666): a
1150
+ // dead PID means both files describe a process that no longer
1151
+ // exists, so keeping the owner file preserves nothing. It used
1152
+ // to be gated, which deleted the status file and stranded the
1153
+ // owner file — and because unbind then enumerated status paths
1154
+ // only, nothing ever rediscovered it. This matches what the
1155
+ // launcher-side sweep already does with a dead PID.
1156
+ removeHelperStatus = true;
1157
+ removeHelperOwner = helperOwnerPath !== null;
1158
+ }
1159
+ else if (!helperOwnershipMatches) {
1160
+ options.log?.("Warning: runtime app helper ownership metadata does not match; preserving status");
1075
1161
  }
1076
1162
  else {
1077
- const wasAlive = isProcessAlive(helper.pid);
1078
- if (!wasAlive) {
1079
- removeHelperStatus = true;
1080
- removeHelperOwner =
1081
- helperOwnershipMatches && helperOwnerPath !== null;
1082
- }
1083
- else if (!helperOwnershipMatches) {
1084
- options.log?.("Warning: runtime app helper ownership metadata does not match; preserving status");
1085
- }
1086
- else {
1087
- const stopped = await stopRuntimeRotationAppHelperProcess(helper, {
1088
- platform,
1089
- log: options.log,
1090
- identityToken: helper.identityToken
1091
- ? helperOwner?.identityToken
1092
- : undefined,
1093
- verifyProcessIdentity: options.verifyProcessIdentity,
1094
- });
1095
- const stillAlive = isProcessAlive(helper.pid);
1096
- removeHelperStatus = stopped && !stillAlive;
1097
- removeHelperOwner =
1098
- removeHelperStatus && helperOwnerPath !== null;
1099
- if (!removeHelperStatus) {
1100
- options.log?.(`Warning: runtime app helper (pid ${helper.pid}) did not stop; preserving status`);
1101
- }
1163
+ const stopped = await stopRuntimeRotationAppHelperProcess(helper, {
1164
+ platform,
1165
+ log: options.log,
1166
+ identityToken: helper.identityToken
1167
+ ? helperOwner?.identityToken
1168
+ : undefined,
1169
+ verifyProcessIdentity: options.verifyProcessIdentity,
1170
+ });
1171
+ const stillAlive = isProcessAlive(helper.pid);
1172
+ removeHelperStatus = stopped && !stillAlive;
1173
+ removeHelperOwner = removeHelperStatus && helperOwnerPath !== null;
1174
+ if (!removeHelperStatus) {
1175
+ options.log?.(`Warning: runtime app helper (pid ${helper.pid}) did not stop; preserving status`);
1102
1176
  }
1103
1177
  }
1104
1178
  }
1105
- else {
1106
- // A non-running, owned record is removable only when its PID is
1107
- // absent or no longer alive. This avoids deleting a status file
1108
- // while a helper is still serving despite a stale state value.
1109
- removeHelperStatus =
1110
- helper.pid === null || !isProcessAlive(helper.pid);
1111
- removeHelperOwner =
1112
- removeHelperStatus &&
1113
- helperOwnershipMatches &&
1114
- helperOwnerPath !== null;
1115
- }
1116
1179
  }
1180
+ else {
1181
+ // A non-running, owned record is removable only when its PID is
1182
+ // absent or no longer alive. This avoids deleting a status file
1183
+ // while a helper is still serving despite a stale state value.
1184
+ // Ownership does not gate the owner file here either, for the same
1185
+ // reason as above: the PID is gone, so neither file describes
1186
+ // anything that can still be running.
1187
+ removeHelperStatus = helper.pid === null || !isProcessAlive(helper.pid);
1188
+ removeHelperOwner = removeHelperStatus && helperOwnerPath !== null;
1189
+ }
1190
+ return {
1191
+ statusPath: helperStatusPath,
1192
+ ownerPath: helperOwnerPath,
1193
+ removeHelperStatus,
1194
+ removeHelperOwner,
1195
+ };
1196
+ });
1197
+ const helperCleanupPaths = [];
1198
+ // Owner paths this pass already reasoned about, whether or not it decided to
1199
+ // remove them — a preserved live helper's owner file must not then be swept
1200
+ // by the orphan pass below.
1201
+ const consideredOwnerPaths = new Set();
1202
+ for (const result of helperResults) {
1203
+ if (!result)
1204
+ continue;
1205
+ if (result.ownerPath !== null)
1206
+ consideredOwnerPaths.add(result.ownerPath);
1207
+ if (result.removeHelperStatus)
1208
+ helperCleanupPaths.push(result.statusPath);
1209
+ if (result.removeHelperOwner && result.ownerPath !== null) {
1210
+ helperCleanupPaths.push(result.ownerPath);
1211
+ }
1212
+ }
1213
+ // Owner files with no status record left to pair them with. Before #666 these
1214
+ // were unreachable: every earlier pass walked status paths only, so an owner
1215
+ // file that outlived its status file was never looked at again. A dead PID is
1216
+ // the whole test — a live PID's owner file belongs to a helper that is still
1217
+ // running, and was already considered above.
1218
+ for (const owner of listRuntimeHelperOwnerPaths(helperBaseDir, helperDirEntries)) {
1219
+ if (consideredOwnerPaths.has(owner.path))
1220
+ continue;
1221
+ if (isProcessAlive(owner.pid)) {
1222
+ // An owner file with no status record whose PID is nonetheless live
1223
+ // is the one shape this pass cannot reclaim. Either a helper is
1224
+ // starting right now and has not published yet, or — the case that
1225
+ // accumulates — the PID was recycled by an unrelated process after
1226
+ // the status file was already gone. Telling those apart needs the
1227
+ // recorded-start-time comparison the launcher-side sweep does, which
1228
+ // unbind has no equivalent of; that is a scope decision, but it
1229
+ // should not be a silent one. Every other preserve in this function
1230
+ // warns, so this one does too.
1231
+ options.log?.(`Warning: runtime app helper owner metadata (pid ${owner.pid}) has no status record but its PID is live; preserving`);
1232
+ continue;
1233
+ }
1234
+ helperCleanupPaths.push(owner.path);
1117
1235
  }
1118
1236
  await removeAppBindStartup(state ?? paths);
1119
1237
  const backup = await readAppBindBackup(paths.backupPath);
@@ -1154,8 +1272,7 @@ async function unbindCodexAppRuntimeRotationLocked(options, paths) {
1154
1272
  paths.backupPath,
1155
1273
  paths.statusPath,
1156
1274
  state?.logPath ?? paths.logPath,
1157
- ...(removeHelperStatus ? [helperStatusPath] : []),
1158
- ...(removeHelperOwner && helperOwnerPath ? [helperOwnerPath] : []),
1275
+ ...helperCleanupPaths,
1159
1276
  ];
1160
1277
  for (const candidate of cleanupCandidates) {
1161
1278
  try {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The fields any helper status record has to expose for the shared selector to
3
+ * reason about it. `rotation status` and runtime account resolution carry
4
+ * different extra fields — request counters on one side, account identity on
5
+ * the other — but they answer "which helper is current" the same way, and used
6
+ * to answer it with two hand-rolled copies that could drift apart within a
7
+ * single command (#667).
8
+ */
9
+ export interface RuntimeHelperSelectable {
10
+ state: string | null;
11
+ pid: number | null;
12
+ startedAt: number | null;
13
+ updatedAt: number | null;
14
+ }
15
+ /**
16
+ * How stale a `running` record may be before it stops counting as live.
17
+ *
18
+ * A running helper republishes its status on every tick, and the publish path
19
+ * heartbeats at least once per `APP_RUNTIME_HELPER_STATUS_HEARTBEAT_MS` (60s)
20
+ * even when nothing in the payload changed. Ten heartbeats of silence is not a
21
+ * helper that is merely quiet — it is a record whose writer is gone.
22
+ *
23
+ * This is the identity check these readers were missing. `kill(pid, 0)` answers
24
+ * "does *a* process hold this integer", so a stale record — classically the
25
+ * legacy shared `runtime-rotation-app-helper.json` left behind by a SIGKILLed
26
+ * pre-upgrade helper — passes liveness as soon as an unrelated process is
27
+ * handed its PID, and can then win selection outright. Freshness is the half of
28
+ * identity available to a synchronous reader: the wrapper verifies identity by
29
+ * probing kernel start times, but that costs a `ps` per candidate, and these
30
+ * two call sites are read-only status paths reached from the interactive menu
31
+ * as well as the CLI. Whoever holds the PID now, they are not the process that
32
+ * last wrote this file.
33
+ */
34
+ export declare const RUNTIME_HELPER_STATUS_STALE_MS: number;
35
+ /** Tolerance for clock skew between the writing helper and the reader. */
36
+ export declare const RUNTIME_HELPER_CLOCK_TOLERANCE_MS: number;
37
+ /**
38
+ * A PID is a positive integer or it is nothing. Both readers used to accept any
39
+ * finite number, so a corrupt or hand-edited record carrying `-1234` reached
40
+ * `process.kill(-1234, 0)` — which on POSIX probes process *group* 1234 and
41
+ * succeeds on any busy machine, reporting a helper that does not exist as live.
42
+ * Fractional values were accepted the same way.
43
+ */
44
+ export declare function readRuntimeHelperPid(value: unknown): number | null;
45
+ /**
46
+ * Best-effort liveness probe. `EPERM` means a process exists that this user may
47
+ * not signal, so it counts as alive; every other errno — including the `EINVAL`
48
+ * some platforms raise for a PID above their ceiling — counts as dead.
49
+ */
50
+ export declare function isRuntimeHelperProcessAlive(pid: number | null): boolean;
51
+ /**
52
+ * The single definition of "this helper is currently serving": a running state,
53
+ * a live PID, and a record recent enough to have been written by that PID's
54
+ * current occupant.
55
+ */
56
+ export declare function isLiveRuntimeHelper(status: RuntimeHelperSelectable, now?: number): boolean;
57
+ /** Every helper that passes {@link isLiveRuntimeHelper}, input order preserved. */
58
+ export declare function liveRuntimeHelpers<T extends RuntimeHelperSelectable>(statuses: readonly T[], now?: number): T[];
59
+ /**
60
+ * Prefer a live helper, and among several the most recently updated. Absent any
61
+ * live helper, fall back to the freshest record so the previous "reports the
62
+ * last helper's final state" behaviour survives.
63
+ *
64
+ * Callers that also need the live count must pass the same `statuses` array and
65
+ * the same `now` to {@link liveRuntimeHelpers}, so the selected helper and the
66
+ * count describe one instant rather than two.
67
+ */
68
+ export declare function selectRuntimeHelperStatus<T extends RuntimeHelperSelectable>(statuses: readonly T[], now?: number): T | null;
69
+ //# sourceMappingURL=app-helper-selection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-helper-selection.d.ts","sourceRoot":"","sources":["../../../lib/runtime/app-helper-selection.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,MAAM,WAAW,uBAAuB;IACvC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,8BAA8B,QAAiB,CAAC;AAE7D,0EAA0E;AAC1E,eAAO,MAAM,iCAAiC,QAAY,CAAC;AAE3D;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAIlE;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAWvE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,uBAAuB,EAC/B,GAAG,GAAE,MAAmB,GACtB,OAAO,CAgBT;AAED,mFAAmF;AACnF,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,uBAAuB,EACnE,QAAQ,EAAE,SAAS,CAAC,EAAE,EACtB,GAAG,GAAE,MAAmB,GACtB,CAAC,EAAE,CAEL;AASD;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,uBAAuB,EAC1E,QAAQ,EAAE,SAAS,CAAC,EAAE,EACtB,GAAG,GAAE,MAAmB,GACtB,CAAC,GAAG,IAAI,CAKV"}