@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,26 +1,111 @@
1
1
  import { flushResponseState } from "../responses/state";
2
2
  import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
3
3
  import {
4
- abortStorageCleanupPolicyJob,
4
+ abortStorageCleanupPolicyJobAsync,
5
5
  setStorageCleanupPolicyJobLiveApply,
6
6
  } from "../storage/policy-job";
7
+ import { abortRestoreTrashJobAsync } from "../storage/restore-job";
7
8
  import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
9
+ import { stopStateStoreSweeper } from "../lib/state-store-sweeper";
10
+ import {
11
+ cancelQueuedStorageWorkerSpawns,
12
+ drainStorageWorkers,
13
+ } from "../storage/worker-lifecycle";
14
+ import { createAdmissionGate, type AdmissionLease, type AdmissionMetrics } from "../lib/admission";
15
+ import { codexWebSocketAdmissionMetrics } from "../codex/websocket-registry";
16
+ import { storageMutationAdmissionMetrics } from "../storage/storage-mutation-coordinator";
17
+ import { storageWorkerAdmissionMetrics } from "../storage/worker-lifecycle";
18
+ import {
19
+ backgroundShellAdmissionMetrics,
20
+ beginBackgroundShellShutdown,
21
+ terminateAllBackgroundShells,
22
+ } from "../adapters/cursor/native-exec-shell";
8
23
 
9
24
  // ---------------------------------------------------------------------------
10
25
  // Active turn tracking + graceful shutdown drain
11
26
  // ---------------------------------------------------------------------------
12
27
 
13
- const activeTurns = new Set<AbortController>();
28
+ export const MAX_ACTIVE_TURNS = 256;
29
+ const turnGate = createAdmissionGate("active_turns", MAX_ACTIVE_TURNS);
30
+ export interface ActiveTurnLease extends AdmissionLease {
31
+ bindAbortController(ac: AbortController): void;
32
+ isTransferred(): boolean;
33
+ }
34
+ const activeTurns = new Map<AbortController, ActiveTurnLease>();
35
+ const admittedTurns = new Set<ActiveTurnLease>();
36
+ const knownTurnControllers = new WeakSet<AbortController>();
37
+ let turnReleaseMisses = 0;
14
38
  let draining = false;
15
39
  let recyclingForExit = false;
16
40
  let _serverRef: ReturnType<typeof Bun.serve> | undefined;
17
41
 
18
42
  export function setServerRef(server: ReturnType<typeof Bun.serve> | undefined): void { _serverRef = server; }
19
43
  export function setDraining(value: boolean): void { draining = value; }
20
- export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
21
- export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
44
+ export function tryAdmitTurn(): ActiveTurnLease | null {
45
+ const gateLease = turnGate.tryAcquire();
46
+ if (!gateLease) return null;
47
+ const controllers = new Set<AbortController>();
48
+ let active = true;
49
+ let transferred = false;
50
+ const lease: ActiveTurnLease = {
51
+ bindAbortController(ac) {
52
+ knownTurnControllers.add(ac);
53
+ if (!active) {
54
+ ac.abort(new Error("turn already settled"));
55
+ return;
56
+ }
57
+ transferred = true;
58
+ controllers.add(ac);
59
+ activeTurns.set(ac, lease);
60
+ },
61
+ isTransferred() { return transferred; },
62
+ release() {
63
+ if (!active) return;
64
+ active = false;
65
+ admittedTurns.delete(lease);
66
+ for (const controller of controllers) {
67
+ if (activeTurns.get(controller) === lease) activeTurns.delete(controller);
68
+ }
69
+ controllers.clear();
70
+ gateLease.release();
71
+ },
72
+ };
73
+ admittedTurns.add(lease);
74
+ return lease;
75
+ }
76
+ export function registerTurn(ac: AbortController, lease?: AdmissionLease): void {
77
+ if (lease && "bindAbortController" in lease) (lease as ActiveTurnLease).bindAbortController(ac);
78
+ }
79
+ export function unregisterTurn(ac: AbortController): void {
80
+ const lease = activeTurns.get(ac);
81
+ if (!lease) {
82
+ if (knownTurnControllers.has(ac)) return;
83
+ turnReleaseMisses += 1;
84
+ return;
85
+ }
86
+ lease.release();
87
+ }
22
88
  export function isDraining(): boolean { return draining; }
23
- export function getActiveTurnCount(): number { return activeTurns.size; }
89
+ export function getActiveTurnCount(): number { return turnGate.metrics().active; }
90
+ export function activeRegistryMetrics(): Record<string, AdmissionMetrics> {
91
+ const turns = turnGate.metrics();
92
+ return {
93
+ activeTurns: { ...turns, releaseMisses: turns.releaseMisses + turnReleaseMisses },
94
+ codexWebSockets: codexWebSocketAdmissionMetrics(),
95
+ cursorBackgroundShells: backgroundShellAdmissionMetrics(),
96
+ storageHomeSlots: storageMutationAdmissionMetrics(),
97
+ storageWorkerReservations: storageWorkerAdmissionMetrics(),
98
+ };
99
+ }
100
+
101
+ export function abortAndReleaseAllTurns(reason: unknown = new Error("server shutdown")): void {
102
+ const owners = [...admittedTurns];
103
+ for (const owner of owners) {
104
+ const controllers = [...activeTurns].filter(([, lease]) => lease === owner).map(([controller]) => controller);
105
+ for (const controller of controllers) controller.abort(reason);
106
+ owner.release();
107
+ }
108
+ }
24
109
  /** Live listen port of the Bun server, when started. */
25
110
  export function getServerListenPort(): number | undefined {
26
111
  const port = _serverRef?.port;
@@ -38,8 +123,9 @@ export function trackStreamLifetime(
38
123
  body: ReadableStream<Uint8Array>,
39
124
  ac: AbortController,
40
125
  onDone?: () => void,
126
+ lease?: AdmissionLease,
41
127
  ): ReadableStream<Uint8Array> {
42
- registerTurn(ac);
128
+ registerTurn(ac, lease);
43
129
  const reader = body.getReader();
44
130
  let closed = false;
45
131
  const finish = () => {
@@ -73,25 +159,69 @@ export async function drainAndShutdown(
73
159
  ): Promise<void> {
74
160
  const s = server ?? _serverRef;
75
161
  draining = true;
76
- const deadline = Date.now() + timeoutMs;
77
- while (activeTurns.size > 0 && Date.now() < deadline) {
78
- await Bun.sleep(100);
79
- }
80
- if (activeTurns.size > 0) {
81
- console.warn(`⚠️ Aborting ${activeTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
82
- for (const ac of activeTurns) {
83
- ac.abort(new Error("server shutdown"));
162
+ beginBackgroundShellShutdown();
163
+ try {
164
+ const deadline = Date.now() + timeoutMs;
165
+ while (admittedTurns.size > 0 && Date.now() < deadline) {
166
+ await Bun.sleep(100);
167
+ }
168
+ if (admittedTurns.size > 0) {
169
+ console.warn(`⚠️ Aborting ${admittedTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
170
+ abortAndReleaseAllTurns(new Error("server shutdown"));
171
+ }
172
+
173
+ const shellDrain = await Promise.allSettled([terminateAllBackgroundShells()]);
174
+ const shellResult = shellDrain[0]!;
175
+ if (shellResult.status === "rejected") {
176
+ console.warn("[cursor] background shell drain failed", { rejected: 1 });
177
+ } else if (shellResult.value.unresolved > 0 || shellResult.value.killFailures > 0) {
178
+ console.warn("[cursor] background shell drain incomplete", shellResult.value);
179
+ }
180
+
181
+ // Debounced replay-state snapshot may still be pending; flush so the last completed turn's
182
+ // previous_response_id chain survives the restart this shutdown is usually part of.
183
+ const responseStateFlush = await Promise.allSettled([flushResponseState()]);
184
+ if (responseStateFlush[0]?.status === "rejected") {
185
+ console.warn("[responses] state flush during shutdown failed");
186
+ }
187
+
188
+ // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
189
+ // Await worker thread exit: on Windows, a still-exiting Bun Worker under
190
+ // `bun test --isolate` panics the whole process at the next realm reclaim.
191
+ // Abort each job independently so one wedged join cannot skip the other,
192
+ // then drain leftovers; failures must not prevent `server.stop`.
193
+ stopStorageCleanupScheduler();
194
+ stopStateStoreSweeper();
195
+ cancelQueuedStorageWorkerSpawns();
196
+ const shutdownJoins = await Promise.allSettled([
197
+ abortStorageCleanupPolicyJobAsync(),
198
+ abortRestoreTrashJobAsync(),
199
+ ]);
200
+ for (const result of shutdownJoins) {
201
+ if (result.status === "rejected") {
202
+ console.warn(
203
+ "[storage] worker abort during shutdown failed:",
204
+ result.reason instanceof Error ? result.reason.message : result.reason,
205
+ );
206
+ }
207
+ }
208
+ try {
209
+ await drainStorageWorkers();
210
+ } catch (err) {
211
+ console.warn(
212
+ "[storage] worker drain during shutdown failed:",
213
+ err instanceof Error ? err.message : err,
214
+ );
215
+ }
216
+ setStorageCleanupPolicyLiveSink(null);
217
+ setStorageCleanupPolicyJobLiveApply(null);
218
+ } finally {
219
+ try {
220
+ // Bun's Server.stop returns Promise<void>; fire-and-forget races the next
221
+ // isolate reclaim / follow-on listen the same way unterminated Workers did.
222
+ if (s) await s.stop(true);
223
+ } finally {
224
+ draining = false;
84
225
  }
85
- activeTurns.clear();
86
226
  }
87
- // Debounced replay-state snapshot may still be pending; flush so the last completed turn's
88
- // previous_response_id chain survives the restart this shutdown is usually part of.
89
- await flushResponseState();
90
- // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
91
- stopStorageCleanupScheduler();
92
- abortStorageCleanupPolicyJob();
93
- setStorageCleanupPolicyLiveSink(null);
94
- setStorageCleanupPolicyJobLiveApply(null);
95
- s?.stop(true);
96
- draining = false;
97
227
  }
@@ -7,6 +7,7 @@ import {
7
7
  codexAutoStartEnabled,
8
8
  hasOwnProvider,
9
9
  isValidProviderName,
10
+ loadConfig,
10
11
  multiAgentGuidanceEnabled,
11
12
  providerBaseUrlConfigError,
12
13
  providerHeadersConfigError,
@@ -58,16 +59,68 @@ import { applySystemEnvToggle } from "../system-env";
58
59
 
59
60
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared";
60
61
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
62
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
61
63
 
62
- let grokApplyChain: Promise<unknown> = Promise.resolve();
63
- /**
64
- * Serializes Grok applies: injectGrokConfig is read-modify-write over a single file,
65
- * so two concurrent clicks must not interleave two cycles.
66
- */
67
- function queueGrokApply<T>(run: () => Promise<T>): Promise<T> {
68
- const next = grokApplyChain.then(run, run);
69
- grokApplyChain = next.catch(() => {});
70
- return next;
64
+ const GROK_APPLY_JOIN_MS = 120_000;
65
+ export const GROK_APPLY_TERMINAL_MS = 10 * 60_000;
66
+ const grokApplyEncoder = new TextEncoder();
67
+ let grokApplyFlight: { startedAt: number; promise: Promise<unknown>; bytes: number } | null = null;
68
+ let grokApplyHighWaterBytes = 0;
69
+ let grokApplyTestHooks: { now?: () => number; run?: () => Promise<unknown> } | null = null;
70
+
71
+ class GrokApplyBusyError extends Error {}
72
+
73
+ export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } {
74
+ return {
75
+ currentBytes: grokApplyFlight?.bytes ?? 0,
76
+ highWaterBytes: grokApplyHighWaterBytes,
77
+ active: grokApplyFlight ? 1 : 0,
78
+ };
79
+ }
80
+
81
+ function runGrokApplyFlight(): Promise<unknown> {
82
+ const at = grokApplyTestHooks?.now?.() ?? Date.now();
83
+ const current = grokApplyFlight;
84
+ if (current) {
85
+ const age = at - current.startedAt;
86
+ if (age < GROK_APPLY_JOIN_MS) return current.promise;
87
+ if (age <= GROK_APPLY_TERMINAL_MS) return Promise.reject(new GrokApplyBusyError("grok_apply_busy"));
88
+ // A permanently hung operation must not monopolize the singleton forever. Its
89
+ // eventual finally is identity-checked, so it cannot clear a replacement flight.
90
+ if (grokApplyFlight === current) grokApplyFlight = null;
91
+ }
92
+
93
+ const flight = { startedAt: at, promise: Promise.resolve() as Promise<unknown>, bytes: 0 };
94
+ flight.promise = (grokApplyTestHooks?.run ?? (async () => {
95
+ const [{ syncGrokConfig }, { readRuntimePort }] = await Promise.all([
96
+ import("../../grok/sync"),
97
+ import("../../config"),
98
+ ]);
99
+ const currentConfig = loadConfig();
100
+ const runtime = readRuntimePort(process.pid);
101
+ const port = runtime?.port ?? currentConfig.port;
102
+ const hostname = runtime?.hostname ?? currentConfig.hostname;
103
+ flight.bytes = grokApplyEncoder.encode(JSON.stringify(currentConfig)).byteLength
104
+ + grokApplyEncoder.encode(hostname ?? "").byteLength;
105
+ grokApplyHighWaterBytes = Math.max(grokApplyHighWaterBytes, flight.bytes);
106
+ return syncGrokConfig(port, currentConfig, hostname !== undefined ? { hostname } : {});
107
+ }))().finally(() => {
108
+ if (grokApplyFlight === flight) grokApplyFlight = null;
109
+ });
110
+ grokApplyFlight = flight;
111
+ return flight.promise;
112
+ }
113
+
114
+ export function runGrokApplyFlightForTests(): Promise<unknown> {
115
+ return runGrokApplyFlight();
116
+ }
117
+
118
+ export function setGrokApplyFlightTestHooks(
119
+ hooks: { now?: () => number; run?: () => Promise<unknown> } | null,
120
+ ): void {
121
+ grokApplyTestHooks = hooks;
122
+ grokApplyFlight = null;
123
+ grokApplyHighWaterBytes = 0;
71
124
  }
72
125
  import type { ManagementContext } from "./context";
73
126
 
@@ -80,12 +133,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
80
133
  if (config.claudeCode?.desktopAutoApply === false) return;
81
134
  if (!config.claudeCode?.desktopProfile) return;
82
135
  const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
83
- const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog");
136
+ const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
84
137
  const allModels = await fetchAllModels(config);
85
138
  const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
86
139
  const result = writeDesktop3pConfig(
87
140
  config.port ?? 10100,
88
- [...visibleNativeSlugs(config)],
141
+ [...desktopVisibleNativeSlugs(config)],
89
142
  routed,
90
143
  config.apiKeys?.[0]?.key,
91
144
  "static",
@@ -131,7 +184,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
131
184
  agentsMaxDepth?: unknown;
132
185
  subagentDeveloperInstructions?: unknown;
133
186
  };
134
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
187
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
135
188
  const wantsFlag = body.enabled !== undefined;
136
189
  const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
137
190
  const wantsMode = body.multiAgentMode !== undefined;
@@ -275,7 +328,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
275
328
  }
276
329
  if (url.pathname === "/api/injection-model" && req.method === "PUT") {
277
330
  let parsedBody: unknown;
278
- try { parsedBody = await req.json(); } catch {
331
+ try { parsedBody = await readManagementJsonBody(req); } catch (error) {
332
+ rethrowManagementBodyTooLarge(error);
279
333
  return jsonResponse({ error: "invalid JSON body" }, 400);
280
334
  }
281
335
  if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
@@ -374,7 +428,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
374
428
  }
375
429
  if (url.pathname === "/api/effort-caps" && req.method === "PUT") {
376
430
  let body: { effortCap?: unknown; subagentEffortCap?: unknown };
377
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
431
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
378
432
  const { isCodexReasoningEffort } = await import("../../reasoning-effort");
379
433
  for (const key of ["effortCap", "subagentEffortCap"] as const) {
380
434
  if (!(key in body)) continue;
@@ -410,7 +464,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
410
464
  }
411
465
  if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
412
466
  let body: { models?: unknown };
413
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
467
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
414
468
  const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : [];
415
469
  config.subagentModels = chosen;
416
470
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
@@ -444,8 +498,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
444
498
  if (url.pathname === "/api/subagent-model-fallback" && req.method === "PUT") {
445
499
  let body: { models?: unknown; pollMs?: unknown };
446
500
  try {
447
- body = await req.json();
448
- } catch {
501
+ body = await readManagementJsonBody(req);
502
+ } catch (error) {
503
+ rethrowManagementBodyTooLarge(error);
449
504
  return jsonResponse({ error: "invalid JSON body" }, 400);
450
505
  }
451
506
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -513,7 +568,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
513
568
  // injectGrokConfig, through the apply route below — this route cannot touch that file.
514
569
  if (url.pathname === "/api/grok/selection" && req.method === "PUT") {
515
570
  let body: { excluded?: unknown };
516
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
571
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
517
572
  const raw = body.excluded;
518
573
  if (!Array.isArray(raw) || raw.some(entry => typeof entry !== "string" || entry.length === 0)) {
519
574
  return jsonResponse({ error: "excluded must be an array of model ids" }, 400);
@@ -533,20 +588,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
533
588
  // duplicated here. Accepts no body: every input comes from persisted state.
534
589
  if (url.pathname === "/api/grok/apply" && req.method === "POST") {
535
590
  try {
536
- const { syncGrokConfig } = await import("../../grok/sync");
537
- const { readRuntimePort } = await import("../../config");
538
- // The host/port the proxy ACTUALLY bound — not the request authority (caller-
539
- // influenced) and not config.hostname, which sync.ts warns may have drifted.
540
- // `ocx ensure` passes live.hostname for the same reason; the runtime-port record
541
- // is the in-process equivalent, written at startup.
542
- const runtime = readRuntimePort(process.pid);
543
- const port = runtime?.port ?? config.port;
544
- const hostname = runtime?.hostname ?? config.hostname;
545
- const result = await queueGrokApply(() => syncGrokConfig(
546
- port,
547
- config,
548
- hostname !== undefined ? { hostname } : {},
549
- ));
591
+ const result = await runGrokApplyFlight() as Awaited<ReturnType<typeof import("../../grok/sync")["syncGrokConfig"]>>;
550
592
  // A policy skip (non-loopback, no ~/.grok) is not a server error: report it as a
551
593
  // result the page can explain rather than a 500 the user cannot act on.
552
594
  return jsonResponse({
@@ -556,6 +598,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
556
598
  ...(result.skippedReason ? { skippedReason: result.skippedReason } : {}),
557
599
  }, result.ok ? 200 : 500);
558
600
  } catch (error) {
601
+ if (error instanceof GrokApplyBusyError) return jsonResponse({ error: "grok_apply_busy" }, 409);
559
602
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
560
603
  }
561
604
  }
@@ -572,7 +615,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
572
615
  }
573
616
  if (url.pathname === "/api/claude-desktop" && req.method === "PUT") {
574
617
  let body: { profile?: unknown };
575
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
618
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
576
619
  try {
577
620
  const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile");
578
621
  const parsed = parseDesktopProfile(body.profile);
@@ -607,7 +650,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
607
650
  config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
608
651
  saveConfigPreservingClaudeCode(config);
609
652
  const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
610
- const { visibleNativeSlugs } = await import("../../codex/catalog");
653
+ const { desktopVisibleNativeSlugs } = await import("../../codex/catalog");
611
654
  const routed = state.models
612
655
  .filter(model => model.available && !model.route.startsWith("native/"))
613
656
  .map(model => {
@@ -616,7 +659,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
616
659
  });
617
660
  const result = writeDesktop3pConfig(
618
661
  Number(url.port) || config.port,
619
- [...visibleNativeSlugs(config)],
662
+ [...desktopVisibleNativeSlugs(config)],
620
663
  routed,
621
664
  config.apiKeys?.[0]?.key,
622
665
  "static",
@@ -770,7 +813,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
770
813
  // regardless on 2.1.207). PUT keeps validating them so hand-written configs
771
814
  // and older GUIs stay safe; GUI saves omit them and the spread preserves them.
772
815
  let parsedBody: unknown;
773
- try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
816
+ try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
774
817
  const isPlainObject = (value: unknown): value is Record<string, unknown> => {
775
818
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
776
819
  const prototype = Object.getPrototypeOf(value);
@@ -0,0 +1,167 @@
1
+ import {
2
+ currentUsageLogRevision,
3
+ readUsageSnapshotForManagement,
4
+ usageLogRevisionKey,
5
+ type PersistedUsageEntry,
6
+ } from "../../usage/log";
7
+
8
+ /**
9
+ * Per-key usage as the API tab renders it.
10
+ *
11
+ * A discriminated union rather than numbers with a flag beside them: when two
12
+ * config entries share an id there IS no per-key total, and an optional marker
13
+ * next to `requests7d: 7` invites a consumer to render the 7 anyway.
14
+ */
15
+ export type ApiKeyUsage =
16
+ | { ambiguous: true }
17
+ | { ambiguous?: false; requests7d: number; totalRequests: number; lastUsedAt?: string };
18
+
19
+ export interface ApiKeyUsageSnapshot {
20
+ rollup: Map<string, ApiKeyUsage>;
21
+ historyTruncated?: true;
22
+ /**
23
+ * Earliest row carrying a recognized `admissionKind`. A property of the DATA
24
+ * SET, not of a key, so it is singular and lives beside the map: it is what
25
+ * lets the GUI tell "this key was used zero times" from "nothing is
26
+ * attributable yet". Keyed on the kind rather than on `apiKeyId`, because an
27
+ * environment or loopback row is attributed traffic with no configured key.
28
+ */
29
+ attributionSince?: string;
30
+ }
31
+
32
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
33
+
34
+ /**
35
+ * A timestamp we can actually do date arithmetic with.
36
+ *
37
+ * `usage.jsonl` is hand-editable and JSON permits numbers outside the Date
38
+ * range: `1e309` survives normalization and then throws `RangeError` from
39
+ * `toISOString()`. Since the caller catches to protect key management, one bad
40
+ * row would have zeroed the rollup for EVERY key — active keys reported as
41
+ * unused is exactly the wrong answer to hand someone deciding what to delete.
42
+ */
43
+ function usableTimestamp(value: unknown): number | null {
44
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
45
+ return Number.isNaN(new Date(value).getTime()) ? null : value;
46
+ }
47
+
48
+ /**
49
+ * Pure: one pass over an already-read snapshot, so it is unit-testable without
50
+ * touching the filesystem.
51
+ *
52
+ * Rows are bucketed only when `admissionKind === "configured"`. Keying on
53
+ * `apiKeyId` alone would let a hand-edited entry whose id is `loopback` absorb
54
+ * traffic it never admitted.
55
+ */
56
+ export function rollupApiKeyUsage(
57
+ entries: PersistedUsageEntry[],
58
+ configuredIds: string[],
59
+ now: number = Date.now(),
60
+ ): ApiKeyUsageSnapshot {
61
+ const duplicated = new Set<string>();
62
+ const seen = new Set<string>();
63
+ for (const id of configuredIds) {
64
+ if (seen.has(id)) duplicated.add(id);
65
+ seen.add(id);
66
+ }
67
+
68
+ const totals = new Map<string, { requests7d: number; totalRequests: number; lastUsedAt?: string }>();
69
+ let attributionSince: number | undefined;
70
+ const cutoff = now - SEVEN_DAYS_MS;
71
+
72
+ for (const entry of entries) {
73
+ if (!entry.admissionKind) continue;
74
+ const timestamp = usableTimestamp(entry.timestamp);
75
+ if (timestamp !== null && (attributionSince === undefined || timestamp < attributionSince)) {
76
+ attributionSince = timestamp;
77
+ }
78
+ if (entry.admissionKind !== "configured" || !entry.apiKeyId) continue;
79
+
80
+ const bucket = totals.get(entry.apiKeyId) ?? { requests7d: 0, totalRequests: 0 };
81
+ // The request happened even if its clock reading is unusable, so it still
82
+ // counts toward the total; only the time-based fields are skipped.
83
+ bucket.totalRequests += 1;
84
+ if (timestamp !== null) {
85
+ if (timestamp >= cutoff) bucket.requests7d += 1;
86
+ const iso = new Date(timestamp).toISOString();
87
+ if (!bucket.lastUsedAt || iso > bucket.lastUsedAt) bucket.lastUsedAt = iso;
88
+ }
89
+ totals.set(entry.apiKeyId, bucket);
90
+ }
91
+
92
+ const rollup = new Map<string, ApiKeyUsage>();
93
+ for (const id of configuredIds) {
94
+ if (duplicated.has(id)) {
95
+ rollup.set(id, { ambiguous: true });
96
+ continue;
97
+ }
98
+ rollup.set(id, totals.get(id) ?? { requests7d: 0, totalRequests: 0 });
99
+ }
100
+ return {
101
+ rollup,
102
+ ...(attributionSince !== undefined ? { attributionSince: new Date(attributionSince).toISOString() } : {}),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Rollup cache keyed by the exact usage-log revision, mirroring the /api/usage
108
+ * summary cache. Without it, every key-list read reparses an append-only log
109
+ * that only ever grows — and the GUI fetches this route on mount and after every
110
+ * create/rename/delete. The compact rollup is a handful of counters per key, so
111
+ * caching it costs nothing; a new row changes the revision and invalidates it.
112
+ */
113
+ let rollupCache: { revisionKey: string; expiresAt: number; snapshot: ApiKeyUsageSnapshot } | null = null;
114
+
115
+ /**
116
+ * The rollup is a function of the log AND of the clock: a request ages out of
117
+ * the seven-day window with no write to bump the file revision, so a purely
118
+ * revision-keyed entry would report a stale `requests7d` indefinitely.
119
+ *
120
+ * A minute is the whole rule. Deriving the exact next-transition instant would
121
+ * mean tracking the OLDEST counted request per key, which the compact rollup
122
+ * deliberately does not keep — and a count that can be at most 60s stale is
123
+ * already far tighter than the window it describes.
124
+ */
125
+ const ROLLUP_CACHE_TTL_MS = 60_000;
126
+
127
+ /** Test seam: the cache is module state and would otherwise leak between cases. */
128
+ export function clearApiKeyUsageCacheForTests(): void {
129
+ rollupCache = null;
130
+ }
131
+
132
+ /**
133
+ * Reads the durable usage snapshot the way /api/usage does, then rolls it up.
134
+ *
135
+ * Never throws: an unreadable snapshot yields empty rollups and no
136
+ * `attributionSince`. Key management working matters more than usage numbers
137
+ * being present, and the GUI already treats an absent field as "no data".
138
+ */
139
+ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadBytes?: number): Promise<ApiKeyUsageSnapshot> {
140
+ // JSON rather than a joined string: ids are only validated as non-empty
141
+ // strings, so `["a\0b","c"]` and `["a","b\0c"]` join to the same value and one
142
+ // config could be served the other's cached rollup.
143
+ const idsKey = JSON.stringify([configuredIds, maxReadBytes]);
144
+ const now = Date.now();
145
+ try {
146
+ const observedKey = `${usageLogRevisionKey(currentUsageLogRevision())}|${idsKey}`;
147
+ if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt) {
148
+ return rollupCache.snapshot;
149
+ }
150
+
151
+ const snapshot = await readUsageSnapshotForManagement(maxReadBytes);
152
+ const rolled = {
153
+ ...rollupApiKeyUsage(snapshot.entries, configuredIds, now),
154
+ ...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}),
155
+ };
156
+ rollupCache = {
157
+ revisionKey: `${usageLogRevisionKey(snapshot.revision)}|${idsKey}`,
158
+ expiresAt: now + ROLLUP_CACHE_TTL_MS,
159
+ snapshot: rolled,
160
+ };
161
+ return rolled;
162
+ } catch {
163
+ const rollup = new Map<string, ApiKeyUsage>();
164
+ for (const id of configuredIds) rollup.set(id, { requests7d: 0, totalRequests: 0 });
165
+ return { rollup };
166
+ }
167
+ }
@@ -0,0 +1,35 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { jsonResponse } from "../auth-cors";
3
+ import {
4
+ DecompressedBodyTooLargeError,
5
+ readBoundedJsonRequestBody,
6
+ } from "../request-decompress";
7
+
8
+ export const MANAGEMENT_JSON_BODY_MAX_BYTES = 4 * 1024 * 1024;
9
+
10
+ export function readManagementJsonBody<T = unknown>(req: Request): Promise<T> {
11
+ return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES) as Promise<T>;
12
+ }
13
+
14
+ export function managementBodyTooLargeResponse(
15
+ error: unknown,
16
+ req: Request,
17
+ config: OcxConfig,
18
+ ): Response | null {
19
+ return error instanceof DecompressedBodyTooLargeError
20
+ ? jsonResponse({ error: "request body too large" }, 413, req, config)
21
+ : null;
22
+ }
23
+
24
+ export function rethrowManagementBodyTooLarge(error: unknown): void {
25
+ if (error instanceof DecompressedBodyTooLargeError) throw error;
26
+ }
27
+
28
+ export async function readManagementJsonBodyOr<T>(req: Request, fallback: T): Promise<unknown | T> {
29
+ try {
30
+ return await readManagementJsonBody(req);
31
+ } catch (error) {
32
+ rethrowManagementBodyTooLarge(error);
33
+ return fallback;
34
+ }
35
+ }