@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * features.ts — codex feature-flag view for $CODEX_HOME/config.toml.
3
3
  *
4
- * Scope boundary: this module mirrors ONLY `multi_agent_v2`, because opencodex has
5
- * to migrate its concurrency value across the v1/v2 boundary and expose the
6
- * multi-agent config surface. Every other upstream feature flag is delegated to
7
- * the native `codex features` command (see src/cli/v2.ts) and must not be
8
- * hardcoded here.
4
+ * Scope boundary: this module mirrors only the flags opencodex has to READ
5
+ * directly from config.toml:
6
+ * - `multi_agent_v2`, because opencodex migrates its concurrency value across
7
+ * the v1/v2 boundary and exposes the multi-agent config surface;
8
+ * - `default_mode_request_user_input` (Codex Auth page toggle), because the
9
+ * management API needs a live reader for the flag it manages.
10
+ * Every other upstream feature flag is delegated to the native `codex features`
11
+ * command (see src/cli/v2.ts) and must not be hardcoded here.
9
12
  *
10
13
  * Upstream reshapes flags freely: in the 1f0566d3f..5a1097ed2 range alone,
11
14
  * `code_mode_host` changed from a boolean to a table (it is Stage::Stable and
@@ -30,9 +33,13 @@
30
33
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
31
34
  import { join, resolve } from "node:path";
32
35
  import { realpathSync } from "node:fs";
33
- import { atomicWriteFile, expandUserPath } from "../config";
36
+ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWriteFile, expandUserPath } from "../config";
37
+ import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl";
34
38
  import { CODEX_CONFIG_PATH } from "./paths";
35
39
 
40
+ /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */
41
+ export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input";
42
+
36
43
  // EOL preservation, local copies of inject.ts dominantEol/applyEol: importing
37
44
  // inject here would close a module cycle (features -> inject -> catalog -> features).
38
45
  function dominantEol(content: string): "\r\n" | "\n" {
@@ -55,7 +62,7 @@ function mergeTrailingComments(existing?: string, migrated?: string): string {
55
62
  return `${existing}; ${migratedText}`;
56
63
  }
57
64
 
58
- function activeCodexConfigPath(): string {
65
+ export function activeCodexConfigPath(): string {
59
66
  const raw = process.env.CODEX_HOME?.trim();
60
67
  if (!raw) return CODEX_CONFIG_PATH;
61
68
  const path = resolve(expandUserPath(raw));
@@ -127,6 +134,22 @@ export function isMultiAgentV2Enabled(configPath?: string): boolean {
127
134
  return false;
128
135
  }
129
136
 
137
+ /**
138
+ * TRUE when the codex `default_mode_request_user_input` feature is enabled in
139
+ * config.toml — lets a Default-mode session pause and ask the user questions
140
+ * through `request_user_input` (upstream FeatureSpec: under development,
141
+ * default_enabled = false). Recognizes the shipped boolean form
142
+ * `[features] default_mode_request_user_input = true`.
143
+ * Missing file/key -> false.
144
+ */
145
+ export function isDefaultModeRequestUserInputEnabled(configPath?: string): boolean {
146
+ const content = readConfigText(configPath);
147
+ if (content === null) return false;
148
+ const features = tomlTableBody(content, "features");
149
+ if (features === null) return false;
150
+ return tomlBoolInBody(features, DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY) === true;
151
+ }
152
+
130
153
  /**
131
154
  * TRUE when config.toml still carries `[agents] max_threads` — codex-rs REFUSES to
132
155
  * boot with that key while multi_agent_v2 is enabled ("agents.max_threads cannot be
@@ -833,10 +856,20 @@ function activeThreadComment(content: string, v2Enabled: boolean): string | unde
833
856
  }
834
857
 
835
858
  let migrationEditSeq = 0;
859
+ /** Both residual classes gate the memo release: a plain residual and a
860
+ * secret-bearing one alike keep their destination memo while the file
861
+ * remains on disk. Exported for the regression seam. */
862
+ export function isAtomicResidualError(error: unknown): boolean {
863
+ return error instanceof AtomicWriteResidualTempError || error instanceof AtomicWriteSecretResidualError;
864
+ }
865
+
836
866
  function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => ConfigEditResult): ConfigEditResult {
837
867
  const content = readConfigText(path);
838
868
  if (content === null) return { ok: false, error: `config.toml not readable at ${path}` };
839
869
  const tempPath = `${path}.ocx-migration.${process.pid}.${++migrationEditSeq}`;
870
+ // An inner residual temp (AtomicWriteResidualTempError) keeps its
871
+ // destination-keyed memo: fail-closed while the residual exists.
872
+ let innerResidual = false;
840
873
  try {
841
874
  atomicWriteFile(tempPath, content);
842
875
  const result = edit(tempPath);
@@ -846,8 +879,19 @@ function applyConfigEditsAtomically(path: string, edit: (tempPath: string) => Co
846
879
  if (edited === content) return { ok: true, changed: false };
847
880
  atomicWriteFile(path, edited);
848
881
  return { ok: true, changed: true };
882
+ } catch (error) {
883
+ if (isAtomicResidualError(error)) innerResidual = true;
884
+ throw error;
849
885
  } finally {
850
- try { unlinkSync(tempPath); } catch { /* already absent */ }
886
+ try {
887
+ unlinkSync(tempPath);
888
+ if (!innerResidual) forgetEphemeralSecretPath(tempPath);
889
+ } catch (error) {
890
+ // Already absent is also proven-absent; other failures keep the memo.
891
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
892
+ if (!innerResidual) forgetEphemeralSecretPath(tempPath);
893
+ }
894
+ }
851
895
  }
852
896
  }
853
897
 
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Durable generation ownership for OpenCodex config bytes.
3
+ *
4
+ * The WP8b C-phase review found that config ABA and moved configuration could
5
+ * not be detected because cooperating saves had no durable counter. This module
6
+ * owns the singleton schema and conditional increment in the existing config
7
+ * mutation database; callers that already hold its transaction reuse that
8
+ * Database handle so bytes and generation remain one cooperating commit.
9
+ *
10
+ * A hand edit from a non-cooperating writer deliberately does not increment this
11
+ * counter. The convergence contract detects that case with its post-commit file
12
+ * observation instead of pretending SQLite can coordinate an external editor.
13
+ */
14
+ import { chmodSync, statSync } from "node:fs";
15
+
16
+ import { Database } from "bun:sqlite";
17
+
18
+ import type {
19
+ ConfigGeneration,
20
+ ConfigGenerationBump,
21
+ ConfigGenerationRead,
22
+ } from "./convergence-types";
23
+
24
+ const CREATE_CONFIG_GENERATION = `
25
+ CREATE TABLE IF NOT EXISTS config_generation (
26
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
27
+ value INTEGER NOT NULL CHECK (value >= 0)
28
+ )`;
29
+ const INITIALIZE_CONFIG_GENERATION = `
30
+ INSERT OR IGNORE INTO config_generation (singleton, value) VALUES (1, 0)`;
31
+ const SELECT_CONFIG_GENERATION = `
32
+ SELECT value FROM config_generation WHERE singleton = 1`;
33
+ const BUMP_CONFIG_GENERATION = `
34
+ UPDATE config_generation
35
+ SET value = value + 1
36
+ WHERE singleton = 1 AND value = ?`;
37
+
38
+ interface ConfigGenerationRow {
39
+ value: unknown;
40
+ }
41
+
42
+ interface SchemaVersionRow {
43
+ schema_version: unknown;
44
+ }
45
+
46
+ /**
47
+ * Observation adds exactly one variant to `ConfigGenerationRead`: `absent`.
48
+ *
49
+ * This used to be deliberately absent itself, on the reasoning that a caller
50
+ * who may only LOOK must not be handed something it could mistake for a
51
+ * known-good baseline of zero. That reasoning still holds, and `absent` does
52
+ * not violate it — because `absent` is not a baseline. It authorizes nothing on
53
+ * its own. A caller may only promote it after taking the config transaction and
54
+ * reading a real zero THERE (`readConfigGenerationInCurrentMutationTransaction`),
55
+ * at which point the zero is observed rather than assumed. A caller with no
56
+ * transaction to open, such as catalog gather, must keep refusing it.
57
+ *
58
+ * What forced the distinction: refusing on absence meant refusing every Codex
59
+ * write on any home whose config predates this database — a permanent refusal
60
+ * for existing users, not a fixture problem.
61
+ *
62
+ * `absent` is returned ONLY for ENOENT on the initial `statSync`. A file that
63
+ * exists but cannot be read, a directory in its place, a bad schema version, or
64
+ * corrupt SQLite all stay `unavailable`, because those are reasons to stop, and
65
+ * collapsing them into absence is how a corrupt coordinator would become a
66
+ * licence to write.
67
+ */
68
+ export type ConfigGenerationObservation =
69
+ | ConfigGenerationRead
70
+ | { kind: "absent" };
71
+
72
+ function errorCode(error: unknown): string {
73
+ return error && typeof error === "object" && "code" in error
74
+ ? String((error as { code?: unknown }).code)
75
+ : "";
76
+ }
77
+
78
+ function isBusy(error: unknown): boolean {
79
+ const code = errorCode(error);
80
+ const message = error instanceof Error ? error.message : "";
81
+ return code === "SQLITE_BUSY"
82
+ || code === "SQLITE_LOCKED"
83
+ || /database (?:is|table is) locked/i.test(message);
84
+ }
85
+
86
+ function unavailable(error: unknown): Extract<ConfigGenerationRead, { kind: "unavailable" }> {
87
+ return { kind: "unavailable", reason: isBusy(error) ? "busy" : "database" };
88
+ }
89
+
90
+ export function initializeConfigGeneration(database: Database): void {
91
+ database.exec(CREATE_CONFIG_GENERATION);
92
+ database.exec(INITIALIZE_CONFIG_GENERATION);
93
+ }
94
+
95
+ export function readConfigGenerationInTransaction(database: Database): ConfigGeneration {
96
+ const row = database.query<ConfigGenerationRow, []>(SELECT_CONFIG_GENERATION).get();
97
+ if (!row || !Number.isSafeInteger(row.value) || Number(row.value) < 0) {
98
+ throw new Error("The config generation singleton is missing or invalid.");
99
+ }
100
+ return { value: Number(row.value) };
101
+ }
102
+
103
+ export function bumpConfigGenerationInTransaction(
104
+ database: Database,
105
+ expected: ConfigGeneration,
106
+ ): ConfigGenerationBump {
107
+ const result = database.query(BUMP_CONFIG_GENERATION).run(expected.value);
108
+ if (result.changes === 1) {
109
+ return { kind: "updated", generation: { value: expected.value + 1 } };
110
+ }
111
+ return { kind: "conflict", current: readConfigGenerationInTransaction(database) };
112
+ }
113
+
114
+ export function bumpCurrentConfigGeneration(database: Database): ConfigGeneration {
115
+ const current = readConfigGenerationInTransaction(database);
116
+ const result = bumpConfigGenerationInTransaction(database, current);
117
+ if (result.kind !== "updated") {
118
+ throw new Error("The config generation changed inside its owning transaction.");
119
+ }
120
+ return result.generation;
121
+ }
122
+
123
+ function runGenerationTransaction<T>(databasePath: string, operation: (database: Database) => T): T {
124
+ let database: Database | undefined;
125
+ let transactionOpen = false;
126
+ try {
127
+ database = new Database(databasePath, { create: true });
128
+ try { chmodSync(databasePath, 0o600); } catch { /* platform may ignore chmod */ }
129
+ database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
130
+ transactionOpen = true;
131
+ initializeConfigGeneration(database);
132
+ const result = operation(database);
133
+ database.exec("COMMIT");
134
+ transactionOpen = false;
135
+ return result;
136
+ } catch (error) {
137
+ if (transactionOpen) {
138
+ try { database?.exec("ROLLBACK"); } catch { /* close releases the transaction */ }
139
+ }
140
+ throw error;
141
+ } finally {
142
+ try { database?.close(); } catch { /* operation already completed */ }
143
+ }
144
+ }
145
+
146
+ export function readConfigGenerationAtPath(databasePath: string): ConfigGenerationRead {
147
+ try {
148
+ return {
149
+ kind: "ready",
150
+ generation: runGenerationTransaction(databasePath, readConfigGenerationInTransaction),
151
+ };
152
+ } catch (error) {
153
+ return unavailable(error);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Observe generation state without preparing the mutation database in any way.
159
+ * Missing storage is a first-class state: only cooperating config writes have
160
+ * authority to create and initialize the generation singleton.
161
+ */
162
+ export function observeConfigGenerationAtPath(
163
+ databasePath: string,
164
+ ): ConfigGenerationObservation {
165
+ try {
166
+ statSync(databasePath);
167
+ } catch (error) {
168
+ // ENOENT alone means absent. Everything else — EACCES, ENOTDIR, EIO — is a
169
+ // reason the question could not be answered, which is not the same answer.
170
+ return errorCode(error) === "ENOENT" ? { kind: "absent" } : unavailable(error);
171
+ }
172
+
173
+ let database: Database | undefined;
174
+ try {
175
+ database = new Database(databasePath, { readonly: true });
176
+ const schema = database.query<SchemaVersionRow, []>("PRAGMA schema_version").get();
177
+ if (!schema || !Number.isSafeInteger(schema.schema_version)) {
178
+ throw new Error("The config generation schema version is invalid.");
179
+ }
180
+ return {
181
+ kind: "ready",
182
+ generation: readConfigGenerationInTransaction(database),
183
+ };
184
+ } catch (error) {
185
+ return unavailable(error);
186
+ } finally {
187
+ try { database?.close(); } catch { /* observation already completed */ }
188
+ }
189
+ }
190
+
191
+ export function bumpConfigGenerationAtPath(
192
+ databasePath: string,
193
+ expected: ConfigGeneration,
194
+ ): ConfigGenerationBump {
195
+ try {
196
+ return runGenerationTransaction(databasePath, database => (
197
+ bumpConfigGenerationInTransaction(database, expected)
198
+ ));
199
+ } catch (error) {
200
+ return unavailable(error);
201
+ }
202
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The parent half of the history unit: derive the operation, dispatch the
3
+ * Worker, and never let its failure become the caller's stall.
4
+ *
5
+ * The operation is DERIVED here from what the caller already decided — its
6
+ * config and the direction its native mutation just took — and then handed down
7
+ * as a fixed value. It is not a request field the Worker trusts, because the
8
+ * distinctions are real: `syncResumeHistory: false` means leave history alone,
9
+ * apply targets opencodex only in legacy mode, and legacy recovery must not
10
+ * touch the manifest that generic restore consumes.
11
+ *
12
+ * Every exit is typed. A Worker that errors, dies, or overruns its watchdog
13
+ * produces an outcome the caller can record, because the alternative — an
14
+ * exception crossing back into a route that already persisted its mutation — is
15
+ * how a successful change gets reported as a 500.
16
+ *
17
+ * Design record: devlog/_plan/260804_codex_write_substrate/020_history_isolation.md.
18
+ */
19
+ import { randomUUID } from "node:crypto";
20
+ import { join } from "node:path";
21
+
22
+ import type {
23
+ CodexHistoryWorkerOperation,
24
+ HistoryWorkerResult,
25
+ } from "./history-worker";
26
+ import { historyBackupPathFor } from "./history-provider";
27
+ import { getCodexHome } from "./paths";
28
+
29
+ /** Where Codex keeps its resume history, and the manifest that shadows it. */
30
+ const STATE_DB_FILE = "state_5.sqlite";
31
+
32
+ /**
33
+ * Resolve the paths a history job needs, at CALL time.
34
+ *
35
+ * `history-provider.ts` resolves its equivalents at module load (`:16`, `:22`),
36
+ * which is fine in one process and wrong for a Worker: the Worker does not
37
+ * inherit them, so anything derived from those constants would address a
38
+ * different home than the caller intended. Resolving here also means a test that
39
+ * moves `CODEX_HOME` is honoured rather than ignored.
40
+ */
41
+ export function resolveCodexHistoryJobTarget(): {
42
+ readonly canonicalCodexHome: string;
43
+ readonly canonicalStateDbPath: string;
44
+ readonly canonicalBackupPath: string;
45
+ } {
46
+ const home = getCodexHome();
47
+ const stateDb = join(home, STATE_DB_FILE);
48
+ return {
49
+ canonicalCodexHome: home,
50
+ canonicalStateDbPath: stateDb,
51
+ // Derived by the provider's own rule rather than guessed: the manifest lives
52
+ // in the config directory under a hash of the state database, so a
53
+ // hand-built path would address a different file entirely.
54
+ canonicalBackupPath: historyBackupPathFor(stateDb),
55
+ };
56
+ }
57
+
58
+ /** How long a history unit may run before the parent stops waiting on it. */
59
+ const WORKER_TIMEOUT_MS = 30_000;
60
+
61
+ export interface CodexHistoryJobRequest {
62
+ readonly canonicalCodexHome: string;
63
+ readonly canonicalStateDbPath: string;
64
+ readonly canonicalBackupPath: string;
65
+ readonly operation: CodexHistoryWorkerOperation;
66
+ }
67
+
68
+ export type CodexHistoryJobOutcome =
69
+ | { readonly kind: "converged"; readonly rows: number; readonly files: number }
70
+ | { readonly kind: "skipped" }
71
+ | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" }
72
+ | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
73
+ readonly message: string };
74
+
75
+ /**
76
+ * Derive the durable history operation from admitted intent.
77
+ *
78
+ * `resumeHistory === false` is the user's explicit opt-out and outranks the
79
+ * direction entirely — an apply that quietly migrated history anyway would be
80
+ * the setting failing silently. `legacyMode` is the only case that routes
81
+ * history TO opencodex; the ordinary apply migrates to native so a later restore
82
+ * has nothing to undo.
83
+ */
84
+ /**
85
+ * Test seam: the boundary suite exercises the validator without standing up a
86
+ * Worker whose malformed message it cannot easily emit from inside a test.
87
+ */
88
+ export function isPlausibleWorkerResultForTests(
89
+ message: Record<string, unknown>,
90
+ requestId: string,
91
+ jobId: string,
92
+ ): boolean {
93
+ return isPlausibleWorkerResult(message, requestId, jobId);
94
+ }
95
+
96
+ /**
97
+ * Reject a message that names a recognized type but lacks its payload.
98
+ *
99
+ * Without this, `{requestId, type:"done"}` reached an unchecked cast and read
100
+ * as `converged` with undefined fields — a success report for work that may not
101
+ * have happened. Each type is checked for the fields it actually carries, and
102
+ * the ids are matched against the request in flight, not merely present.
103
+ */
104
+ function isPlausibleWorkerResult(
105
+ message: Record<string, unknown>,
106
+ requestId: string,
107
+ jobId: string,
108
+ ): boolean {
109
+ if (message.requestId !== requestId || message.jobId !== jobId) return false;
110
+ switch (message.type) {
111
+ case "done":
112
+ return (message.outcome === "converged" || message.outcome === "skipped")
113
+ && typeof message.rows === "number"
114
+ && typeof message.files === "number";
115
+ case "blocked":
116
+ return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path";
117
+ case "error":
118
+ return typeof message.message === "string";
119
+ default:
120
+ return false;
121
+ }
122
+ }
123
+
124
+ export function deriveCodexHistoryOperation(intent: {
125
+ readonly direction: "apply" | "restore";
126
+ readonly resumeHistory: boolean;
127
+ readonly legacyMode: boolean;
128
+ }): CodexHistoryWorkerOperation {
129
+ if (!intent.resumeHistory) return "skip";
130
+ if (intent.direction === "restore") return "restore-openai";
131
+ return intent.legacyMode ? "apply-opencodex" : "migrate-openai";
132
+ }
133
+
134
+ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome {
135
+ if (result.type === "blocked") return { kind: "blocked", reason: result.reason };
136
+ if (result.type === "error") {
137
+ return { kind: "failed", reason: "worker-error", message: result.message };
138
+ }
139
+ return result.outcome === "skipped"
140
+ ? { kind: "skipped" }
141
+ : { kind: "converged", rows: result.rows, files: result.files };
142
+ }
143
+
144
+ /**
145
+ * Run one history unit in a Worker and join it before returning.
146
+ *
147
+ * The join is not optional politeness: returning while the thread may still be
148
+ * mutating CODEX_HOME would let a caller's next step observe a half-applied
149
+ * transition, and would let a test suite reach its next file with a worker still
150
+ * exiting behind it.
151
+ */
152
+ export async function runCodexHistoryJob(
153
+ request: CodexHistoryJobRequest,
154
+ options: { readonly timeoutMs?: number } = {},
155
+ ): Promise<CodexHistoryJobOutcome> {
156
+ const requestId = randomUUID();
157
+ const jobId = randomUUID();
158
+ const timeoutMs = options.timeoutMs ?? WORKER_TIMEOUT_MS;
159
+
160
+ // `skip` writes nothing, so spawning a thread to decide that would be pure
161
+ // cost. It still returns a recorded outcome rather than silence.
162
+ if (request.operation === "skip") return { kind: "skipped" };
163
+
164
+ let worker: Worker;
165
+ try {
166
+ worker = new Worker(new URL("./history-worker.ts", import.meta.url).href);
167
+ } catch (error) {
168
+ return {
169
+ kind: "failed",
170
+ reason: "worker-died",
171
+ message: error instanceof Error ? error.message : "history_worker_spawn_failed",
172
+ };
173
+ }
174
+
175
+ return new Promise<CodexHistoryJobOutcome>(resolve => {
176
+ let settled = false;
177
+ const finish = (outcome: CodexHistoryJobOutcome) => {
178
+ if (settled) return;
179
+ settled = true;
180
+ clearTimeout(timer);
181
+ // Terminate and join before settling: see the note above.
182
+ const done = () => resolve(outcome);
183
+ try {
184
+ const terminated = worker.terminate() as unknown;
185
+ if (terminated && typeof (terminated as Promise<unknown>).then === "function") {
186
+ void (terminated as Promise<unknown>).then(done, done);
187
+ return;
188
+ }
189
+ } catch { /* already gone */ }
190
+ done();
191
+ };
192
+
193
+ const timer = setTimeout(() => {
194
+ finish({ kind: "failed", reason: "timeout", message: "history_worker_timeout" });
195
+ }, timeoutMs);
196
+
197
+ const died = (detail: string): void => {
198
+ finish({ kind: "failed", reason: "worker-died", message: detail });
199
+ };
200
+
201
+ worker.onmessage = (event: MessageEvent<unknown>) => {
202
+ const data = event.data;
203
+ if (!data || typeof data !== "object") {
204
+ // Not the shape at all: this is a death signal, not silence. Ignoring it
205
+ // would let the watchdog call a dead Worker a timeout.
206
+ died("history_worker_malformed_message");
207
+ return;
208
+ }
209
+ const message = data as Record<string, unknown>;
210
+ // A reply for a different request is somebody else's; ignoring it is not
211
+ // the same as accepting it.
212
+ if (message.requestId !== requestId) return;
213
+ if (message.type !== "done" && message.type !== "blocked" && message.type !== "error") {
214
+ died("history_worker_unknown_message_type");
215
+ return;
216
+ }
217
+ if (!isPlausibleWorkerResult(message, requestId, jobId)) {
218
+ // A recognized type with a missing payload read as `converged` with
219
+ // undefined fields once — success for work that may not have happened.
220
+ died("history_worker_malformed_payload");
221
+ return;
222
+ }
223
+ finish(classifyWorkerResult(message as unknown as HistoryWorkerResult));
224
+ };
225
+
226
+ /*
227
+ * A Worker that exits early — without erroring — is not an error event, so
228
+ * without these it surfaced as `timeout` after the full wait. Both are death
229
+ * signals. Neither can overturn a settled success: `finish` is idempotent,
230
+ * and the Worker always closes after posting its result.
231
+ */
232
+ worker.addEventListener("messageerror", () => died("history_worker_unserializable_message"));
233
+ worker.addEventListener("close", () => died("history_worker_closed_early"));
234
+
235
+ worker.onerror = (event: ErrorEvent) => {
236
+ finish({
237
+ kind: "failed",
238
+ reason: "worker-died",
239
+ message: event.message || "history_worker_failed",
240
+ });
241
+ };
242
+
243
+ worker.postMessage({
244
+ type: "run",
245
+ requestId,
246
+ jobId,
247
+ operation: request.operation,
248
+ canonicalCodexHome: request.canonicalCodexHome,
249
+ canonicalStateDbPath: request.canonicalStateDbPath,
250
+ canonicalBackupPath: request.canonicalBackupPath,
251
+ env: {
252
+ ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}),
253
+ ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
254
+ },
255
+ });
256
+ });
257
+ }