@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -200,6 +200,7 @@ export function takeOption(args: string[], flag: string): string | undefined {
200
200
  return value;
201
201
  }
202
202
 
203
+ /** Consume one boolean option using the supported on/off synonyms; reject other values. */
203
204
  export function takeBooleanOption(args: string[], flag: string): boolean | undefined {
204
205
  const raw = takeOption(args, flag);
205
206
  if (raw === undefined) return undefined;
@@ -208,11 +209,13 @@ export function takeBooleanOption(args: string[], flag: string): boolean | undef
208
209
  throw new CliUsageError(`${flag} must be on or off`);
209
210
  }
210
211
 
212
+ /** Parse a decimal safe integer, allowing separators only between digits. */
211
213
  export function takeIntegerOption(args: string[], flag: string, options: { min?: number } = {}): number | undefined {
212
214
  const raw = takeOption(args, flag);
213
215
  if (raw === undefined) return undefined;
214
- const value = Number(raw.replace(/[_,]/g, ""));
215
- if (!Number.isInteger(value) || value < (options.min ?? Number.MIN_SAFE_INTEGER)) {
216
+ const decimal = raw.trim();
217
+ const value = /^[+-]?\d+(?:[_,]\d+)*$/.test(decimal) ? Number(decimal.replace(/[_,]/g, "")) : NaN;
218
+ if (!Number.isSafeInteger(value) || value < (options.min ?? Number.MIN_SAFE_INTEGER)) {
216
219
  throw new CliUsageError(`${flag} must be an integer${options.min !== undefined ? ` >= ${options.min}` : ""}`);
217
220
  }
218
221
  return value;
@@ -72,6 +72,11 @@ export function formatOAuthHealthForStatus(
72
72
  parts.push(CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE);
73
73
  break;
74
74
  }
75
+ const policy = report.mainAccountHardLock;
76
+ if (policy) {
77
+ const warning = policy.externalUsage ? "; possible usage outside opencodex — the lock may not prevent exhaustion" : "";
78
+ parts.push(`Codex main-account protection: ${policy.state} (5h ≥ ${policy.thresholds.short}%, weekly/monthly ≥ ${policy.thresholds.long}%)${warning}`);
79
+ }
75
80
  const oauthBlock = formatEntryBlock(report.entries);
76
81
  if (oauthBlock) parts.push(oauthBlock);
77
82
  return parts.join("\n");
package/src/cli/status.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CodexMainAccountPolicyHealth } from "../oauth/health";
1
2
  import { durableBunRuntime } from "../lib/bun-runtime";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { codexAutoStartEnabled, getConfigPath, readConfigDiagnostics } from "../config";
@@ -104,6 +105,7 @@ export type CliRemoteHubStatus = {
104
105
  };
105
106
 
106
107
  export type CliStatusJson = {
108
+ mainAccountHardLock?: CodexMainAccountPolicyHealth;
107
109
  schemaVersion: 1;
108
110
  /**
109
111
  * This machine's topology role, named rather than inferred (#4236).
@@ -676,7 +678,8 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str
676
678
  ];
677
679
  }
678
680
 
679
- export async function collectStatus(): Promise<CliStatusView> {
681
+ /** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */
682
+ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise<CliStatusView> {
680
683
  const configDiagnostics = readConfigDiagnostics();
681
684
  const config = configDiagnostics.config;
682
685
  const claudeDesktop = {
@@ -714,6 +717,9 @@ export async function collectStatus(): Promise<CliStatusView> {
714
717
  const live = await findLiveProxy({
715
718
  configFn: () => ({ port: config.port, hostname: config.hostname }),
716
719
  });
720
+ const mainAccountHardLock = options.mainAccountPolicy && live
721
+ ? (await (await import("../oauth/health")).fetchCodexHealthFromLiveProxy(undefined, async () => live)).mainAccountHardLock
722
+ : undefined;
717
723
  const pidFile = readPid();
718
724
  // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile.
719
725
  const pid = resolveStatusPid(live, pidFile);
@@ -865,6 +871,7 @@ export async function collectStatus(): Promise<CliStatusView> {
865
871
  proxyLabel,
866
872
  healthLabel: health.label,
867
873
  json: {
874
+ ...(mainAccountHardLock ? { mainAccountHardLock } : {}),
868
875
  schemaVersion: 1,
869
876
  runtimeRole: config.runtimeRole ?? "standalone",
870
877
  proxy: {
@@ -64,19 +64,41 @@ export function parseStopApproval(argv: string[]): StopApprovalParse {
64
64
  }
65
65
 
66
66
  export function matchesStopApproval(expected: StopApproval, now: ResolveJson): boolean {
67
- return now.schema === "ocx-resolve/1" && now.liveness.status === "live"
68
- && now.liveness.pid === expected.pid && now.liveness.port === expected.port
69
- && now.port.effective === expected.port && (now.liveness.hostname ?? "") === expected.hostname
70
- && now.liveness.role !== "client" && now.configHome === expected.configHome
71
- && now.cliVersion === expected.cliVersion && now.ownership.kind !== "unknown"
72
- && now.takeover.kind === "supported" && now.takeover.token === expected.compatibilityToken;
67
+ return stopApprovalMismatch(expected, now) === null;
73
68
  }
74
69
 
75
- export function approvalChanged(): StopOutcome {
70
+ /**
71
+ * The single guard fact that no longer holds, or null when everything still matches.
72
+ * The guarded stop collapses every re-verification into one `approval-changed` outcome;
73
+ * without the specific check, a desktop log cannot tell a genuine ownership or token
74
+ * drift from a transient probe failure on an unchanged runtime.
75
+ */
76
+ export function stopApprovalMismatch(expected: StopApproval, now: ResolveJson): string | null {
77
+ if (now.schema !== "ocx-resolve/1") return "the resolve contract changed";
78
+ if (now.liveness.status !== "live") return `the approved runtime is no longer live (${now.liveness.status})`;
79
+ if (now.liveness.pid !== expected.pid) {
80
+ return `the live PID is now ${now.liveness.pid ?? "unverified"} (approved ${expected.pid})`;
81
+ }
82
+ if (now.liveness.port !== expected.port) return `the live port is now ${now.liveness.port} (approved ${expected.port})`;
83
+ if (now.port.effective !== expected.port) return `the effective port is now ${now.port.effective} (approved ${expected.port})`;
84
+ if ((now.liveness.hostname ?? "") !== expected.hostname) return "the bind hostname changed";
85
+ if (now.liveness.role === "client") return "the runtime now routes as a client";
86
+ if (now.configHome !== expected.configHome) return "the config home changed";
87
+ if (now.cliVersion !== expected.cliVersion) return `the resolving CLI is now ${now.cliVersion} (approved ${expected.cliVersion})`;
88
+ if (now.ownership.kind === "unknown") return "the ownership claim could not be re-verified";
89
+ if (now.takeover.kind !== "supported") {
90
+ return `takeover compatibility is now blocked (${now.takeover.reason})`;
91
+ }
92
+ if (now.takeover.token !== expected.compatibilityToken) return "the compatibility fingerprint changed";
93
+ return null;
94
+ }
95
+
96
+ export function approvalChanged(detail?: string): StopOutcome {
76
97
  return { ok: false, summary: {
77
98
  schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "approval-changed", exitCode: 1,
78
99
  runtimeDown: false, service: "absent", proxy: "unknown", sharedTeardown: "skipped",
79
- message: "The approved runtime or managing CLI changed; no stop was attempted.",
100
+ ...(detail ? { detail } : {}),
101
+ message: `The approved runtime or managing CLI changed${detail ? `: ${detail}` : ""}; no stop was attempted.`,
80
102
  } };
81
103
  }
82
104
 
@@ -104,10 +126,20 @@ export async function runApprovedStop(
104
126
  const now = await read();
105
127
  const target = tracked();
106
128
  const manager = managerTarget();
107
- if (!now || !matchesStopApproval(expected, now) || !target
108
- || target.pid !== expected.pid || target.port !== expected.port
109
- || target.hostname !== expected.hostname || manager.kind === "unknown"
110
- || (manager.kind === "bound" && manager.pid !== expected.pid)) return approvalChanged();
129
+ if (now === null) return approvalChanged("the runtime could not be re-resolved");
130
+ const mismatch = stopApprovalMismatch(expected, now);
131
+ if (mismatch !== null) return approvalChanged(mismatch);
132
+ if (target === null) return approvalChanged("the tracked runtime record could not be verified");
133
+ if (target.pid !== expected.pid || target.port !== expected.port
134
+ || target.hostname !== expected.hostname) {
135
+ return approvalChanged("the tracked runtime record changed");
136
+ }
137
+ if (manager.kind === "unknown") {
138
+ return approvalChanged(`the service manager could not be re-verified (${manager.reason})`);
139
+ }
140
+ if (manager.kind === "bound" && manager.pid !== expected.pid) {
141
+ return approvalChanged("a different manager owns the approved runtime");
142
+ }
111
143
  snapshot = { approval: expected, manager };
112
144
  } catch { return approvalChanged(); }
113
145
  return snapshot ? stop(snapshot) : approvalChanged();
@@ -149,13 +181,20 @@ export async function runGuardedManagerStep(
149
181
  managerState: () => Promise<"inactive" | "active" | "unknown">;
150
182
  },
151
183
  ): Promise<{ service: StopServiceOutcome; effect: "stopped" | "approval-changed" | "manager-still-active" | "failed";
152
- proxy: "stopped" | "unknown"; handledByProxy: boolean }> {
184
+ proxy: "stopped" | "unknown"; handledByProxy: boolean; detail?: string }> {
153
185
  try {
154
- if (!sameGuardedManager(snapshot.manager, io.revalidateManager())) {
155
- return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
186
+ const current = io.revalidateManager();
187
+ if (!sameGuardedManager(snapshot.manager, current)) {
188
+ // An unprovable manager is not a proven change: name the observation failure,
189
+ // not a swap, or the stop log loses why the revalidation could not answer.
190
+ const detail = current.kind === "unknown"
191
+ ? `the service manager could not be re-verified (${current.reason})`
192
+ : "the service manager changed between approval and stop";
193
+ return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false, detail };
156
194
  }
157
195
  } catch {
158
- return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
196
+ return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false,
197
+ detail: "the service manager could not be re-validated" };
159
198
  }
160
199
  const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager();
161
200
  if (snapshot.manager.kind === "bound"
@@ -76,6 +76,11 @@ export interface StopSummaryJson {
76
76
  sharedTeardown: StopSharedTeardownOutcome;
77
77
  /** One stable human-readable line for a caller's UI. */
78
78
  message: string;
79
+ /**
80
+ * The single guard fact that failed on a `approval-changed` refusal, when the
81
+ * run can name one. Additive on `ocx-stop/1`; absent on every other outcome.
82
+ */
83
+ detail?: string;
79
84
  }
80
85
 
81
86
  /** What handleStop returns: the pre-existing boolean plus the structured twin. */
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Detection of Codex-based omo (LazyCodex), the variant that reads Codex role files and
3
+ * `codex.agents.<role>.model` in `~/.omo/omo.jsonc`.
4
+ *
5
+ * "omo" names three products. Pi-based omo (senpi) owns `~/.omo/agent` and is the omo file
6
+ * integration in `src/integrations/registry.ts`; OpenCode-based omo (oh-my-opencode) keeps its
7
+ * own config under OpenCode. Neither proves LazyCodex is installed, and `~/.omo` exists for all
8
+ * of them, so this reads only LazyCodex's footprint inside CODEX_HOME: the Codex plugin
9
+ * `omo@sisyphuslabs` enabled in config.toml (the key LazyCodex's own doctor checks) and an
10
+ * installed copy of it carrying the `lazycodex-install.json` receipt its installer writes.
11
+ */
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ export const LAZYCODEX_PLUGIN_ID = "omo@sisyphuslabs";
16
+
17
+ export interface LazyCodexDetection {
18
+ readonly detected: boolean;
19
+ readonly pluginEnabled: boolean;
20
+ readonly pluginInstalled: boolean;
21
+ }
22
+
23
+ function pluginEnabled(codexHome: string): boolean {
24
+ let parsed: unknown;
25
+ try {
26
+ parsed = Bun.TOML.parse(readFileSync(join(codexHome, "config.toml"), "utf8"));
27
+ } catch {
28
+ return false;
29
+ }
30
+ const plugins = (parsed as { plugins?: Record<string, { enabled?: unknown } | undefined> } | null)?.plugins;
31
+ return plugins?.[LAZYCODEX_PLUGIN_ID]?.enabled === true;
32
+ }
33
+
34
+ function pluginInstalled(codexHome: string): boolean {
35
+ const root = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo");
36
+ try {
37
+ return readdirSync(root, { withFileTypes: true })
38
+ .some(entry => entry.isDirectory() && existsSync(join(root, entry.name, "lazycodex-install.json")));
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ export function detectLazyCodex(codexHome: string): LazyCodexDetection {
45
+ const enabled = pluginEnabled(codexHome);
46
+ const installed = pluginInstalled(codexHome);
47
+ return { detected: enabled && installed, pluginEnabled: enabled, pluginInstalled: installed };
48
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * omo (Codex / LazyCodex)'s per-role model setting, `codex.agents.<role>.model` in `~/.omo/omo.jsonc`.
3
+ *
4
+ * LazyCodex 5.1.1 and later reads it, and callers reach this only after `detectLazyCodex`
5
+ * says LazyCodex is installed. The file is omo's, so this writes only on an explicit
6
+ * dashboard or CLI pick, never creates the file, and refuses a file with comments: the write
7
+ * re-serializes JSON, and a comment the user wrote would be lost without a word.
8
+ */
9
+ import { lstatSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
13
+ import { assertIntegrationWriteOwnership } from "../integrations/config-io";
14
+ import { hasJsoncComments, parseJsonc } from "../lib/jsonc";
15
+
16
+ export type OmoRoleModelsState =
17
+ | { readonly state: "absent" }
18
+ | { readonly state: "comments" }
19
+ | { readonly state: "invalid" }
20
+ | { readonly state: "unreadable" }
21
+ | { readonly state: "present"; readonly models: Readonly<Record<string, string>> };
22
+
23
+ export type OmoRoleModelWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | "invalid";
24
+
25
+ /** omo resolves its home from HOME, then USERPROFILE, then the OS home; this follows it. */
26
+ export function omoJsoncPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
27
+ const base = env.HOME?.trim() || env.USERPROFILE?.trim() || home;
28
+ return join(base, ".omo", "omo.jsonc");
29
+ }
30
+
31
+ type JsonObject = Record<string, unknown>;
32
+
33
+ function isObject(value: unknown): value is JsonObject {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ type Loaded =
38
+ | { kind: "absent" | "comments" | "invalid" }
39
+ | { kind: "document"; text: string; doc: JsonObject };
40
+
41
+ function load(path: string): Loaded {
42
+ let text: string;
43
+ try {
44
+ if (!lstatSync(path).isFile()) return { kind: "invalid" };
45
+ text = readFileSync(path, "utf8");
46
+ } catch (error) {
47
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
48
+ throw error;
49
+ }
50
+ if (hasJsoncComments(text)) return { kind: "comments" };
51
+ try {
52
+ const doc = parseJsonc(text.replace(/^\ufeff/, ""));
53
+ return isObject(doc) ? { kind: "document", text, doc } : { kind: "invalid" };
54
+ } catch {
55
+ return { kind: "invalid" };
56
+ }
57
+ }
58
+
59
+ function agentsOf(doc: JsonObject): JsonObject | null | undefined {
60
+ const codex = doc.codex;
61
+ if (codex === undefined) return undefined;
62
+ if (!isObject(codex)) return null;
63
+ const agents = codex.agents;
64
+ if (agents === undefined) return undefined;
65
+ return isObject(agents) ? agents : null;
66
+ }
67
+
68
+ export function readOmoRoleModels(path: string = omoJsoncPath()): OmoRoleModelsState {
69
+ let loaded: Loaded;
70
+ try {
71
+ loaded = load(path);
72
+ } catch {
73
+ return { state: "unreadable" };
74
+ }
75
+ if (loaded.kind !== "document") return { state: loaded.kind };
76
+ const agents = agentsOf(loaded.doc);
77
+ if (agents === null) return { state: "invalid" };
78
+ const models: Record<string, string> = {};
79
+ for (const [role, entry] of Object.entries(agents ?? {})) {
80
+ if (isObject(entry) && typeof entry.model === "string") models[role] = entry.model;
81
+ }
82
+ return { state: "present", models };
83
+ }
84
+
85
+ function serialize(text: string, doc: JsonObject): string {
86
+ const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " ";
87
+ let out = JSON.stringify(doc, null, indent);
88
+ if (/\r?\n$/.test(text)) out += "\n";
89
+ if (text.includes("\r\n")) out = out.replace(/\n/g, "\r\n");
90
+ return text.startsWith("\ufeff") ? `\ufeff${out}` : out;
91
+ }
92
+
93
+ export function writeOmoRoleModel(role: string, model: string, path: string = omoJsoncPath()): OmoRoleModelWriteStatus {
94
+ const loaded = load(path);
95
+ if (loaded.kind !== "document") return loaded.kind === "comments" ? "skipped_comments" : loaded.kind;
96
+ const { doc, text } = loaded;
97
+ // Only an absent key is created; an explicit null is a value the reader calls invalid.
98
+ const codex = doc.codex === undefined ? {} : doc.codex;
99
+ if (!isObject(codex)) return "invalid";
100
+ const agents = codex.agents === undefined ? {} : codex.agents;
101
+ if (!isObject(agents)) return "invalid";
102
+ const entry = agents[role] === undefined ? {} : agents[role];
103
+ if (!isObject(entry)) return "invalid";
104
+ if (entry.model === model) return "unchanged";
105
+ agents[role] = { ...entry, model };
106
+ codex.agents = agents;
107
+ doc.codex = codex;
108
+ assertIntegrationWriteOwnership(path);
109
+ atomicWriteFileNoFollowUnclaimed(path, serialize(text, doc));
110
+ return "written";
111
+ }
@@ -0,0 +1,99 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
3
+ import { isThirtyDayOnlyCodexPlan } from "./plan";
4
+ import {
5
+ CODEX_EXHAUSTED_USAGE_PERCENT,
6
+ isTerminalShortWindow,
7
+ resetAtToMs,
8
+ type StoredAccountQuota,
9
+ } from "./quota-types";
10
+
11
+ /**
12
+ * Whether an account may keep serving once one of its usage windows is full (#6334).
13
+ *
14
+ * Upstream does not refuse an account that holds ChatGPT credits at 100%: it serves the request
15
+ * and draws the balance. Selection only takes an account off on quota after a refusal, so an
16
+ * account with credits was never moved off. Spending is opt-in: only the ids in
17
+ * `creditCodexAccountIds` may keep serving from credits, and every other account leaves rotation
18
+ * at 100% and comes back after its reset. Pool accounts and the `__main__` login both carry the
19
+ * switch; the main login is also checked where the main-account hard lock is.
20
+ */
21
+ export function codexAccountUsesCreditsAfterLimit(
22
+ config: Pick<OcxConfig, "creditCodexAccountIds">,
23
+ accountId: string,
24
+ ): boolean {
25
+ return config.creditCodexAccountIds?.includes(accountId) ?? false;
26
+ }
27
+
28
+ /** Persist the switch for one account. Only the accounts allowed to spend credits are stored. */
29
+ export function setCodexAccountCreditsAfterLimit(config: OcxConfig, accountId: string, enabled: boolean): void {
30
+ const ids = new Set(config.creditCodexAccountIds ?? []);
31
+ if (enabled) ids.add(accountId);
32
+ else ids.delete(accountId);
33
+ writeCreditAccountIds(config, ids);
34
+ }
35
+
36
+ /**
37
+ * The dashboard's global switch. On allows every account the caller passes, which is every
38
+ * current account; off clears the list, so a later account still starts with spending off.
39
+ */
40
+ export function setAllCodexAccountsCreditsAfterLimit(config: OcxConfig, accountIds: readonly string[], enabled: boolean): void {
41
+ writeCreditAccountIds(config, new Set(enabled ? accountIds : []));
42
+ }
43
+
44
+ function writeCreditAccountIds(config: OcxConfig, ids: ReadonlySet<string>): void {
45
+ if (ids.size > 0) config.creditCodexAccountIds = [...ids];
46
+ else deleteConfigTopLevelKey(config, "creditCodexAccountIds");
47
+ }
48
+
49
+ export function forgetCodexAccountCreditUse(config: OcxConfig, accountId: string): void {
50
+ setCodexAccountCreditsAfterLimit(config, accountId, false);
51
+ }
52
+
53
+ /**
54
+ * A usage window that is full right now.
55
+ *
56
+ * Stricter than the selection score about time, on purpose. The score keeps a 100% reading until
57
+ * a new observation replaces it, which is safe there because an account at 100% still receives
58
+ * traffic and that traffic brings the next observation. An account held by this switch receives
59
+ * none, so the reading itself has to say when it ends: a long window counts only while its reset
60
+ * is still ahead, and one without a reset is not trusted. The burst window uses the rule routing
61
+ * and the dashboard already share.
62
+ */
63
+ export function isCodexUsageLimitReached(quota: StoredAccountQuota | null, plan: unknown, now: number): boolean {
64
+ return codexUsageLimitResetAt(quota, plan, now) !== undefined;
65
+ }
66
+
67
+ /**
68
+ * When the full window ends, in milliseconds, or undefined when no window is full. With several
69
+ * full windows the latest reset is the earliest moment the account is usable again.
70
+ */
71
+ export function codexUsageLimitResetAt(quota: StoredAccountQuota | null, plan: unknown, now: number): number | undefined {
72
+ if (!quota) return undefined;
73
+ const full: number[] = [];
74
+ if (isTerminalShortWindow(quota, now)) {
75
+ // A reset-less burst reading counts while it is fresh, so there is no instant to report.
76
+ full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0 ? resetAtToMs(quota.shortResetAt) : now);
77
+ }
78
+ const longWindows: Array<[number | undefined, number | undefined]> = isThirtyDayOnlyCodexPlan(plan)
79
+ ? [[quota.monthlyPercent, quota.monthlyResetAt]]
80
+ : [[quota.weeklyPercent, quota.weeklyResetAt], [quota.monthlyPercent, quota.monthlyResetAt]];
81
+ for (const [percent, resetAt] of longWindows) {
82
+ if (typeof percent !== "number" || percent < CODEX_EXHAUSTED_USAGE_PERCENT) continue;
83
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) continue;
84
+ const resetMs = resetAtToMs(resetAt);
85
+ if (resetMs > now) full.push(resetMs);
86
+ }
87
+ return full.length > 0 ? Math.max(...full) : undefined;
88
+ }
89
+
90
+ /** Whether automatic selection must skip this account so that it keeps its credits. */
91
+ export function isCodexAccountHeldForCredits(
92
+ config: Pick<OcxConfig, "creditCodexAccountIds">,
93
+ accountId: string,
94
+ quota: StoredAccountQuota | null,
95
+ plan: unknown,
96
+ now: number,
97
+ ): boolean {
98
+ return !codexAccountUsesCreditsAfterLimit(config, accountId) && isCodexUsageLimitReached(quota, plan, now);
99
+ }
@@ -18,6 +18,7 @@ import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
18
18
  import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache";
19
19
  import { extractAccountIdClaims } from "../oauth/chatgpt";
20
20
  import { forgetCodexAccountPause } from "./account-pause";
21
+ import { forgetCodexAccountCreditUse } from "./account-credit-use";
21
22
  import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
22
23
  import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
23
24
  import { forgetCodexAccountAutoSwitchThreshold } from "./account-auto-switch";
@@ -179,6 +180,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
179
180
  runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? [])
180
181
  .filter(account => account.isMain || account.id !== accountId);
181
182
  forgetCodexAccountPause(runtimeConfig, accountId);
183
+ forgetCodexAccountCreditUse(runtimeConfig, accountId);
182
184
  forgetCodexAccountPriority(runtimeConfig, accountId);
183
185
  forgetCodexAccountAutoSwitchThreshold(runtimeConfig, accountId);
184
186
  if (runtimeConfig.codexQuotaAutoRefresh?.[accountId]) {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The dashboard's write of one Codex agent role's model, and optionally its reasoning effort.
3
+ *
4
+ * Codex overrides the spawn-time model with the root `model` pin in
5
+ * `$CODEX_HOME/agents/<role>.toml`, so that pin is what decides a child's model. This module
6
+ * edits exactly that value, plus the root `model_reasoning_effort` when a caller passes one, and
7
+ * nothing else: every other byte of the file, including the instructions multiline string that
8
+ * usually mentions `model =` in prose, is left as it was.
9
+ * It runs only on an explicit user action; no sync or startup path calls it.
10
+ */
11
+ import { lstatSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
14
+ import { assertIntegrationWriteOwnership } from "../integrations/config-io";
15
+ import { encodeBasicString, findInvalidCharacter } from "./prompt-layers/encoding";
16
+ import { dominantEol } from "./prompt-layers/toml-edit";
17
+ import { isDeclaredReasoningEffort } from "../reasoning-effort";
18
+ import {
19
+ listCodexAgentRoles,
20
+ locateTomlModelKey,
21
+ locateTomlReasoningEffortKey,
22
+ type TomlModelKeyLocation,
23
+ } from "./subagent-model-fallback";
24
+
25
+ export type AgentRoleModelErrorCode =
26
+ | "unknown_role"
27
+ | "invalid_model"
28
+ | "invalid_effort"
29
+ | "unsupported_model_value"
30
+ | "unsafe_target"
31
+ | "invalid_role_file";
32
+
33
+ export class AgentRoleModelError extends Error {
34
+ constructor(readonly code: AgentRoleModelErrorCode, message: string) {
35
+ super(message);
36
+ }
37
+ }
38
+
39
+ export interface CodexAgentRoleModel {
40
+ readonly role: string;
41
+ readonly model: string | null;
42
+ }
43
+
44
+ export function validateAgentRoleModel(model: unknown): string {
45
+ if (typeof model !== "string") throw new AgentRoleModelError("invalid_model", "model must be a string");
46
+ const trimmed = model.trim();
47
+ if (trimmed === "") throw new AgentRoleModelError("invalid_model", "model must not be empty");
48
+ if (trimmed.includes("\n") || findInvalidCharacter(trimmed) !== null) {
49
+ throw new AgentRoleModelError("invalid_model", "model must not contain control characters");
50
+ }
51
+ return trimmed;
52
+ }
53
+
54
+ export function validateAgentRoleEffort(effort: unknown): string {
55
+ if (typeof effort !== "string" || !isDeclaredReasoningEffort(effort)) {
56
+ throw new AgentRoleModelError("invalid_effort", "effort must be a reasoning effort level such as low, medium or high");
57
+ }
58
+ return effort;
59
+ }
60
+
61
+ /**
62
+ * Set a root string key. An existing value is replaced inside its quotes and keeps its
63
+ * quote style when the new value allows it; a missing key is inserted after the leading
64
+ * comment block with the file's own line ending.
65
+ */
66
+ function setTomlRootString(content: string, location: TomlModelKeyLocation | null, key: string, value: string): string {
67
+ if (location?.inRootTable) {
68
+ if (!location.span) {
69
+ throw new AgentRoleModelError("unsupported_model_value", `the existing ${key} value is not a one-line string`);
70
+ }
71
+ const lines = content.split("\n");
72
+ const line = lines[location.line]!;
73
+ const literal = line[location.span.start] === "'" && !value.includes("'")
74
+ ? `'${value}'`
75
+ : encodeBasicString(value);
76
+ lines[location.line] = line.slice(0, location.span.start) + literal + line.slice(location.span.end);
77
+ return lines.join("\n");
78
+ }
79
+ const eol = dominantEol(content);
80
+ const bom = content.startsWith("\ufeff") ? 1 : 0;
81
+ const lines = content.slice(bom).split("\n");
82
+ let offset = bom;
83
+ for (const line of lines) {
84
+ if (!/^\s*#/.test(line)) break;
85
+ offset += line.length + 1;
86
+ }
87
+ const assignment = `${key} = ${encodeBasicString(value)}`;
88
+ if (offset > content.length) return `${content}${eol}${assignment}${eol}`;
89
+ return `${content.slice(0, offset)}${assignment}${eol}${content.slice(offset)}`;
90
+ }
91
+
92
+ export function setTomlRootModel(content: string, model: string): string {
93
+ return setTomlRootString(content, locateTomlModelKey(content), "model", model);
94
+ }
95
+
96
+ export function setTomlRootReasoningEffort(content: string, effort: string): string {
97
+ return setTomlRootString(content, locateTomlReasoningEffortKey(content), "model_reasoning_effort", effort);
98
+ }
99
+
100
+ export function readCodexAgentRoleEffort(role: string, codexHome: string): string | null {
101
+ try {
102
+ const location = locateTomlReasoningEffortKey(readFileSync(roleFile(role, codexHome), "utf8"));
103
+ return location?.inRootTable ? location.value : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function roleFile(role: string, codexHome: string): string {
110
+ return join(codexHome, "agents", `${role}.toml`);
111
+ }
112
+
113
+ function requireKnownRole(role: string, codexHome: string): void {
114
+ // Membership in the directory listing is the whole path check: a name with a separator or
115
+ // `..` can never equal a listed file stem.
116
+ if (!listCodexAgentRoles(codexHome).includes(role)) {
117
+ throw new AgentRoleModelError("unknown_role", `no Codex agent role named ${JSON.stringify(role)}`);
118
+ }
119
+ }
120
+
121
+ export function listCodexAgentRoleModels(codexHome: string): CodexAgentRoleModel[] {
122
+ return listCodexAgentRoles(codexHome).sort().map(role => {
123
+ let model: string | null = null;
124
+ try {
125
+ const location = locateTomlModelKey(readFileSync(roleFile(role, codexHome), "utf8"));
126
+ model = location?.inRootTable ? location.value : null;
127
+ } catch { /* an unreadable role reports no pin */ }
128
+ return { role, model };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Codex refuses a role file that is not valid TOML. Editing one anyway could turn a role that
134
+ * Codex rejects into one it loads, so both the original and the edited document must parse.
135
+ */
136
+ function assertValidRoleToml(role: string, text: string, stage: "before" | "after"): void {
137
+ try {
138
+ Bun.TOML.parse(text);
139
+ } catch {
140
+ throw new AgentRoleModelError(
141
+ "invalid_role_file",
142
+ stage === "before"
143
+ ? `${role}.toml is not valid TOML; fix it before setting a model`
144
+ : `setting the model would leave ${role}.toml invalid; the file was not changed`,
145
+ );
146
+ }
147
+ }
148
+
149
+ export function writeCodexAgentRoleModel(
150
+ role: string,
151
+ model: string,
152
+ codexHome: string,
153
+ effort?: string,
154
+ ): { status: "written" | "unchanged" } {
155
+ requireKnownRole(role, codexHome);
156
+ const path = roleFile(role, codexHome);
157
+ if (!lstatSync(path).isFile()) {
158
+ throw new AgentRoleModelError("unsafe_target", `${role}.toml is not a regular file; edit it where it points`);
159
+ }
160
+ const before = readFileSync(path, "utf8");
161
+ assertValidRoleToml(role, before, "before");
162
+ const withModel = setTomlRootModel(before, validateAgentRoleModel(model));
163
+ const after = effort === undefined
164
+ ? withModel
165
+ : setTomlRootReasoningEffort(withModel, validateAgentRoleEffort(effort));
166
+ if (after === before) return { status: "unchanged" };
167
+ assertValidRoleToml(role, after, "after");
168
+ assertIntegrationWriteOwnership(path);
169
+ atomicWriteFileNoFollowUnclaimed(path, after);
170
+ return { status: "written" };
171
+ }