@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { loadConfig, mutatePersistedConfig } from "../config";
24
24
  import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
25
+ import { runStartupReadinessSync, type ReadinessGate, type SyncOutcomeLike } from "../server/readiness";
25
26
 
26
27
  /** Clients whose durable intent this module owns. */
27
28
  export type DurableIntentClientId = keyof OcxClientIntegrationsConfig;
@@ -36,8 +37,11 @@ export type DurableIntentClientId = keyof OcxClientIntegrationsConfig;
36
37
  export interface CodexStartupSyncOutcome {
37
38
  catalogWritten: boolean;
38
39
  cacheSynced: boolean;
40
+ /** Readiness-observable fields carried by the raw startup sync. */
41
+ ok?: boolean;
42
+ warning?: string;
39
43
  }
40
- export type CodexStartupSync = (port: number) => Promise<CodexStartupSyncOutcome | undefined>;
44
+ export type CodexStartupSync = (port: number) => Promise<SyncOutcomeLike | undefined>;
41
45
 
42
46
  export type CodexDesiredStateResult =
43
47
  | { readonly ok: true; readonly status: "committed" | "unchanged"; readonly enabled: boolean }
@@ -66,6 +70,11 @@ export function codexIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrati
66
70
  return integrationEnabled(config, "codex");
67
71
  }
68
72
 
73
+ /** Whether a Codex sync is permitted for this admitted config snapshot. */
74
+ export function shouldSyncCodexOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
75
+ return codexIntegrationEnabled(config);
76
+ }
77
+
69
78
  /**
70
79
  * Grok's toggle SHIPPED without this, which is the bug: it strips the fence in
71
80
  * `~/.grok/config.toml` and records nothing, so the next `ocx start` calls
@@ -138,6 +147,20 @@ export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateRe
138
147
  return setIntegrationEnabled("grok", enabled);
139
148
  }
140
149
 
150
+ /** Whether Claude Desktop's managed gateway profile is wanted. */
151
+ export function claudeDesktopIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrations">): boolean {
152
+ return integrationEnabled(config, "claude-desktop");
153
+ }
154
+
155
+ /** The same question when no admitted config snapshot is in hand. */
156
+ export function claudeDesktopIntegrationEnabledNow(): boolean {
157
+ return claudeDesktopIntegrationEnabled(loadConfig());
158
+ }
159
+
160
+ export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesiredStateResult {
161
+ return setIntegrationEnabled("claude-desktop", enabled);
162
+ }
163
+
141
164
  /**
142
165
  * The startup gate, as a function rather than an `if` buried in `handleStart`.
143
166
  *
@@ -161,13 +184,23 @@ export async function syncCodexOnStartIfEnabled(
161
184
  port: number,
162
185
  config: Pick<OcxConfig, "clientIntegrations">,
163
186
  sync: CodexStartupSync = defaultStartupSync,
187
+ readinessGate?: ReadinessGate,
164
188
  ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> {
165
- if (!codexIntegrationEnabled(config)) {
189
+ if (!shouldSyncCodexOnStart(config)) {
190
+ // The user explicitly turned Codex off: there is nothing to sync, so the
191
+ // proxy is ready as soon as it is up. The gate is driven here so /readyz
192
+ // does not stay pending forever for a deployment that deliberately disabled
193
+ // the native Codex integration.
194
+ readinessGate?.markReady();
166
195
  return { ran: false, catalogWritten: false, cacheSynced: false };
167
196
  }
168
197
  // The `.catch` is deliberate and stays: a failure to APPLY must not stop the
169
- // proxy from coming up. A failed sync simply reports no writes.
170
- const outcome = await sync(port).catch(() => undefined);
198
+ // proxy from coming up. A failed sync simply reports no writes. The readiness
199
+ // gate observes the real outcome so /readyz reflects the sync state exactly as
200
+ // the PR contract defines (ready only on ok=true with no warning).
201
+ const outcome = readinessGate
202
+ ? await runStartupReadinessSync(readinessGate, async () => (await sync(port)) ?? null)
203
+ : await sync(port).catch(() => undefined);
171
204
  return {
172
205
  ran: true,
173
206
  catalogWritten: outcome?.catalogWritten === true,
@@ -24,6 +24,7 @@ import type {
24
24
  HistoryWorkerResult,
25
25
  } from "./history-worker";
26
26
  import { historyBackupPathFor } from "./history-provider";
27
+ import type { CodexHistoryFailureReason } from "./history-provider";
27
28
  import { getCodexHome } from "./paths";
28
29
 
29
30
  /** Where Codex keeps its resume history, and the manifest that shadows it. */
@@ -63,14 +64,15 @@ export interface CodexHistoryJobRequest {
63
64
  readonly canonicalStateDbPath: string;
64
65
  readonly canonicalBackupPath: string;
65
66
  readonly operation: CodexHistoryWorkerOperation;
67
+ readonly expectedDesiredEnabled?: boolean;
66
68
  }
67
69
 
68
70
  export type CodexHistoryJobOutcome =
69
71
  | { readonly kind: "converged"; readonly rows: number; readonly files: number }
70
72
  | { readonly kind: "skipped" }
71
- | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" }
73
+ | { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
72
74
  | { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
73
- readonly message: string };
75
+ readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason };
74
76
 
75
77
  /**
76
78
  * Derive the durable history operation from admitted intent.
@@ -113,9 +115,11 @@ function isPlausibleWorkerResult(
113
115
  && typeof message.rows === "number"
114
116
  && typeof message.files === "number";
115
117
  case "blocked":
116
- return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path";
118
+ return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"
119
+ || message.reason === "desired_disabled" || message.reason === "desired_enabled";
117
120
  case "error":
118
- return typeof message.message === "string";
121
+ return typeof message.message === "string"
122
+ && (message.reason === undefined || message.reason === "busy" || message.reason === "permission");
119
123
  default:
120
124
  return false;
121
125
  }
@@ -134,7 +138,12 @@ export function deriveCodexHistoryOperation(intent: {
134
138
  function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutcome {
135
139
  if (result.type === "blocked") return { kind: "blocked", reason: result.reason };
136
140
  if (result.type === "error") {
137
- return { kind: "failed", reason: "worker-error", message: result.message };
141
+ return {
142
+ kind: "failed",
143
+ reason: "worker-error",
144
+ message: result.message,
145
+ ...(result.reason ? { historyFailureReason: result.reason } : {}),
146
+ };
138
147
  }
139
148
  return result.outcome === "skipped"
140
149
  ? { kind: "skipped" }
@@ -248,6 +257,7 @@ export async function runCodexHistoryJob(
248
257
  canonicalCodexHome: request.canonicalCodexHome,
249
258
  canonicalStateDbPath: request.canonicalStateDbPath,
250
259
  canonicalBackupPath: request.canonicalBackupPath,
260
+ ...(request.expectedDesiredEnabled === undefined ? {} : { expectedDesiredEnabled: request.expectedDesiredEnabled }),
251
261
  env: {
252
262
  ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}),
253
263
  ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}),
@@ -166,12 +166,16 @@ function patchFirstLineProviderInPlace(path: string, expectedId: string, provide
166
166
 
167
167
  export type CodexHistoryProvider = "openai" | "opencodex";
168
168
 
169
+ export type CodexHistoryFailureReason = "busy" | "permission";
170
+
169
171
  export interface CodexHistorySyncResult {
170
172
  rows: number;
171
173
  files: number;
172
174
  ejectedRows?: number;
173
175
  /** Set when a lock/busy error survived retries and the sync was SKIPPED, not empty. */
174
176
  failed?: true;
177
+ /** Why the retry budget was exhausted when `failed` is set. */
178
+ failureReason?: CodexHistoryFailureReason;
175
179
  }
176
180
 
177
181
  interface ThreadRow {
@@ -515,19 +519,24 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
515
519
  return { rows: rows.length, files };
516
520
  }
517
521
 
518
- export function isRecoverableHistoryError(error: unknown): boolean {
522
+ export function classifyRecoverableHistoryError(error: unknown): CodexHistoryFailureReason | null {
519
523
  const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
520
524
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
521
- return code === "SQLITE_BUSY"
525
+ if (code === "SQLITE_BUSY"
522
526
  || code === "SQLITE_LOCKED"
523
527
  || code === "EBUSY"
524
- || code === "EPERM"
525
- || code === "EACCES"
526
528
  || message.includes("database is locked")
527
529
  || message.includes("database is busy")
528
- || message.includes("resource busy")
530
+ || message.includes("resource busy")) return "busy";
531
+ if (code === "EPERM"
532
+ || code === "EACCES"
529
533
  || message.includes("operation not permitted")
530
- || message.includes("permission denied");
534
+ || message.includes("permission denied")) return "permission";
535
+ return null;
536
+ }
537
+
538
+ export function isRecoverableHistoryError(error: unknown): boolean {
539
+ return classifyRecoverableHistoryError(error) !== null;
531
540
  }
532
541
 
533
542
  const HISTORY_RETRY_DELAY_MS = 500;
@@ -540,21 +549,29 @@ const HISTORY_RETRY_ATTEMPTS = 2;
540
549
  * error — callers surface that as `failed: true` instead of a silent no-op. Hard errors
541
550
  * (corruption, programming bugs) still throw.
542
551
  */
543
- export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null {
552
+ function withHistoryRetryResult<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}):
553
+ | { ok: true; value: T }
554
+ | { ok: false; reason: CodexHistoryFailureReason } {
544
555
  const sleepFn = io.sleepFn ?? Bun.sleepSync;
545
556
  const attempts = Math.max(1, io.attempts ?? HISTORY_RETRY_ATTEMPTS);
546
557
  const delayMs = io.delayMs ?? HISTORY_RETRY_DELAY_MS;
547
558
  for (let attempt = 0; ; attempt++) {
548
559
  try {
549
- return fn();
560
+ return { ok: true, value: fn() };
550
561
  } catch (error) {
551
- if (!isRecoverableHistoryError(error)) throw error;
552
- if (attempt >= attempts - 1) return null;
562
+ const reason = classifyRecoverableHistoryError(error);
563
+ if (!reason) throw error;
564
+ if (attempt >= attempts - 1) return { ok: false, reason };
553
565
  try { sleepFn(delayMs); } catch { /* sleep is best-effort */ }
554
566
  }
555
567
  }
556
568
  }
557
569
 
570
+ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void; attempts?: number; delayMs?: number } = {}): T | null {
571
+ const result = withHistoryRetryResult(fn, io);
572
+ return result.ok ? result.value : null;
573
+ }
574
+
558
575
  /**
559
576
  * True when a READONLY probe proves the openai-direction restore would be a no-op:
560
577
  * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the
@@ -581,8 +598,8 @@ export function syncCodexHistoryProvider(
581
598
  && openaiRestoreIsNoop(stateDbPath, backupPath)) {
582
599
  return { rows: 0, files: 0 };
583
600
  }
584
- return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath))
585
- ?? { rows: 0, files: 0, failed: true };
601
+ const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath));
602
+ return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
586
603
  }
587
604
 
588
605
  function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult {
@@ -734,8 +751,8 @@ export function migrateHistoryToOpenai(
734
751
  // nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate
735
752
  // (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds.
736
753
  if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 };
737
- return withHistoryRetry(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts)
738
- ?? { rows: 0, files: 0, failed: true };
754
+ const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts);
755
+ return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
739
756
  }
740
757
 
741
758
  export interface PendingHistoryCount {
@@ -24,11 +24,14 @@
24
24
  * Design record: devlog/_fin/260804_codex_write_substrate/020_history_isolation.md.
25
25
  */
26
26
  import { withHistoryWriteSerialization } from "./history-lock";
27
+ import { loadConfig } from "../config";
28
+ import { shouldSyncCodexOnStart } from "./desired-state";
27
29
  import {
28
30
  writeHistoryProviderTransition,
29
31
  writeLegacyOpenaiHistoryRecovery,
30
32
  type HistoryWriteTarget,
31
33
  } from "./internal/history-writer";
34
+ import type { CodexHistoryFailureReason } from "./history-provider";
32
35
 
33
36
  /**
34
37
  * The durable operation, mirrored into the request for diagnostics only.
@@ -53,6 +56,8 @@ export interface HistoryWorkerRunMessage {
53
56
  readonly canonicalCodexHome: string;
54
57
  readonly canonicalStateDbPath: string;
55
58
  readonly canonicalBackupPath: string;
59
+ /** When set, prove this transition's desired direction while H is held. */
60
+ readonly expectedDesiredEnabled?: boolean;
56
61
  /** Env snapshot: a Worker may not observe parent mutations on every platform. */
57
62
  readonly env?: { readonly CODEX_HOME?: string; readonly OPENCODEX_HOME?: string };
58
63
  }
@@ -62,9 +67,9 @@ export type HistoryWorkerResult =
62
67
  readonly outcome: "converged" | "skipped";
63
68
  readonly rows: number; readonly files: number }
64
69
  | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string;
65
- readonly reason: "busy" | "database" | "unsafe-path" }
70
+ readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
66
71
  | { readonly type: "error"; readonly requestId: string; readonly jobId: string;
67
- readonly message: string };
72
+ readonly message: string; readonly reason?: CodexHistoryFailureReason };
68
73
 
69
74
  const OPERATIONS: ReadonlySet<string> = new Set<CodexHistoryWorkerOperation>([
70
75
  "skip",
@@ -93,7 +98,8 @@ export function isHistoryWorkerRunMessage(data: unknown): data is HistoryWorkerR
93
98
  && OPERATIONS.has(message.operation)
94
99
  && nonEmpty(message.canonicalCodexHome)
95
100
  && nonEmpty(message.canonicalStateDbPath)
96
- && nonEmpty(message.canonicalBackupPath);
101
+ && nonEmpty(message.canonicalBackupPath)
102
+ && (message.expectedDesiredEnabled === undefined || typeof message.expectedDesiredEnabled === "boolean");
97
103
  }
98
104
 
99
105
  /**
@@ -120,6 +126,10 @@ export function runHistoryUnitUnderLock(
120
126
  message.canonicalCodexHome,
121
127
  message.canonicalStateDbPath,
122
128
  permit => {
129
+ if (message.expectedDesiredEnabled !== undefined
130
+ && shouldSyncCodexOnStart(loadConfig()) !== message.expectedDesiredEnabled) {
131
+ return { desiredStateChanged: true as const };
132
+ }
123
133
  if (operation === "recover-legacy-openai") {
124
134
  return writeLegacyOpenaiHistoryRecovery(permit, target);
125
135
  }
@@ -134,8 +144,22 @@ export function runHistoryUnitUnderLock(
134
144
  return { type: "blocked", requestId, jobId, reason: acquired.reason };
135
145
  }
136
146
  const result = acquired.value;
147
+ if ("desiredStateChanged" in result) {
148
+ return {
149
+ type: "blocked",
150
+ requestId,
151
+ jobId,
152
+ reason: message.expectedDesiredEnabled ? "desired_disabled" : "desired_enabled",
153
+ };
154
+ }
137
155
  if (result.failed === true) {
138
- return { type: "error", requestId, jobId, message: "history_transition_failed" };
156
+ return {
157
+ type: "error",
158
+ requestId,
159
+ jobId,
160
+ message: "history_transition_failed",
161
+ ...(result.failureReason ? { reason: result.failureReason } : {}),
162
+ };
139
163
  }
140
164
  return {
141
165
  type: "done",
@@ -229,7 +229,19 @@ export function recomputeInjectWitness(options: {
229
229
  /** Project a non-acquired lock result into the injection result shape. */
230
230
  export function codexInjectLockOutcome(
231
231
  result: Exclude<CodexWriteLockResult<unknown>, { status: "acquired" }>,
232
- ): { success: false; message: string; retryable: boolean } {
232
+ ): { success: false; message: string; retryable: boolean } | {
233
+ success: true; status: "skipped"; skippedReason: "desired_disabled" | "desired_enabled"; message: string;
234
+ } {
235
+ if (result.status === "skipped") {
236
+ return {
237
+ success: true,
238
+ status: "skipped",
239
+ skippedReason: result.reason,
240
+ message: result.reason === "desired_disabled"
241
+ ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed."
242
+ : "Codex integration was re-enabled; native restore was skipped.",
243
+ };
244
+ }
233
245
  if (result.status === "busy") {
234
246
  return {
235
247
  success: false,