@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  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/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -5,6 +5,7 @@
5
5
 
6
6
  export interface GrpcWebTrailer {
7
7
  status: number;
8
+ statusValues: string[];
8
9
  statusMessage?: string;
9
10
  metadata: Record<string, string>;
10
11
  }
@@ -12,6 +13,8 @@ export interface GrpcWebTrailer {
12
13
  export interface DecodedGrpcWebResponse {
13
14
  messages: Uint8Array[];
14
15
  status: number;
16
+ statusValues: string[];
17
+ framingComplete: boolean;
15
18
  statusMessage?: string;
16
19
  trailers?: GrpcWebTrailer;
17
20
  }
@@ -51,6 +54,7 @@ export function parseGrpcWebTrailers(bytes: Uint8Array): GrpcWebTrailer {
51
54
  const text = new TextDecoder("utf-8").decode(bytes);
52
55
  const lines = text.split(/\r?\n/);
53
56
  const metadata: Record<string, string> = {};
57
+ const statusValues: string[] = [];
54
58
  let status = 0;
55
59
  let statusMessage: string | undefined;
56
60
 
@@ -62,6 +66,7 @@ export function parseGrpcWebTrailers(bytes: Uint8Array): GrpcWebTrailer {
62
66
  if (!key) continue;
63
67
  metadata[key] = value;
64
68
  if (key === "grpc-status") {
69
+ statusValues.push(value);
65
70
  const parsed = parseInt(value, 10);
66
71
  if (!Number.isNaN(parsed)) {
67
72
  status = parsed;
@@ -75,7 +80,7 @@ export function parseGrpcWebTrailers(bytes: Uint8Array): GrpcWebTrailer {
75
80
  }
76
81
  }
77
82
 
78
- return { status, statusMessage, metadata };
83
+ return { status, statusValues, statusMessage, metadata };
79
84
  }
80
85
 
81
86
  /**
@@ -83,8 +88,11 @@ export function parseGrpcWebTrailers(bytes: Uint8Array): GrpcWebTrailer {
83
88
  */
84
89
  export function decodeGrpcWebResponse(bytes: Uint8Array): DecodedGrpcWebResponse {
85
90
  const messages: Uint8Array[] = [];
91
+ const statusValues: string[] = [];
86
92
  let offset = 0;
87
93
  let trailer: GrpcWebTrailer | undefined;
94
+ let firstTrailerPayload: Uint8Array | undefined;
95
+ let framingComplete = true;
88
96
 
89
97
  while (offset + HEADER_SIZE <= bytes.length) {
90
98
  const flag = bytes[offset];
@@ -99,10 +107,22 @@ export function decodeGrpcWebResponse(bytes: Uint8Array): DecodedGrpcWebResponse
99
107
 
100
108
  const payload = bytes.subarray(frameStart, frameEnd);
101
109
 
110
+ // Preserve legacy decoding; callers needing confirmation inspect framingComplete.
102
111
  if (flag === FRAME_DATA) {
112
+ if (firstTrailerPayload !== undefined) framingComplete = false;
103
113
  messages.push(payload);
104
114
  } else if (flag === FRAME_TRAILER) {
115
+ const firstTrailer = firstTrailerPayload;
116
+ if (firstTrailer === undefined) {
117
+ firstTrailerPayload = payload;
118
+ } else if (payload.length !== firstTrailer.length
119
+ || payload.some((byte, index) => byte !== firstTrailer[index])) {
120
+ framingComplete = false;
121
+ }
105
122
  trailer = parseGrpcWebTrailers(payload);
123
+ for (const value of trailer.statusValues) statusValues.push(value);
124
+ } else {
125
+ framingComplete = false;
106
126
  }
107
127
 
108
128
  offset = frameEnd;
@@ -114,6 +134,8 @@ export function decodeGrpcWebResponse(bytes: Uint8Array): DecodedGrpcWebResponse
114
134
  return {
115
135
  messages,
116
136
  status: finalStatus,
137
+ statusValues,
138
+ framingComplete: framingComplete && offset === bytes.length,
117
139
  statusMessage: finalMessage,
118
140
  trailers: trailer,
119
141
  };
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
5
  import { atomicWriteFileNoFollow } from "../config/atomic-write";
6
+ import { assertConfigWriteDestination, publishConfigWrite, withConfigWriteLock, type ConfigWriteLockOutcome } from "../codex/config-write-lock";
6
7
  import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject";
7
8
  import {
8
9
  grokDefaultReasoningEffort,
@@ -23,7 +24,18 @@ export interface GrokInjectResult {
23
24
  ok: boolean;
24
25
  changed: boolean;
25
26
  message: string;
26
- skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback" | "refresh-only";
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
+ };
27
39
  }
28
40
 
29
41
  const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
@@ -1155,145 +1167,152 @@ export function injectGrokConfig(
1155
1167
  const configPath = join(grokHome, "config.toml");
1156
1168
  const backupPath = join(grokHome, "config.toml.bak-opencodex");
1157
1169
  try {
1158
- const configExisted = existsSync(configPath);
1159
- const rawContent = configExisted ? readFileSync(configPath, "utf8") : "";
1160
- const eol = dominantEol(rawContent);
1161
- const originalContent = applyEol(rawContent, "\n");
1162
- const originalRegion = findManagedRegion(originalContent);
1163
- if (opts.refreshOnly && (!configExisted || !originalRegion || originalRegion.orphaned
1164
- || !opts.refreshOnly.admit() || isSymlink(configPath) || isSymlink(backupPath))) {
1165
- return refreshOnlySkipped();
1166
- }
1167
- // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the
1168
- // entire file.
1169
- if (originalRegion?.orphaned) return orphanedMarkerResult("injection");
1170
- const previousManagedModels = managedModelAliases(originalContent, originalRegion);
1171
-
1172
- // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale
1173
- // duplicate is replaced instead of routed around forever. Runs inside the normalized
1174
- // window so the user's dominant EOL is still restored below.
1175
- // Durably marked rows use the full UNFILTERED catalog: explicitly excluded and otherwise
1176
- // hidden current models must still lose stale generated tables. Ambiguous pre-marker legacy
1177
- // rows are migrated only when this write emits their replacement. Direct callers that do not
1178
- // have a separate catalog keep the historical `models` behavior.
1179
- const catalogModelIds = opts.catalogModelIds ?? new Set(models.map(model => model.id));
1180
- const emittedModelIds = new Set(models
1181
- .filter(model => !opts.excluded?.has(model.id))
1182
- .map(model => model.id));
1183
- const orphans = findOpencodexOrphans(originalContent, originalRegion)
1184
- .filter(orphan =>
1185
- // A provider table carries no alias and no model id: its strict predicate (our key
1186
- // + loopback + durable marker) is itself the deletion authority, and a leftover
1187
- // collides with the regenerated provider table (duplicate key).
1188
- orphan.alias === ""
1189
- || (orphan.ownership === "legacy"
1190
- // A legacy fingerprint is not durable deletion authority. Migrate it only when this
1191
- // same write will replace the row with a marked managed table.
1192
- ? emittedModelIds.has(orphan.modelId)
1193
- : catalogModelIds.has(orphan.modelId)
1194
- || isDisabledProviderModelId(
1195
- orphan.modelId,
1196
- opts.disabledProviderNamespaces,
1197
- opts.comboPublicModelIds,
1198
- )));
1199
- const content = removeOrphanTables(originalContent, orphans);
1200
- // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic,
1201
- // so the splice below cannot cut the file in the wrong place.
1202
- const region = orphans.length > 0 ? findManagedRegion(content) : originalRegion;
1203
-
1204
- const buildCandidate = (reservedAliases: ReadonlySet<string>): string => {
1205
- const block = buildGrokManagedBlock(port, models, opts.hostname, reservedAliases, opts.excluded);
1206
- let candidate: string;
1207
- if (region) {
1208
- candidate = content.slice(0, region.start) + block + content.slice(region.end);
1209
- } else if (content.length === 0) {
1210
- candidate = `${block}\n`;
1211
- } else {
1212
- // One separator newline preserves the user's original terminator for stripping.
1213
- candidate = `${content}\n${block}\n`;
1214
- }
1215
-
1216
- // Repoint selectors after allocation, so parsing below checks the actual write bytes.
1217
- const nextManagedModels = managedModelAliases(candidate, findManagedRegion(candidate));
1218
- const survivors = new Map<string, string>();
1219
- for (const [alias, modelId] of nextManagedModels) {
1220
- if (!survivors.has(modelId)) survivors.set(modelId, alias);
1221
- }
1222
- const replacements = new Map<string, string | null>();
1223
- for (const removed of [
1224
- ...orphans.filter(orphan => orphan.alias !== "")
1225
- .map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })),
1226
- ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })),
1227
- ]) {
1228
- if (nextManagedModels.get(removed.alias) === removed.modelId) continue;
1229
- const replacement = survivors.get(removed.modelId) ?? null;
1230
- if (replacement !== removed.alias) replacements.set(removed.alias, replacement);
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();
1231
1179
  }
1232
- return rewriteAliasReferences(candidate, replacements);
1233
- };
1234
-
1235
- const userContent = region ? content.slice(0, region.start) + content.slice(region.end) : content;
1236
- let userContentValid = false;
1237
- try {
1238
- Bun.TOML.parse(userContent);
1239
- userContentValid = true;
1240
- } catch {
1241
- // Preserve the old, conservative reservation and write behavior for malformed user TOML.
1242
- }
1243
- let nextContent: string;
1244
- if (!userContentValid) {
1245
- nextContent = buildCandidate(userModelAliases(content, region, true));
1246
- } else {
1247
- 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);
1248
1219
  let candidate: string;
1249
- try {
1250
- candidate = buildCandidate(userModelAliases(content, region, includeNested));
1251
- } catch (error) {
1252
- if (error instanceof Error && error.message === "Grok config rewrite refused: Bun could not parse the TOML document safely.") return null;
1253
- 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`;
1254
1227
  }
1255
- try {
1256
- Bun.TOML.parse(applyEol(candidate, eol));
1257
- return candidate;
1258
- } catch {
1259
- return null;
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);
1260
1234
  }
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);
1244
+ }
1245
+ return rewriteAliasReferences(candidate, replacements);
1261
1246
  };
1262
- nextContent = validCandidate(false) ?? validCandidate(true) ?? "";
1263
- if (nextContent === "") {
1264
- throw new Error("Grok config injection refused: neither alias choice produced valid TOML after model-reference rewriting.");
1265
- }
1266
- }
1267
1247
 
1268
- const output = applyEol(nextContent, eol);
1269
- if (output === rawContent) {
1270
- return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." };
1271
- }
1272
- // Back up before a first-time fence write AND before any sweep, since adopting an orphan
1273
- // deletes a table the user has in their file. Previously the adjacent-orphan layout got a
1274
- // backup only as a side effect of the fence being destroyed (which made `region` falsy);
1275
- // preserving the fence must not silently drop that safety net.
1276
- if (opts.refreshOnly) {
1277
- const refreshOnly = opts.refreshOnly;
1278
- atomicWriteFileNoFollow(configPath, output, undefined, {
1279
- validateBeforeRename: () => {
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;
1280
1262
  try {
1281
- if (!refreshOnly.admit() || !lstatSync(configPath).isFile()
1282
- || readFileSync(configPath, "utf8") !== rawContent) throw new Error();
1283
- } catch { throw new GrokRefreshOnlyRefusal(); }
1284
- },
1285
- });
1286
- } else {
1287
- if (configExisted && (!region || orphans.length > 0)) copyBackupOnce(configPath, backupPath);
1288
- atomicWriteFile(configPath, output);
1289
- }
1290
- return {
1291
- ok: true,
1292
- changed: true,
1293
- message: region
1294
- ? "Updated the opencodex managed block in Grok config."
1295
- : "Added the opencodex managed block to Grok config.",
1296
- };
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
+ }
1279
+ }
1280
+
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;
1297
1316
  } catch (error) {
1298
1317
  if (error instanceof GrokRefreshOnlyRefusal) return refreshOnlySkipped();
1299
1318
  return errorResult("inject", error);
@@ -1317,95 +1336,100 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes
1317
1336
  }
1318
1337
 
1319
1338
  try {
1320
- const rawContent = readFileSync(configPath, "utf8");
1321
- const eol = dominantEol(rawContent);
1322
- const content = applyEol(rawContent, "\n");
1323
- const originalRegion = findManagedRegion(content);
1324
- if (originalRegion?.orphaned) return orphanedMarkerResult("cleanup");
1325
-
1326
- // Remove the fence against its ORIGINAL offsets first. A pre-fence orphan's span is clamped
1327
- // at the fence start and can include the separator newline injection added. Sweeping that
1328
- // orphan first and then applying this separator undo would remove one additional USER newline.
1329
- let stripped: string;
1330
- let orphanCount = 0;
1331
- if (originalRegion) {
1332
- const fullOrphans = findOpencodexOrphans(content, originalRegion)
1333
- .filter(orphan => orphan.ownership === "explicit");
1334
- let removalEnd = originalRegion.end;
1335
- if (content.startsWith("\n", removalEnd)) removalEnd += 1;
1336
- let prefix = content.slice(0, originalRegion.start);
1337
- const restOfFile = content.slice(removalEnd);
1338
- // Undo the single separator newline injection added. Two cases, mirroring inject:
1339
- // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
1340
- // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
1341
- // A block the user has appended content after is left alone: we never shrink their bytes.
1342
- if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
1343
- else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
1344
- // Keep the old fence boundary while sweeping. Concatenating first would let the last
1345
- // pre-fence orphan absorb comment-only or bare-key user content appended after the fence.
1346
- const prefixOrphans = findOpencodexOrphans(prefix, null)
1347
- .filter(orphan => orphan.ownership === "explicit");
1348
- const tailOrphans = findOpencodexOrphans(restOfFile, null)
1349
- .filter(orphan => orphan.ownership === "explicit");
1350
- const removedAliases = new Set(
1351
- [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1352
- .map(orphan => orphan.alias)
1353
- // Provider orphans carry no alias; their ranges above already removed the table.
1354
- .filter(alias => alias !== ""),
1355
- );
1356
- // The backup must cover every removed TABLE, not just aliased rows: provider-only
1357
- // orphans carry no alias and would otherwise be swept without any backup.
1358
- orphanCount = new Set(
1359
- [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1360
- .flatMap(orphan => orphanRanges([orphan])),
1361
- ).size;
1362
- const fullRanges = orphanRanges(fullOrphans);
1363
- const prefixRanges = [
1364
- ...orphanRanges(prefixOrphans),
1365
- ...fullRanges.filter(range => range.end <= originalRegion.start),
1366
- ];
1367
- const tailRanges = [
1368
- ...orphanRanges(tailOrphans),
1369
- ...fullRanges
1370
- .filter(range => range.start >= removalEnd)
1371
- .map(range => ({ start: range.start - removalEnd, end: range.end - removalEnd })),
1372
- ];
1373
- // Preserve the original fence as a structural boundary while cleaning references too.
1374
- // Joining first can re-parent a headerless tail under the last table in `prefix`.
1375
- stripped = removeAliasReferences(
1376
- removeTableRanges(prefix, prefixRanges),
1377
- removedAliases,
1378
- ) + removeAliasReferences(
1379
- removeTableRanges(restOfFile, tailRanges),
1380
- removedAliases,
1381
- false,
1382
- );
1383
- } else {
1384
- // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the
1385
- // fence while the integration is enabled. Teardown owns those strictly identified tables
1386
- // even after Grok has re-serialized the file and dropped our marker comments.
1387
- const orphans = findOpencodexOrphans(content, null)
1388
- .filter(orphan => orphan.ownership === "explicit");
1389
- if (orphans.length === 0) {
1390
- 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
+ );
1391
1418
  }
1392
- orphanCount = orphans.length;
1393
- stripped = removeOrphanTables(content, orphans);
1394
- stripped = removeAliasReferences(
1395
- stripped,
1396
- new Set(orphans.map(orphan => orphan.alias).filter(alias => alias !== "")),
1397
- );
1398
- }
1399
- if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex"));
1400
- atomicWriteFile(configPath, applyEol(stripped, eol));
1401
-
1402
- return {
1403
- ok: true,
1404
- changed: true,
1405
- message: originalRegion
1406
- ? "Removed the opencodex managed block from Grok config."
1407
- : "Removed stale opencodex-managed model entries from Grok config.",
1408
- };
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;
1409
1433
  } catch (error) {
1410
1434
  return errorResult("strip", error);
1411
1435
  }