@bitkyc08/opencodex 2.78.0 → 2.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  2. package/gui/dist/assets/App-MbDVfUeC.js +54 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-UJt6npXk.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/cursor/live-transport.ts +5 -3
  10. package/src/adapters/cursor/native-exec-fs.ts +12 -9
  11. package/src/adapters/cursor/native-exec.ts +13 -5
  12. package/src/adapters/cursor/protobuf-request.ts +13 -7
  13. package/src/adapters/cursor/request-builder.ts +23 -10
  14. package/src/adapters/cursor/tool-definitions.ts +9 -6
  15. package/src/adapters/cursor/tool-guidance.ts +29 -8
  16. package/src/adapters/cursor/tool-naming.ts +40 -7
  17. package/src/adapters/cursor/tool-wording.ts +46 -0
  18. package/src/adapters/cursor.ts +3 -0
  19. package/src/adapters/devin.ts +37 -2
  20. package/src/adapters/openai-chat/messages.ts +3 -1
  21. package/src/claude/desktop-picker.ts +11 -9
  22. package/src/claude/inbound-content-options.ts +29 -2
  23. package/src/claude/inbound.ts +9 -0
  24. package/src/claude/intercept/local-ca-files.ts +265 -0
  25. package/src/claude/intercept/local-ca-windows.ts +73 -0
  26. package/src/claude/intercept/local-ca.ts +12 -24
  27. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  28. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  29. package/src/claude/intercept/picker-ca-store.ts +70 -0
  30. package/src/claude/intercept/picker-ca.ts +43 -9
  31. package/src/claude/intercept/picker-runtime.ts +14 -4
  32. package/src/claude/intercept/runtime.ts +32 -23
  33. package/src/cli/ensure-readiness.ts +48 -0
  34. package/src/cli/gui-pair-client.ts +15 -7
  35. package/src/cli/gui.ts +4 -1
  36. package/src/cli/hub.ts +1 -0
  37. package/src/cli/index.ts +10 -11
  38. package/src/cli/status.ts +2 -2
  39. package/src/cli/update-restart-child.ts +4 -1
  40. package/src/codex/account-credit-use.ts +8 -2
  41. package/src/codex/auth-api/account-pause-group.ts +113 -0
  42. package/src/codex/auth-api/routes.ts +18 -12
  43. package/src/codex/auth-context.ts +45 -3
  44. package/src/codex/autostart-health.ts +13 -3
  45. package/src/codex/journal.ts +36 -5
  46. package/src/codex/pool-credit-policy.ts +67 -0
  47. package/src/codex/shim-overlay.ts +9 -1
  48. package/src/combos/jev.ts +3 -1
  49. package/src/generated/compatibility-version.json +135 -63
  50. package/src/lib/gui-pair-capability.ts +1 -1
  51. package/src/lib/gui-pair-intent.ts +170 -0
  52. package/src/lib/windows-owner-acl.ts +143 -0
  53. package/src/providers/openai-sidecar-credit.ts +26 -0
  54. package/src/providers/openai-sidecar.ts +35 -0
  55. package/src/server/claude-messages.ts +1 -0
  56. package/src/server/gui-pair-delivery.ts +23 -0
  57. package/src/server/index/serve-options.ts +5 -8
  58. package/src/server/index.ts +12 -14
  59. package/src/server/live-native-calls.ts +94 -0
  60. package/src/server/live.ts +28 -5
  61. package/src/server/local-management-read-client.ts +15 -11
  62. package/src/server/management/oauth-account-routes.ts +15 -0
  63. package/src/server/management/route-registry.ts +1 -0
  64. package/src/server/management/shared.ts +9 -3
  65. package/src/server/messages-native-scope.ts +60 -0
  66. package/src/server/messages-native.ts +9 -0
  67. package/src/server/responses/adapter-dispatch.ts +31 -2
  68. package/src/server/responses/combo-requested-effort.ts +14 -0
  69. package/src/server/responses/compact.ts +17 -2
  70. package/src/server/responses/core-auth.ts +18 -1
  71. package/src/server/responses/core-codex-account.ts +18 -8
  72. package/src/server/responses/core-combo.ts +5 -7
  73. package/src/server/responses/non-replayable-error.ts +48 -0
  74. package/src/server/responses/passthrough-delivery.ts +5 -1
  75. package/src/server/responses/passthrough-dispatch.ts +21 -19
  76. package/src/server/responses/request-prepare.ts +11 -1
  77. package/src/server/responses/sidecar-execution.ts +1 -0
  78. package/src/server/search.ts +4 -0
  79. package/src/server/startup-health-cache.ts +2 -6
  80. package/src/service/desktop-startup.ts +100 -3
  81. package/src/vision/describe.ts +17 -10
  82. package/src/vision/index.ts +1 -0
  83. package/src/web-search/executor.ts +17 -10
  84. package/src/web-search/format-result.ts +304 -20
  85. package/src/web-search/header-deadline.ts +67 -0
  86. package/src/web-search/loop.ts +7 -6
  87. package/src/web-search/passthrough-bridge.ts +1 -0
  88. package/src/web-search/run-turn-loop.ts +1 -1
  89. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  90. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  91. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -24,6 +24,7 @@ import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service
24
24
  import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config";
25
25
  import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
26
26
  import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
27
+ import { withCodexAccountPauseGroup } from "./account-pause-group";
27
28
 
28
29
  export async function handleCodexAuthAPI(
29
30
  req: Request,
@@ -100,18 +101,23 @@ export async function handleCodexAuthAPI(
100
101
  || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
101
102
  if (!exists) return jsonResponse({ error: "Account not found" }, 404);
102
103
 
103
- setCodexAccountPaused(runtimeConfig, id, body.paused);
104
- if (body.paused) {
105
- clearThreadAccountMapForAccount(id);
106
- selectFallbackAfterPause(runtimeConfig, id);
107
- }
108
- saveRuntimeConfig(config, runtimeConfig);
109
- return jsonResponse({
110
- ok: true,
111
- id,
112
- paused: body.paused,
113
- activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
114
- appliesImmediately: true,
104
+ const paused = body.paused;
105
+ return withCodexAccountPauseGroup(runtimeConfig, id, accountIds => {
106
+ // Publish every exclusion before reconciling: a duplicate must never become the fallback.
107
+ for (const accountId of accountIds) setCodexAccountPaused(runtimeConfig, accountId, paused);
108
+ if (paused) {
109
+ for (const accountId of accountIds) clearThreadAccountMapForAccount(accountId);
110
+ for (const accountId of accountIds) selectFallbackAfterPause(runtimeConfig, accountId);
111
+ }
112
+ saveRuntimeConfig(config, runtimeConfig);
113
+ return jsonResponse({
114
+ ok: true,
115
+ id,
116
+ paused,
117
+ affectedAccountIds: accountIds,
118
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
119
+ appliesImmediately: true,
120
+ });
115
121
  });
116
122
  }
117
123
 
@@ -1,3 +1,4 @@
1
+ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy";
1
2
  import { hasSpendableCodexCredits } from "./quota-types";
2
3
  import { noteMainAccountActivity } from "./main-account-external-usage";
3
4
  import { codexAccountPriorityFailbackEnabled } from "./account-priority";
@@ -525,6 +526,23 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
525
526
  }
526
527
  }
527
528
 
529
+ /** A stored pool account's spending policy is independent of account selection. */
530
+ export class CodexPoolAccountCreditsOffError extends CodexAccountCooldownError {
531
+ readonly resetAt: number;
532
+
533
+ constructor(accountId: string, resetAt: number) {
534
+ super(accountId, resetAt);
535
+ this.name = "CodexPoolAccountCreditsOffError";
536
+ this.resetAt = resetAt;
537
+ this.message = "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available."
538
+ + " Wait for the limit to reset, choose another account, or explicitly allow credits for this account in Codex Auth.";
539
+ }
540
+ }
541
+
542
+ function assertPoolAccountCredits(accountId: string, resetAt: number | undefined): void {
543
+ if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt);
544
+ }
545
+
528
546
  /** The main login may not spend credits and one of its usage windows is full (#6334). */
529
547
  export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
530
548
  readonly resetAt?: number;
@@ -597,7 +615,7 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
597
615
 
598
616
  export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
599
617
  "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
600
- | "creditCodexAccountIds"
618
+ | "creditCodexAccountIds" | "codexAccounts"
601
619
  >>;
602
620
 
603
621
  interface CodexAuthMaterializationOptions {
@@ -725,6 +743,22 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
725
743
  * Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth
726
744
  * file, which several callers are forbidden to open, so every long window counts instead.
727
745
  */
746
+ /** Recheck live account policy at the physical send, after pacing or retry backoff. */
747
+ export function createCodexAuthDispatchGuard(
748
+ ctx: CodexAuthContext,
749
+ config: CodexAuthPolicyConfig,
750
+ modelId: string,
751
+ admission?: Pick<DataPlaneAdmission, "source">,
752
+ terminalHelper = false,
753
+ ): ((headers: Headers) => void) | undefined {
754
+ const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper);
755
+ if (ctx.kind !== "pool") return reserveGuard;
756
+ return headers => {
757
+ assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config));
758
+ reserveGuard?.(headers);
759
+ };
760
+ }
761
+
728
762
  function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
729
763
  if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)
730
764
  && hasSpendableCodexCredits(getMainPolicyQuota())) return undefined;
@@ -868,6 +902,7 @@ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSele
868
902
  // A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown
869
903
  // cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy.
870
904
  || err instanceof CodexMainAccountCreditsOffError
905
+ || err instanceof CodexPoolAccountCreditsOffError
871
906
  || err instanceof CodexReserveUnavailableError
872
907
  // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
873
908
  // one, and the quota recovery advice below would send the operator after a cooldown that
@@ -917,6 +952,7 @@ export class CodexThreadAffinityExpiredError extends Error {
917
952
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
918
953
  return !(cause instanceof CodexMainAccountHardLockError)
919
954
  && !(cause instanceof CodexMainAccountCreditsOffError)
955
+ && !(cause instanceof CodexPoolAccountCreditsOffError)
920
956
  && !(cause instanceof CodexAccountValidationPendingError)
921
957
  && !(cause instanceof CodexReserveUnavailableError)
922
958
  && !(cause instanceof CodexCredentialGenerationConflictError)
@@ -1408,6 +1444,7 @@ export async function resolveCodexAuthContext(
1408
1444
  // deferred credential must never become request auth through that fallback.
1409
1445
  try {
1410
1446
  assertCodexAccountValidationReady(accountId);
1447
+ assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1411
1448
  } catch (cause) {
1412
1449
  // Nothing will reach upstream, so give the trial back instead of leaving the held account
1413
1450
  // unprobeable until the lease deadline lapses (#4701).
@@ -1533,7 +1570,8 @@ export async function resolveCodexAuthContext(
1533
1570
  try {
1534
1571
  const token = await getValidCodexToken(accountId, { signal: options.signal });
1535
1572
  assertCodexAccountValidationReady(accountId);
1536
- return {
1573
+ assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1574
+ return bindPoolCreditPolicy({
1537
1575
  kind: "pool",
1538
1576
  accountId,
1539
1577
  writerGeneration,
@@ -1548,11 +1586,12 @@ export async function resolveCodexAuthContext(
1548
1586
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1549
1587
  ...(affinityDecision ? { affinityDecision } : {}),
1550
1588
  ...(transientProbe ? { transientProbe } : {}),
1551
- };
1589
+ }, config, policy);
1552
1590
  } catch (cause) {
1553
1591
  releaseTransientProbeGrant();
1554
1592
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1555
1593
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1594
+ if (cause instanceof CodexPoolAccountCreditsOffError) throw cause;
1556
1595
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
1557
1596
  markAccountNeedsReauth(accountId, writerGeneration);
1558
1597
  }
@@ -1562,6 +1601,7 @@ export async function resolveCodexAuthContext(
1562
1601
 
1563
1602
  export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
1564
1603
  if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
1604
+ if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
1565
1605
  // A context holding the probe lease was deliberately admitted through the cooldown.
1566
1606
  if (ctx.probeLeaseId) return;
1567
1607
  const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
@@ -1577,6 +1617,7 @@ export function applyCodexAuthContextToProvider(
1577
1617
  ): OcxRuntimeProviderConfig {
1578
1618
  if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
1579
1619
  assertCodexAccountValidationReady(ctx.accountId);
1620
+ if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
1580
1621
  return {
1581
1622
  ...provider,
1582
1623
  _codexAccountOverride: {
@@ -1620,6 +1661,7 @@ export function materializeCodexUpstreamAuth(
1620
1661
  }
1621
1662
  if (ctx.kind === "pool" || ctx.kind === "main-pool") {
1622
1663
  assertCodexAccountValidationReady(ctx.accountId);
1664
+ if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config));
1623
1665
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
1624
1666
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
1625
1667
  if (ctx.kind === "main-pool") {
@@ -4,12 +4,22 @@ import type { OcxConfig } from "../types";
4
4
  import { getCodexRoutingKind, type CodexRoutingKind } from "./inject";
5
5
  import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption";
6
6
  import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim";
7
- import { diagnoseMacDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup";
7
+ import { diagnoseDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup";
8
8
 
9
9
  export type StartupProtection = "service" | "desktop" | "shim" | "none";
10
10
  export type StartupHealthStatus = "native" | "protected" | "at-risk";
11
11
  export type ShimCoverage = "full" | "cli-only" | "none";
12
12
 
13
+ /** Bound the isolated service-manager probe and let its reader outlive that probe. */
14
+ export function startupHealthProbeBudgetMs(platform: NodeJS.Platform = process.platform): number {
15
+ return platform === "win32" ? 15_000 : 5_000;
16
+ }
17
+
18
+ export function startupHealthReadBudgetMs(platform: NodeJS.Platform = process.platform): number {
19
+ // The endpoint waits an extra 500ms for child settlement; reserve another second for HTTP.
20
+ return startupHealthProbeBudgetMs(platform) + 1_500;
21
+ }
22
+
13
23
  export interface StartupHealthInputs {
14
24
  desktop?: DesktopStartupDiagnostic;
15
25
  routingKind: CodexRoutingKind;
@@ -80,7 +90,7 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth
80
90
  // We can only credit an opencodex service/shim for routing that opencodex owns.
81
91
  // An arbitrary localhost gateway has an independent lifecycle that OCX cannot repair.
82
92
  const ownsLocalRouting = inputs.routingKind === "opencodex-local";
83
- const desktopEffective = inputs.platform === "darwin" && !inputs.diagnosticStale
93
+ const desktopEffective = (inputs.platform === "darwin" || inputs.platform === "linux") && !inputs.diagnosticStale
84
94
  && inputs.desktop?.owned === true && inputs.desktop.loginEnabled
85
95
  && inputs.desktop.running && inputs.desktop.viable;
86
96
  const protection: StartupProtection = ownsLocalRouting && inputs.serviceViable
@@ -137,7 +147,7 @@ export function collectStartupHealth(
137
147
  const shim = diagnostics.shim ?? diagnoseCodexShim();
138
148
  const service = diagnostics.service ?? diagnoseService();
139
149
  const routingKind = diagnostics.routingKind ?? getCodexRoutingKind();
140
- const desktop = diagnostics.desktop ?? diagnoseMacDesktopStartup();
150
+ const desktop = diagnostics.desktop ?? diagnoseDesktopStartup();
141
151
  const routingAdoption = diagnostics.routingAdoption
142
152
  ?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined);
143
153
  return deriveStartupHealth({
@@ -85,6 +85,9 @@ export interface RestoreJournalResult {
85
85
  ownershipRefusal?: CodexHomeOwnerRefusalReason;
86
86
  configRestored: boolean;
87
87
  profileRestored: boolean;
88
+ /** True only when restore rewrote or removed a file (not when state was already original). */
89
+ configRewritten: boolean;
90
+ profileRewritten: boolean;
88
91
  configChanged: boolean;
89
92
  profileChanged: boolean;
90
93
  complete: boolean;
@@ -299,12 +302,13 @@ export function restoreJournalState(): RestoreJournalResult {
299
302
  try { assertCodexHomeOwner(CODEX_HOME); }
300
303
  catch (error) {
301
304
  if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
302
- return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false,
303
- complete: false, unverified: true, ownershipRefusal: error.reason };
305
+ return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
306
+ configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason };
304
307
  }
305
308
  const journal = readJournal();
306
309
  if (!journal) {
307
- return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false };
310
+ return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false,
311
+ configChanged: false, profileChanged: false, complete: false, unverified: false };
308
312
  }
309
313
  const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null;
310
314
  const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null;
@@ -316,6 +320,8 @@ export function restoreJournalState(): RestoreJournalResult {
316
320
  return {
317
321
  configRestored: comparison.configAlreadyOriginal,
318
322
  profileRestored: comparison.profileAlreadyOriginal,
323
+ configRewritten: false,
324
+ profileRewritten: false,
319
325
  configChanged: !configUnchanged,
320
326
  profileChanged: !profileUnchanged,
321
327
  complete: false,
@@ -326,14 +332,18 @@ export function restoreJournalState(): RestoreJournalResult {
326
332
  assertCodexHomeOwner(CODEX_HOME);
327
333
  let configRestored = comparison.configAlreadyOriginal;
328
334
  let profileRestored = comparison.profileAlreadyOriginal;
335
+ let configRewritten = false;
336
+ let profileRewritten = false;
329
337
  if (configUnchanged && !configRestored) {
330
338
  atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig);
331
339
  configRestored = true;
340
+ configRewritten = true;
332
341
  }
333
342
  if (profileUnchanged && !profileRestored) {
334
343
  if (comparison.originalProfile !== null) {
335
344
  atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile);
336
345
  profileRestored = true;
346
+ profileRewritten = true;
337
347
  } else if (existsSync(CODEX_PROFILE_PATH)) {
338
348
  // "There was no profile before, so remove the one we generated." Claiming success
339
349
  // without checking is how a caller ends up deleting the journal, reporting a clean
@@ -343,6 +353,7 @@ export function restoreJournalState(): RestoreJournalResult {
343
353
  try {
344
354
  unlinkSync(CODEX_PROFILE_PATH);
345
355
  profileRestored = true;
356
+ profileRewritten = true;
346
357
  } catch (error) {
347
358
  profileRestored = (error as NodeJS.ErrnoException).code === "ENOENT";
348
359
  }
@@ -355,6 +366,8 @@ export function restoreJournalState(): RestoreJournalResult {
355
366
  return {
356
367
  configRestored,
357
368
  profileRestored,
369
+ configRewritten,
370
+ profileRewritten,
358
371
  configChanged: !configUnchanged,
359
372
  profileChanged: !profileUnchanged,
360
373
  complete,
@@ -367,6 +380,11 @@ export function restoreJournal(): boolean {
367
380
  return restoreJournalState().complete;
368
381
  }
369
382
 
383
+ /** A no-rewrite recovery is quiet only when the stale journal is actually gone. */
384
+ function warnIfJournalRetained(): void {
385
+ if (existsSync(JOURNAL_PATH)) console.error("⚠️ Codex journal recovery found nothing to restore, but the journal could not be removed; it will be retried on the next start.");
386
+ }
387
+
370
388
  export interface ReconcileJournalOptions {
371
389
  activeClientApiKeyId?: string;
372
390
  }
@@ -388,7 +406,13 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
388
406
  console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
389
407
  return false;
390
408
  }
391
- if (!restored.configRestored && !restored.profileRestored) return false;
409
+ if (!restored.complete) {
410
+ console.error("⚠️ Codex journal recovery was incomplete; the journal was preserved — check the Codex files.");
411
+ return false;
412
+ }
413
+ // Warn only when restore actually rewrote state; an already-original
414
+ // snapshot still cleans itself up but has nothing to report.
415
+ if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; }
392
416
  console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`);
393
417
  return true;
394
418
  }
@@ -406,7 +430,14 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
406
430
  console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
407
431
  return false;
408
432
  }
409
- if (!restored.configRestored && !restored.profileRestored) return false;
433
+ if (!restored.complete) {
434
+ console.error("⚠️ Codex journal recovery was incomplete; the journal was preserved — check the Codex files.");
435
+ return false;
436
+ }
437
+ // Warn only when restore actually rewrote state. A stale journal whose
438
+ // artifacts are already original still cleans itself up (complete removes
439
+ // it) but has nothing to report — claiming a restore then cries wolf.
440
+ if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; }
410
441
  console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`);
411
442
  return true;
412
443
  }
@@ -0,0 +1,67 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { codexUsageLimitResetAt, isCodexAccountHeldForCredits } from "./account-credit-use";
3
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
4
+ import { isSelectableCodexPoolAccount } from "./account-id";
5
+ import { getAccountQuota } from "./quota";
6
+
7
+ export type PoolCreditPolicyConfig = Readonly<Pick<OcxConfig, "creditCodexAccountIds" | "codexAccounts">>;
8
+ type PoolContext = { kind: "pool"; accountId: string };
9
+
10
+ // Keep live policy references private: they must not enter DTOs or serialized auth context.
11
+ const policies = new WeakMap<object, {
12
+ config: PoolCreditPolicyConfig;
13
+ policy: PoolCreditPolicyConfig;
14
+ }>();
15
+
16
+ /** Read cached usage only. This helper never opens or refreshes a credential. */
17
+ export function poolCreditHoldResetAt(
18
+ policy: PoolCreditPolicyConfig,
19
+ accountId: string,
20
+ catalog: PoolCreditPolicyConfig = policy,
21
+ now = Date.now(),
22
+ ): number | undefined {
23
+ // Native main has an independent identity-bound quota policy. Never read its physical plan.
24
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return undefined;
25
+ const quota = getAccountQuota(accountId);
26
+ const plan = catalog.codexAccounts
27
+ ?.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
28
+ if (!isCodexAccountHeldForCredits(policy, accountId, quota, plan, now)) return undefined;
29
+ return codexUsageLimitResetAt(quota, plan, now);
30
+ }
31
+
32
+ /** Remember policy by reference so later credential materialization rechecks current values. */
33
+ export function bindPoolCreditPolicy<T extends PoolContext>(
34
+ context: T,
35
+ config: PoolCreditPolicyConfig,
36
+ policy: PoolCreditPolicyConfig = config,
37
+ ): T {
38
+ policies.set(context, { config, policy });
39
+ return context;
40
+ }
41
+
42
+ /**
43
+ * Carry the resolver's live policy onto a context rebuilt by spread copy. The WeakMap keys on
44
+ * object identity, so `{ ...ctx }` alone loses the binding and every caller that omits an
45
+ * explicit override would skip credit-hold enforcement on the copy. A source with no binding
46
+ * leaves the target unbound rather than inventing a policy.
47
+ */
48
+ export function rebindPoolCreditPolicy<S extends PoolContext, T extends object>(source: S, target: T): T {
49
+ const bound = policies.get(source);
50
+ if (bound) policies.set(target, bound);
51
+ return target;
52
+ }
53
+
54
+ /** Explicit policy wins; omitted materialization options retain the resolver's policy. */
55
+ export function poolContextCreditHoldResetAt(
56
+ context: PoolContext,
57
+ override?: PoolCreditPolicyConfig,
58
+ now = Date.now(),
59
+ ): number | undefined {
60
+ const bound = policies.get(context);
61
+ const policy = override ?? bound?.policy;
62
+ // Unbound contexts skip credit-hold enforcement entirely: callers that require
63
+ // enforcement must supply `override` (or resolve through bindPoolCreditPolicy).
64
+ if (!policy) return undefined;
65
+ const catalog = override?.codexAccounts ? override : bound?.config ?? policy;
66
+ return poolCreditHoldResetAt(policy, context.accountId, catalog, now);
67
+ }
@@ -48,11 +48,19 @@ function lexical(path: string): Stats | null {
48
48
  try { return lstatSync(path); }
49
49
  catch (error) { if (fileErrorCode(error) === "ENOENT") return null; throw error; }
50
50
  }
51
+ /** Name the path and the failed check; a group/world-writable owned file gets the chmod that fixes it. */
52
+ function untrustedArtifact(path: string, stat: Stats): Error {
53
+ const prefix = `Private Codex artifact is not an owned regular file: ${path}`;
54
+ if (stat.isSymbolicLink() || !stat.isFile()) return new Error(`${prefix} is not a regular file`);
55
+ if (stat.uid !== process.getuid?.()) return new Error(`${prefix} is owned by uid ${stat.uid}`);
56
+ const mode = (stat.mode & 0o777).toString(8).padStart(4, "0");
57
+ return new Error(`${prefix} is group- or world-writable (mode ${mode}); run chmod 600 ${shQuote(path)} and retry`);
58
+ }
51
59
  function snapshot(path: string): Snapshot | null {
52
60
  const before = lexical(path);
53
61
  if (!before) return null;
54
62
  if (!before.isFile() || before.isSymbolicLink() || before.uid !== process.getuid?.() || (before.mode & 0o022)) {
55
- throw new Error("Private Codex artifact is not an owned regular file");
63
+ throw untrustedArtifact(path, before);
56
64
  }
57
65
  const bounded = readBoundedRegularFile(path, PRIVATE_FILE_MAX_BYTES);
58
66
  if (!bounded || "warning" in bounded) throw new Error("Private Codex artifact is unreadable or changed");
package/src/combos/jev.ts CHANGED
@@ -47,6 +47,7 @@ const TOOL_NAME_CHARS = 160;
47
47
  const VISIBLE_TEXT_CHUNK_CHARS = 16_384;
48
48
 
49
49
  const ENVELOPE_TAGS = [
50
+ "system-reminder",
50
51
  "codex_internal_context",
51
52
  "recommended_plugins",
52
53
  "environment_context",
@@ -302,7 +303,8 @@ function taskWithoutProtectedEnvelopes(text: string): string {
302
303
  }
303
304
  } else {
304
305
  if (stack.length === 0) appendTrimmedRange(visible, "\n", 0, 1);
305
- if (!completedGoal && !goal && tag === "codex_internal_context") {
306
+ // A Codex goal quoted inside a Claude reminder is context, never a fallback task.
307
+ if (!completedGoal && !goal && tag === "codex_internal_context" && !stack.includes("system-reminder")) {
306
308
  goal = { sample: emptyTextSample(), pendingWhitespace: emptyTextSample() };
307
309
  goalDepth = stack.length;
308
310
  }