@bitkyc08/opencodex 2.47.0 → 2.48.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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT
3
+ * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so
4
+ * once a thread has used sub-agents, a routed Responses destination answers the whole body
5
+ * with `422 unknown item type "agent_message"` and every later turn of that thread fails the
6
+ * same way. Rewrite the item as the public user message it already is.
7
+ *
8
+ * Genuine ciphertext and unknown part types keep their existing fail-closed path: the
9
+ * encrypted v2 task surface owns those, through `unreadable_encrypted_agent_task` and the
10
+ * opt-in recovery route. Providers using `authMode: "forward"` never reach this function.
11
+ */
12
+ export function normalizeRoutedAgentMessages(
13
+ body: unknown,
14
+ { allowStringContent = false }: { allowStringContent?: boolean } = {},
15
+ ): unknown {
16
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
17
+ const record = body as Record<string, unknown>;
18
+ if (!Array.isArray(record.input)) return body;
19
+ let changed = false;
20
+ const input = record.input.map((item: unknown) => {
21
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
22
+ const message = item as Record<string, unknown>;
23
+ if (message.type !== "agent_message") return item;
24
+ // xAI rejects the private item even when a complete child result is a plain string.
25
+ // Trimming decides emptiness only; the original result bytes remain caller-owned.
26
+ const content = allowStringContent && typeof message.content === "string" && message.content.trim().length > 0
27
+ ? [{ type: "input_text", text: message.content }]
28
+ : message.content;
29
+ if (!Array.isArray(content) || content.length === 0) return item;
30
+ // Genuine ciphertext and unknown part types must retain their existing fail-closed path.
31
+ if (!content.every(part => part && typeof part === "object"
32
+ && ["input_text", "input_image", "input_file"].includes(part.type))) return item;
33
+ const identities = Object.fromEntries(["author", "recipient"]
34
+ .filter(key => typeof message[key] === "string")
35
+ .map(key => [key, message[key]]));
36
+ changed = true;
37
+ return {
38
+ type: "message", role: "user",
39
+ content: [
40
+ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []),
41
+ ...content,
42
+ ],
43
+ };
44
+ });
45
+ return changed ? { ...record, input } : body;
46
+ }
@@ -29,6 +29,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined {
29
29
  name: raw.name,
30
30
  ...(typeof raw.description === "string" ? { description: raw.description } : {}),
31
31
  parameters: raw.input_schema as Record<string, unknown>,
32
+ // Anthropic opts into strict tool use explicitly, while Responses reads an
33
+ // omitted strict as permission to normalize the schema into strict mode. That
34
+ // turns an optional input_schema parameter into a required one and breaks the
35
+ // call, so carry the source intent instead of the destination default. A
36
+ // non-boolean value is not a valid Anthropic opt-in and must not become one.
37
+ strict: typeof raw.strict === "boolean" ? raw.strict : false,
32
38
  });
33
39
  continue;
34
40
  }
@@ -40,6 +40,10 @@ import {
40
40
  } from "./account-priority";
41
41
  import {
42
42
  claimDueCodexQuotaRecoveryProbes,
43
+ claimManualResetCooldowns,
44
+ settleManualResetCooldown,
45
+ type ManualResetCooldownClaim,
46
+ type ManualResetRefreshLineage,
43
47
  clearCodexAccountCooldown,
44
48
  clearThreadAccountMapForAccount,
45
49
  getEffectiveActiveCodexAccountId,
@@ -98,6 +102,8 @@ import {
98
102
  getMainAccountInfoCache,
99
103
  getMainQuotaCredentialGeneration,
100
104
  isMainAccountIdentityGenerationLive,
105
+ isMainQuotaWriterLive,
106
+ type MainQuotaWriter,
101
107
  matchesMainQuotaCredential,
102
108
  observeMainQuotaCredential,
103
109
  setMainAccountCredentialPresence,
@@ -387,6 +393,8 @@ interface ResetCreditAuth {
387
393
  chatgptAccountId: string;
388
394
  nativeMainLease?: AdmissionLease;
389
395
  nativeMainSharedClaimHeld?: true;
396
+ poolGeneration?: number;
397
+ mainProof?: MainResetQuotaProof;
390
398
  }
391
399
 
392
400
  async function withResetCreditAuth<T>(
@@ -407,10 +415,15 @@ async function withResetCreditAuth<T>(
407
415
  if (!tokens) {
408
416
  return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
409
417
  }
418
+ reconcileMainCodexAccountRuntimeState();
419
+ const physicalId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
420
+ const writer = physicalId === tokens.account_id
421
+ ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined;
410
422
  return {
411
423
  ok: true,
412
424
  value: await operation({
413
425
  isMain: true,
426
+ ...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}),
414
427
  accessToken: tokens.access_token,
415
428
  chatgptAccountId: tokens.account_id,
416
429
  nativeMainLease,
@@ -439,6 +452,7 @@ async function withResetCreditAuth<T>(
439
452
  ok: true,
440
453
  value: await operation({
441
454
  isMain: false,
455
+ poolGeneration: cred.generation,
442
456
  accessToken: cred.accessToken,
443
457
  chatgptAccountId: cred.chatgptAccountId,
444
458
  }),
@@ -776,8 +790,14 @@ async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
776
790
  }
777
791
  }
778
792
 
793
+ interface MainResetQuotaProof {
794
+ writer: MainQuotaWriter;
795
+ credentialGeneration: number;
796
+ }
797
+
779
798
  interface MainAccountInfoFetchResult {
780
799
  info: MainAccountInfo;
800
+ resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number };
781
801
  /** Ephemeral result of this attempt, omitted when no WHAM request was made. */
782
802
  quotaRefresh?: CodexQuotaRefreshOutcome;
783
803
  /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */
@@ -914,6 +934,7 @@ async function fetchMainAccountInfoWhileOwned(
914
934
  let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
915
935
  let quotaRefreshGeneration = captureMainAccountIdentityGeneration();
916
936
  try {
937
+ const dispatchSequence = ++quotaDispatchSequence;
917
938
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
918
939
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
919
940
  signal: quotaSignal,
@@ -923,6 +944,10 @@ async function fetchMainAccountInfoWhileOwned(
923
944
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
924
945
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
925
946
  if (retried) return retried;
947
+ if (dispatchSequence < mainQuotaPublishedSequence) {
948
+ return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
949
+ credentialChecked: true, hasCredential: true };
950
+ }
926
951
  if (terminalAuthFailure) {
927
952
  // Account for this attempt's own synchronous invalidation, never prior external drift.
928
953
  const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration);
@@ -945,6 +970,12 @@ async function fetchMainAccountInfoWhileOwned(
945
970
  if (data === null || typeof data !== "object" || Array.isArray(data)) {
946
971
  throw new Error("Invalid WHAM usage object");
947
972
  }
973
+ // Check after body/retry awaits and before any cache, credits, policy or
974
+ // Reserve publication. Returning cached state supplies no fresh recovery proof.
975
+ if (dispatchSequence < mainQuotaPublishedSequence) {
976
+ return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO,
977
+ credentialChecked: true, hasCredential: true };
978
+ }
948
979
  quotaPhase = "publish";
949
980
  // A delayed response from a replaced bearer cannot revoke a newer Reserve grant,
950
981
  // even in the same workspace or after an A→B→A credential transition.
@@ -985,6 +1016,7 @@ async function fetchMainAccountInfoWhileOwned(
985
1016
  if (result.quota) {
986
1017
  setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
987
1018
  }
1019
+ mainQuotaPublishedSequence = dispatchSequence;
988
1020
  return {
989
1021
  info: result,
990
1022
  quotaRefresh: { status: quota ? "ok" : "not_reported" },
@@ -992,6 +1024,11 @@ async function fetchMainAccountInfoWhileOwned(
992
1024
  credentialChecked: true,
993
1025
  hasCredential: true,
994
1026
  ...(quota ? { freshQuota: quota } : {}),
1027
+ ...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter)
1028
+ && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
1029
+ && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)
1030
+ ? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } }
1031
+ : {}),
995
1032
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
996
1033
  };
997
1034
  } catch (error) {
@@ -1011,6 +1048,8 @@ async function fetchMainAccountInfoWhileOwned(
1011
1048
  }
1012
1049
 
1013
1050
  interface PoolQuotaResult {
1051
+ /** Actual refresh result attached only to the successful usage replay. */
1052
+ resetRefreshLineage?: ManualResetRefreshLineage;
1014
1053
  quota: StoredAccountQuota | null;
1015
1054
  needsReauth: boolean;
1016
1055
  /** Credential generation whose cache or network result this DTO state belongs to. */
@@ -1025,15 +1064,25 @@ interface PoolQuotaResult {
1025
1064
  freshResetCredits?: number;
1026
1065
  quotaProbeSkipped?: true;
1027
1066
  /** Positive evidence captured immediately before an upstream WHAM dispatch. */
1028
- quotaProbeAttempted?: { at: number; credentialGeneration: number };
1067
+ quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number };
1029
1068
  }
1030
1069
 
1070
+ // Process-local ordering, never a timestamp or a serialized account identifier.
1071
+ let quotaDispatchSequence = 0;
1072
+ // Shared native-main ownership permits concurrent usage readers. Only a later
1073
+ // successfully published response advances this fence; failed reads do not win.
1074
+ let mainQuotaPublishedSequence = 0;
1075
+
1031
1076
  interface PoolQuotaProbeEvidence {
1077
+ onDispatch?: (sequence: number) => void;
1078
+ mayPublish?: () => boolean;
1032
1079
  attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>;
1033
1080
  }
1034
1081
 
1035
1082
  function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void {
1036
- evidence.attempted = { at: Date.now(), credentialGeneration };
1083
+ const dispatchSequence = ++quotaDispatchSequence;
1084
+ evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence };
1085
+ evidence.onDispatch?.(dispatchSequence);
1037
1086
  }
1038
1087
 
1039
1088
  function withQuotaProbeEvidence(
@@ -1045,6 +1094,8 @@ function withQuotaProbeEvidence(
1045
1094
 
1046
1095
  interface PoolQuotaRefreshFlight {
1047
1096
  state: {
1097
+ dispatchSequence?: number;
1098
+ superseded?: boolean;
1048
1099
  startCredentialGeneration?: number;
1049
1100
  resolvedCredentialGeneration?: number;
1050
1101
  };
@@ -1280,9 +1331,18 @@ async function recoverPoolQuotaFrom401(ctx: {
1280
1331
  }
1281
1332
  return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
1282
1333
  }
1283
- return await commitPoolQuotaResponse(replay, {
1334
+ const result = await commitPoolQuotaResponse(replay, {
1284
1335
  accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration,
1336
+ mayPublish: ctx.quotaProbeEvidence.mayPublish,
1285
1337
  });
1338
+ return result.freshCredentialGeneration === refreshed.generation ? {
1339
+ ...result,
1340
+ resetRefreshLineage: {
1341
+ fromGeneration: rejectedGeneration,
1342
+ toGeneration: refreshed.generation,
1343
+ provenance: refreshed.provenance,
1344
+ },
1345
+ } : result;
1286
1346
  }
1287
1347
 
1288
1348
  /** Backoff after a refresh failure that proved nothing about the credential. */
@@ -1314,10 +1374,14 @@ async function commitPoolQuotaResponse(
1314
1374
  configuredPlan: string | undefined;
1315
1375
  generation: number;
1316
1376
  writerGeneration: number;
1377
+ mayPublish?: () => boolean;
1317
1378
  },
1318
1379
  ): Promise<PoolQuotaResult> {
1319
1380
  const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
1320
1381
  const data = (await resp.json()) as WhamUsageResponse;
1382
+ if (ctx.mayPublish?.() === false) {
1383
+ return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation };
1384
+ }
1321
1385
  const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
1322
1386
  const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
1323
1387
  const freshResetCredits = quota?.resetCredits;
@@ -1350,10 +1414,10 @@ async function fetchFreshPoolAccountQuota(
1350
1414
  configuredPlan?: string,
1351
1415
  onCredentialGeneration?: (generation: number) => void,
1352
1416
  getValidToken: typeof getValidCodexToken = getValidCodexToken,
1417
+ quotaProbeEvidence: PoolQuotaProbeEvidence = {},
1353
1418
  ): Promise<PoolQuotaResult> {
1354
1419
  const writerGeneration = captureConfigGeneration();
1355
1420
  let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
1356
- const quotaProbeEvidence: PoolQuotaProbeEvidence = {};
1357
1421
  try {
1358
1422
  const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
1359
1423
  requestCredentialGeneration = generation;
@@ -1387,6 +1451,7 @@ async function fetchFreshPoolAccountQuota(
1387
1451
  }
1388
1452
  const committed = await commitPoolQuotaResponse(resp, {
1389
1453
  accountId, existing, configuredPlan, generation, writerGeneration,
1454
+ mayPublish: quotaProbeEvidence.mayPublish,
1390
1455
  });
1391
1456
  return withQuotaProbeEvidence(committed, quotaProbeEvidence);
1392
1457
  } catch (e) {
@@ -1417,9 +1482,10 @@ async function fetchPoolAccountQuota(
1417
1482
  forceRefresh = false,
1418
1483
  configuredPlan?: string,
1419
1484
  getValidToken: typeof getValidCodexToken = getValidCodexToken,
1485
+ afterDispatchSequence?: number,
1420
1486
  ): Promise<PoolQuotaResult> {
1421
1487
  const existing = getAccountQuota(accountId);
1422
- if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
1488
+ if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
1423
1489
  return {
1424
1490
  quota: existing,
1425
1491
  needsReauth: false,
@@ -1434,11 +1500,18 @@ async function fetchPoolAccountQuota(
1434
1500
  const current = flights && [...flights].find(flight => {
1435
1501
  const generation = flight.state.resolvedCredentialGeneration
1436
1502
  ?? flight.state.startCredentialGeneration;
1437
- return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
1503
+ return !flight.state.superseded
1504
+ && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
1505
+ && generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
1438
1506
  });
1439
1507
  if (current) return current.promise;
1440
1508
  if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
1441
1509
 
1510
+ // A post-reset request must not let an older same-account response overwrite its evidence.
1511
+ // Flags live only as long as the bounded flights; no retained per-account sequence map.
1512
+ if (afterDispatchSequence !== undefined) {
1513
+ for (const flight of flights ?? []) flight.state.superseded = true;
1514
+ }
1442
1515
  const state: PoolQuotaRefreshFlight["state"] = {
1443
1516
  startCredentialGeneration: record?.generation,
1444
1517
  };
@@ -1448,6 +1521,10 @@ async function fetchPoolAccountQuota(
1448
1521
  configuredPlan,
1449
1522
  generation => { state.resolvedCredentialGeneration = generation; },
1450
1523
  getValidToken,
1524
+ {
1525
+ onDispatch: sequence => { state.dispatchSequence = sequence; },
1526
+ mayPublish: () => state.superseded !== true,
1527
+ },
1451
1528
  );
1452
1529
  const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
1453
1530
  const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
@@ -1463,6 +1540,74 @@ async function fetchPoolAccountQuota(
1463
1540
  }
1464
1541
  }
1465
1542
 
1543
+ function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boolean {
1544
+ if (!auth.isMain) {
1545
+ const record = readCodexAccountRecord(accountId);
1546
+ return auth.poolGeneration !== undefined
1547
+ && isCodexAccountGenerationLive(accountId, auth.poolGeneration)
1548
+ && record?.credential?.chatgptAccountId === auth.chatgptAccountId;
1549
+ }
1550
+ const tokens = readCodexTokens();
1551
+ return !!auth.mainProof && !!tokens
1552
+ && tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId
1553
+ && isMainQuotaWriterLive(auth.mainProof.writer)
1554
+ && auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration()
1555
+ && matchesMainQuotaCredential(auth.accessToken, auth.chatgptAccountId);
1556
+ }
1557
+
1558
+ /** A confirmed spend remains successful even when its optional usage observation fails. */
1559
+ async function refreshAfterManualReset(
1560
+ config: OcxConfig,
1561
+ accountId: string,
1562
+ auth: ResetCreditAuth,
1563
+ claims: ManualResetCooldownClaim[],
1564
+ didReset: boolean,
1565
+ ): Promise<number | undefined> {
1566
+ const afterDispatchSequence = quotaDispatchSequence;
1567
+ try {
1568
+ if (!manualResetAuthStillLive(accountId, auth)) return undefined;
1569
+ if (auth.isMain) {
1570
+ const result = await fetchMainAccountInfoAttempt(true, 1, auth.nativeMainLease,
1571
+ auth.nativeMainSharedClaimHeld === true, false);
1572
+ const proof = result.resetRecoveryProof;
1573
+ const recovered = didReset && manualResetAuthStillLive(accountId, auth)
1574
+ && !!proof && !!auth.mainProof
1575
+ && proof.dispatchSequence > afterDispatchSequence
1576
+ && proof.credentialGeneration === auth.mainProof.credentialGeneration
1577
+ && proof.writer.identityKey === auth.mainProof.writer.identityKey
1578
+ && proof.writer.identityGeneration === auth.mainProof.writer.identityGeneration
1579
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan);
1580
+ for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered);
1581
+ return manualResetAuthStillLive(accountId, auth) ? result.freshResetCredits : undefined;
1582
+ }
1583
+ const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
1584
+ if (!account) return undefined;
1585
+ // Reuse the just-authenticated consume credential for the first usage request.
1586
+ // getValidCodexToken can silently advance a generation without exposing refresh
1587
+ // provenance. A 401 here instead uses the existing classified refresh/replay path.
1588
+ const resetToken: typeof getValidCodexToken = async () => {
1589
+ if (auth.poolGeneration === undefined || !manualResetAuthStillLive(accountId, auth)) {
1590
+ throw new CodexCredentialGenerationConflictError();
1591
+ }
1592
+ return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration };
1593
+ };
1594
+ const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken,
1595
+ didReset ? afterDispatchSequence : undefined);
1596
+ const record = readCodexAccountRecord(accountId);
1597
+ const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
1598
+ && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
1599
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
1600
+ for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered, {
1601
+ credentialGeneration: result.freshCredentialGeneration,
1602
+ refreshLineage: result.resetRefreshLineage,
1603
+ });
1604
+ return record?.credential?.chatgptAccountId === auth.chatgptAccountId ? result.freshResetCredits : undefined;
1605
+ } catch {
1606
+ // The upstream reset already happened. A failed refresh must not invite another spend.
1607
+ return undefined;
1608
+ }
1609
+ }
1610
+
1466
1611
  let primeInFlight: Promise<void> | null = null;
1467
1612
  /**
1468
1613
  * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
@@ -2341,7 +2486,7 @@ export async function handleCodexAuthAPI(
2341
2486
  const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
2342
2487
  // The ledger keys manual operations by the *physical* ChatGPT account, which is
2343
2488
  // only known after the auth wrapper resolves credentials. Open here, not earlier.
2344
- const identity = requestedOperationId === undefined
2489
+ let identity = requestedOperationId === undefined
2345
2490
  ? undefined
2346
2491
  : {
2347
2492
  accountId,
@@ -2377,75 +2522,74 @@ export async function handleCodexAuthAPI(
2377
2522
  return response;
2378
2523
  }
2379
2524
  // Canonical id, which an alias join may map to an earlier caller id.
2525
+ identity = { ...identity, operationId: opened.operationId };
2380
2526
  idempotencyKey = opened.operationId;
2381
2527
  } else {
2382
2528
  idempotencyKey = crypto.randomUUID();
2383
2529
  }
2384
- let resp: Response;
2530
+ const claims = manualResetAuthStillLive(accountId, auth)
2531
+ ? claimManualResetCooldowns(getRuntimeConfig(config), accountId, Date.now(), auth.poolGeneration) : [];
2385
2532
  try {
2386
- resp = await fetch(
2387
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
2388
- {
2389
- method: "POST",
2390
- headers: {
2391
- Authorization: `Bearer ${auth.accessToken}`,
2392
- "ChatGPT-Account-Id": auth.chatgptAccountId,
2393
- "Content-Type": "application/json",
2533
+ let resp: Response;
2534
+ try {
2535
+ resp = await fetch(
2536
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
2537
+ {
2538
+ method: "POST",
2539
+ headers: {
2540
+ Authorization: `Bearer ${auth.accessToken}`,
2541
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
2542
+ "Content-Type": "application/json",
2543
+ },
2544
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
2545
+ signal: AbortSignal.timeout(10_000),
2394
2546
  },
2395
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
2396
- signal: AbortSignal.timeout(10_000),
2397
- },
2398
- );
2399
- } catch (error) {
2400
- // Dispatch outcome unknown: the credit may or may not have been spent.
2401
- // Mark ambiguous so a replay of this same id is never treated as new.
2402
- if (identity) markManualResetCreditOperationAmbiguous(identity);
2403
- throw error;
2404
- }
2405
- if (!resp.ok) {
2406
- await resp.body?.cancel().catch(() => {});
2407
- if (identity) markManualResetCreditOperationAmbiguous(identity);
2408
- return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
2409
- }
2410
- const result = safeResetCreditConsumeDto(await resp.json());
2411
- if (identity) {
2412
- // Narrow explicitly rather than casting: `safeResetCreditConsumeDto`
2413
- // normalizes anything unrecognized to "unknown", and settling that
2414
- // would come back as a mismatch and leave the row pending anyway.
2415
- // Settlement failure never downgrades the user-visible outcome: the
2416
- // spend already happened upstream, and reporting failure would invite
2417
- // a manual retry -- the exact double-spend this unit removes.
2418
- if (result.code === "reset" || result.code === "already_redeemed"
2419
- || result.code === "nothing_to_reset" || result.code === "no_credit") {
2420
- settleManualResetCreditOperation(identity, result.code);
2421
- } else {
2422
- markManualResetCreditOperationAmbiguous(identity);
2547
+ );
2548
+ } catch (error) {
2549
+ // Dispatch outcome unknown: the credit may or may not have been spent.
2550
+ // Mark ambiguous so a replay of this same id is never treated as new.
2551
+ if (identity) markManualResetCreditOperationAmbiguous(identity);
2552
+ throw error;
2423
2553
  }
2424
- }
2425
- // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
2426
- // and return remaining only when that refresh freshly parsed available_count.
2427
- // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
2428
- if (result.code === "reset" || result.code === "already_redeemed") {
2429
- let freshResetCredits: number | undefined;
2430
- if (auth.isMain) {
2431
- ({ freshResetCredits } = await fetchMainAccountInfoAttempt(
2432
- true,
2433
- 1,
2434
- auth.nativeMainLease,
2435
- auth.nativeMainSharedClaimHeld === true,
2436
- ));
2437
- } else {
2438
- const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
2439
- ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan));
2554
+ if (!resp.ok) {
2555
+ await resp.body?.cancel().catch(() => {});
2556
+ if (identity) markManualResetCreditOperationAmbiguous(identity);
2557
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
2440
2558
  }
2441
- return jsonResponse({
2442
- code: result.code,
2443
- ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
2444
- ? { remaining: freshResetCredits }
2445
- : {}),
2446
- });
2559
+ const result = safeResetCreditConsumeDto(await resp.json());
2560
+ if (identity) {
2561
+ // Narrow explicitly rather than casting: `safeResetCreditConsumeDto`
2562
+ // normalizes anything unrecognized to "unknown", and settling that
2563
+ // would come back as a mismatch and leave the row pending anyway.
2564
+ // Settlement failure never downgrades the user-visible outcome: the
2565
+ // spend already happened upstream, and reporting failure would invite
2566
+ // a manual retry -- the exact double-spend this unit removes.
2567
+ if (result.code === "reset" || result.code === "already_redeemed"
2568
+ || result.code === "nothing_to_reset" || result.code === "no_credit") {
2569
+ settleManualResetCreditOperation(identity, result.code);
2570
+ } else {
2571
+ markManualResetCreditOperationAmbiguous(identity);
2572
+ }
2573
+ }
2574
+ // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
2575
+ // and return remaining only when that refresh freshly parsed available_count.
2576
+ // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
2577
+ if (result.code === "reset" || result.code === "already_redeemed") {
2578
+ const freshResetCredits = await refreshAfterManualReset(
2579
+ config, accountId, auth, claims, result.code === "reset",
2580
+ );
2581
+ return jsonResponse({
2582
+ code: result.code,
2583
+ ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
2584
+ ? { remaining: freshResetCredits }
2585
+ : {}),
2586
+ });
2587
+ }
2588
+ return jsonResponse(result);
2589
+ } finally {
2590
+ // Release only this invocation's leases, including every ambiguous/error outcome.
2591
+ for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, false);
2447
2592
  }
2448
- return jsonResponse(result);
2449
2593
  });
2450
2594
  return operation.ok ? operation.value : operation.response;
2451
2595
  } catch (e) {
@@ -38,6 +38,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri
38
38
  import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl";
39
39
  import { CODEX_CONFIG_PATH } from "./paths";
40
40
  import { resolveAndPersistCodexRuntime } from "./runtime";
41
+ import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy";
41
42
 
42
43
  /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */
43
44
  export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input";
@@ -1091,7 +1092,8 @@ export function setMultiAgentModeHintText(value: string | null, configPath?: str
1091
1092
  };
1092
1093
  }
1093
1094
  }
1094
- return setV2StringField("multi_agent_mode_hint_text", value, configPath);
1095
+ const canonicalValue = value === null ? null : canonicalizeOpenCodexModeHint(value);
1096
+ return setV2StringField("multi_agent_mode_hint_text", canonicalValue, configPath);
1095
1097
  }
1096
1098
 
1097
1099
  export const MODE_HINT_CAPABILITY_CACHE_MAX_ENTRIES = 8;
@@ -0,0 +1,24 @@
1
+ export const MULTI_AGENT_MODE_HINT_RECOMMENDATION = {
2
+ revision: "proactive-trigger-v1",
3
+ text: [
4
+ "Proactive multi-agent delegation is active.",
5
+ "Only the delegation trigger changes: a separate explicit request is no longer required.",
6
+ "All existing user, authority, task-scope, and collaboration-tool rules continue to apply.",
7
+ "Delegate eligible independent work when parallel execution could materially improve speed or quality.",
8
+ "User requests override this hint.",
9
+ "This mode remains active until a later multi-agent mode developer message changes it.",
10
+ ].join(" "),
11
+ } as const;
12
+
13
+ /** Byte-exact presets previously written by OpenCodex dashboard releases. */
14
+ export const LEGACY_OPENCODEX_MODE_HINTS = [
15
+ "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.",
16
+ "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it.",
17
+ ] as const;
18
+
19
+ /** Upgrade only known OpenCodex-owned values; user-authored variants stay byte-identical. */
20
+ export function canonicalizeOpenCodexModeHint(text: string): string {
21
+ return LEGACY_OPENCODEX_MODE_HINTS.some(legacy => legacy === text)
22
+ ? MULTI_AGENT_MODE_HINT_RECOMMENDATION.text
23
+ : text;
24
+ }