@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
package/src/config.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, truncateSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
5
  import { join, resolve } from "node:path";
6
+ import { Database } from "bun:sqlite";
5
7
  import * as z from "zod/v4";
6
8
  import {
7
9
  CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
@@ -11,7 +13,13 @@ import {
11
13
  MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
12
14
  } from "./codex/account-namespace-match";
13
15
  import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
14
- import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl";
16
+ import {
17
+ forgetHardenedSecretPath,
18
+ hardenSecretDir,
19
+ hardenSecretPath,
20
+ hardenSecretPathAsync,
21
+ windowsSecretAclApplies,
22
+ } from "./lib/windows-secret-acl";
15
23
  import { recordOwnedConfigPath } from "./lib/config-ownership";
16
24
  import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
17
25
  import { providerDestinationConfigError } from "./lib/destination-policy";
@@ -23,11 +31,17 @@ import {
23
31
  REASONING_SUMMARY_DELIVERY_VALUES,
24
32
  type OcxClaudeCodeConfig,
25
33
  type OcxConfig,
34
+ type OcxApiKeyEntry,
26
35
  type OcxProviderConfig,
27
36
  } from "./types";
28
37
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
29
38
  import { parseDesktopProfile } from "./claude/desktop-profile";
30
39
  import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort";
40
+ import {
41
+ DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
42
+ MAX_APP_OWNED_MEMORY_BUDGET_MB,
43
+ MIN_APP_OWNED_MEMORY_BUDGET_MB,
44
+ } from "./lib/app-owned-memory";
31
45
 
32
46
  let _atomicSeq = 0;
33
47
 
@@ -108,6 +122,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
108
122
  io.harden(tmp);
109
123
  hardened = true;
110
124
  io.rename(tmp, path);
125
+ forgetHardenedSecretPath(tmp);
111
126
  } catch (cause) {
112
127
  let scrubbed = false;
113
128
  try {
@@ -134,6 +149,7 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO
134
149
  if (!removed && !hardened) {
135
150
  try { io.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
136
151
  }
152
+ if (removed) forgetHardenedSecretPath(tmp);
137
153
  if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
138
154
  throw cause;
139
155
  }
@@ -192,6 +208,7 @@ export async function atomicWriteFileAsync(
192
208
  await effective.harden(tmp);
193
209
  hardened = true;
194
210
  await effective.rename(tmp, path);
211
+ forgetHardenedSecretPath(tmp);
195
212
  } catch (cause) {
196
213
  let scrubbed = false;
197
214
  try {
@@ -218,6 +235,7 @@ export async function atomicWriteFileAsync(
218
235
  if (!removed && !hardened) {
219
236
  try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ }
220
237
  }
238
+ if (removed) forgetHardenedSecretPath(tmp);
221
239
  if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
222
240
  throw cause;
223
241
  }
@@ -292,7 +310,10 @@ export function backupConfigBeforeOpenAiTierMigration(
292
310
  write: (target, bytes) => writeFileSync(target, bytes),
293
311
  harden: target => {
294
312
  try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
295
- if (process.platform === "win32") hardenSecretPath(target, { required: true });
313
+ // Soft-fail: a wedged/failed icacls on CI temp volumes must not abort
314
+ // startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR).
315
+ // chmod above still applies; live credential writes keep required:true.
316
+ if (process.platform === "win32") hardenSecretPath(target, { required: false });
296
317
  },
297
318
  publishNoReplace: (temp, backup) => linkSync(temp, backup),
298
319
  truncate: target => truncateSync(target, 0),
@@ -331,7 +352,6 @@ export function backupConfigBeforeOpenAiTierMigration(
331
352
 
332
353
  const scrubUnpublishedTemp = (): void => {
333
354
  cleanupAttempted = true;
334
- if (!io.exists(temp)) return;
335
355
  let scrubbed = false;
336
356
  try {
337
357
  io.truncate(temp);
@@ -347,14 +367,19 @@ export function backupConfigBeforeOpenAiTierMigration(
347
367
  io.unlink(temp);
348
368
  removed = true;
349
369
  } catch (error) {
350
- if (isMissingPathError(error) || !io.exists(temp)) removed = true;
370
+ if (isMissingPathError(error)) {
371
+ removed = true;
372
+ }
351
373
  else {
352
374
  try { io.unlink(temp); removed = true; }
353
375
  catch (retryError) {
354
- if (isMissingPathError(retryError) || !io.exists(temp)) removed = true;
376
+ if (isMissingPathError(retryError)) {
377
+ removed = true;
378
+ }
355
379
  }
356
380
  }
357
381
  }
382
+ if (removed) forgetHardenedSecretPath(temp);
358
383
  if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
359
384
  if (!removed) throw new OpenAiTierBackupCleanupError();
360
385
  };
@@ -375,10 +400,18 @@ export function backupConfigBeforeOpenAiTierMigration(
375
400
  published = true;
376
401
  try {
377
402
  io.unlink(temp);
378
- } catch {
379
- try {
403
+ forgetHardenedSecretPath(temp);
404
+ } catch (firstError) {
405
+ if (isMissingPathError(firstError)) {
406
+ forgetHardenedSecretPath(temp);
407
+ } else try {
380
408
  io.unlink(temp);
381
- } catch {
409
+ forgetHardenedSecretPath(temp);
410
+ } catch (secondError) {
411
+ if (isMissingPathError(secondError)) {
412
+ forgetHardenedSecretPath(temp);
413
+ return "created";
414
+ }
382
415
  // temp and backup are hard links to the same inode. Roll back the backup
383
416
  // link before any truncation so the downgrade snapshot is never zeroed.
384
417
  try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); }
@@ -430,12 +463,25 @@ function resolveRuntimePortPath(): string {
430
463
  }
431
464
 
432
465
  const warnedConfigFallbacks = new Set<string>();
466
+ let lastWarningReconciledGeneration = 0;
467
+
468
+ export function reconcileConfigWarningMemos(generation: number): number {
469
+ if (generation <= lastWarningReconciledGeneration) return 0;
470
+ const removed = warnedConfigFallbacks.size;
471
+ warnedConfigFallbacks.clear();
472
+ lastWarningReconciledGeneration = generation;
473
+ return removed;
474
+ }
433
475
 
434
476
  const providerConfigSchema = z.object({
435
477
  adapter: z.string().min(1),
436
478
  baseUrl: z.string().min(1),
479
+ mcpMaxTools: z.number().int().positive().optional(),
480
+ mcpMaxSchemaBytes: z.number().int().positive().optional(),
481
+ mcpMaxResultBytes: z.number().int().positive().optional(),
437
482
  apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
438
483
  responsesPath: z.string().min(1).optional(),
484
+ statelessResponses: z.boolean().optional(),
439
485
  allowPrivateNetwork: z.boolean().optional(),
440
486
  codexAccountMode: z.enum(["pool", "direct"]).optional(),
441
487
  responsesItemIdRepair: z.object({
@@ -666,8 +712,44 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
666
712
  }
667
713
  }).pipe(z.record(z.string(), z.string()));
668
714
 
715
+ /**
716
+ * Deliberately permissive. A user's config is not ours to invalidate: a strict
717
+ * entry fails the whole parse, and loadConfig's fallback then backs the file up
718
+ * and returns defaults — losing providers and pool accounts because one key name
719
+ * was too long. Length and charset rules live at the POST/PATCH boundary, where
720
+ * rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key
721
+ * properties across a load -> mutate -> save round trip.
722
+ *
723
+ * Only `key` is load-bearing: admission compares that string and nothing else
724
+ * (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one
725
+ * field that must be a usable string, and every piece of metadata around it
726
+ * degrades instead of taking the credential down with it. Dropping a working key
727
+ * because its `name` was hand-edited to a number would be a silent revocation —
728
+ * and on a remote bind, potentially a server that refuses to start.
729
+ *
730
+ * "Usable" matches admission exactly. The presented token is trimmed before the
731
+ * comparison but the stored value is not, so a key with surrounding whitespace
732
+ * can never match either form of itself. Keeping one would be worse than dropping
733
+ * it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
734
+ * clients, so a junk first entry would mask a valid later one.
735
+ */
736
+ const apiKeyEntrySchema = z.object({
737
+ key: z.string().refine(isUsableApiKeySecret),
738
+ // Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
739
+ // which fills it deterministically so the id is stable across loads.
740
+ id: z.string().catch(""),
741
+ name: z.string().catch(""),
742
+ createdAt: z.string().catch(""),
743
+ }).passthrough();
744
+
669
745
  const configSchema = z.object({
670
746
  port: z.number().int().min(0).max(65535).default(10100),
747
+ managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
748
+ appOwnedMemoryBudgetMb: z.number().int()
749
+ .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
750
+ .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
751
+ .default(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024))
752
+ .catch(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)),
671
753
  // A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
672
754
  // carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one
673
755
  // away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to
@@ -701,6 +783,23 @@ const configSchema = z.object({
701
783
  // non-string must not trip the backup-and-defaults repair path. Unset then
702
784
  // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
703
785
  experimentalRealtimeWsBaseUrl: z.string().optional().catch(undefined),
786
+ // Salvage element by element, and never fail the parse. Two spellings were
787
+ // measured on this zod version and both lose data:
788
+ // `z.array(entry).catch(undefined)` -> one bad entry discards EVERY key
789
+ // `z.array(z.unknown())` -> a non-array value still raises
790
+ // invalid_type, reaching the
791
+ // backup-and-defaults repair path
792
+ // Starting from `unknown` is what makes both survivable. A key the user still
793
+ // has deployed must not be collateral damage for one bad neighbour, and on a
794
+ // remote bind an emptied array is worse than cosmetic: assertServerAuthConfig
795
+ // refuses to start without a data credential.
796
+ apiKeys: z.unknown().optional().transform(value => {
797
+ if (value === undefined) return undefined;
798
+ if (!Array.isArray(value)) return undefined;
799
+ return value
800
+ .filter(row => apiKeyEntrySchema.safeParse(row).success)
801
+ .map(row => apiKeyEntrySchema.parse(row) as OcxApiKeyEntry);
802
+ }),
704
803
  }).passthrough().superRefine((config, ctx) => {
705
804
  const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
706
805
  if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
@@ -1016,6 +1115,97 @@ function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
1016
1115
  }
1017
1116
  }
1018
1117
 
1118
+ /**
1119
+ * The apiKeys schema salvages entry by entry rather than failing the parse, so a
1120
+ * dropped key is otherwise invisible — and it will not be re-saved by the next
1121
+ * mutation. Say so out loud. Compares the raw array against the validated one,
1122
+ * the same shape as the degrade warnings above.
1123
+ */
1124
+ /** One definition of "usable secret", shared by the schema and the warnings. */
1125
+ function isUsableApiKeySecret(value: unknown): value is string {
1126
+ return typeof value === "string" && value.length > 0 && value === value.trim();
1127
+ }
1128
+
1129
+ /**
1130
+ * Give every salvaged key a stable, targetable id.
1131
+ *
1132
+ * Pure and deterministic on purpose. Two earlier spellings were wrong: minting a
1133
+ * UUID inside the schema transform handed out a different id on every parse, and
1134
+ * repairing-then-writing during `loadConfig` put a file write on the read path,
1135
+ * where it could clobber a concurrent legitimate save with a stale snapshot.
1136
+ *
1137
+ * So the replacement id is derived from the entry's position, which is already
1138
+ * how the file orders these rows: same file in, same ids out, no I/O and no
1139
+ * randomness. It is not derived from the secret — a public identifier should
1140
+ * never be a function of key material.
1141
+ */
1142
+ function normalizeApiKeyIds(config: OcxConfig): OcxConfig {
1143
+ const keys = config.apiKeys;
1144
+ if (!keys?.length) return config;
1145
+ // Reserve every explicit id BEFORE synthesizing any, or a synthetic
1146
+ // `salvaged-1` assigned to row 1 would push a row that legitimately owns that
1147
+ // id onto `salvaged-2`. An id the user already has is the one thing this
1148
+ // repair must never take away.
1149
+ const reserved = new Set<string>();
1150
+ for (const entry of keys) {
1151
+ if (entry.id) reserved.add(entry.id);
1152
+ }
1153
+ const taken = new Set<string>(reserved);
1154
+ const kept = new Set<string>();
1155
+ keys.forEach((entry, index) => {
1156
+ // The first row holding an explicit id keeps it; later collisions are the
1157
+ // ones that move.
1158
+ if (entry.id && !kept.has(entry.id)) {
1159
+ kept.add(entry.id);
1160
+ return;
1161
+ }
1162
+ let candidate = `salvaged-${index + 1}`;
1163
+ let suffix = 1;
1164
+ while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`;
1165
+ entry.id = candidate;
1166
+ taken.add(candidate);
1167
+ kept.add(candidate);
1168
+ });
1169
+ return config;
1170
+ }
1171
+
1172
+ function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void {
1173
+ if (!rawParsed || typeof rawParsed !== "object") return;
1174
+ const raw = (rawParsed as Record<string, unknown>).apiKeys;
1175
+ if (raw === undefined) return;
1176
+ if (!Array.isArray(raw)) {
1177
+ console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`);
1178
+ return;
1179
+ }
1180
+ const dropped = raw.length - (validated.apiKeys?.length ?? 0);
1181
+ if (dropped > 0) {
1182
+ console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`);
1183
+ }
1184
+ // Same-length repairs are invisible to the count above, and they are the ones
1185
+ // that show up as a blank name or an unknown date in the dashboard. Say so.
1186
+ const repaired = raw.filter(row => {
1187
+ if (!row || typeof row !== "object") return false;
1188
+ const entry = row as Record<string, unknown>;
1189
+ // Must match the schema exactly: a row whose key is unusable was DROPPED, and
1190
+ // saying "the key still works" about it would be a lie.
1191
+ if (!isUsableApiKeySecret(entry.key)) return false;
1192
+ return typeof entry.id !== "string" || !entry.id
1193
+ || typeof entry.name !== "string"
1194
+ || typeof entry.createdAt !== "string";
1195
+ }).length;
1196
+ if (repaired > 0) {
1197
+ console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`);
1198
+ }
1199
+ // A duplicate id is repaired too, and it is not visible in either count above.
1200
+ const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record<string, unknown>).key))
1201
+ .map(row => (row as Record<string, unknown>).id)
1202
+ .filter((id): id is string => typeof id === "string" && !!id);
1203
+ const duplicates = ids.length - new Set(ids).size;
1204
+ if (duplicates > 0) {
1205
+ console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`);
1206
+ }
1207
+ }
1208
+
1019
1209
  const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
1020
1210
 
1021
1211
  function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
@@ -1125,9 +1315,10 @@ export function loadConfig(): OcxConfig {
1125
1315
  const parsed = JSON.parse(raw);
1126
1316
  const result = configSchema.safeParse(parsed);
1127
1317
  if (result.success) {
1128
- const config = result.data as OcxConfig;
1318
+ const config = normalizeApiKeyIds(result.data as OcxConfig);
1129
1319
  warnDegradedStreamMode(parsed, config);
1130
1320
  warnDegradedHostname(parsed, config);
1321
+ warnDegradedApiKeys(parsed, config);
1131
1322
  warnDegradedClaudeSubagentEffort(parsed);
1132
1323
  warnDegradedNativeSubagentConfig(parsed, config);
1133
1324
  return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
@@ -1144,8 +1335,9 @@ export function loadConfig(): OcxConfig {
1144
1335
  const retryResult = configSchema.safeParse(merged);
1145
1336
  if (retryResult.success) {
1146
1337
  warnConfigRepaired(configPath, result.error);
1147
- const config = retryResult.data as OcxConfig;
1338
+ const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
1148
1339
  warnDegradedHostname(parsed, config);
1340
+ warnDegradedApiKeys(parsed, config);
1149
1341
  warnDegradedClaudeSubagentEffort(parsed);
1150
1342
  warnDegradedNativeSubagentConfig(parsed, config);
1151
1343
  return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed);
@@ -1167,6 +1359,12 @@ export type ConfigDiagnostics = {
1167
1359
  warnings?: string[];
1168
1360
  };
1169
1361
 
1362
+ type ConfigFileSnapshot = {
1363
+ diagnostics: ConfigDiagnostics;
1364
+ /** Exact file contents, including a possible BOM, used as the optimistic revision. */
1365
+ raw?: string;
1366
+ };
1367
+
1170
1368
  function configPlaceholderWarnings(config: OcxConfig): string[] {
1171
1369
  const warnings: string[] = [];
1172
1370
  for (const [name, provider] of Object.entries(config.providers)) {
@@ -1248,31 +1446,37 @@ function claudeSubagentEffortError(value: unknown): string | null {
1248
1446
  return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
1249
1447
  }
1250
1448
 
1449
+ function appOwnedMemoryBudgetError(value: unknown): string | null {
1450
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1451
+ const budget = (value as Record<string, unknown>).appOwnedMemoryBudgetMb;
1452
+ if (budget === undefined) return null;
1453
+ if (typeof budget !== "number" || !Number.isInteger(budget)
1454
+ || budget < MIN_APP_OWNED_MEMORY_BUDGET_MB || budget > MAX_APP_OWNED_MEMORY_BUDGET_MB) {
1455
+ return `schema_invalid: appOwnedMemoryBudgetMb: must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}`;
1456
+ }
1457
+ return null;
1458
+ }
1459
+
1251
1460
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
1252
1461
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
1253
- const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value);
1462
+ const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value);
1254
1463
  if (boundaryError) return { ok: false, error: boundaryError };
1255
1464
  const result = configSchema.safeParse(value);
1256
- if (result.success) return { ok: true, config: result.data as OcxConfig };
1465
+ if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
1257
1466
  return { ok: false, error: schemaDiagnosticsError(result.error) };
1258
1467
  }
1259
1468
 
1260
- export function readConfigDiagnostics(): ConfigDiagnostics {
1261
- const configPath = getConfigPath();
1262
- if (!existsSync(configPath)) {
1263
- return { config: getDefaultConfig(), source: "default", error: null };
1264
- }
1469
+ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
1265
1470
  try {
1266
- const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
1267
- const parsed = JSON.parse(raw);
1471
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
1268
1472
  const result = configSchema.safeParse(parsed);
1269
1473
  if (result.success) {
1270
- return validFileConfigDiagnostics(result.data as OcxConfig, parsed);
1474
+ return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
1271
1475
  }
1272
1476
 
1273
1477
  const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
1274
1478
  if (retryResult.success) {
1275
- return validFileConfigDiagnostics(retryResult.data as OcxConfig, parsed);
1479
+ return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
1276
1480
  }
1277
1481
 
1278
1482
  return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
@@ -1281,23 +1485,224 @@ export function readConfigDiagnostics(): ConfigDiagnostics {
1281
1485
  }
1282
1486
  }
1283
1487
 
1284
- export function saveConfig(config: OcxConfig): void {
1488
+ function readConfigFileSnapshot(): ConfigFileSnapshot {
1489
+ try {
1490
+ const raw = readFileSync(getConfigPath(), "utf-8");
1491
+ return { diagnostics: configDiagnosticsFromRaw(raw), raw };
1492
+ } catch (error) {
1493
+ if (isMissingPathError(error)) {
1494
+ return {
1495
+ diagnostics: { config: getDefaultConfig(), source: "default", error: null },
1496
+ };
1497
+ }
1498
+ return {
1499
+ diagnostics: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
1500
+ };
1501
+ }
1502
+ }
1503
+
1504
+ export function readConfigDiagnostics(): ConfigDiagnostics {
1505
+ return readConfigFileSnapshot().diagnostics;
1506
+ }
1507
+
1508
+ const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
1509
+ const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
1510
+ let warnedConfigMutationDirectoryAcl = false;
1511
+
1512
+ export class ConfigMutationLockError extends Error {
1513
+ readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE";
1514
+
1515
+ constructor(message: string, options?: { cause?: unknown }) {
1516
+ super(message, options);
1517
+ this.name = "ConfigMutationLockError";
1518
+ }
1519
+ }
1520
+
1521
+ function configMutationDatabasePath(): string {
1285
1522
  const dir = getConfigDir();
1286
- // First statement on purpose: a rejected write must leave nothing behind, not a
1287
- // freshly created/chmod'd directory. See src/lib/test-home-guard.ts.
1523
+ // First statement on purpose: a rejected mutation must leave nothing behind, not a
1524
+ // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
1288
1525
  assertNotRealHomeUnderTest(dir);
1289
1526
  if (!existsSync(dir)) {
1290
1527
  mkdirSync(dir, { recursive: true, mode: 0o700 });
1291
1528
  } else {
1292
1529
  try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
1293
1530
  }
1294
- if (process.platform === "win32") {
1295
- hardenSecretDir(dir, { required: true });
1531
+ if (windowsSecretAclApplies()) {
1532
+ try {
1533
+ // Distinct timeout memo from management-token directory harden: a required
1534
+ // management-dir timeout must not poison config mutation on the same home
1535
+ // (windows-latest server-management-auth cases).
1536
+ hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` });
1537
+ } catch (error) {
1538
+ if (!warnedConfigMutationDirectoryAcl) {
1539
+ warnedConfigMutationDirectoryAcl = true;
1540
+ const diagnostics = error instanceof Error ? error.message : "ACL hardening failed";
1541
+ console.warn(
1542
+ `[opencodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`,
1543
+ );
1544
+ }
1545
+ }
1546
+ }
1547
+ const path = join(dir, CONFIG_MUTATION_DB_FILENAME);
1548
+ recordOwnedConfigPath(dir, path);
1549
+ for (const suffix of CONFIG_MUTATION_DB_SIDECARS) {
1550
+ recordOwnedConfigPath(dir, `${path}${suffix}`);
1551
+ }
1552
+ return path;
1553
+ }
1554
+
1555
+ let configMutationLockDepth = 0;
1556
+
1557
+ /**
1558
+ * Serialize synchronous config and Codex credential-generation commits across processes with an
1559
+ * OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must
1560
+ * fail immediately under contention rather than freeze the Bun event loop. Process exit releases
1561
+ * SQLite locks without stale-owner deletion or lease recovery races.
1562
+ *
1563
+ * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
1564
+ */
1565
+ export function withConfigMutationLockSync<T>(fn: () => T): T {
1566
+ if (configMutationLockDepth > 0) {
1567
+ configMutationLockDepth += 1;
1568
+ try {
1569
+ return fn();
1570
+ } finally {
1571
+ configMutationLockDepth -= 1;
1572
+ }
1573
+ }
1574
+ const path = configMutationDatabasePath();
1575
+ let database: Database | undefined;
1576
+ let transactionOpen = false;
1577
+ try {
1578
+ database = new Database(path, { create: true });
1579
+ try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
1580
+ database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
1581
+ transactionOpen = true;
1582
+ } catch (cause) {
1583
+ try { database?.close(); } catch { /* acquisition already failed */ }
1584
+ const code = cause && typeof cause === "object" && "code" in cause
1585
+ ? String((cause as { code?: unknown }).code)
1586
+ : "";
1587
+ throw new ConfigMutationLockError(
1588
+ code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction",
1589
+ { cause },
1590
+ );
1591
+ }
1592
+
1593
+ configMutationLockDepth = 1;
1594
+ try {
1595
+ const value = fn();
1596
+ database.exec("COMMIT");
1597
+ transactionOpen = false;
1598
+ return value;
1599
+ } catch (error) {
1600
+ if (transactionOpen) {
1601
+ try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
1602
+ transactionOpen = false;
1603
+ }
1604
+ throw error;
1605
+ } finally {
1606
+ configMutationLockDepth = 0;
1607
+ try { database.close(); } catch { /* the OS lock is released with the handle */ }
1296
1608
  }
1609
+ }
1610
+
1611
+ function persistConfigUnlocked(config: OcxConfig): void {
1297
1612
  const configPath = getConfigPath();
1298
1613
  atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n");
1299
1614
  }
1300
1615
 
1616
+ export function saveConfig(config: OcxConfig): void {
1617
+ // Keep the real-home assertion ahead of even lock-directory preparation.
1618
+ assertNotRealHomeUnderTest(getConfigDir());
1619
+ withConfigMutationLockSync(() => persistConfigUnlocked(config));
1620
+ }
1621
+
1622
+ export type PersistedConfigMutation<T> = {
1623
+ changed: boolean;
1624
+ value: T;
1625
+ };
1626
+
1627
+ export type PersistedConfigMutationOutcome<T> =
1628
+ | { status: "committed" | "unchanged"; value: T }
1629
+ | { status: "unavailable"; reason: "missing" | "invalid" | "conflict" };
1630
+
1631
+ const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3;
1632
+ let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null;
1633
+
1634
+ /** Test-only one-shot seam: inject a competing mutation after the first decision, before freshness revalidation. */
1635
+ export function setPersistedConfigMutationBeforeCommitForTests(hook: (() => void) | null): void {
1636
+ persistedConfigMutationBeforeCommitForTests = hook;
1637
+ }
1638
+
1639
+ function unavailableConfigMutationReason(snapshot: ConfigFileSnapshot): "missing" | "invalid" {
1640
+ return snapshot.diagnostics.source === "default" ? "missing" : "invalid";
1641
+ }
1642
+
1643
+ /**
1644
+ * Patch a schema-valid on-disk config under the shared mutation lock. Cooperating writers are
1645
+ * serialized; the callback is rerun on the newest snapshot so observed direct byte changes rebase
1646
+ * and credential predicates are re-evaluated immediately before the atomic commit. A writer that
1647
+ * ignores the coordinator can still change bytes after the final check because the filesystem has
1648
+ * no portable conditional rename. Missing or malformed config always fails closed and is never
1649
+ * recreated from a prior snapshot.
1650
+ */
1651
+ export function mutatePersistedConfig<T>(
1652
+ mutate: (config: OcxConfig) => PersistedConfigMutation<T>,
1653
+ ): PersistedConfigMutationOutcome<T> {
1654
+ // Avoid creating/opening the coordinator database for a read-path update that already knows
1655
+ // there is no valid config. The same check runs again under the transaction for authority.
1656
+ const observed = readConfigFileSnapshot();
1657
+ if (observed.diagnostics.source !== "file" || observed.raw === undefined) {
1658
+ return { status: "unavailable", reason: unavailableConfigMutationReason(observed) };
1659
+ }
1660
+ return withConfigMutationLockSync(() => {
1661
+ let base = readConfigFileSnapshot();
1662
+ for (let attempt = 0; attempt < CONFIG_MUTATION_MAX_REBASE_ATTEMPTS; attempt += 1) {
1663
+ if (base.diagnostics.source !== "file" || base.raw === undefined) {
1664
+ return { status: "unavailable", reason: unavailableConfigMutationReason(base) };
1665
+ }
1666
+
1667
+ const tentativeConfig = structuredClone(base.diagnostics.config);
1668
+ const tentative = mutate(tentativeConfig);
1669
+ if (!tentative.changed) return { status: "unchanged", value: tentative.value };
1670
+
1671
+ const hook = persistedConfigMutationBeforeCommitForTests;
1672
+ persistedConfigMutationBeforeCommitForTests = null;
1673
+ hook?.();
1674
+
1675
+ const latest = readConfigFileSnapshot();
1676
+ if (latest.diagnostics.source !== "file" || latest.raw === undefined) {
1677
+ return { status: "unavailable", reason: unavailableConfigMutationReason(latest) };
1678
+ }
1679
+ if (latest.raw !== base.raw) {
1680
+ base = latest;
1681
+ continue;
1682
+ }
1683
+
1684
+ // Re-run against a fresh clone even when config bytes are unchanged: a Codex credential
1685
+ // generation lives in a separate file and may have changed at the injected seam.
1686
+ const confirmedConfig = structuredClone(latest.diagnostics.config);
1687
+ const confirmed = mutate(confirmedConfig);
1688
+ if (!confirmed.changed) return { status: "unchanged", value: confirmed.value };
1689
+
1690
+ const commitBase = readConfigFileSnapshot();
1691
+ if (commitBase.diagnostics.source !== "file" || commitBase.raw === undefined) {
1692
+ return { status: "unavailable", reason: unavailableConfigMutationReason(commitBase) };
1693
+ }
1694
+ if (commitBase.raw !== latest.raw) {
1695
+ base = commitBase;
1696
+ continue;
1697
+ }
1698
+
1699
+ persistConfigUnlocked(confirmedConfig);
1700
+ return { status: "committed", value: confirmed.value };
1701
+ }
1702
+ return { status: "unavailable", reason: "conflict" };
1703
+ });
1704
+ }
1705
+
1301
1706
  export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
1302
1707
  return config.websockets === true;
1303
1708
  }
@@ -1527,36 +1932,38 @@ function readPersistedServerBinding(
1527
1932
  * guarantee.
1528
1933
  */
1529
1934
  export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
1530
- const bindingBaseline = persistedLiveServerBinding.get(config);
1531
- const onDisk = claudeCodeBaseline.has(config) || bindingBaseline
1532
- ? readRawConfigJson()
1533
- : undefined;
1534
- if (claudeCodeBaseline.has(config)) {
1535
- if (onDisk !== undefined) {
1536
- const baseline = claudeCodeBaseline.get(config);
1537
- const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
1538
- const diskChanged = !deepEqual(persistedClaudeCode, baseline);
1539
- const weChanged = !deepEqual(config.claudeCode, baseline);
1540
- if (diskChanged && !weChanged) {
1541
- config.claudeCode = persistedClaudeCode;
1935
+ withConfigMutationLockSync(() => {
1936
+ const bindingBaseline = persistedLiveServerBinding.get(config);
1937
+ const onDisk = claudeCodeBaseline.has(config) || bindingBaseline
1938
+ ? readRawConfigJson()
1939
+ : undefined;
1940
+ if (claudeCodeBaseline.has(config)) {
1941
+ if (onDisk !== undefined) {
1942
+ const baseline = claudeCodeBaseline.get(config);
1943
+ const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
1944
+ const diskChanged = !deepEqual(persistedClaudeCode, baseline);
1945
+ const weChanged = !deepEqual(config.claudeCode, baseline);
1946
+ if (diskChanged && !weChanged) {
1947
+ config.claudeCode = persistedClaudeCode;
1948
+ }
1542
1949
  }
1543
1950
  }
1544
- }
1545
- const persistedBinding = bindingBaseline && onDisk
1546
- ? readPersistedServerBinding(onDisk, bindingBaseline)
1547
- : bindingBaseline;
1548
- if (persistedBinding) {
1549
- const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port };
1550
- if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
1551
- else persistedConfig.hostname = persistedBinding.hostname;
1552
- saveConfig(persistedConfig);
1553
- persistedLiveServerBinding.set(config, persistedBinding);
1554
- } else {
1555
- saveConfig(config);
1556
- }
1557
- if (claudeCodeBaseline.has(config)) {
1558
- claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
1559
- }
1951
+ const persistedBinding = bindingBaseline && onDisk
1952
+ ? readPersistedServerBinding(onDisk, bindingBaseline)
1953
+ : bindingBaseline;
1954
+ if (persistedBinding) {
1955
+ const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port };
1956
+ if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
1957
+ else persistedConfig.hostname = persistedBinding.hostname;
1958
+ persistConfigUnlocked(persistedConfig);
1959
+ persistedLiveServerBinding.set(config, persistedBinding);
1960
+ } else {
1961
+ persistConfigUnlocked(config);
1962
+ }
1963
+ if (claudeCodeBaseline.has(config)) {
1964
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
1965
+ }
1966
+ });
1560
1967
  }
1561
1968
 
1562
1969
  export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
@@ -1584,6 +1991,8 @@ export function getDefaultConfig(): OcxConfig {
1584
1991
  // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
1585
1992
  return {
1586
1993
  port: 10100,
1994
+ managementUsageMaxReadBytes: 64 * 1024 * 1024,
1995
+ appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
1587
1996
  // Fresh/re-initialized configs are already written in the current three-tier
1588
1997
  // OpenAI shape. Mark them as such so startup does not mistake them for a
1589
1998
  // legacy config and collide with an immutable backup from an earlier setup.
@@ -1767,15 +2176,61 @@ export function parsePidFile(raw: string): number | null {
1767
2176
  export function isOcxStartCommandLine(commandLine: string): boolean {
1768
2177
  const normalized = commandLine.toLowerCase().replace(/\\/g, "/");
1769
2178
  // "src/cli.ts" matches pre-restructure installs still running; "src/cli/index.ts" is current.
2179
+ // `@bitkyc08/.opencodex-*` is npm's in-place rename of the global package during
2180
+ // `npm install -g` — a Windows service wrapper can respawn from that temp tree
2181
+ // mid-update, and must still count as ocx for port reclaim.
1770
2182
  const hasOcxEntrypoint = normalized.includes("src/cli.ts")
1771
2183
  || normalized.includes("src/cli/index.ts")
1772
2184
  || normalized.includes("@bitkyc08/opencodex")
2185
+ || /@bitkyc08\/\.opencodex-/.test(normalized)
1773
2186
  || /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
1774
2187
  return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
1775
2188
  }
1776
2189
 
1777
2190
  /** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
1778
2191
  const ocxStartProcessCache = new Map<number, boolean>();
2192
+ let ocxStartProcessSweepCursor = 0;
2193
+ let ocxStartProcessProbe: (pid: number) => void = pid => { process.kill(pid, 0); };
2194
+
2195
+ export function setOcxStartProcessProbeForTests(probe: ((pid: number) => void) | null): void {
2196
+ ocxStartProcessProbe = probe ?? (pid => { process.kill(pid, 0); });
2197
+ }
2198
+
2199
+ export function setOcxStartProcessCacheForTests(entries: Iterable<readonly [number, boolean]>): void {
2200
+ ocxStartProcessCache.clear();
2201
+ for (const [pid, value] of entries) ocxStartProcessCache.set(pid, value);
2202
+ ocxStartProcessSweepCursor = 0;
2203
+ }
2204
+
2205
+ export function sweepDeadOcxStartProcessCache(maxProbes = 64): number {
2206
+ const pids: number[] = [];
2207
+ let removed = 0;
2208
+ for (const pid of ocxStartProcessCache.keys()) {
2209
+ if (Number.isSafeInteger(pid) && pid > 0) pids.push(pid);
2210
+ else if (ocxStartProcessCache.delete(pid)) removed += 1;
2211
+ }
2212
+ if (pids.length === 0 || maxProbes <= 0) {
2213
+ ocxStartProcessSweepCursor = 0;
2214
+ return removed;
2215
+ }
2216
+ const probeCount = Math.min(Math.floor(maxProbes), pids.length);
2217
+ const start = ocxStartProcessSweepCursor % pids.length;
2218
+ for (let offset = 0; offset < probeCount; offset += 1) {
2219
+ const pid = pids[(start + offset) % pids.length]!;
2220
+ try {
2221
+ ocxStartProcessProbe(pid);
2222
+ } catch (error) {
2223
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
2224
+ if (ocxStartProcessCache.delete(pid)) removed += 1;
2225
+ }
2226
+ }
2227
+ ocxStartProcessSweepCursor = (start + probeCount) % pids.length;
2228
+ return removed;
2229
+ }
2230
+
2231
+ export function ocxStartProcessCacheSizeForTests(): number {
2232
+ return ocxStartProcessCache.size;
2233
+ }
1779
2234
 
1780
2235
  function isLikelyOcxStartProcess(pid: number): boolean {
1781
2236
  const cached = ocxStartProcessCache.get(pid);