@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -1,7 +1,9 @@
1
- import { constants, copyFileSync, existsSync, readFileSync, statSync } from "node:fs";
1
+ import { constants, copyFileSync, existsSync, readFileSync, lstatSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
+ import { atomicWriteFileNoFollow } from "../config/atomic-write";
6
+ import { assertConfigWriteDestination, publishConfigWrite, withConfigWriteLock, type ConfigWriteLockOutcome } from "../codex/config-write-lock";
5
7
  import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject";
6
8
  import {
7
9
  grokDefaultReasoningEffort,
@@ -22,7 +24,18 @@ export interface GrokInjectResult {
22
24
  ok: boolean;
23
25
  changed: boolean;
24
26
  message: string;
25
- skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback";
27
+ skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback" | "refresh-only" | "locked" | "unsafe";
28
+ retryable?: boolean;
29
+ }
30
+
31
+ function grokConfigLockRefusal(failure: Exclude<ConfigWriteLockOutcome<unknown>, { ok: true }>): GrokInjectResult {
32
+ const retryable = failure.error === "locked";
33
+ return {
34
+ ok: false, changed: false, retryable,
35
+ skippedReason: retryable ? "locked" : "unsafe",
36
+ message: retryable ? "Grok config write refused: another OpenCodex writer is active; retry shortly."
37
+ : "Grok config write refused: its destination or lock cannot be verified; files were preserved.",
38
+ };
26
39
  }
27
40
 
28
41
  const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
@@ -78,6 +91,18 @@ export function findManagedRegion(content: string): ManagedRegion | null {
78
91
  return { start: begin.index, end: endMatch.index + endMatch[0].length, orphaned: false };
79
92
  }
80
93
 
94
+ /** A complete managed fence is the existing opt-in required by refresh-only callers. */
95
+ export function grokManagedBlockPresent(grokHome?: string): boolean {
96
+ try {
97
+ const path = join(resolveGrokHome(grokHome), "config.toml");
98
+ if (!lstatSync(path).isFile()) return false;
99
+ const region = findManagedRegion(applyEol(readFileSync(path, "utf8"), "\n"));
100
+ return region != null && !region.orphaned;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
81
106
  /**
82
107
  * A TOML key segment as it may be spelled in a table header: bare, basic string, or literal
83
108
  * string. All three spellings of the same key address the SAME table, so both segments of a
@@ -1074,12 +1099,27 @@ export function buildGrokManagedBlock(
1074
1099
  return lines.join("\n");
1075
1100
  }
1076
1101
 
1102
+ class GrokRefreshOnlyRefusal extends Error {}
1103
+
1104
+ function refreshOnlySkipped(): GrokInjectResult {
1105
+ return { ok: true, changed: false, message: "Grok refresh skipped", skippedReason: "refresh-only" };
1106
+ }
1107
+
1108
+ function isSymlink(path: string): boolean {
1109
+ try { return lstatSync(path).isSymbolicLink(); }
1110
+ catch (error) {
1111
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1112
+ throw error;
1113
+ }
1114
+ }
1115
+
1077
1116
  export function injectGrokConfig(
1078
1117
  port: number,
1079
1118
  models: GrokInjectModel[],
1080
1119
  opts: {
1081
1120
  grokHome?: string;
1082
1121
  hostname?: string;
1122
+ refreshOnly?: { admit: () => boolean };
1083
1123
  excluded?: ReadonlySet<string>;
1084
1124
  /** Unfiltered known ids used only to distinguish hidden current models from retired ones. */
1085
1125
  catalogModelIds?: ReadonlySet<string>;
@@ -1108,6 +1148,7 @@ export function injectGrokConfig(
1108
1148
  // at all here; the user configures models manually, outside our fence, where nothing we do
1109
1149
  // can clobber their credential.
1110
1150
  if (!isLoopbackHostname(opts.hostname)) {
1151
+ if (opts.refreshOnly) return refreshOnlySkipped();
1111
1152
  const removed = stripGrokConfig({ ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}) });
1112
1153
  const cleanup = removed.changed
1113
1154
  ? " Removed the previously generated block, which pointed at a loopback address."
@@ -1126,130 +1167,154 @@ export function injectGrokConfig(
1126
1167
  const configPath = join(grokHome, "config.toml");
1127
1168
  const backupPath = join(grokHome, "config.toml.bak-opencodex");
1128
1169
  try {
1129
- const configExisted = existsSync(configPath);
1130
- const rawContent = configExisted ? readFileSync(configPath, "utf8") : "";
1131
- const eol = dominantEol(rawContent);
1132
- const originalContent = applyEol(rawContent, "\n");
1133
- const originalRegion = findManagedRegion(originalContent);
1134
- // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the
1135
- // entire file.
1136
- if (originalRegion?.orphaned) return orphanedMarkerResult("injection");
1137
- const previousManagedModels = managedModelAliases(originalContent, originalRegion);
1138
-
1139
- // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale
1140
- // duplicate is replaced instead of routed around forever. Runs inside the normalized
1141
- // window so the user's dominant EOL is still restored below.
1142
- // Durably marked rows use the full UNFILTERED catalog: explicitly excluded and otherwise
1143
- // hidden current models must still lose stale generated tables. Ambiguous pre-marker legacy
1144
- // rows are migrated only when this write emits their replacement. Direct callers that do not
1145
- // have a separate catalog keep the historical `models` behavior.
1146
- const catalogModelIds = opts.catalogModelIds ?? new Set(models.map(model => model.id));
1147
- const emittedModelIds = new Set(models
1148
- .filter(model => !opts.excluded?.has(model.id))
1149
- .map(model => model.id));
1150
- const orphans = findOpencodexOrphans(originalContent, originalRegion)
1151
- .filter(orphan =>
1152
- // A provider table carries no alias and no model id: its strict predicate (our key
1153
- // + loopback + durable marker) is itself the deletion authority, and a leftover
1154
- // collides with the regenerated provider table (duplicate key).
1155
- orphan.alias === ""
1156
- || (orphan.ownership === "legacy"
1157
- // A legacy fingerprint is not durable deletion authority. Migrate it only when this
1158
- // same write will replace the row with a marked managed table.
1159
- ? emittedModelIds.has(orphan.modelId)
1160
- : catalogModelIds.has(orphan.modelId)
1161
- || isDisabledProviderModelId(
1162
- orphan.modelId,
1163
- opts.disabledProviderNamespaces,
1164
- opts.comboPublicModelIds,
1165
- )));
1166
- const content = removeOrphanTables(originalContent, orphans);
1167
- // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic,
1168
- // so the splice below cannot cut the file in the wrong place.
1169
- const region = orphans.length > 0 ? findManagedRegion(content) : originalRegion;
1170
-
1171
- const buildCandidate = (reservedAliases: ReadonlySet<string>): string => {
1172
- const block = buildGrokManagedBlock(port, models, opts.hostname, reservedAliases, opts.excluded);
1173
- let candidate: string;
1174
- if (region) {
1175
- candidate = content.slice(0, region.start) + block + content.slice(region.end);
1176
- } else if (content.length === 0) {
1177
- candidate = `${block}\n`;
1178
- } else {
1179
- // One separator newline preserves the user's original terminator for stripping.
1180
- candidate = `${content}\n${block}\n`;
1181
- }
1182
-
1183
- // Repoint selectors after allocation, so parsing below checks the actual write bytes.
1184
- const nextManagedModels = managedModelAliases(candidate, findManagedRegion(candidate));
1185
- const survivors = new Map<string, string>();
1186
- for (const [alias, modelId] of nextManagedModels) {
1187
- if (!survivors.has(modelId)) survivors.set(modelId, alias);
1170
+ const locked = withConfigWriteLock(configPath, (held): GrokInjectResult => {
1171
+ const configExisted = existsSync(configPath);
1172
+ const rawContent = configExisted ? readFileSync(configPath, "utf8") : "";
1173
+ const eol = dominantEol(rawContent);
1174
+ const originalContent = applyEol(rawContent, "\n");
1175
+ const originalRegion = findManagedRegion(originalContent);
1176
+ if (opts.refreshOnly && (!configExisted || !originalRegion || originalRegion.orphaned
1177
+ || !opts.refreshOnly.admit() || isSymlink(configPath) || isSymlink(backupPath))) {
1178
+ return refreshOnlySkipped();
1188
1179
  }
1189
- const replacements = new Map<string, string | null>();
1190
- for (const removed of [
1191
- ...orphans.filter(orphan => orphan.alias !== "")
1192
- .map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })),
1193
- ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })),
1194
- ]) {
1195
- if (nextManagedModels.get(removed.alias) === removed.modelId) continue;
1196
- const replacement = survivors.get(removed.modelId) ?? null;
1197
- if (replacement !== removed.alias) replacements.set(removed.alias, replacement);
1198
- }
1199
- return rewriteAliasReferences(candidate, replacements);
1200
- };
1201
-
1202
- const userContent = region ? content.slice(0, region.start) + content.slice(region.end) : content;
1203
- let userContentValid = false;
1204
- try {
1205
- Bun.TOML.parse(userContent);
1206
- userContentValid = true;
1207
- } catch {
1208
- // Preserve the old, conservative reservation and write behavior for malformed user TOML.
1209
- }
1210
- let nextContent: string;
1211
- if (!userContentValid) {
1212
- nextContent = buildCandidate(userModelAliases(content, region, true));
1213
- } else {
1214
- const validCandidate = (includeNested: boolean): string | null => {
1180
+ // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the
1181
+ // entire file.
1182
+ if (originalRegion?.orphaned) return orphanedMarkerResult("injection");
1183
+ const previousManagedModels = managedModelAliases(originalContent, originalRegion);
1184
+
1185
+ // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale
1186
+ // duplicate is replaced instead of routed around forever. Runs inside the normalized
1187
+ // window so the user's dominant EOL is still restored below.
1188
+ // Durably marked rows use the full UNFILTERED catalog: explicitly excluded and otherwise
1189
+ // hidden current models must still lose stale generated tables. Ambiguous pre-marker legacy
1190
+ // rows are migrated only when this write emits their replacement. Direct callers that do not
1191
+ // have a separate catalog keep the historical `models` behavior.
1192
+ const catalogModelIds = opts.catalogModelIds ?? new Set(models.map(model => model.id));
1193
+ const emittedModelIds = new Set(models
1194
+ .filter(model => !opts.excluded?.has(model.id))
1195
+ .map(model => model.id));
1196
+ const orphans = findOpencodexOrphans(originalContent, originalRegion)
1197
+ .filter(orphan =>
1198
+ // A provider table carries no alias and no model id: its strict predicate (our key
1199
+ // + loopback + durable marker) is itself the deletion authority, and a leftover
1200
+ // collides with the regenerated provider table (duplicate key).
1201
+ orphan.alias === ""
1202
+ || (orphan.ownership === "legacy"
1203
+ // A legacy fingerprint is not durable deletion authority. Migrate it only when this
1204
+ // same write will replace the row with a marked managed table.
1205
+ ? emittedModelIds.has(orphan.modelId)
1206
+ : catalogModelIds.has(orphan.modelId)
1207
+ || isDisabledProviderModelId(
1208
+ orphan.modelId,
1209
+ opts.disabledProviderNamespaces,
1210
+ opts.comboPublicModelIds,
1211
+ )));
1212
+ const content = removeOrphanTables(originalContent, orphans);
1213
+ // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic,
1214
+ // so the splice below cannot cut the file in the wrong place.
1215
+ const region = orphans.length > 0 ? findManagedRegion(content) : originalRegion;
1216
+
1217
+ const buildCandidate = (reservedAliases: ReadonlySet<string>): string => {
1218
+ const block = buildGrokManagedBlock(port, models, opts.hostname, reservedAliases, opts.excluded);
1215
1219
  let candidate: string;
1216
- try {
1217
- candidate = buildCandidate(userModelAliases(content, region, includeNested));
1218
- } catch (error) {
1219
- if (error instanceof Error && error.message === "Grok config rewrite refused: Bun could not parse the TOML document safely.") return null;
1220
- throw error;
1220
+ if (region) {
1221
+ candidate = content.slice(0, region.start) + block + content.slice(region.end);
1222
+ } else if (content.length === 0) {
1223
+ candidate = `${block}\n`;
1224
+ } else {
1225
+ // One separator newline preserves the user's original terminator for stripping.
1226
+ candidate = `${content}\n${block}\n`;
1227
+ }
1228
+
1229
+ // Repoint selectors after allocation, so parsing below checks the actual write bytes.
1230
+ const nextManagedModels = managedModelAliases(candidate, findManagedRegion(candidate));
1231
+ const survivors = new Map<string, string>();
1232
+ for (const [alias, modelId] of nextManagedModels) {
1233
+ if (!survivors.has(modelId)) survivors.set(modelId, alias);
1221
1234
  }
1222
- try {
1223
- Bun.TOML.parse(applyEol(candidate, eol));
1224
- return candidate;
1225
- } catch {
1226
- return null;
1235
+ const replacements = new Map<string, string | null>();
1236
+ for (const removed of [
1237
+ ...orphans.filter(orphan => orphan.alias !== "")
1238
+ .map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })),
1239
+ ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })),
1240
+ ]) {
1241
+ if (nextManagedModels.get(removed.alias) === removed.modelId) continue;
1242
+ const replacement = survivors.get(removed.modelId) ?? null;
1243
+ if (replacement !== removed.alias) replacements.set(removed.alias, replacement);
1227
1244
  }
1245
+ return rewriteAliasReferences(candidate, replacements);
1228
1246
  };
1229
- nextContent = validCandidate(false) ?? validCandidate(true) ?? "";
1230
- if (nextContent === "") {
1231
- throw new Error("Grok config injection refused: neither alias choice produced valid TOML after model-reference rewriting.");
1247
+
1248
+ const userContent = region ? content.slice(0, region.start) + content.slice(region.end) : content;
1249
+ let userContentValid = false;
1250
+ try {
1251
+ Bun.TOML.parse(userContent);
1252
+ userContentValid = true;
1253
+ } catch {
1254
+ // Preserve the old, conservative reservation and write behavior for malformed user TOML.
1255
+ }
1256
+ let nextContent: string;
1257
+ if (!userContentValid) {
1258
+ nextContent = buildCandidate(userModelAliases(content, region, true));
1259
+ } else {
1260
+ const validCandidate = (includeNested: boolean): string | null => {
1261
+ let candidate: string;
1262
+ try {
1263
+ candidate = buildCandidate(userModelAliases(content, region, includeNested));
1264
+ } catch (error) {
1265
+ if (error instanceof Error && error.message === "Grok config rewrite refused: Bun could not parse the TOML document safely.") return null;
1266
+ throw error;
1267
+ }
1268
+ try {
1269
+ Bun.TOML.parse(applyEol(candidate, eol));
1270
+ return candidate;
1271
+ } catch {
1272
+ return null;
1273
+ }
1274
+ };
1275
+ nextContent = validCandidate(false) ?? validCandidate(true) ?? "";
1276
+ if (nextContent === "") {
1277
+ throw new Error("Grok config injection refused: neither alias choice produced valid TOML after model-reference rewriting.");
1278
+ }
1232
1279
  }
1233
- }
1234
1280
 
1235
- const output = applyEol(nextContent, eol);
1236
- if (output === rawContent) {
1237
- return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." };
1238
- }
1239
- // Back up before a first-time fence write AND before any sweep, since adopting an orphan
1240
- // deletes a table the user has in their file. Previously the adjacent-orphan layout got a
1241
- // backup only as a side effect of the fence being destroyed (which made `region` falsy);
1242
- // preserving the fence must not silently drop that safety net.
1243
- if (configExisted && (!region || orphans.length > 0)) copyBackupOnce(configPath, backupPath);
1244
- atomicWriteFile(configPath, output);
1245
- return {
1246
- ok: true,
1247
- changed: true,
1248
- message: region
1249
- ? "Updated the opencodex managed block in Grok config."
1250
- : "Added the opencodex managed block to Grok config.",
1251
- };
1281
+ const output = applyEol(nextContent, eol);
1282
+ if (output === rawContent) {
1283
+ return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." };
1284
+ }
1285
+ // Back up before a first-time fence write AND before any sweep, since adopting an orphan
1286
+ // deletes a table the user has in their file. Previously the adjacent-orphan layout got a
1287
+ // backup only as a side effect of the fence being destroyed (which made `region` falsy);
1288
+ // preserving the fence must not silently drop that safety net.
1289
+ if (opts.refreshOnly) {
1290
+ const refreshOnly = opts.refreshOnly;
1291
+ publishConfigWrite(configPath, held, (destination, hooks) => atomicWriteFileNoFollow(destination, output, undefined, {
1292
+ ...hooks,
1293
+ validateBeforeRename: () => {
1294
+ try {
1295
+ hooks.validateBeforeRename?.(destination);
1296
+ if (!refreshOnly.admit() || !lstatSync(configPath).isFile()
1297
+ || readFileSync(configPath, "utf8") !== rawContent) throw new Error();
1298
+ } catch { throw new GrokRefreshOnlyRefusal(); }
1299
+ },
1300
+ }));
1301
+ } else {
1302
+ const destination = assertConfigWriteDestination(configPath, held);
1303
+ if (configExisted && (!region || orphans.length > 0)) copyBackupOnce(destination, backupPath);
1304
+ publishConfigWrite(configPath, held, (target, hooks) => atomicWriteFile(target, output, undefined, hooks));
1305
+ }
1306
+ return {
1307
+ ok: true,
1308
+ changed: true,
1309
+ message: region
1310
+ ? "Updated the opencodex managed block in Grok config."
1311
+ : "Added the opencodex managed block to Grok config.",
1312
+ };
1313
+ });
1314
+ if (!locked.ok) return grokConfigLockRefusal(locked);
1315
+ return locked.value;
1252
1316
  } catch (error) {
1317
+ if (error instanceof GrokRefreshOnlyRefusal) return refreshOnlySkipped();
1253
1318
  return errorResult("inject", error);
1254
1319
  }
1255
1320
  }
@@ -1271,95 +1336,100 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes
1271
1336
  }
1272
1337
 
1273
1338
  try {
1274
- const rawContent = readFileSync(configPath, "utf8");
1275
- const eol = dominantEol(rawContent);
1276
- const content = applyEol(rawContent, "\n");
1277
- const originalRegion = findManagedRegion(content);
1278
- if (originalRegion?.orphaned) return orphanedMarkerResult("cleanup");
1279
-
1280
- // Remove the fence against its ORIGINAL offsets first. A pre-fence orphan's span is clamped
1281
- // at the fence start and can include the separator newline injection added. Sweeping that
1282
- // orphan first and then applying this separator undo would remove one additional USER newline.
1283
- let stripped: string;
1284
- let orphanCount = 0;
1285
- if (originalRegion) {
1286
- const fullOrphans = findOpencodexOrphans(content, originalRegion)
1287
- .filter(orphan => orphan.ownership === "explicit");
1288
- let removalEnd = originalRegion.end;
1289
- if (content.startsWith("\n", removalEnd)) removalEnd += 1;
1290
- let prefix = content.slice(0, originalRegion.start);
1291
- const restOfFile = content.slice(removalEnd);
1292
- // Undo the single separator newline injection added. Two cases, mirroring inject:
1293
- // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
1294
- // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
1295
- // A block the user has appended content after is left alone: we never shrink their bytes.
1296
- if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
1297
- else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
1298
- // Keep the old fence boundary while sweeping. Concatenating first would let the last
1299
- // pre-fence orphan absorb comment-only or bare-key user content appended after the fence.
1300
- const prefixOrphans = findOpencodexOrphans(prefix, null)
1301
- .filter(orphan => orphan.ownership === "explicit");
1302
- const tailOrphans = findOpencodexOrphans(restOfFile, null)
1303
- .filter(orphan => orphan.ownership === "explicit");
1304
- const removedAliases = new Set(
1305
- [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1306
- .map(orphan => orphan.alias)
1307
- // Provider orphans carry no alias; their ranges above already removed the table.
1308
- .filter(alias => alias !== ""),
1309
- );
1310
- // The backup must cover every removed TABLE, not just aliased rows: provider-only
1311
- // orphans carry no alias and would otherwise be swept without any backup.
1312
- orphanCount = new Set(
1313
- [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1314
- .flatMap(orphan => orphanRanges([orphan])),
1315
- ).size;
1316
- const fullRanges = orphanRanges(fullOrphans);
1317
- const prefixRanges = [
1318
- ...orphanRanges(prefixOrphans),
1319
- ...fullRanges.filter(range => range.end <= originalRegion.start),
1320
- ];
1321
- const tailRanges = [
1322
- ...orphanRanges(tailOrphans),
1323
- ...fullRanges
1324
- .filter(range => range.start >= removalEnd)
1325
- .map(range => ({ start: range.start - removalEnd, end: range.end - removalEnd })),
1326
- ];
1327
- // Preserve the original fence as a structural boundary while cleaning references too.
1328
- // Joining first can re-parent a headerless tail under the last table in `prefix`.
1329
- stripped = removeAliasReferences(
1330
- removeTableRanges(prefix, prefixRanges),
1331
- removedAliases,
1332
- ) + removeAliasReferences(
1333
- removeTableRanges(restOfFile, tailRanges),
1334
- removedAliases,
1335
- false,
1336
- );
1337
- } else {
1338
- // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the
1339
- // fence while the integration is enabled. Teardown owns those strictly identified tables
1340
- // even after Grok has re-serialized the file and dropped our marker comments.
1341
- const orphans = findOpencodexOrphans(content, null)
1342
- .filter(orphan => orphan.ownership === "explicit");
1343
- if (orphans.length === 0) {
1344
- return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." };
1339
+ const locked = withConfigWriteLock(configPath, (held): GrokInjectResult => {
1340
+ const rawContent = readFileSync(configPath, "utf8");
1341
+ const eol = dominantEol(rawContent);
1342
+ const content = applyEol(rawContent, "\n");
1343
+ const originalRegion = findManagedRegion(content);
1344
+ if (originalRegion?.orphaned) return orphanedMarkerResult("cleanup");
1345
+
1346
+ // Remove the fence against its ORIGINAL offsets first. A pre-fence orphan's span is clamped
1347
+ // at the fence start and can include the separator newline injection added. Sweeping that
1348
+ // orphan first and then applying this separator undo would remove one additional USER newline.
1349
+ let stripped: string;
1350
+ let orphanCount = 0;
1351
+ if (originalRegion) {
1352
+ const fullOrphans = findOpencodexOrphans(content, originalRegion)
1353
+ .filter(orphan => orphan.ownership === "explicit");
1354
+ let removalEnd = originalRegion.end;
1355
+ if (content.startsWith("\n", removalEnd)) removalEnd += 1;
1356
+ let prefix = content.slice(0, originalRegion.start);
1357
+ const restOfFile = content.slice(removalEnd);
1358
+ // Undo the single separator newline injection added. Two cases, mirroring inject:
1359
+ // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
1360
+ // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
1361
+ // A block the user has appended content after is left alone: we never shrink their bytes.
1362
+ if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
1363
+ else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
1364
+ // Keep the old fence boundary while sweeping. Concatenating first would let the last
1365
+ // pre-fence orphan absorb comment-only or bare-key user content appended after the fence.
1366
+ const prefixOrphans = findOpencodexOrphans(prefix, null)
1367
+ .filter(orphan => orphan.ownership === "explicit");
1368
+ const tailOrphans = findOpencodexOrphans(restOfFile, null)
1369
+ .filter(orphan => orphan.ownership === "explicit");
1370
+ const removedAliases = new Set(
1371
+ [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1372
+ .map(orphan => orphan.alias)
1373
+ // Provider orphans carry no alias; their ranges above already removed the table.
1374
+ .filter(alias => alias !== ""),
1375
+ );
1376
+ // The backup must cover every removed TABLE, not just aliased rows: provider-only
1377
+ // orphans carry no alias and would otherwise be swept without any backup.
1378
+ orphanCount = new Set(
1379
+ [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1380
+ .flatMap(orphan => orphanRanges([orphan])),
1381
+ ).size;
1382
+ const fullRanges = orphanRanges(fullOrphans);
1383
+ const prefixRanges = [
1384
+ ...orphanRanges(prefixOrphans),
1385
+ ...fullRanges.filter(range => range.end <= originalRegion.start),
1386
+ ];
1387
+ const tailRanges = [
1388
+ ...orphanRanges(tailOrphans),
1389
+ ...fullRanges
1390
+ .filter(range => range.start >= removalEnd)
1391
+ .map(range => ({ start: range.start - removalEnd, end: range.end - removalEnd })),
1392
+ ];
1393
+ // Preserve the original fence as a structural boundary while cleaning references too.
1394
+ // Joining first can re-parent a headerless tail under the last table in `prefix`.
1395
+ stripped = removeAliasReferences(
1396
+ removeTableRanges(prefix, prefixRanges),
1397
+ removedAliases,
1398
+ ) + removeAliasReferences(
1399
+ removeTableRanges(restOfFile, tailRanges),
1400
+ removedAliases,
1401
+ false,
1402
+ );
1403
+ } else {
1404
+ // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the
1405
+ // fence while the integration is enabled. Teardown owns those strictly identified tables
1406
+ // even after Grok has re-serialized the file and dropped our marker comments.
1407
+ const orphans = findOpencodexOrphans(content, null)
1408
+ .filter(orphan => orphan.ownership === "explicit");
1409
+ if (orphans.length === 0) {
1410
+ return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." };
1411
+ }
1412
+ orphanCount = orphans.length;
1413
+ stripped = removeOrphanTables(content, orphans);
1414
+ stripped = removeAliasReferences(
1415
+ stripped,
1416
+ new Set(orphans.map(orphan => orphan.alias).filter(alias => alias !== "")),
1417
+ );
1345
1418
  }
1346
- orphanCount = orphans.length;
1347
- stripped = removeOrphanTables(content, orphans);
1348
- stripped = removeAliasReferences(
1349
- stripped,
1350
- new Set(orphans.map(orphan => orphan.alias).filter(alias => alias !== "")),
1351
- );
1352
- }
1353
- if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex"));
1354
- atomicWriteFile(configPath, applyEol(stripped, eol));
1355
-
1356
- return {
1357
- ok: true,
1358
- changed: true,
1359
- message: originalRegion
1360
- ? "Removed the opencodex managed block from Grok config."
1361
- : "Removed stale opencodex-managed model entries from Grok config.",
1362
- };
1419
+ const destination = assertConfigWriteDestination(configPath, held);
1420
+ if (orphanCount > 0) copyBackupOnce(destination, join(grokHome, "config.toml.bak-opencodex"));
1421
+ publishConfigWrite(configPath, held, (target, hooks) => atomicWriteFile(target, applyEol(stripped, eol), undefined, hooks));
1422
+
1423
+ return {
1424
+ ok: true,
1425
+ changed: true,
1426
+ message: originalRegion
1427
+ ? "Removed the opencodex managed block from Grok config."
1428
+ : "Removed stale opencodex-managed model entries from Grok config.",
1429
+ };
1430
+ });
1431
+ if (!locked.ok) return grokConfigLockRefusal(locked);
1432
+ return locked.value;
1363
1433
  } catch (error) {
1364
1434
  return errorResult("strip", error);
1365
1435
  }