@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -5,7 +5,8 @@ import type { ServiceDiagnostic } from "./diagnostics";
5
5
  import { assertServiceEnvironmentMatchesInstall, assertServiceAuthEnvironment } from "./guards";
6
6
  import { installLaunchd, restartLaunchdJob } from "./launchd";
7
7
  import type { LaunchdInstallOutcome } from "./launchd";
8
- import { TASK, writeServiceInstallState, serviceSourceDir } from "./state";
8
+ import { TASK, writeServiceInstallState, serviceSourceDir, resolveServiceOwnership } from "./state";
9
+ import type { ServiceOwnership, ServiceOwnershipResolution } from "./state";
9
10
  import { installSystemd } from "./systemd";
10
11
  import { writeWindowsSchedulerAssets, reregisterWindowsSchedulerTask, windowsSchedulerRegistrationMatchesSnapshot, restoreWindowsSchedulerTaskIfAbsent, startWindows, stopWindows, statusWindowsXml } from "./windows-ops";
11
12
  import { probeWindowsSchedulerTask, SCHEDULER_SETTLE_DELAYS_MS, settleDelay } from "./windows-scheduler";
@@ -35,6 +36,8 @@ export interface RepairServiceDeps {
35
36
  repairSystemd?: () => void;
36
37
  /** Restarts a launchd job the install path deliberately left alone. `restart` only. */
37
38
  restartLaunchd?: () => void;
39
+ /** Resolves the recorded runtime owner across every state path. */
40
+ readOwnership?: () => ServiceOwnershipResolution;
38
41
  /**
39
42
  * Which CLI verb is being served. `repair` must leave a healthy service alone — that no-op
40
43
  * IS the #4236 fix — while `restart` promises a new process, so on darwin it kicks the job
@@ -61,6 +64,37 @@ export interface RepairServiceDeps {
61
64
  platform?: NodeJS.Platform;
62
65
  }
63
66
 
67
+ /**
68
+ * Why a repair stops when something other than this CLI owns the runtime.
69
+ *
70
+ * The npm service registration is KEPT — the maintainer's decision is that a user's install
71
+ * is never deleted — so the recorded owner is the only thing standing between a takeover the
72
+ * user consented to and the next `ocx service repair`, which would re-enable and restart the
73
+ * npm launcher without mentioning it. Repair runs incidentally: a tray helper, `ocx update`,
74
+ * a doctor suggestion. `install` is the deliberate act, so `install` is the verb that takes
75
+ * ownership back.
76
+ */
77
+ export function foreignServiceOwnerRefusal(ownership: ServiceOwnership, action = "repair"): string {
78
+ return `Background service ${action} stopped: the desktop app owns the runtime `
79
+ + `(install ${ownership.installId}, consent generation ${ownership.consentGeneration}).\n`
80
+ + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n"
81
+ + "Quit the desktop app and run 'ocx service install' to hand the runtime back to this CLI.";
82
+ }
83
+
84
+ /**
85
+ * Why an unreadable or contradictory record stops a repair too.
86
+ *
87
+ * Collapsing "I could not read the claim" into "there is no claim" is how a consented
88
+ * takeover gets reactivated by a permissions error. `ocx service install` is deliberately
89
+ * not gated, so this never leaves an operator without a way forward.
90
+ */
91
+ export function unknownServiceOwnerRefusal(reason: string, action = "repair"): string {
92
+ return `Background service ${action} stopped: ${reason}, so the runtime's recorded owner could `
93
+ + "not be determined.\n"
94
+ + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n"
95
+ + "Repair the service-state file or its permissions, then run 'ocx service install' to take the runtime back.";
96
+ }
97
+
64
98
  async function assertSchedulerSnapshotBeforeStart(
65
99
  readSchedulerXml: () => string,
66
100
  expectedXml: string,
@@ -128,6 +162,16 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
128
162
  throw new Error("Background service is not installed. Run 'ocx service install' first.");
129
163
  }
130
164
 
165
+ // Before anything is asserted, written, stopped or started. A repair that has already
166
+ // rewritten the assets has changed the thing it was supposed to leave alone.
167
+ const ownership = (deps.readOwnership ?? resolveServiceOwnership)();
168
+ if (ownership.kind === "unknown") throw new Error(unknownServiceOwnerRefusal(ownership.reason));
169
+ // `!== "cli"` rather than `=== "desktop"`: this is the CLI's own repair path, so any owner
170
+ // that is not this CLI is one whose runtime it must not touch.
171
+ if (ownership.kind === "owned" && ownership.ownership.owner !== "cli") {
172
+ throw new Error(foreignServiceOwnerRefusal(ownership.ownership));
173
+ }
174
+
131
175
  (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
132
176
  (deps.assertAuth ?? assertServiceAuthEnvironment)();
133
177
 
@@ -0,0 +1,269 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ realpathSync,
12
+ rmdirSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ type Stats,
16
+ } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+
19
+ const SERVICE_STATE_LOCK_WAIT_MS = 2_000;
20
+ const SERVICE_STATE_LOCK_POLL_MS = 20;
21
+ const SERVICE_STATE_LOCK_STALE_MS = 30_000;
22
+ const PROCESS_INSTANCE = randomUUID();
23
+
24
+ interface ServiceStateLockRecord {
25
+ readonly version: 1;
26
+ readonly pid: number;
27
+ readonly processInstance: string;
28
+ readonly token: string;
29
+ readonly createdAt: number;
30
+ }
31
+
32
+ interface ServiceStateLockSnapshot {
33
+ readonly record: ServiceStateLockRecord;
34
+ readonly ownerPath: string;
35
+ readonly lockIdentity: Pick<Stats, "dev" | "ino">;
36
+ readonly ownerIdentity: Pick<Stats, "dev" | "ino" | "size">;
37
+ readonly mtimeMs: number;
38
+ }
39
+
40
+ export interface ServiceStateLockHooks {
41
+ readonly now?: () => number;
42
+ readonly sleep?: (ms: number) => void;
43
+ readonly processAlive?: (pid: number) => boolean;
44
+ readonly beforeStaleDelete?: (lockPath: string) => void;
45
+ readonly beforeRelease?: (lockPath: string) => void;
46
+ }
47
+
48
+ interface HeldServiceStateLock {
49
+ depth: number;
50
+ readonly snapshot: ServiceStateLockSnapshot;
51
+ }
52
+
53
+ const heldLocks = new Map<string, HeldServiceStateLock>();
54
+
55
+ function lockPathForStatePath(statePath: string): string {
56
+ try { return `${realpathSync.native(statePath)}.lock`; }
57
+ catch {
58
+ try { return join(realpathSync.native(dirname(statePath)), `${basename(statePath)}.lock`); }
59
+ catch { return `${statePath}.lock`; }
60
+ }
61
+ }
62
+
63
+ function lockOwnerProcessAlive(pid: number): boolean {
64
+ try {
65
+ process.kill(pid, 0);
66
+ return true;
67
+ } catch (error) {
68
+ // EPERM means the process exists but this account cannot signal it. Unknown failures
69
+ // also fail closed: only ESRCH proves the holder is gone.
70
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
71
+ }
72
+ }
73
+
74
+ function sameIdentity(
75
+ left: Pick<Stats, "dev" | "ino">,
76
+ right: Pick<Stats, "dev" | "ino">,
77
+ ): boolean {
78
+ return left.dev === right.dev && left.ino === right.ino;
79
+ }
80
+
81
+ function ownerFileName(record: ServiceStateLockRecord): string {
82
+ return `v1-${record.pid}-${record.processInstance}-${record.token}.json`;
83
+ }
84
+
85
+ function parseOwnerFileName(name: string): { pid: number; pathToken: string } | null {
86
+ const match = /^v1-([1-9][0-9]*)-([0-9a-f-]+)-([0-9a-f-]+)[.]json$/i.exec(name);
87
+ if (!match) return null;
88
+ const pid = Number(match[1]);
89
+ return Number.isSafeInteger(pid) && pid > 0 ? { pid, pathToken: `${match[2]}-${match[3]}` } : null;
90
+ }
91
+
92
+ function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null {
93
+ let lockIdentity: Stats;
94
+ let entries: string[];
95
+ try {
96
+ lockIdentity = lstatSync(lockPath);
97
+ if (!lockIdentity.isDirectory()) return null;
98
+ entries = readdirSync(lockPath);
99
+ } catch {
100
+ return null;
101
+ }
102
+ if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null;
103
+ const ownerPath = join(lockPath, entries[0]!);
104
+ try {
105
+ const ownerIdentity = lstatSync(ownerPath);
106
+ if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null;
107
+ const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial<ServiceStateLockRecord>;
108
+ if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0
109
+ || typeof value.processInstance !== "string" || value.processInstance.length === 0
110
+ || typeof value.token !== "string" || value.token.length === 0
111
+ || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null;
112
+ const record = value as ServiceStateLockRecord;
113
+ if (entries[0] !== ownerFileName(record)) return null;
114
+ const currentLock = lstatSync(lockPath);
115
+ const currentOwner = lstatSync(ownerPath);
116
+ if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock)
117
+ || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner)
118
+ || currentOwner.size !== ownerIdentity.size) return null;
119
+ return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs };
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function sameLock(left: ServiceStateLockSnapshot, right: ServiceStateLockSnapshot): boolean {
126
+ return left.record.token === right.record.token
127
+ && left.record.pid === right.record.pid
128
+ && left.record.processInstance === right.record.processInstance
129
+ && sameIdentity(left.lockIdentity, right.lockIdentity)
130
+ && sameIdentity(left.ownerIdentity, right.ownerIdentity)
131
+ && left.ownerIdentity.size === right.ownerIdentity.size;
132
+ }
133
+
134
+ function parsedIncompleteOwner(lockPath: string): { ownerPath: string | null; pid: number | null; mtimeMs: number } | null {
135
+ try {
136
+ const lock = lstatSync(lockPath);
137
+ if (!lock.isDirectory()) return null;
138
+ const entries = readdirSync(lockPath);
139
+ if (entries.length === 0) return { ownerPath: null, pid: null, mtimeMs: lock.mtimeMs };
140
+ if (entries.length !== 1) return null;
141
+ const parsed = parseOwnerFileName(entries[0]!);
142
+ if (!parsed) return null;
143
+ const ownerPath = join(lockPath, entries[0]!);
144
+ const owner = lstatSync(ownerPath);
145
+ return owner.isFile() ? { ownerPath, pid: parsed.pid, mtimeMs: owner.mtimeMs } : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function reclaimStaleLock(lockPath: string, hooks: ServiceStateLockHooks): boolean {
152
+ const now = hooks.now ?? Date.now;
153
+ const processAlive = hooks.processAlive ?? lockOwnerProcessAlive;
154
+ const snapshot = readLockSnapshot(lockPath);
155
+ const incomplete = snapshot ? null : parsedIncompleteOwner(lockPath);
156
+ if (!snapshot && !incomplete) return false;
157
+ const ownerPath = snapshot?.ownerPath ?? incomplete!.ownerPath;
158
+ const ownerPid = snapshot?.record.pid ?? incomplete!.pid;
159
+ const createdAt = snapshot ? Math.max(snapshot.record.createdAt, snapshot.mtimeMs) : incomplete!.mtimeMs;
160
+ if (now() - createdAt <= SERVICE_STATE_LOCK_STALE_MS || (ownerPid !== null && processAlive(ownerPid))) {
161
+ return false;
162
+ }
163
+ if (snapshot) {
164
+ const current = readLockSnapshot(lockPath);
165
+ if (!current || !sameLock(snapshot, current)) return false;
166
+ }
167
+ hooks.beforeStaleDelete?.(lockPath);
168
+ try {
169
+ // The owner filename contains the holder's PID, process-instance nonce and token. A
170
+ // successor has a different name, so this unlink cannot delete the successor's owner.
171
+ if (ownerPath) unlinkSync(ownerPath);
172
+ rmdirSync(lockPath);
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function acquireOne(lockPath: string, hooks: ServiceStateLockHooks, waitMs: number): ServiceStateLockSnapshot {
180
+ const held = heldLocks.get(lockPath);
181
+ if (held) { held.depth += 1; return held.snapshot; }
182
+ const now = hooks.now ?? Date.now;
183
+ const sleep = hooks.sleep ?? (ms => Bun.sleepSync(ms));
184
+ const deadline = now() + waitMs;
185
+ if (!existsSync(dirname(lockPath))) mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
186
+ for (;;) {
187
+ const record: ServiceStateLockRecord = {
188
+ version: 1,
189
+ pid: process.pid,
190
+ processInstance: PROCESS_INSTANCE,
191
+ token: randomUUID(),
192
+ createdAt: now(),
193
+ };
194
+ const ownerPath = join(lockPath, ownerFileName(record));
195
+ let createdDirectory = false;
196
+ let descriptor: number | null = null;
197
+ try {
198
+ mkdirSync(lockPath, { mode: 0o700 });
199
+ createdDirectory = true;
200
+ descriptor = openSync(ownerPath, "wx", 0o600);
201
+ writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
202
+ fsyncSync(descriptor);
203
+ closeSync(descriptor);
204
+ descriptor = null;
205
+ const snapshot = readLockSnapshot(lockPath);
206
+ if (!snapshot || snapshot.record.token !== record.token) throw new Error("service state lock ownership could not be verified");
207
+ heldLocks.set(lockPath, { depth: 1, snapshot });
208
+ return snapshot;
209
+ } catch (error) {
210
+ if (descriptor !== null) { try { closeSync(descriptor); } catch { /* best-effort */ } }
211
+ if (createdDirectory) {
212
+ try { unlinkSync(ownerPath); } catch { /* incomplete owner may remain for dead-PID recovery */ }
213
+ try { rmdirSync(lockPath); } catch { /* another entry or uncertain owner remains */ }
214
+ }
215
+ const code = (error as NodeJS.ErrnoException).code;
216
+ if (code !== "EEXIST") throw error;
217
+ if (reclaimStaleLock(lockPath, hooks)) continue;
218
+ if (now() >= deadline) {
219
+ throw new Error(`another process owns the service state lock at ${lockPath}; nothing was written`);
220
+ }
221
+ sleep(SERVICE_STATE_LOCK_POLL_MS);
222
+ }
223
+ }
224
+ }
225
+
226
+ function releaseOne(lockPath: string, hooks: ServiceStateLockHooks): void {
227
+ const held = heldLocks.get(lockPath);
228
+ if (!held) return;
229
+ held.depth -= 1;
230
+ if (held.depth > 0) return;
231
+ heldLocks.delete(lockPath);
232
+ hooks.beforeRelease?.(lockPath);
233
+ try {
234
+ const current = readLockSnapshot(lockPath);
235
+ if (!current || !sameLock(held.snapshot, current)) return;
236
+ unlinkSync(held.snapshot.ownerPath);
237
+ rmdirSync(lockPath);
238
+ } catch { /* a verified future holder or stale recovery owns cleanup */ }
239
+ }
240
+
241
+ export function assertServiceStateLocksOwned(statePaths: readonly string[]): void {
242
+ for (const path of statePaths) {
243
+ const lockPath = lockPathForStatePath(path);
244
+ const held = heldLocks.get(lockPath);
245
+ const current = readLockSnapshot(lockPath);
246
+ if (!held || !current || !sameLock(held.snapshot, current)) {
247
+ throw new Error(`service state lock ownership changed before commit: ${lockPath}`);
248
+ }
249
+ }
250
+ }
251
+
252
+ export function withServiceStateLocks<T>(
253
+ statePaths: readonly string[],
254
+ run: () => T,
255
+ options: { readonly waitMs?: number; readonly hooks?: ServiceStateLockHooks } = {},
256
+ ): T {
257
+ const hooks = options.hooks ?? {};
258
+ const lockPaths = [...new Set(statePaths.map(lockPathForStatePath))].sort();
259
+ const acquired: string[] = [];
260
+ try {
261
+ for (const lockPath of lockPaths) {
262
+ acquireOne(lockPath, hooks, options.waitMs ?? SERVICE_STATE_LOCK_WAIT_MS);
263
+ acquired.push(lockPath);
264
+ }
265
+ return run();
266
+ } finally {
267
+ for (const lockPath of acquired.reverse()) releaseOne(lockPath, hooks);
268
+ }
269
+ }
@@ -0,0 +1,36 @@
1
+ export interface ServiceOwnershipRecord {
2
+ readonly owner: "cli" | "desktop";
3
+ readonly installId: string;
4
+ readonly consentGeneration: number;
5
+ readonly [key: string]: unknown;
6
+ }
7
+
8
+ export declare const SERVICE_OWNERSHIP_PROTOCOL_VERSION: 1;
9
+ export declare const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION: "2.61.0";
10
+
11
+ export interface ServiceInstallStateRecord {
12
+ readonly version: 1 | 2;
13
+ readonly codexHome: string;
14
+ readonly opencodexHome: string;
15
+ readonly revision?: number;
16
+ readonly ownership?: ServiceOwnershipRecord;
17
+ readonly [key: string]: unknown;
18
+ }
19
+
20
+ export type ServiceStateRecordEvidence =
21
+ | { readonly path: string; readonly kind: "absent" }
22
+ | { readonly path: string; readonly kind: "unreadable"; readonly reason: string }
23
+ | { readonly path: string; readonly kind: "invalid" }
24
+ | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallStateRecord };
25
+
26
+ export type AuthoritativeServiceStateRecord =
27
+ | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false }
28
+ | { readonly kind: "state"; readonly state: ServiceInstallStateRecord; readonly revision: number; readonly needsRepair: boolean }
29
+ | { readonly kind: "unknown"; readonly reason: string };
30
+
31
+ export declare function parseServiceOwnershipRecord(value: unknown): ServiceOwnershipRecord | null;
32
+ export declare function parseServiceInstallStateRecord(value: unknown): ServiceInstallStateRecord | null;
33
+ export declare function serviceStatePathsForHomes(opencodexHome: string, defaultOpenCodexHome: string, platform?: NodeJS.Platform): string[];
34
+ export declare function inspectServiceStateRecords(paths: readonly string[], read?: (path: string) => string): readonly ServiceStateRecordEvidence[];
35
+ export declare function serviceStateFingerprint(value: unknown): string;
36
+ export declare function selectAuthoritativeServiceState(evidence: readonly ServiceStateRecordEvidence[]): AuthoritativeServiceStateRecord;
@@ -0,0 +1,138 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import { posix, win32 } from "node:path";
3
+
4
+ export const SERVICE_OWNERSHIP_PROTOCOL_VERSION = 1;
5
+ export const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION = "2.61.0";
6
+
7
+ const isObject = value => Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ const isNonNegativeSafeInteger = value => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
9
+ const nonEmptyString = value => typeof value === "string" && value.length > 0;
10
+
11
+ /** Parse the ownership payload shared by the Bun service code and the Node launcher. */
12
+ export function parseServiceOwnershipRecord(value) {
13
+ if (!isObject(value)) return null;
14
+ if (value.owner !== "cli" && value.owner !== "desktop") return null;
15
+ if (!nonEmptyString(value.installId) || !isNonNegativeSafeInteger(value.consentGeneration)) return null;
16
+ return value;
17
+ }
18
+
19
+ /** Parse the COMPLETE install record; ownership alone is not enough to trust the file. */
20
+ export function parseServiceInstallStateRecord(value) {
21
+ if (!isObject(value) || (value.version !== 1 && value.version !== 2)) return null;
22
+ if (!nonEmptyString(value.codexHome) || !nonEmptyString(value.opencodexHome)) return null;
23
+ for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) {
24
+ if (value[key] !== undefined && !nonEmptyString(value[key])) return null;
25
+ }
26
+ if (value.cliPath !== undefined && value.cliPath !== null && !nonEmptyString(value.cliPath)) return null;
27
+ if (value.revision !== undefined && !isNonNegativeSafeInteger(value.revision)) return null;
28
+ if (value.consentGenerationCeiling !== undefined && !isNonNegativeSafeInteger(value.consentGenerationCeiling)) return null;
29
+ if (value.ownershipProtocolVersion !== undefined && !isNonNegativeSafeInteger(value.ownershipProtocolVersion)) return null;
30
+ if (value.ownership !== undefined && parseServiceOwnershipRecord(value.ownership) === null) return null;
31
+ if (value.version === 1) {
32
+ if (value.backend !== undefined) return null;
33
+ } else if (value.backend !== "scheduler" && value.backend !== "native") return null;
34
+ return value;
35
+ }
36
+
37
+ export function serviceStatePathsForHomes(opencodexHome, defaultOpenCodexHome, platform = process.platform) {
38
+ const tools = platform === "win32" ? win32 : posix;
39
+ const primary = tools.join(opencodexHome, "service-state.json");
40
+ const legacy = tools.join(defaultOpenCodexHome, "service-state.json");
41
+ const key = path => {
42
+ let canonical;
43
+ try { canonical = realpathSync.native(path); }
44
+ catch {
45
+ try { canonical = tools.join(realpathSync.native(tools.dirname(path)), tools.basename(path)); }
46
+ catch { canonical = tools.resolve(path); }
47
+ }
48
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
49
+ };
50
+ return key(primary) === key(legacy) ? [primary] : [primary, legacy];
51
+ }
52
+
53
+ function errorCode(error) {
54
+ return error && typeof error === "object" && "code" in error ? String(error.code ?? "") : "";
55
+ }
56
+
57
+ /** Read every supplied state path without collapsing absent, invalid and unreadable. */
58
+ export function inspectServiceStateRecords(paths, read = path => readFileSync(path, "utf8")) {
59
+ return paths.map(path => {
60
+ let raw;
61
+ try {
62
+ raw = read(path);
63
+ } catch (error) {
64
+ const code = errorCode(error);
65
+ return code === "ENOENT"
66
+ ? { path, kind: "absent" }
67
+ : { path, kind: "unreadable", reason: code || String(error) };
68
+ }
69
+ try {
70
+ const state = parseServiceInstallStateRecord(JSON.parse(raw));
71
+ return state ? { path, kind: "valid", state } : { path, kind: "invalid" };
72
+ } catch {
73
+ return { path, kind: "invalid" };
74
+ }
75
+ });
76
+ }
77
+
78
+ function canonical(value) {
79
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
80
+ if (!isObject(value)) return JSON.stringify(value);
81
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
82
+ }
83
+
84
+ export function serviceStateFingerprint(value) {
85
+ return canonical(value);
86
+ }
87
+
88
+ /**
89
+ * Select one authoritative generation from mirrored state.
90
+ *
91
+ * The final path is authoritative. A lower-revision mirror is repairable; a same-or-newer
92
+ * disagreement is unordered evidence and fails closed. Before the authority exists, one valid
93
+ * active-home record is imported exactly once as migration input.
94
+ */
95
+ export function selectAuthoritativeServiceState(evidence) {
96
+ const authority = evidence.at(-1);
97
+ if (!authority) return { kind: "none", revision: 0, needsRepair: false };
98
+ if (authority.kind === "unreadable") {
99
+ return { kind: "unknown", reason: `the authoritative service state could not be read (${authority.reason})` };
100
+ }
101
+ if (authority.kind === "invalid") {
102
+ return { kind: "unknown", reason: "the authoritative service install record is present but not valid" };
103
+ }
104
+ if (authority.kind === "valid") {
105
+ const fingerprint = canonical(authority.state);
106
+ const authorityRevision = authority.state.revision ?? 0;
107
+ const unorderedConflict = evidence.slice(0, -1).find(entry => entry.kind === "valid"
108
+ && (entry.state.revision ?? 0) >= authorityRevision
109
+ && canonical(entry.state) !== fingerprint);
110
+ if (unorderedConflict) {
111
+ return { kind: "unknown", reason: `a service state mirror conflicts with authority revision ${authorityRevision}` };
112
+ }
113
+ return {
114
+ kind: "state",
115
+ state: authority.state,
116
+ revision: authorityRevision,
117
+ needsRepair: evidence.slice(0, -1).some(entry => entry.kind !== "valid" || canonical(entry.state) !== fingerprint),
118
+ };
119
+ }
120
+
121
+ // The authority has never been established. A single valid active-home mirror is the
122
+ // migration source; after the first write it can no longer vote against the authority.
123
+ const migration = evidence.slice(0, -1);
124
+ const unreadable = migration.find(entry => entry.kind === "unreadable");
125
+ if (unreadable) return { kind: "unknown", reason: `a legacy service state path could not be read (${unreadable.reason})` };
126
+ if (migration.some(entry => entry.kind === "invalid")) {
127
+ return { kind: "unknown", reason: "a legacy service install record is present but not valid" };
128
+ }
129
+ const valid = migration.filter(entry => entry.kind === "valid");
130
+ if (valid.length === 0) return { kind: "none", revision: 0, needsRepair: false };
131
+ const revision = Math.max(...valid.map(entry => entry.state.revision ?? 0));
132
+ const newest = valid.filter(entry => (entry.state.revision ?? 0) === revision);
133
+ const fingerprint = canonical(newest[0].state);
134
+ if (newest.some(entry => canonical(entry.state) !== fingerprint)) {
135
+ return { kind: "unknown", reason: `legacy service state mirrors disagree at revision ${revision}` };
136
+ }
137
+ return { kind: "state", state: newest[0].state, revision, needsRepair: true };
138
+ }